From a613a3cb904241c813dc32d691ca5174c8739c0b Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Thu, 29 Oct 2026 01:33:50 +0800 Subject: [PATCH] =?UTF-8?q?iter41-=E5=8D=A11=EF=BC=9A=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E9=9D=A2=E7=BB=93=E6=9E=84=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E6=89=B9=EF=BC=9A85=E9=94=AE=E5=85=A8=E6=B8=85=E2=80=94?= =?UTF-8?q?=E2=80=94=E4=B8=89=E6=97=8F=E6=8B=86=E5=9F=9F=EF=BC=8Bconfig=5F?= =?UTF-8?q?db=E5=BD=92=E4=BD=8D=E5=AE=B9=E5=99=A8=EF=BC=8B=E5=AD=98?= =?UTF-8?q?=E6=A1=A3=E6=8B=86=E4=BB=B6=EF=BC=8Bmeta=E7=98=A6=E8=BA=AB?= =?UTF-8?q?=EF=BC=8Badventure=E6=8B=86=E6=AE=B5=EF=BC=8Bspawn=E6=9D=9F?= =?UTF-8?q?=E5=8C=96=EF=BC=9B=E8=A1=8C=E4=B8=BA=E9=9B=B6=E5=8F=98=E5=8C=96?= =?UTF-8?q?=EF=BC=8C8=E6=96=B0=E4=BB=B6=E5=9C=A8=E9=98=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design/art/art-assets-checklist.md | 72 +- design/art/art-bible.md | 605 ++-- design/art/art-prompts.md | 10 +- design/art/art-status-checklist.md | 14 +- design/art/asset-path-map.md | 16 +- design/art/character-design.md | 112 +- design/art/remote-gen-prompts.md | 9 +- design/engineering/process-conventions.md | 2 +- design/engineering/test-conventions.md | 2 +- design/gdd/02-系统清单.md | 2 +- design/gdd/08-Alpha版本标准.md | 4 +- design/gdd/术语表.md | 16 +- game/core/action_economy.gd | 23 +- game/core/adventure_runner.gd | 14 +- game/core/attune_loadout.gd | 6 +- game/core/attunement.gd | 2 +- game/core/battle_fx_host.gd | 2 +- game/core/battle_orchestrator.gd | 42 +- game/core/board_generator.gd | 211 +- game/core/config_db.gd | 358 +-- game/core/config_db_cross_validator.gd | 135 +- game/core/config_db_resolver.gd | 114 +- game/core/config_domain_combat.gd | 12 + game/core/config_domain_crossrun.gd | 11 + game/core/config_domain_run.gd | 12 + game/core/curio_deck.gd | 34 +- game/core/curio_stop.gd | 18 +- game/core/damage_controller.gd | 50 +- game/core/effect_engine.gd | 278 +- game/core/hero_stats.gd | 10 +- game/core/level_runner.gd | 8 +- game/core/meta_progression.gd | 329 +- game/core/meta_state_store.gd | 176 ++ game/core/minesweeper_rules.gd | 22 +- game/core/node_map_generator.gd | 343 ++- game/core/refresh_bank.gd | 4 +- game/core/reward_economy.gd | 64 +- game/core/reward_grant.gd | 8 +- game/core/save_manager.gd | 662 +--- game/core/settlement/combat_segment.gd | 4 +- game/core/settlement/grant_segment.gd | 4 +- game/core/shop_session.gd | 32 +- game/core/slot_store.gd | 189 ++ game/core/status_container.gd | 68 +- game/core/treasure_items.gd | 70 +- game/core/unit_container.gd | 89 +- game/data/schemas/adventure_cap_segment.gd | 15 + game/data/schemas/adventure_quota_segment.gd | 18 + game/data/schemas/adventure_table.gd | 118 +- game/data/schemas/adventure_value_segment.gd | 18 + game/data/schemas/board_gen_table.gd | 149 +- game/data/schemas/difficulty_table.gd | 8 + game/data/schemas/enemy_table.gd | 227 +- game/data/schemas/forge_table.gd | 178 +- game/data/schemas/level_gen_params.gd | 113 +- game/data/schemas/level_table.gd | 5 + game/data/schemas/meta_table.gd | 45 +- game/data/schemas/meta_tree_table.gd | 147 +- game/data/schemas/reward_table.gd | 183 +- game/data/schemas/shop_table.gd | 52 + game/data/schemas/skill_table.gd | 149 +- game/data/schemas/status_effect_table.gd | 66 +- game/data/schemas/treasure_item_table.gd | 311 +- game/data/schemas/variation_table.gd | 158 +- game/data/tables/adventure.tres | 44 +- game/presentation/battle_narrator.gd | 2 +- game/presentation/board_resolvers.gd | 8 +- game/presentation/camp_shop_presenter.gd | 16 +- .../presentation/curio_interact_controller.gd | 6 +- game/presentation/hud_presenter.gd | 30 +- game/presentation/main.gd | 18 +- game/presentation/main_adventure_flow.gd | 2 +- game/presentation/main_overlay_flow.gd | 4 +- game/presentation/main_ui_build.gd | 6 +- .../treasure_interact_controller.gd | 10 +- game/presentation/tune_sim_controller.gd | 2 +- game/presentation/ui_assembler.gd | 4 +- game/presentation/views/battle_log_view.gd | 4 +- game/probe/probe_ui_util.gd | 10 +- game/probe/screen_flow_probe.gd | 2 +- game/probe/ui_click_probe.gd | 28 +- game/probe/ui_input_probe.gd | 4 +- game/probe/ui_probe.gd | 2 +- game/probe/ui_struct_probe.gd | 8 +- game/probe/ui_tune_probe.gd | 12 +- game/sim/adventure_monte_carlo.gd | 4 +- game/sim/board_stat_probe.gd | 2 +- game/sim/calibrate_boards.gd | 12 +- game/sim/damage_probe.gd | 32 +- game/sim/dump_battle.gd | 2 +- game/sim/experiment_density.gd | 12 +- game/sim/level_autoplay.gd | 4 +- game/sim/monte_carlo.gd | 4 +- game/sim/rhythm_batch.gd | 2 +- game/sim/skill_rhythm_probe.gd | 4 +- game/sim/verifier_batch.gd | 18 +- game/tests/README.md | 80 +- .../integration/cross_system_flow_test.gd | 30 +- .../integration/node_map_determinism_test.gd | 18 +- .../integration/save_rng_roundtrip_test.gd | 2 +- game/tests/run_all.gd | 74 +- game/tests/smoke_boot.gd | 2 + game/tests/system_test.gd | 24 +- .../damage_controller_test.gd | 46 +- .../battle_settlement/damage_math_test.gd | 14 +- .../battle_settlement/roll_multiplier_test.gd | 51 +- .../round_flow_skill_pool_test.gd | 125 +- .../battle_settlement/seal_polarity_test.gd | 50 +- .../battle_settlement/skill_unlock_test.gd | 6 +- .../battle_settlement/trigger_cond_test.gd | 24 +- .../variant_placement_test.gd | 36 +- .../unit/contracts/system_contracts_test.gd | 6 +- game/tests/unit/core/adventure_feats_test.gd | 163 +- game/tests/unit/core/board_gen_reward_test.gd | 40 +- game/tests/unit/core/board_generator_test.gd | 6 +- .../tests/unit/core/breakpoint_resume_test.gd | 6 +- .../core/config_db_cross_validator_test.gd | 1 + .../unit/core/config_db_resolver_test.gd | 25 +- .../unit/core/curio_refresh_paid_test.gd | 19 +- game/tests/unit/core/feat_vouchers_test.gd | 72 +- game/tests/unit/core/hero_ledger_test.gd | 16 +- .../unit/core/level_map_type_vis_test.gd | 8 +- .../unit/core/level_shop_economy_test.gd | 120 +- .../unit/core/meta_progression_forge_test.gd | 239 +- .../unit/core/node_map_generator_test.gd | 3 +- game/tests/unit/core/node_map_growth_test.gd | 6 +- game/tests/unit/core/pool_case_d_test.gd | 16 +- .../unit/core/save_boot_no_activate_test.gd | 28 +- .../unit/core/save_manager_domain_test.gd | 180 +- .../unit/core/save_slot_isolation_test.gd | 42 +- .../tests/unit/core/save_slot_summary_test.gd | 28 +- game/tests/unit/core/settle_segments_test.gd | 37 +- game/tests/unit/core/treasure_effects_test.gd | 40 +- .../unit/core/tree_requires_level_test.gd | 12 +- .../data_config/config_db_fatal_oob_test.gd | 2 +- game/tests/unit/data_config/config_db_test.gd | 49 +- .../unit/data_config/forge_table_test.gd | 43 +- .../unit/data_config/level_gen_params_test.gd | 45 +- .../unit/data_config/rng_stream_boot_test.gd | 13 +- .../data_config/table_structure_guard_test.gd | 135 +- .../unit/presentation/battle_narrator_test.gd | 6 +- .../unit/presentation/forge_workbench_test.gd | 16 +- .../unit/presentation/pacing_queue_test.gd | 4 +- .../presentation/player_copy_sweep_test.gd | 14 +- .../presentation/standee_fx_sweep_test.gd | 20 +- .../presentation/terrain_tile_sets_test.gd | 2 +- .../presentation/theme_projection_test.gd | 48 +- .../tests/unit/presentation/type_glow_test.gd | 13 +- .../unit/presentation/ui_font_asset_test.gd | 17 +- game/tests/验证集清单.md | 2682 +++++++++++++++++ .../工程改动报告-单元闸挂死守护-2026-10-28.md | 32 - ...·¥程改动报告-存量断言精简AB档-2026-10-28.md | 76 - tools/doc_link_audit.py | 6 +- tools/gdd_lint.py | 20 +- tools/pipeline_gate.py | 39 +- tools/undo_proof.py | 159 + 待办清单.md | 59 +- 157 files changed, 7453 insertions(+), 4288 deletions(-) create mode 100644 game/core/config_domain_combat.gd create mode 100644 game/core/config_domain_crossrun.gd create mode 100644 game/core/config_domain_run.gd create mode 100644 game/core/meta_state_store.gd create mode 100644 game/core/slot_store.gd create mode 100644 game/data/schemas/adventure_cap_segment.gd create mode 100644 game/data/schemas/adventure_quota_segment.gd create mode 100644 game/data/schemas/adventure_value_segment.gd create mode 100644 game/tests/验证集清单.md delete mode 100644 reports/工程改动报告-单元闸挂死守护-2026-10-28.md delete mode 100644 reports/工程改动报告-存量断言精简AB档-2026-10-28.md create mode 100644 tools/undo_proof.py diff --git a/design/art/art-assets-checklist.md b/design/art/art-assets-checklist.md index 9de254b4..26069bbc 100644 --- a/design/art/art-assets-checklist.md +++ b/design/art/art-assets-checklist.md @@ -1,23 +1,23 @@ # 美术资源依赖清单(Art Assets Checklist) > **性质**:表现层升级批与战斗表现批的生产侧清单——把散在战斗表现 / HUD框架 / 关卡节点地图 / 敌人系统里的资产需求收拢成一张可执行的工单视图,**兼资产维度单一登记处**。**规范效力归各 owner 文档**(战斗表现、美术圣经、04-美术音频方向),本文只补「生产视角的需求规格」,冲突时以 owner 文档为准。 -> **建立**:场景一(战斗画面呈现)讨论产物;原 asset-checklist.md 并入本文后删除,本文为两份清单合并后的唯一清单。历次修订明细见 git 提交史。 +> **建立**:2026-09-18 场景一(战斗画面呈现)讨论产物。**2026-09-22 GDD 过期账清偿批**:原 asset-checklist.md 并入本文后删除,本文自此为两份清单合并后的唯一清单;同批修正敌人键集/徽章数量/样张状态等过期事实(详见 art-bible 的 git 提交史)。**2026-09-22 §5 角色设计落地批**:剪影引用迁 art-bible §5.1、护甲区分方案落 §5.3(清偿两处待设计项)、提示词卡 v2.0 重生成(详见 art-bible 的 git 提交史)。**2026-09-23 复用盘点批**:立素材站复用渠道与复用闸(§0);新增 §3.4–3.6 三条此前漏登记的需求(增益状态效果图标 / 营地与元进度件 / 字体);各条目标注来源渠道。**2026-09-23 §5 抽离批**:角色设计内容迁独立文档 `character-design.md`(art-bible §5 自此为引用存根,小节号不变),本文 §5.x 剪影/护甲引用经存根仍可达;主角特征批(直剑→挖掘铲、加旗朱背旗)顺带,主角形象锚见 character-design.md §5.4。**2026-09-23 §6–9 补全批**:art-bible 四章成文(§6 环境/§7 UI/§8 资产规范/§9 参考与反参考);本文 §0 交付格式与像素尺寸行、参考图行、§6 依赖链顺带改指 §8/§9 现行。 > **更新纪律**:各条目「现状」行顺带更新(未开始 / 候选已出 / 已裁决 / 已交付);做完不删条目,改状态。与待办清单的联动:本文是资产维度明细,待办清单只留指路存根。 ## 0. 生产渠道、规格与风格宪法 -**三条生产渠道**(第三条为**复用优先**——能从公开素材站轻改复用的不去生成,尽可能少生成): +**三条生产渠道**(2026-09-23 用户拍板补第三条:**复用优先**——能从公开素材站轻改复用的不去生成,尽可能少生成): | 渠道 | 出什么 | 先例 | |---|---|---| -| ♻ **素材站复用(轻改)** | UI 九宫格面板与按钮、字体、通用粒子与光效、通用图标、无缝纹理底噪 | 来源偏好 CC0(如 Kenney 全站、OpenGameArt 标 CC0 件、itch.io 免费包) | -| ✦ **ComfyUI(用户自跑)** | 角色、插画级素材;每个敌种/主角建议出 **2–3 版候选**供裁决,不直接出终稿 | 主角站姿样张(脚本管线产物,源文件已清理);提示词规格卡 art-prompts.md **v2.0**(按美术圣经 §5 现行重生成) | +| ♻ **素材站复用(轻改)** | UI 九宫格面板与按钮、字体、通用粒子与光效、通用图标、无缝纹理底噪 | 无(2026-09-23 本批立渠道);来源偏好 CC0(如 Kenney 全站、OpenGameArt 标 CC0 件、itch.io 免费包) | +| ✦ **ComfyUI(用户自跑)** | 角色、插画级素材;每个敌种/主角建议出 **2–3 版候选**供裁决,不直接出终稿 | 主角站姿样张(2026-09-16 脚本管线产物,源文件已清理);提示词规格卡 art-prompts.md **v2.0 已重生成**(2026-09-22,美术圣经 §5 落地后正序重生;v1.0 移除决策归状态记录 125) | | ⚙ **脚本管线(代理出)** | 图标、格面、样张类精度资产 | 格面占位渲染与程序化徽标(现行) | **通用规格纪律**: -- 交付格式 PNG 透明底;动画用序列帧或精灵表(SpriteFrames),帧数规格随样张定(候选档已成文 art-bible §8.4)。命名规范候选草案见 art-bible §8.3。 -- **分辨率档位已定案:1280×800 + 1920×1080 两档**,窗口不可调、格边长按档位计算,后续调整走修订;像素画布基准见 **art-bible §8.2**(主角人设原图 48×72〔人设侧画布〕;站桩立绘/战斗状态图源高 48;棋盘小人 32×32 二头身;敌种逐键源档 36–94;整数倍缩放纪律;终值归样张冻结,待办清单已登记)。 +- 交付格式 PNG 透明底;动画用序列帧或精灵表(SpriteFrames),帧数规格随样张批定(候选档已成文 art-bible §8.4)。命名规范候选草案见 art-bible §8.3。 +- **分辨率档位已定案(2026-09-22 用户确认):1280×800 + 1920×1080 两档**,窗口不可调、格边长按档位计算,后续调整走修订;像素画布已定暂定基准(**art-bible §8.2**:主角原图与战斗状态图 48×72、棋盘小人 32×32 二头身〔用户暂定〕;敌种立绘三档 48/64/96 候选;1× 基准整数倍缩放纪律;1920 档缩放策略与帧数规格待样张批,待办清单已登记)。 - 程序化占位是现行结构冻结条款:资产未就位的槽位保持占位渲染,不空窗。 - **复用闸(♻ 渠道四道验收,一道不过不入包)**:① 色板量化——改色对齐 art-bible §4.1 token,不保留原包杂色;② 吸色检查——无 HSV 色相 300°–345°(紫赤禁入带);③ 灰度/色弱可辨——去色相后仍可辨且不与语义件混淆;④ 授权干净——CC0 优先,CC-BY 署名记入设置/关于页,license 不明的一律不用。 @@ -27,14 +27,14 @@ |---|---| | 一句话视觉规则 | 明快的地牢,轻快的扫荡,仔细的推理——**明亮像素幻想,全年龄,非暗黑**,角色 2~3 头身 | | 原则一「预告上格」 | 格内绘制优先级 **数字(含色牌)> 小人 > 封印印记 > 变异纹带 > 装饰变体 > 底色**;任何元素禁止遮挡数字字形区;类型微光为底效泛光层,不入遮挡栈 | -| 原则二「明亮为皮,明度分档为骨」 | 暖倾向;数字亮墨直画已开格底(亮墨 L≥0.75 / 已开格底 L 0.08–0.14,art-bible §4.1),对比 ≥4.5:1;色牌 `#F5EAD3` 为复杂格底后备(牌上深墨 L≤0.027);未开格底 L≥0.30、HSV 饱和 ≤0.55 | +| 原则二「明亮为皮,明度分档为骨」(v3.0) | 暖倾向;数字亮墨直画已开格底(候选亮墨 L≥0.55 / 已开底 L 0.20–0.32,样张 v3 冻结),对比 ≥4.5:1;色牌 `#F5EAD3` 降级为复杂格底后备(牌上深墨 L≤0.027);未开格底 L≥0.30、HSV 饱和 ≤0.55 | | 原则三「一砖一世界」 | 变体优先换色板不加新资产;危险状态必须**形状+颜色双通道**(色弱灰度可辨) | -| 类型预读单通道(§4.6.3) | 未开格 = **类型常显微光**(五组色带 §4.2:芥末黄/黄绿/青/紫罗兰/绯红,色相+明度双码,避 300°–345° 禁入带,样张冻结);不呈现极性、不泄危险格与安全格(角嵌形状通道已不存在——微光格只留微光) | +| 类型预读单通道(§4.6.3) | 未开格 = **类型常显微光**(五组色带 §4.2:芥末黄/黄绿/青/紫罗兰/绯红,色相+明度双码,均候选值,避 300°–345° 禁入带,样张批冻结);不呈现极性、不泄危险格与安全格〔角嵌形状通道 2026-09-28 退役——用户裁微光格只留微光〕 | | 剪影哲学(§5.1) | **所有角色自带轮廓通道:亮色描边+落影底盘**,识别不依赖背景;四类原型见各条目 | -| 语义色规则(§4.2) | 红只说危险(危险红 `#A8321F` 单档)、金只说收益(`#F0A828`)、蓝只说己方(`#2E86DE`);封印印记现行色 = 封印黑 `#000000`(色史归 art-bible §4.1);**紫赤 `#8F2F6A` 为 Boss 独占,HSV 色相 300°–345° 全场禁入带**(微光群伤组绯红为类型先验色带域,例外见 §4.2) | +| 语义色规则(§4.2) | 红只说危险(危险红 `#A8321F` 单档——旗朱已退役归封印色〔现行封印黑 `#000000`〕,2026-09-26 封印紫批→2026-09-28 封印黑批)、金只说收益(`#F0A828`)、蓝只说己方(`#2E86DE`)、**紫赤 `#8F2F6A` 为 Boss 独占,HSV 色相 300°–345° 全场禁入带**(微光群伤组绯红为类型先验色带域,例外见 §4.2) | | UI 形状语法(§7.1,§3.3 存方向指导) | 半拟物:面板=石板圆角矩形(与地砖同圆角率),按钮=可按的砖,仪式确认=圆形蜡封;数据区直角细线无纹理 | -**参考图**:`design/art/references/` 三款×2 张(Into the Breach / Moonlighter / Peglin,与圣经点名池一一对应;gungeon 已退场,反面条款见 art-bible §9.2;正面取/弃与反面条款已成文 art-bible §9,反面图证待素材生成后增补)。 +**参考图**:`design/art/references/` 三款×2 张(Into the Breach / Moonlighter / Peglin,与圣经点名池一一对应;gungeon 已退场〔2026-09-22 裁决〕;正面取/弃与反面条款已成文 art-bible §9,反面图证待素材生成后另批增补)。 --- @@ -50,25 +50,25 @@ ### 1.2 战斗一屏样张(双舞台,2–3 版候选)⚙格面骨架 + ✦角色剪影候选 -- **要什么**:按双舞台布局(战斗画面与扫雷图同屏常驻)出的整屏试验图。要锁的四件事:**角色剪影方向 / 动画方案 / 战斗空间 / 敌人层绘制优先级插入位置**;**敌种呈现形态已裁决:全站桩式(主角同站桩、状态小图标挂血条下),卡片式已弃,正式样张按站桩形态单版出样**。 +- **要什么**:按双舞台布局(战斗画面与扫雷图同屏常驻)出的整屏试验图。要锁的四件事:**角色剪影方向 / 动画方案 / 战斗空间 / 敌人层绘制优先级插入位置**;~~顺带裁决**敌人卡形态:卡片式 vs 站桩式(无框立绘+底座)**——两版候选各出一版~~——**已提前裁决(2026-09-20 用户看对照样张拍板):全站桩式(主角同站桩、状态小图标挂血条下),卡片式退役,正式样张按站桩形态单版出样**(对照样张存档 `production/mockups/`)。 - **风格/规格**:敌人剪影按 art-bible §5.1 四类原型出(见 2.2);战斗舞台背景=明亮地窟,紫赤仅 Boss;像素尺寸按已定案两档换算(见 §0 通用规格纪律)。 - **验收**:战斗表现「一屏样张的验收标准」(已成文)。 -- **联动**:样张未过,**不启动任何终稿资产与动画设计**(文档明文的排期硬前置);格边长的计算口径不在本清单——现行唯一出处是 [battle.md](../gdd/ux/battle.md)「棋盘格尺寸公式」,HUD框架只判装板可行性(固定两档分辨率、窗口不可调、格子量增加则格边长相应变小;**档位值已定案 1280×800 + 1920×1080**)。 +- **联动**:样张未过,**不启动任何终稿资产与动画设计**(文档明文的排期硬前置);格边长的计算口径不在本清单——现行唯一出处是 [battle.md](../gdd/ux/battle.md)「棋盘格尺寸公式」,HUD框架只判装板可行性(2026-09-18 用户拍板:固定两档分辨率、窗口不可调、格子量增加则格边长相应变小;**档位值已定案 1280×800 + 1920×1080**)。 --- ## 2. P1 —— 角色级(样张通过后开工,ComfyUI) -> 提示词规格卡 art-prompts.md 现行为 **v2.0**(主角三态+八敌种,逐资产自包含完整提示词,整段可直贴)。 +> 提示词规格卡 art-prompts.md 已于 2026-09-22 按「圣经 §5 落地后重生成」拍板重写为 **v2.0**(主角三态+八敌种,逐资产自包含完整提示词,整段可直贴);v1.0 移除决策归状态记录 125 完成登记(production/session-state/active.md)。 ### 2.1 主角战斗小人 - **要什么**:待机 / 攻击 / 受击 三态起步(移动态样张后定);透明底 PNG。 -- **风格/规格**:小人原型——**窄、直立、对称,轮廓凹凸 ≤2**;2~3 头身两档下剪影均成立;主色小人蓝 `#2E86DE`(候选值,样张冻结终稿);格内占位最窄、不吞数字位。 +- **风格/规格**:小人原型——**窄、直立、对称,轮廓凹凸 ≤2**;2~3 头身两档下剪影均成立;主色小人蓝 `#2E86DE`(候选值,样张批冻结终稿);格内占位最窄、不吞数字位。 - **来源**:✦ ComfyUI(prompt 卡 v2.0 §2);剪影原型是设计载荷(宽≥高/凹凸计数/甲档/紫赤独占),素材站无对的,不复用。 - **现状**:站姿样张已有先例(源文件已清理);战斗三态未出。 -### 2.2 八敌种形象(键集事实源 = `data/tables/enemy.tres` 行键) +### 2.2 八敌种形象(键集事实源 = `data/tables/enemy.tres` 行键,2026-09-22 核) | 敌种(enemy_id) | 剪影原型(§5.1) | 护甲类型 | 风格要点 | |---|---|---|---| @@ -81,11 +81,11 @@ | 钩爪恐魔 hook_horror | 普通怪(钩爪/多足轮廓注意凹凸计数控制) | light | 第三关群行捕食者,小体量可辨 | | 灰矮人石君王 duergar_king | **Boss:锐角与不对称**,体量最大 | heavy | 第三关 Boss;紫赤色相独占+形状独占双通道 | -- **通用规格**:亮色描边+落影底盘轮廓通道硬规则;透明底 PNG;每敌种 2–3 版候选;护甲类型视觉区分方案已落 art-bible §5.3(候选方案,样张冻结终稿)。 +- **通用规格**:亮色描边+落影底盘轮廓通道硬规则;透明底 PNG;每敌种 2–3 版候选;护甲类型视觉区分方案已落 art-bible §5.3(候选方案,样张批冻结)。 - **来源**:✦ ComfyUI(prompt 卡 v2.0 §3,八敌种+L2 熊地精变体共 9 卡);理由同 §2.1——不复用。 -- **联动**:显示名以 `data/tables/enemy.tres` 与敌人系统文档为准(L2 熊地精=bugbear 复用,显示名熊地精系)。 +- **联动**:显示名以 `data/tables/enemy.tres` 与敌人系统文档为准(L2 熊地精=bugbear 复用,显示名熊地精系——2026-09-17 用户拍板)。 -### 2.3 意图准备动画三档 +### 2.3 意图准备动画三档(2026-09-18 用户拍板) - **要什么**:用**动作动画**替代文字徽标预告敌方下一拍,三档: 1. **普通攻击 = 常态准备动画**——待机姿态本身就是预示,不另做动作; @@ -94,7 +94,7 @@ - **风格/规格**:三档在**灰度/剪影层也可辨**(双通道纪律);每档动画时长与结算节拍对齐(预告时序 = 下一拍、挖掘期全程可见)。 - **联动**:HUD框架「意图徽标不预告技能名与数值」条款由本拍板承接为动画承载(文字与技能名彻底退出);战斗表现「特殊技两型动画」输入清单按三档改写;映射按 enemy_id 全局恒定(跨 run 禁重掷);契约锚 = art-bible §4.6.4 意图姿态行。 - **来源**:✦/⚙ 自产——三档表演层挂在我们自己的精灵图上,动画本体不可复用;通用蓄力/施法光效素材(♻)仅可作参考与粒子底料,过复用闸。 -- **现状**:占位符号已落地(普通攻击「—」/ ⚔ / ✦ / 重击类「蓄力」,技能名退出徽标);终稿动画待样张后开工。 +- **现状**:占位符号已落地(2026-09-18:普通攻击「—」/ ⚔ / ✦ / 重击类「蓄力」,技能名退出徽标);终稿动画待样张后开工。 --- @@ -102,7 +102,7 @@ ### 3.1 格面资源徽章 19 枚(九型 × 正负 18 枚 + 清图槽 1 枚)⚙ -- **要什么**:九家族(增伤/减伤/回复/爆裂/削弱/穿刺/魔法/行动/GP)各正负一枚 + 清图群伤一枚。**键名录读 `reward.tres` 与地图战斗资源 GDD 九型字典,本文不抄键名**(防再腐烂:gp 组键名 gp_coin/gp_lose 以配置为准)。 +- **要什么**:九家族(增伤/减伤/回复/爆裂/削弱/穿刺/魔法/行动/GP)各正负一枚 + 清图群伤一枚。**键名录读 `reward.tres` 与地图战斗资源 GDD 九型字典,本文不抄键名**(防再腐烂——2026-09-22 勘误先例:gp 组键名 gp_coin/gp_lose 以配置为准)。 - **风格/规格**:图标键契约 art-bible §4.6(冻结);**极性视觉 = 金族满框(正向)/ 红族缺框(负向)**,不新增语义色;徽章=附属小图标,非完整凸形(不构成第二焦点),栈位在旗之下、变异纹理之上;负向九枚 weight=0 纯字典,用于踩雷反噬结算呈现。 - **来源**:⚙ 脚本管线(键契约件,程序化最稳);通用图标包只作造型参考,不直接入包。 - **现状**:程序化占位徽标(文本/符号),够试玩不够观感。 @@ -112,34 +112,34 @@ - **要什么**:四类节点图标(战斗 / 奖励 / 恢复 / Boss)+ 图版行进器形象(棋子)。 - **风格/规格**:容器走石板圆角语法;Boss 节点可用紫赤;奖励节点可用奖励金;恢复节点绿=自然生机(零逻辑语义,仅氛围);行进器=小人同族蓝。 - **来源**:♻ 素材站复用优先——通用地图/桌游图标(如 Kenney 件)改色量化,过复用闸;⚙ 兜底。行进器与小人同族,宜 ✦/⚙ 与主角同批出。 -- **联动**:正式节点图 UI 设计未成文(登记待办:排秘宝内容设计后与美术设计同期待裁决);HUD框架义务清单漏「关卡节点地图」行,同时补。 +- **联动**:正式节点图 UI 设计未成文(登记待办:排秘宝内容批后与美术批同期待拍板);HUD框架义务清单漏「关卡节点地图」行,顺带补。 - **现状**:程序化占位,够试玩。 ### 3.3 雷 / 封印印记 / 数字格面像素化 ⚙ - **要什么**:格子样张通过后按冻结参数出全套砖面资产(含图类型换色板组)。 -- **来源**:⚙ 脚本管线自产——未开/已开/危险格/变异纹带是扫雷专属语义(角嵌纹样已不存在),素材站没有对的;砖面**纹理底噪**可 ♻ 用 CC0 无缝石/土纹理作脚本管线的纹理输入,量化到色板(混合路线)。 +- **来源**:⚙ 脚本管线自产——未开/已开/危险格/变异纹带是扫雷专属语义〔角嵌纹样 2026-09-28 退役〕,素材站没有对的;砖面**纹理底噪**可 ♻ 用 CC0 无缝石/土纹理作脚本管线的纹理输入,量化到色板(混合路线)。 - **前置**:§1.1 格子样张通过(**未跑**)。 - **现状**:程序化绘制,可读性达标(对比硬指标已是现行实现依据),观感待升级。 -### 3.4 增益状态效果图标 9 枚 ♻优先/⚙兜底 +### 3.4 增益状态效果图标 9 枚 ♻优先/⚙兜底(2026-09-23 新登记,此前清单漏登记) - **要什么**:HUD 状态槽挂图(敌人血条下小图标),九行键族——**键名录读 art-bible §4.6.1 与 `status_effects.tres`,本文不抄键名**(键名录指路纪律,同 §3.1)。 - **风格/规格**:♻ 素材站复用优先——通用增益/减益图标包改色为墨族,按 §4.6.1 极性列统一叠极性视觉(有利=金族满框/有害=红族缺框,art-bible §4.6 通道分配全键族通用),过复用闸四道(灰度可辨=色弱冗余通道);⚙ 兜底程序化。一键一纹样、映射按 effect_id 全局恒定禁换图(图标键契约)。 - **现状**:已接入(2026-09-24,定稿九枚入 HUD 状态槽,合并 71ae51c;色板量化豁免=用户逐键验收的自产件,非外采)。 -### 3.5 营地场景图 +### 3.5 营地场景图(2026-10-22 重写;原「营地与元进度件·三柜台」条目作废) - **要什么**:营地场景图 9 张——`scene_camp_bg`(背景)+ `scene_camp_gate / forge / chest / notebook / dummy / board / campfire`(七个可点物件)+ `scene_camp_tent`(承载件)。钥石顶栏图标沿用现役 `icon_keystone`。 - **来源/规格**:**✦ 插画级**(作者自跑远程生图管线);烛光黄 `#F6C667` + 木色 `#8A5A34`(art-bible §6.2);2× 源档出图、最近邻降采样到 1280×800;后处理=抠底/降采样/色板量化/描边统一。**每张出图须登记模型名+分辨率+seed+对话/日期**。 - **排期**:营地交互重做**美术自立批**(不占迭代位;UI 贴图重构批已搁置)。终稿落 `game/assets/art/scene/`,消费常量 `UiKit.TEX_CAMP_*`。 - **现状**:全占位。 -### 3.6 字体(全局 UI 像素字,已定稿单套) +### 3.6 字体(全局 UI 像素字,已定稿单套)(2026-09-23 新登记;2026-10-24 用户裁收束定稿) -- **要什么**:全局 UI 字体一套。**已定稿:缝合像素字体 Fusion Pixel 单套单字重即终稿**——拉丁字符与数字沿用其自带拉丁字形,不另接拉丁像素字专件(旧拉丁候选 Kenney Fonts 已弃)、不上第二个字重;现行已接入 `ui_font_fusion_pixel_zh_hans.ttf`。 -- **来源/规格**:♻ 复用开源——缝合像素字体(Fusion Pixel,SIL OFL 1.1,授权总表在册);墨色走主墨系(art-bible §7.2)。换字体是全局决策;样张用字即此定稿件,过样张闸第 1 项(数字可读性 ≥4.5:1,禁以换字形逃避)。 -- **现状**:✅ 已定稿在役(现状即终稿)。 +- **要什么**:全局 UI 字体一套。**已定稿(2026-10-24 用户裁):缝合像素字体 Fusion Pixel 单套单字重即终稿**——拉丁字符与数字沿用其自带拉丁字形,不另接拉丁像素字专件(旧拉丁候选 Kenney Fonts 退役不再接入)、不上第二个字重;现行已接入 `ui_font_fusion_pixel_zh_hans.ttf`。 +- **来源/规格**:♻ 复用开源——缝合像素字体(Fusion Pixel,SIL OFL 1.1,授权总表在册);墨色走主墨系(art-bible §7.2)。换字体是全局决策;样张批用字即此定稿件,过样张闸第 1 项(数字可读性 ≥4.5:1,禁以换字形逃避)。 +- **现状**:✅ 已定稿在役(2026-09-27 用户拍板接入;2026-10-24 用户裁现状即终稿)。 --- @@ -150,7 +150,7 @@ | 受击反馈 / 死亡表现 / 顺位标记 | 敌人系统登记的资产需求**下限三类** | 死亡=血条归零后**灰化+碎裂退场,禁止瞬间消失**;顺位标记需在敌多时可读 | ⚙/✦ 挂自产精灵——死亡灰化碎裂是语义硬规则,不可整包复用;烟尘碎屑粒子可 ♻ 改色 | | 特殊技动画终稿 | 并入 §2.3 三档意图动画,不另起体系 | 攻/防两型旧口径由三档替代 | ✦/⚙ 自产(同 §2.3) | | 连锁坍塌显形演出 | 连锁展开触发时的连锁演出 | 与音频方向同批配对登记;「坍塌显形」=连锁展开的玩家可见名 | ♻ 粒子/碎块改色 + ⚙ 坍塌结构自产 | -| 封印特效 | 该动作的呈现名 = **封印**(art-bible §3.2):**施印瞬间特效 + 印记常显形态**两件 | 印记旗面走封印黑 `#000000`(现行封印色,色史归 art-bible §4.1);特效走运动+色相(§2:状态/情绪不走明度,空间光照可走);现行占位 = 封印黑「旗」字形;印记常显形态可与 §3.3 旗面像素化合并出 | ✦/⚙ 自产(精灵+粒子,烟尘光点可 ♻ 改色) | +| 封印特效(插旗动作) | 插旗的呈现名 = **封印**(2026-09-25 术语批,art-bible §3.2):**施印瞬间特效 + 印记常显形态**两件 | 印记旗面走封印黑 `#000000`(旗朱→封印紫→封印黑,2026-09-28 封印黑批);特效走运动+色相(§2:状态/情绪不走明度,空间光照可走——v3.0);现行占位 = 封印黑「旗」字形;印记常显形态可与 §3.3 旗面像素化合并出 | ✦/⚙ 自产(精灵+粒子,烟尘光点可 ♻ 改色) | | 弹道/退场等动画密度 | 战斗表现输入清单,方案未定 | 样张锁动画方案后启动 | ♻ 通用弹道/光点/消散粒子改色为主 | --- @@ -158,10 +158,10 @@ ## 5. 明确不在本清单 - **音乐音效**:文档定稿「MVP 占位即可,核心循环验证后再定」。 -- **秘宝图标**:内容归 Alpha 秘宝整体重设计,内容未定不能先做;渠道届时按 ♻/✦ 另评。 +- **秘宝图标**:内容归 Alpha 秘宝整体重设计批(状态记录 112 口径),内容未定不能先做;渠道届时按 ♻/✦ 另评。 - **封面图/宣传图**:Alpha 后另批,不阻塞切片;需项目角色出镜,素材站合成观感差,宜 ✦。 -- **地形变异专属纹理**:六类型已定稿,纹带需求 = 2 纹形(伤害轴刺缺形/信息轴遮蔽形)× 3 图类型色板,**样张后可排产**;形状通道规则见 art-bible §3.2。 -- **精英/Boss 护甲类型视觉区分**:已落 art-bible §5.3(候选方案:甲面覆盖率×甲材质色三档,样张冻结终稿)。 +- **地形变异专属纹理**:~~变异表为空,纹带种类待地形变异批定夺~~——**六类型已定稿(2026-09-20 地形变异批)**,纹带需求 = 2 纹形(伤害轴刺缺形/信息轴遮蔽形)× 3 图类型色板,**样张批后可排产**;形状通道规则见 art-bible §3.2。 +- **精英/Boss 护甲类型视觉区分**:~~待设计项~~ **已落 art-bible §5.3**(候选方案:甲面覆盖率×甲材质色三档,样张批冻结)。 ## 6. 执行顺序(依赖链) @@ -170,16 +170,16 @@ │ 战斗一屏样张(1.2) ──> 角色终稿(2.1/2.2) ──> 意图动画三档(2.3) ──> 演出类(P4) │ - └─> 敌种呈现形态裁决 ✅已完成(全站桩,卡片式已弃) + └─> 敌人卡形态裁决 ✅已完成(2026-09-20 拍板全站桩,卡片式退役) 徽章(3.1)/节点图(3.2)/效果图标(3.4) —— 独立线,可随时插队 ♻ 复用件(3.2/3.4/3.5/3.6/演出粒子) —— 不依赖样张,可随时插队(入包闸 = §0 复用四道) 字体(3.6)接入归表现批;营地场景图(3.5)归营地交互重做美术自立批 分辨率档位 ✅已定案(1280×800 + 1920×1080,2026-09-22 用户确认);art-bible §8 资产规范 ✅已成文(2026-09-23)——像素基准/画布/命名/帧数候选值就位,终值归样张批冻结 ``` -## 附、已评素材包授权与结论登记(♻ 复用渠道) +## 附、已评素材包授权与结论登记(2026-09-26 并档,♻ 复用渠道) -> 来源:素材站复用三批复审记录(批目录已归档清退,本表为唯一仓内登记);详录已随复审出盘,沿革查 git 提交史。 +> 来源:tile-reuse-2026-09-23 / ui-reuse-2026-09-23 / terrain-walls-2026-09-25 三批复审记录(批目录已归档清退,本表为唯一仓内登记);详录已随复审批出盘,沿革查 git 提交史。 ### 授权总表(闸④预检结论) @@ -198,5 +198,5 @@ | Golden UI 系列 | CC-BY-SA | — | **闸④不过,未下载** | | Gemini 生图(用户自跑) | 自产 | 无 | **terrain_ 高墙瓦片材质源(现行定稿)** | -### 复用四闸(现行) +### 复用四闸(状态记录 130,现行) 色板量化 / 禁入带吸色 / 灰度可辨 / 授权干净(SA 不可、来路不明不可)。 diff --git a/design/art/art-bible.md b/design/art/art-bible.md index 7954fa0d..9e819ac8 100644 --- a/design/art/art-bible.md +++ b/design/art/art-bible.md @@ -1,12 +1,12 @@ # 美术圣经(Art Bible)— Dungeon Sweeper -> 项目:Dungeon Sweeper(扫雷 × 自动战斗 闯关 Roguelike)· 状态:**第 1–9 节定稿**;现行为 v3.21,历次修订明细见 git 提交史 +> 项目:Dungeon Sweeper(扫雷 × 自动战斗 闯关 Roguelike)· 状态:**第 1–9 节定稿**(§6 环境/§7 UI/§8 资产规范/§9 参考与反参考已于 2026-09-23 v1.8 按 D1/D2/D4/D5 框架成文;D3=§5 角色已于 v1.6 落地、v1.7 抽离独立文档;框架 2026-09-22 用户拍板,原登记于中间产物修订指导,经裁决不入库,明细归状态记录 125);现行为 v3.20,历次修订明细见 git 提交史 > -> **Art Director Sign-Off(AD-ART-BIBLE)**:历次批均未签核——此为 Lean/Solo 模式(非 PHASE-GATE)下的现行治理状态;full 门禁签核时机待用户表态。 -> 风格基调:地牢题材但**非暗黑风**——轻快明亮的幻想冒险风,全年龄;像素风;角色 2~3 头身。 -> 参考池(用户审阅选定,三者相融):**Into the Breach**(网格信息分层;雷图棋盘呈现基准 = 其战棋地块质感 × 正俯视角,见 §3.2)× **Moonlighter**(明亮像素地牢质感)× **Peglin**(明快全年龄地牢色彩);参考截图存于 `design/art/references/`(三款 × 2 张,与点名池一一对应);Enter the Gungeon 不作参考,反面条款见 §9.2。 -> 约束:本圣经是**纯表现层文档**——明亮轻快是美术风格,不是玩法成分。圣经禁止引入、暗示或预设任何玩法语义,不改变任何 GDD 已锁机制;凡表现层需要玩法钩子才能表达之处(如完美封印检测、Boss 预警),一律标注「待 GDD 定夺」,圣经只规定"若有,则如何呈现"。GDD 机制与圣经冲突时,机制语义以 GDD 为准,呈现方案以本圣经为准。 -> **条款验收标注纪律(全文书写规范)**:条款尾部按三档标注验收方式——**`验收:机检〈判据>`**(可量化,脚本能断言)/**`验收:目检〈判据>`**(机器测不了,但看什么、怎么算有明确判据)/**`验收:方向性`**(气质与方向原则,不承担验收);推导源尾注「←」继续表达推导来源,与验收标注同句并存时,验收标注放句末;数值类条款推荐「**待定现用**」态(现行即执法、修订走登记,释义见 §4.1) +> **Art Director Sign-Off (AD-ART-BIBLE)**:历次批均 SKIPPED(v1.8 §6–9 补全亦 SKIPPED)— Lean/Solo mode(非 PHASE-GATE;§6–9 已成文,full 门禁签核时机待用户表态) +> 风格基调(2026-09-10 用户定稿):地牢题材但**非暗黑风**——轻快明亮的幻想冒险风,全年龄;像素风;角色 2~3 头身 +> 参考池(用户审阅选定,三者相融):**Into the Breach**(网格信息分层;雷图棋盘呈现基准 = 其战棋地块质感 × 正俯视角,见 §3.2)× **Moonlighter**(明亮像素地牢质感)× **Peglin**(明快全年龄地牢色彩);参考截图存于 `design/art/references/`(三款×2 张,与点名池一一对应;gungeon 两张经 2026-09-22 裁决退场;正面取/弃与反面条款已成文 §9,反面图证待素材生成后另批增补) +> 约束(2026-09-10 用户强调):本圣经是**纯表现层文档**——明亮轻快是美术风格,不是玩法成分。圣经禁止引入、暗示或预设任何玩法语义,不改变任何 GDD 已锁机制;凡表现层需要玩法钩子才能表达之处(如完美封印检测、Boss 预警),一律标注「待 GDD 定夺」,圣经只规定"若有,则如何呈现"。GDD 机制与圣经冲突时,机制语义以 GDD 为准,呈现方案以本圣经为准 +> **条款验收标注纪律(2026-10-06 P2-4 用户拍板,全文书写规范)**:条款尾部按三档标注验收方式——**`←机检: <断言>`**(脚本可自动判定)/ **`←目检: <判据>`**(机器测不了,但看什么、算过有明确判据)/ **`←方向性`**(气质与方向原则,不承担验收);**新条款一律按此书写**,存量口号型条款分章补标(约 55%,待办清单挂账「口号型条款机检化批」分章推进、每章交付抽验);数值类条款推荐「**待定现用**」态(现行即执法、修订走登记,释义见 §4.1) --- @@ -14,261 +14,255 @@ ### 1.1 一句话视觉规则 -**「明快的地牢,轻快的扫荡,仔细的推理。」** 验收:方向性 +**「明快的地牢,轻快的扫荡,仔细的推理。」** -三拍定调:世界观(明快的地牢)× 玩法动作(轻快的扫荡)× 玩法心智(仔细的推理)。本句为气质宣言,不承担裁决;视觉决策歧义时,依 1.2 三条支撑原则依次裁决(每条附设计测试),原则全部不过的方案否决。本节是美术圣经的最高裁决层,后续章节与本节冲突时,以本节为准。 +三拍定调:世界观(明快的地牢)× 玩法动作(轻快的扫荡)× 玩法心智(仔细的推理)。本句为气质宣言,不承担裁决;视觉决策歧义时,依 1.2 三条支撑原则依次裁决(每条附设计测试),原则全部不过的方案否决。本节是美术圣经的最高裁决层,后续章节与本节冲突时,以本节为准。 ### 1.2 支撑原则 -**原则一|预告上格**(取自 Into the Breach;锚定 P1 推理的掌控感、P2 规划的延迟兑现) +**原则一|预告上格**(取自 Into the Breach;锚定 P1 推理的掌控感、P2 规划的延迟兑现) -继承其格内信息分层与"攻击预告画在目标格上"的范式:一切威胁、伤害与结算预告必须直接绘制在雷图格本体上,禁止只进侧栏或悬浮提示;格内绘制优先级固定:**数字(含色牌) > 小人标记 > 旗 > 变异纹带 > 装饰变体 > 底色**,任何元素禁止遮挡数字。格面类型预读 = 类型微光单通道,色相+明度双码(色带见 §4.2、键契约见 §4.6;徽章小图标族保留给库存与结算明细;类型微光为底效泛光层,不入遮挡栈、不遮任何元素)。与 ITB 之别:只取其信息骨架,弃其冷暗军事色调。验收:目检〈预告画在雷图格本体上,任何元素不遮挡数字〉 +继承其格内信息分层与"攻击预告画在目标格上"的范式:一切威胁、伤害与结算预告必须直接绘制在雷图格本体上,禁止只进侧栏或悬浮提示;格内绘制优先级固定:**数字(含色牌) > 小人标记 > 旗 > 变异纹带 > 装饰变体 > 底色**,任何元素禁止遮挡数字(格面类型预读 = 类型微光单通道〔色相+明度双码,2026-09-28 角嵌退役〕,色带见 §4.2、键契约见 §4.6;徽章小图标族保留给库存与结算明细;类型微光为底效泛光层,不入遮挡栈、不遮任何元素)。与 ITB 之别:只取其信息骨架,弃其冷暗军事色调。 -*范围注:本原则约束"已存在的信息往哪里画",不新增信息种类;预告类信息以战斗侧 GDD 实际定义的为准(如 P2 已锁的结算来源标注),GDD 未定义的预告不因本条而存在。* +*范围注(2026-09-10):本原则约束"已存在的信息往哪里画",不新增信息种类;预告类信息以战斗侧 GDD 实际定义的为准(如 P2 已锁的结算来源标注),GDD 未定义的预告不因本条而存在。* -*设计测试:当某信息歧义于"画进格子"还是"收进面板"时,画进格子。* +*设计测试:当某信息歧义于"画进格子"还是"收进面板"时,画进格子。* -**原则二|明亮为皮,明度分档为骨**(取自 Moonlighter、Peglin;锚定 P1 可读性前提、P4 探险氛围) +**原则二|明亮为皮,明度分档为骨**(取自 Moonlighter、Peglin;锚定 P1 可读性前提、P4 探险氛围;2026-09-29 v3.0 修订——原「色牌为骨」降级,病根与修法见审视报告(已退役,git fbaadb8)病根 3) -取 Moonlighter 的暖质地砖与亲和冒险者气质、Peglin 的明快全年龄洞穴色彩:全局暖倾向、无压抑。可读性由**格底明度分档**承载——已开格(数字所在格)明度走世界底层档(§2 明度分层;格态语义见《排雷玩法》;美术侧据此未开格走受光墙顶档、已开格走挖出空间沉暗档,与 §8.5 图底分离同轴),亮族数字墨直接画在格底上,直画对比 ≥4.5:1 恒成立。**数字色牌为后备手段**——仅复杂格底(物件纹样/变异纹带/微光叠加致格底对比不足)时垫牌后备(画法 §3.3、色值 §4.1)。格子本体美术放开:纹理、材质、装饰变体、类型纹样皆可入画;格底参数域见 §4.1 硬指标(分档明度域、饱和上限),仍避 Boss 禁入色相带。与参考之别:ITB 以半透明压暗板承载信息;本作格底功能分档(信息可读性服务,属 §2 明度分层允许域),情绪压暗仍禁。验收:机检〈1–8 全档数字对所在已开格底对比 ≥4.5:1〉 +取 Moonlighter 的暖质地砖与亲和冒险者气质、Peglin 的明快全年龄洞穴色彩:全局暖倾向、无压抑。可读性由**格底明度分档**承载——已开格(数字所在格)明度走世界底层档(§2 明度分层;格态语义见《排雷玩法》;美术侧据此未开格走受光墙顶档、已开格走挖出空间沉暗档,与 §8.5 图底分离同轴),亮族数字墨直接画在格底上,对比硬指标不变(≥4.5:1 恒成立)。**数字色牌降级为后备手段**——仅复杂格底(物件纹样/变异纹带/微光叠加致格底对比不足)时垫牌兜底(画法 §3.3、色值 §4.1)。格子本体美术自此放开:纹理、材质、装饰变体、类型纹样皆可入画;格底参数域见 §4.1 硬指标(分档明度域、饱和上限),仍避 Boss 禁入色相带。与参考之别:ITB 以半透明压暗板承载信息;本作格底功能分档(信息可读性服务,属 §2 明度分层允许域),情绪压暗仍禁。 -*设计测试:当格子美术与数字可读性冲突时,先调格底明度档,复杂格底垫色牌后备,不动格子画。* +*设计测试:当格子美术与数字可读性冲突时,先调格底明度档,复杂格底垫色牌兜底,不动格子画。* -**原则三|一砖一世界**(融合 Moonlighter 地砖观;锚定 P3 风险与收益同源、P4 地牢下探隐喻) +**原则三|一砖一世界**(融合 Moonlighter 地砖观;锚定 P3 风险与收益同源、P4 地牢下探隐喻) -格子即地牢地砖:未开 = 覆尘完整砖,已开 = 碎裂裸露,雷 = 被惊动的巢穴(格态定义与玩家说法见《排雷玩法》),图类型(board_type)= 同款地砖换色板。图类型与变异格的视觉差异一律先做色板变体,不做新资产;一切危险状态必须"形状 + 颜色"双通道冗余,色弱可辨。像 Moonlighter 之处在砖缝质感与暖色道具,但装饰禁止伪装成逻辑差异——玩家须一眼分清"好看"与"有雷"。验收:目检〈危险状态均为形状+颜色双通道,色弱模拟下可辨〉 +格子即地牢地砖:未开 = 覆尘完整砖,已开 = 碎裂裸露,雷 = 被惊动的巢穴(格态定义与玩家说法见《排雷玩法》),图类型(board_type)= 同款地砖换色板。图类型与变异格的视觉差异一律先做色板变体,不做新资产;一切危险状态必须"形状 + 颜色"双通道冗余,色弱可辨。像 Moonlighter 之处在砖缝质感与暖色道具,但装饰禁止伪装成逻辑差异——玩家须一眼分清"好看"与"有雷"。 -格子同时是**内容舞台**,格与格的美术表达按格内实态分层多样化(画法细则 §3.2)。已开格按当格结果整格表达——产资源格嵌对应家族物件纹样(地图战斗资源九类型,§4.6.3)、纯安全格呈现干净裸土、危险格巢穴更凶。未开格在覆尘完整砖之上叠加**装饰变体**(裂纹/碎石斑/物件级点缀等氛围层),用途是破除大面积同砖平铺的重复感。物件级装饰(小石块/陶片/骨殖等)须走**专属装饰形状家族**:形与信息元素(数字/小人/旗/纹样)形状语言隔离,灰度下不与任何逻辑元素同形。零信息纪律:随机分布、与雷/安/类型零相关、禁止构成可读线索、分布每板一次跨轮不复掷。焦点预算约束:装饰不构成完整凸形、不抢读(§3.4);数量配额按图类型定(候选 3–5 种,样张已冻结,§1.4)。未开格的类型预读画为**类型微光单通道**(色相+明度双码;仍只表达类型,不泄危险格与安全格——呈现契约见 §4.6.3;概念定义、可见范围与生命周期见《HUD框架》「未开格类型微光」节)。验收:目检〈物件级装饰与逻辑元素形状隔离;装饰不构成完整凸形〉 +格子同时是**内容舞台**,格与格的美术表达按格内实态分层多样化(画法细则 §3.2):已开格按当格结果整格表达——产资源格嵌对应家族物件纹样(地图战斗资源九类型,§4.6.3)、纯安全格呈现干净裸土、危险格巢穴更凶;未开格在覆尘完整砖之上叠加**装饰变体**(裂纹/碎石斑/物件级点缀等氛围层;2026-09-29 v3.0 物件级解禁——物件级装饰〔小石块/陶片/骨殖等〕须走**专属装饰形状家族**:形与信息元素〔数字/小人/旗/纹样〕形状语言隔离,灰度下不与任何逻辑元素同形;零信息纪律不变——随机分布、与雷/安/类型零相关、禁止构成可读线索、分布每板一次跨轮不复掷;焦点预算约束不变——装饰不构成完整凸形、不抢读,§3.4;数量配额按图类型定,候选 3–5 种,样张 v3 冻结;用途=破除大面积同砖平铺的重复感);未开格的类型预读画为**类型微光单通道**(色相+明度双码;仍只表达类型,不泄危险格与安全格——呈现契约见 §4.6.3;概念定义、可见范围与生命周期见《HUD框架》「未开格类型微光」节)。 -*设计测试:当新图类型或新变异格需要新视觉时,先换色板加形状,再谈新资产;当格子多样化与"好看/有雷"之辨冲突时,砍装饰,不动逻辑通道。* +*设计测试:当新图类型或新变异格需要新视觉时,先换色板加形状,再谈新资产;当格子多样化与"好看/有雷"之辨冲突时,砍装饰,不动逻辑通道。* -### 1.4 格子样张测试(验收标准) -> 立意:扫雷数字可读性与暗色氛围相冲突,由一次格子样张测试定风格,具体形态由样张测试定稿。**样张** = 一张按本圣经参数画成的格子风格试验图,是砖面形态的定稿闸——样张通过前,§3.2/§4.1 的一切参数只是候选,通过后冻结为制作标准。 -**样张内容清单**(一图齐备,缺项即无效):未开格(覆尘完整砖,含至少两种装饰变体)、已开格(碎裂裸露,含 0 格与 1–8 全档数字)、危险格(被惊动的巢穴)、旗、小人两态(独立占格 + 与数字同格——后者须验"边框高亮不覆盖文本")、图类型色板变体组(§4.3 四槽换色,至少两套)、数字呈现组(已开格底亮墨直画组 + 复杂格底色牌后备组——素底/装饰变体底/变异纹带底至少三种格底上各一组数字)、已开格结果表达组(纯安全格 + 至少两类型物件纹样)、**类型微光呈现组**(至少三个类型的微光在纯砖格上呈现——样张为静态定格测试图,非运行时行为承诺,实际可见时机见《HUD框架》「未开格类型微光」节;含两两相邻格微光并列;微光格内零角嵌/箭头/加号类附加图形)。验收:目检〈样张图含清单全部组项〉 +### 1.4 格子样张测试(验收标准,2026-09-13 成文) -**验收项按序判定,第一项不过 = 整张否决(数字可读性定风格,风格服从可读)**: +> 立意(自概念期 brainstorm 内联):扫雷数字可读性 × 暗色氛围的冲突 → 由一次格子样张测试定风格;具体形态由样张测试定稿。**样张** = 一张按本圣经参数画成的格子风格试验图,是砖面形态的定稿闸——样张通过前,§3.2/§4.1 的一切参数只是候选,通过后冻结为制作标准。 -1. **第一验收项|数字可读性(硬指标,可测)**:1–8 全档数字对其所在**已开格底**直画 WCAG 对比 ≥4.5:1(参数域见 §4.1:已开格底走世界底层档、棋盘数字墨亮族);复杂格底(物件纹样/变异纹带/微光叠加致直画对比不足)启用**色牌后备**,牌墨组合 ≥4.5:1(色牌与牌上深墨族数字的定值见 §4.1),牌缘灰度可分;0.5× 缩放抽检数字仍可辨;纹带/裂纹/巢穴纹/物件纹样禁止侵入数字字形占位区(砖心留白,§3.2)。验收:机检〈1–8 数字对已开格底直画与牌墨组合对比均 ≥4.5:1〉 -2. **格态三形可辨(灰度通道)**:未开/已开/雷三态转灰度截图后仍可逐格分类;明度差(墙亮/空间暗)与碎裂形同为合法分类通道,双通道冗余(§3.2 完整度叙事)。装饰变体禁止制造新的分类特征——同一格态下带与不带变体的灰度差异禁止成为三态分类线索。色弱模拟(去色相)下旗、巢穴纹不与裂纹混淆。**五组类型微光互辨**:色弱玩家的类型主通道 = 微光,色相+明度双码,微光以明度梯度承载分组(组内不细分,属 §2 信息面层内梯度);微光禁止侵入数字字形占位区,明度梯度禁止压破格底明度档(三态分类不受扰)。验收:目检〈三态灰度逐格可分类;五组微光互辨;色弱模拟下旗、巢穴纹不与裂纹混淆〉 -3. **焦点预算成立(§3.4)**:任意一格至多一个完整凸形;数字所在格无第二焦点。验收:目检〈任一格至多一个完整凸形,数字所在格无第二焦点〉 -4. **图类型换皮抽测(§4.3)**:变体组中数字墨亮族三档、封印黑、危险红、奖励金、小人蓝、数字色牌(后备件)、类型微光五组恒定不动;变体格底仍过第 1、2 项指标。验收:机检〈换皮后不变槽色值恒定,变体格底仍过第 1、2 项〉 +**样张内容清单**(一图齐备,缺项即无效):未开格(覆尘完整砖,含至少两种装饰变体)、已开格(碎裂裸露,含 0 格与 1–8 全档数字)、危险格(被惊动的巢穴)、旗、小人两态(独立占格 + 与数字同格——后者须验"边框高亮不覆盖文本")、图类型色板变体组(§4.3 四槽换色,至少两套)、数字呈现组(2026-09-29 v3.0:已开格底亮墨直画组 + 复杂格底色牌后备组——素底/装饰变体底/变异纹带底至少三种格底上各一组数字)、已开格结果表达组(纯安全格 + 至少两类型物件纹样)、**类型微光呈现组**(至少三个类型的微光在纯砖格上呈现——样张为静态定格测试图,非运行时行为承诺,实际可见时机见《HUD框架》「未开格类型微光」节;含两两相邻格微光并列;微光格内零角嵌/箭头/加号类附加图形——2026-09-28 用户裁决)。 -**判定与回退**:第 1 项不过时按原则二设计测试回退——先调格底明度档,复杂格底垫色牌后备,禁止缩字号换字形逃避;第 2–4 项不过时修形不修数。样张通过 = 砖面形态与色板冻结;新增图类型/变异色板须重跑本测试(§4.3)。执行时机:正式砖面资产开工前;《HUD框架》以本节为引用依据。验收:机检〈样张机检断言集全绿后方可冻结砖面形态与色板〉 +**验收项按序判定,第一项不过 = 整张否决(数字可读性定风格,风格服从可读)**: -**整屏样张闸**:格子样张验"格",整屏样张验"屏"——战斗屏/关卡屏/菜单屏/营地屏各出一张整屏样张,验收四项:①全部文字(含菜单/说明/tooltip/按钮)对其直接背景 ≥4.5:1(WCAG AA),大字(≥18px 或加粗档)底线 3:1、目标 4.5:1;②明度分层三档带成立(信息面层 > 世界中层 > 世界底层,层间灰度可分);③整屏焦点预算(§3.4:首焦唯一——数字区/立绘/主按钮,无第二焦点竞争;负空间服务焦点,不属空窗);④文字禁直接压纹理底(垫容器或罩层)。验收顺序 = **方向稿目检先行**(防"机检过闸但气质否决"式返工),目检过后机检冻结。执行时机 = 屏级视觉落码开工前;过闸前一切屏级视觉为占位临时态(§7.1 完成条款)。验收:机检〈整屏样张四项断言全绿,且目检先行过审〉 +1. **第一验收项|数字可读性(硬指标,可测;2026-09-29 v3.0 改格底分档承载)**:1–8 全档数字对其所在**已开格底**直画 WCAG 对比 ≥4.5:1(§4.1 参数域:已开格底走世界底层档,候选 L 0.08–0.14;棋盘数字墨亮族,L≥0.75 候选——均待样张 v3 冻结〔v3.0 原拟 L 0.20–0.32/L≥0.55 经 WCAG 实测不可达 4.5:1,候选带随样张批修正〕);复杂格底(物件纹样/变异纹带/微光叠加致直画对比不足)启用**色牌后备**,牌墨组合 ≥4.5:1(色牌 L≥0.70、牌上数字用深墨族),牌缘灰度可分;0.5× 缩放抽检数字仍可辨;纹带/裂纹/巢穴纹/物件纹样禁止侵入数字字形占位区(砖心留白,§3.2)。 +2. **格态三形可辨(灰度通道)**:未开/已开/雷三态转灰度截图后仍可逐格分类(§3.2 完整度叙事;v3.0 起明度差〔墙亮/空间暗〕为合法分类通道,与碎裂形双通道冗余);装饰变体禁止制造新的分类特征——同一格态下带与不带变体的灰度差异禁止成为三态分类线索;色弱模拟(去色相)下旗、巢穴纹不与裂纹混淆;**五组类型微光互辨**——色弱玩家的类型主通道 = 微光(色相+明度双码,2026-09-28 角嵌退役后微光以明度梯度承载分组(组内不细分),§2 信息面层内梯度),微光禁止侵入数字字形占位区、明度梯度禁止压破格底明度档(三态分类不受扰)。 +3. **焦点预算成立(§3.4)**:任意一格至多一个完整凸形;数字所在格无第二焦点。 +4. **图类型换皮抽测(§4.3)**:变体组中数字墨亮族三档、封印黑、危险红、奖励金、小人蓝、数字色牌(后备件)、类型微光五组恒定不动;变体格底仍过第 1、2 项指标。 -**样张现状**:格子样张已冻结,冻结凭据 = git 提交 7a847c7(机检报告)与 §4.1/§8.2 定值表;样张 v3 产物原件已丢失、未入 git。现行冻结态:色板与帧数档维持冻结;数字形制 = 亮墨三档直画 + 色牌后备(值见 §4.1);新增图类型/变异色板须重跑本节测试。验收:机检〈冻结项定值均以 §4.1/§8.2 对应 owner 表为准〉 +**判定与回退**:第 1 项不过 → 按原则二设计测试回退——先调格底明度档,复杂格底垫色牌兜底,禁止缩字号换字形逃避;第 2–4 项不过 → 修形不修数。样张通过 = 砖面形态与色板冻结;新增图类型/变异色板须重跑本测试(§4.3)。执行时机:正式砖面资产开工前;《HUD框架》以本节为引用依据(2026-09-07 门禁:样张测试并入 art-bible 执行)。 + +**整屏样张闸(2026-09-29 v3.0 新增)**:格子样张验"格",整屏样张验"屏"——战斗屏/关卡屏/菜单屏/营地屏各出一张整屏样张,验收四项:①全部文字(含菜单/说明/tooltip/按钮)对其直接背景 ≥4.5:1(WCAG AA;大字〔≥18px 或加粗档〕底线 3:1、目标 4.5:1);②明度分层三档带成立(信息面层 > 世界中层 > 世界底层,层间灰度可分);③整屏焦点预算(§3.4:首焦唯一——数字区/立绘/主按钮,无第二焦点竞争;负空间服务焦点,不属空窗);④文字禁直接压纹理底(垫容器或罩层)。闸形 = **方向稿目检先行**(防"机检过闸但气质否决"式返工),目检过后机检冻结。执行时机 = 屏级视觉落码开工前;过闸前一切屏级视觉为占位临时态(§7.1 完成条款)。现行 §1.4 格子样张机检断言集中涉色牌/明度档条目随样张 v3 重跑同步改造(登记待办清单)。 + +> **样张 v2 记录(2026-09-28,四冻结生效)**:机检 16/16 全绿(扩展断言集:A3 图底分离 Δ≥55/F6 墙格顶面立面双明度带 Δ≥18/F7 整板悬崖 DEPTH_PX 与游戏渲染常量同源/A6 观感密度梯度均值 ≥4.70=v1 实测 4.09×1.15)。**四冻结**——①砖面形态:方案甲 21 瓦片+高墙感 D 投影档+整板悬崖(§3.2 高墙瓦片体系条定稿参数);②色板:小人蓝 #2E86DE/接触带微光 #D6ECFF/类型微光五组定稿(§4.1/§4.2);③动画方案:§8.4 帧数档冻结;④数字形制:色牌形制定稿(#F5EAD3 素色直角小牌+Fusion Pixel+字号 max(18, ceil(格边×0.5))+墨三档,§7.4)。产物=`artwork/mockups/sample-batch-v2-2026-09-28.png`,机检报告=git 提交 f9b0765;气质终审=批末用户目检(闸形 A,2026-09-27 拍板)。**(v3.0 注:四冻结之①砖面形态的数字承载面与④数字形制随色牌降级解冻,待样张 v3 重冻;②色板③动画维持冻结不变)** + +> **样张 v3 冻结记录(2026-09-30,迭代 9 T1 批 A——四冻结①④重冻生效,圣经升 v3.1)**:机检 17/17 全绿,断言定值两件落锁——A3b 图底分离**定值 Δ≥30**(方向稿实测 38;v2 阈值 55 系色牌制地板 L0.036 时代校定,与格底分档带数学冲突退役)/A4 尺寸基线**硬等冻结**(立绘 46×48+棋盘小人源 32×32;资产升档批换代须显式解冻重校);F6 沿用 v3.4 双明度带口径 Δ≥18。**重冻两件**——①砖面形态:E 格高墙 30% 承接词表扩展(南邻墙地面格南部 30% 承接墙面、墙顶纹理**原亮度**直贴零暗档零阴影、(b)-(c) 连接处无边框〔渲染格线跳段〕、连续墙体内侧不出立面;南向厚度面+v3.4 暗档 D 投影保留)+**格底明度分档**(已开格底=世界底层档 L≈0.10,候选带 0.08–0.14;落码程序归一,地板素材重产归资产批)+装饰物件级解禁(专属形状家族,零信息纪律不变)/④数字形制:**亮墨三档直画**(1–3 冷亮 #D8ECF0/4–5 暖白 #F0E2C8/6–8 金亮 #F8E3A8,直画已开格底,实测对比 6.0–6.3:1)+**色牌后备**(#F5EAD3 仅复杂格底——物件纹样/装饰变体格——垫牌兜底,牌上深墨族 12:1)。②色板③动画维持 v2 冻结不变。产物=`artwork/mockups/sample-batch-v3-final-2026-09-30.png`,机检报告=git 提交 7a847c7【冻结】;气质终审=2026-09-29 用户验收豁免(新 v3.5 方向稿验收通过,状态记录 281)。 --- ## 2. 氛围与情绪(Mood & Atmosphere) -> 本节服从第 1 节宣言「明快的地牢,轻快的扫荡,仔细的推理」。能量值为全作统一 0–10 刻度:0 静谧,10 沸腾。 +> 本节服从第 1 节宣言「明快的地牢,轻快的扫荡,仔细的推理」。能量值为全作统一 0–10 刻度:0 静谧 → 10 沸腾。 > -> **明度分层(硬规则)**:明度按视觉层分带——**信息面层**(数字/UI 数据/预告/语义标识)最高、**世界中层**(格底顶面/角色/物件)居中、**世界底层**(背景/纵深/远景/已开空间)可更低;层间明度差服务空间纵深与光照(Moonlighter 式洞穴层次)。**情绪明暗禁令**:明度变化禁止用于**情绪表达**——画面禁止靠压暗/渐晕阴沉下来;roguelike 失败重开,世界无光暗记忆;Darkest Dungeon 式压暗仍在 §9.2 反面清单。**状态表达不受本禁令约束**:状态可用明度、色相、饱和、运动、元素任一手段承载。**允许域**:空间纵深、光照氛围、形体塑造。类型微光的明度梯度(§4.2)属信息面层内梯度,不扰世界层明度档。验收:目检〈情绪表达不走压暗/渐晕;三档明度带层间灰度可分〉 +> **明度分层(硬规则,2026-09-29 v3.0 替代「明度恒定」)**:明度按视觉层分带——**信息面层**(数字/UI 数据/预告/语义标识)最高、**世界中层**(格底顶面/角色/物件)居中、**世界底层**(背景/纵深/远景/已开空间)可更低;层间明度差服务空间纵深与光照(Moonlighter 式洞穴层次)。**禁令保留(2026-10-22 缩窄至情绪域)**:明度变化禁止用于**情绪表达**——画面不得靠压暗/渐晕阴沉下来;roguelike 失败重开,世界无光暗记忆;Darkest Dungeon 式压暗仍在 §9.2 反面清单。**状态表达不受本禁令约束**:状态可用明度、色相、饱和、运动、元素任一手段承载。roguelike 失败重开,世界无光暗记忆;Darkest Dungeon 式压暗仍在 §9.2 反面清单。**允许域**:空间纵深、光照氛围、形体塑造(砖缘立体面既有例外上升为通则)。类型微光的明度梯度(§4.2)属信息面层内梯度,不扰世界层明度档。 -### 2.1 挖掘阶段(核心状态,占时最长) +### 2.1 挖掘阶段(核心状态,占时最长) -- **主情绪**:棋手式清明专注——视线始终被数字牵引,画面无一处杂音抢焦;久推不生视觉疲劳 -- **色彩性格**:暖石色低饱和打底(世界中层),高饱和高亮只属于信息面层(数字与封印印记) -- **氛围形容词**:清爽、井然、可读、耐心 -- **能量**:2/10 -- **承载元素**:可挖域**接触带呼吸微光**——光画在英雄格周界的接触面上(正交邻墙 = 整条边,斜向邻墙 = 共享角点 + 向两侧各延伸半条边;画法 §3.2,蓝族色 §4.2)——画面里唯一在动的,是玩家的思路。验收:目检〈挖掘期仅接触带微光在动,棋盘与数字静止〉 +- **主情绪**:棋手式清明专注——视线始终被数字牵引,画面无一处杂音抢焦;久推不生视觉疲劳 +- **色彩性格**:暖石色低饱和打底(世界中层),高饱和高亮只属于信息面层(数字与封印印记) +- **氛围形容词**:清爽、井然、可读、耐心 +- **能量**:2/10 +- **承载元素**:可挖域**接触带呼吸微光**——光画在英雄格周界的接触面上(正交邻墙=整条边,斜向邻墙=共享角点+向两侧各延伸半条边,画法 §3.2,蓝族色 §4.2)——画面里唯一在动的,是玩家的思路 ### 2.2 轮末自动战斗结算 -- **主情绪**:延迟兑现的清算爽感——扫雷之因逐行兑现为战果;完美封印(概念定义与兑付结构见《地图战斗资源》)的高光兑现——结算带高光行,以其为全场喝彩级的最高反馈档 -- **色彩性格**:情绪不走明度(§2 禁令),色彩转向金橙主导、饱和拉高;数字色转译为弹道色沿行推进 -- **氛围形容词**:酣畅、递进、一气呵成 -- **能量**:7/10,随连击爬升 -- **承载元素**:兑现光带逐行横扫棋盘,开格化箭雨、印记化暴击砸向敌人,因果全程可见可归因。验收:目检〈兑现光带逐行横扫、因果可见可归因;情绪表达不走压暗〉 +- **主情绪**:延迟兑现的清算爽感——扫雷之因逐行兑现为战果;完美封印(概念定义与兑付结构见《地图战斗资源》)的高光兑现——结算带高光行,以其为全场喝彩级的最高反馈档 +- **色彩性格**:情绪不走明度(§2 禁令),色彩转向金橙主导、饱和拉高;数字色转译为弹道色沿行推进 +- **氛围形容词**:酣畅、递进、一气呵成 +- **能量**:7/10,随连击爬升 +- **承载元素**:兑现光带逐行横扫棋盘,开格化箭雨、印记化暴击砸向敌人,因果全程可见可归因 ### 2.3 清图奖励与换图 -- **主情绪**:短促丰收——宝箱揭盖式的喜悦,三五秒即收,不拖节奏 -- **色彩性格**:全屏饱和峰值:金粒喷泉与彩纸迸发,饱和瞬时拉满后回落;明度不动(§2 情绪禁令) -- **氛围形容词**:丰收、轻快、干脆 -- **能量**:8/10,尖峰后速降 -- **承载元素**:最后一格碎砖中喷出的 GP 喷泉,叮当落入 UI 钱袋。验收:目检〈金粒喷泉短促收敛,饱和峰值后回落〉 +- **主情绪**:短促丰收——宝箱揭盖式的喜悦,三五秒即收,不拖节奏 +- **色彩性格**:全屏饱和峰值:金粒喷泉与彩纸迸发,饱和瞬时拉满后回落;明度不动(§2 情绪禁令) +- **氛围形容词**:丰收、轻快、干脆 +- **能量**:8/10,尖峰后速降 +- **承载元素**:最后一格碎砖中喷出的 GP 喷泉,叮当落入 UI 钱袋 ### 2.4 战斗胜利 -- **主情绪**:从容自信——「我算对了」的盖章确认;克制而非狂欢,胜利属于头脑 -- **色彩性格**:暖白与金,不用红与爆炸 -- **氛围形容词**:笃定、舒展、体面 -- **能量**:6/10 -- **承载元素**:棋盘中央落下一枚金色蜡封,碎砖自动拼出胜利纹章。验收:方向性 +- **主情绪**:从容自信——「我算对了」的盖章确认;克制而非狂欢,胜利属于头脑 +- **色彩性格**:暖白与金,不用红与爆炸 +- **氛围形容词**:笃定、舒展、体面 +- **能量**:6/10 +- **承载元素**:棋盘中央落下一枚金色蜡封,碎砖自动拼出胜利纹章 -### 2.5 战斗失败(冒险终结) +### 2.5 战斗失败(冒险终结) -- **主情绪**:温柔的惋惜与「再来一次」的邀请——有分量、不惩罚,像合上一册冒险日志 -- **色彩性格**:情绪不走明度(§2 禁令),色相转暖灰掺暮蓝;全作禁血红与黑场,唯此一拍最静 -- **氛围形容词**:惋惜、安静、余温、可再来 -- **能量**:1/10 -- **承载元素**:小人坐在碎砖上摇头掸灰,落下的封印印记逐枚化作光点收回行囊。验收:目检〈无血红与黑场;色相转暖灰掺暮蓝〉 +- **主情绪**:温柔的惋惜与「再来一次」的邀请——有分量、不惩罚,像合上一册冒险日志 +- **色彩性格**:情绪不走明度(§2 禁令),色相转暖灰掺暮蓝;全作禁血红与黑场,唯此一拍最静 +- **氛围形容词**:惋惜、安静、余温、可再来 +- **能量**:1/10 +- **承载元素**:小人坐在碎砖上摇头掸灰,落下的封印印记逐枚化作光点收回行囊 ### 2.6 Boss 与精英遭遇 -- **主情绪**:被注视的郑重感——预告更华丽,但预告上格规则不变,掌控仍在玩家 -- **色彩性格**:格底保持基准,Boss 专属紫赤为全场唯一占用的色相——郑重感来自色相独占,不来自压暗 -- **氛围形容词**:郑重、锋利、仪式感 -- **能量**:5/10,静水深流,结算时才沸腾 -- **承载元素**:Boss 影子先于本体爬过棋盘,扫过处短暂留下紫赤足迹(纯视觉预告;是否绑定机制由战斗侧 GDD 定夺)。验收:目检〈紫赤足迹为纯视觉预告;高饱和紫赤仅 Boss 战占用〉 +- **主情绪**:被注视的郑重感——预告更华丽,但预告上格规则不变,掌控仍在玩家 +- **色彩性格**:格底保持基准,Boss 专属紫赤为全场唯一占用的色相——郑重感来自色相独占,不来自压暗 +- **氛围形容词**:郑重、锋利、仪式感 +- **能量**:5/10,静水深流,结算时才沸腾 +- **承载元素**:Boss 影子先于本体爬过棋盘,扫过处短暂留下紫赤足迹(纯视觉预告;是否绑定机制由战斗侧 GDD 定夺) -### 2.7 下探过渡(关卡间) +### 2.7 下探过渡(关卡间) -- **主情绪**:纵深的期待——「下面还有一层」的探险引力,如降落时一瞬失重 -- **色彩性格**:整层色板随图类型(board_type)递进换挡,以色相层进预告「更深更险」;世界层明度带允许随深度整体微移(空间纵深表达,非情绪;候选,待整屏样张冻结) -- **氛围形容词**:纵深、期待、换气、进阶 -- **能量**:4/10 -- **承载元素**:镜头沿竖井匀速下移,两侧砖层色板渐变,预演下一层主色。验收:目检〈下探过渡中砖层色板随图类型渐变〉 +- **主情绪**:纵深的期待——「下面还有一层」的探险引力,如降落时一瞬失重 +- **色彩性格**:整层色板随图类型(board_type)递进换挡,以色相层进预告「更深更险」;世界层明度带允许随深度整体微移(空间纵深表达,非情绪——候选,整屏样张冻结) +- **氛围形容词**:纵深、期待、换气、进阶 +- **能量**:4/10 +- **承载元素**:镜头沿竖井匀速下移,两侧砖层色板渐变,预演下一层主色 -### 2.8 菜单与元进度(存档/永久强化) +### 2.8 菜单与元进度(存档/永久强化) -- **主情绪**:可靠的营地感——成长可见、可点、可规划,归来即蓄势 -- **色彩性格**:全作最暖:烛光黄与木色,中等饱和,无棋盘式高对比 -- **氛围形容词**:温暖、笃实、归属 -- **能量**:3/10 -- **承载元素**:**一张可点击的营地场景图**——远景天空与远山,中景木围墙(密排竖木桩、桩顶削尖、不透缝无横木)与半开的营地大门,近景中央的篝火、左侧锻炉铁砧、右侧帐篷与宝箱、帐篷旁的笔记本,另有木桩与挂在围墙上的布告板;点画面里的物件进入对应界面(冒险/锻造工坊/秘宝匣/生涯记录/天赋树/图鉴/离开营地)。钥石余额常驻画面顶部的一行小 HUD 栏。场景构成、七物件绑定与呈现位见 §6.2。验收:目检〈点营地物件进入对应界面,钥石余额常驻顶部 HUD 栏〉 +- **主情绪**:可靠的营地感——成长可见、可点、可规划,归来即蓄势 +- **色彩性格**:全作最暖:烛光黄与木色,中等饱和,无棋盘式高对比 +- **氛围形容词**:温暖、笃实、归属 +- **能量**:3/10 +- **承载元素**:**一张可点击的营地场景图**——远景天空与远山,中景木围墙(密排竖木桩、桩顶削尖、不透缝无横木)与半开的营地大门,近景中央的篝火、左侧锻炉铁砧、右侧帐篷与宝箱、帐篷旁的笔记本,另有木桩与挂在围墙上的布告板;点画面里的物件进入对应界面(冒险/锻造工坊/秘宝匣/生涯记录/天赋树/图鉴/离开营地)。钥石余额常驻画面顶部的一行小 HUD 栏。场景构成、七物件绑定与呈现位见 §6.2 -### 2.9 状态间区分度自检 +### 状态间区分度自检 -推理与兑现靠「谁在动 + 饱和」区分:挖掘时棋盘静止,唯光标微光在动、高饱和只在数字上;结算时棋盘自体运动,金色光带横扫、饱和扩散至全屏。胜利与失败以**色相**即判(金蜡封对暮蓝收印),Boss 战以紫赤色相是否登场作标记。验收:目检〈任两场景依「谁在动+饱和」或色相可即时区分〉 +推理与兑现靠「谁在动 + 饱和」区分:挖掘时棋盘静止,唯光标微光在动、高饱和只在数字上;结算时棋盘自体运动,金色光带横扫、饱和扩散至全屏。胜利与失败以**色相**即判(金蜡封对暮蓝收印),Boss 战以紫赤色相是否登场作标记。 -自动战斗过场化与「我算对了」缺高光的风险对策见 2.2;全年龄失败表现对策见 2.5。 +(风险呼应:自动战斗过场化 → 2.2 可见兑现与爆发支点;「我算对了」缺高光 → 2.2 爆发支点;全年龄失败表现 → 2.5 有分量不压抑。) --- ## 3. 形状语言(Shape Language) -> 规则尾注「←」标注推导来源:P1–P4=游戏支柱(P1 推理的掌控感/P2 规划的延迟兑现/P3 风险与收益同源/P4 地牢下探隐喻,语义见 §1.2 各原则「锚定」注);原一/二/三=1.2 支撑原则;§2.x=第 2 节条目。本节只定画法;凡涉具体变异、状态种类之处一律待 GDD 定夺,本节只规定"若有,则如何画"。 +> 规则尾注「←」标注推导源:P1–P4=游戏支柱(P1 推理的掌控感/P2 规划的延迟兑现/P3 风险与收益同源/P4 地牢下探隐喻——支柱编号源自概念期 brainstorm,现行 GDD 树不再单列,语义内联于此并见 §1.2 各原则「锚定」注);原一/二/三=1.2 支撑原则;§2.x=第 2 节条目。本节只定画法;凡涉具体变异、状态种类之处一律待 GDD 定夺,本节只规定"若有,则如何画"。 -**总纲|圆角为世,直线为信**:本作形状二分——一切**物件**用圆角方形(地砖、角色体块、UI 面板共用同一圆角率),一切**逻辑信息**用直线直角(数字框、准线、结算光带)。玩家凭"圆还是方"即分清世界与信息。验收:目检〈物件呈圆角方形、逻辑信息呈直线直角〉 ←气质宣言;原一 +**总纲|圆角为世,直线为信**:本作形状二分——一切**物件**用圆角方形(地砖、角色体块、UI 面板共用同一圆角率),一切**逻辑信息**用直线直角(数字框、准线、结算光带)。玩家凭"圆还是方"即分清世界与信息。←气质宣言;原一 -### 3.1 道具点剪影(角色剪影见 [character-design.md](character-design.md) §5.1) +### 3.1 道具点剪影(角色剪影已迁 §5.1) -角色四类 archetype(小人/普通怪/精英/Boss)与轮廓通道硬规则见 [character-design.md](character-design.md) §5.1;本节保留唯一非生物剪影: +角色四类 archetype(小人/普通怪/精英/Boss)与轮廓通道硬规则已迁 **§5.1**(2026-09-22 v1.6 迁入圣经 §5.1;2026-09-23 v1.7 随 §5 抽离迁独立文档 `character-design.md`,经 §5 存根可达);本节保留唯一非生物剪影: -- **道具点**:唯一非生物剪影——直线几何体(晶体/箱体),禁有机曲线;风险与收益在剪影层即为两类。验收:目检〈道具点均为直线几何体,无有机曲线〉 ←P3 +- **道具点**:唯一非生物剪影——直线几何体(晶体/箱体),禁有机曲线;风险与收益在剪影层即为两类。←P3 ### 3.2 环境几何 -- **雷图棋盘呈现基准**:战棋地块质感 × 正俯视角——取 ITB 参考图(`references/itb_1.jpg`)的地表质感骨架:地块纹理多样、起伏体块感、岛缘/砖缘厚度、格上装饰小立牌;但相机一律正上方垂直向下,格线屏轴对齐,**禁等距/斜视角与菱形格**;形状语言仍守总纲圆角方形。砖缘厚度与岛缘立体面属形体塑造,不属状态/情绪表达,不受 §2 情绪明暗禁令约束(属 §2 明度分层允许域;顶面格底仍守 §4.1 参数域)。装饰小立牌属 ITB 参考的观感元素,本作不采用;装饰变体走氛围层纪律(纹理级/物件级形状家族,见下方装饰变体条),零信息纪律以原则三为准。验收:目检〈棋盘正俯视、格线屏轴对齐、无菱形格〉 ←原二;原三;§2 -- **地砖**:一切环境体由同一圆角方形衍生:未开 = 完整凸砖,已开 = 同形缺角碎裂,雷 = 碎裂加巢穴纹(危险格玩家说法见《排雷玩法》);只以"完整度"叙事,不为状态新造形。验收:目检〈三格态仅以完整度区分,无新造状态形〉 ←原三;P4 -- **装饰变体(未开格氛围层)**:覆尘完整砖之上叠加裂纹/碎石斑/物件级点缀等装饰。物件级装饰(小石块/陶片/骨殖等)须走**专属装饰形状家族**(与信息元素形状语言隔离、灰度下不与任何逻辑元素同形),数量配额按图类型定(候选 3–5 种,样张已冻结,§1.4),一律残缺、内凹、低对比而自然后退(§3.4);零信息纪律(分布随机、与雷/安/类型零相关、灰度下禁止制造新分类特征、分布每板一次跨轮不复掷)以原则三为准,此处不赘。验收:目检〈物件级装饰不与任何逻辑元素同形;装饰残缺低对比后退〉 ←原三 -- **结果整格表达(已开格)**:已开格按当格结果整格表达(结果清单见原则三);画法——产资源格的家族物件纹样用 §3.1 道具点剪影语言(直线几何体)角嵌于砖缘,砖心与数字字形占位区留白(已开格仍承载数字);危险格巢穴纹较已开格更凶一档,形状+颜色双通道。验收:目检〈产资源格嵌家族纹样且砖心留白;巢穴纹更凶一档〉 ←原三;P3 -- **未开格预读通道(微光单通道)**:类型预读 = 类型微光单通道(色相+明度双码,色带 §4.2);色弱类型主通道 = 微光明度梯度(组粒度——组间互辨;§2 微光明度例外);格内零角嵌/箭头/加号类附加形(微光格只留微光)。验收:目检〈微光格内零角嵌/箭头/加号类附加形〉 ←原一;原三 -- **封印印记**:封印动作的叙事呈现 = **封印**(玩家可见专名,术语定义见 [术语表](../gdd/术语表.md)「封印」条;动作面见《排雷玩法》)——封印动作落在格上的印记称封印印记;封印色见 §4.1(现行封印黑),格内优先级栈位见 §1.2。封印特效(施印瞬间 + 印记常显形态)资产归清单 §4 演出类;封印印记纹理已落位(现行件 = `seal_mark.png`,32×32 可选件),数字字形占位已不存在。验收:目检〈封印印记呈现与封印黑一致,不侵入数字字形占位区〉 ←原一;P2 -- **可挖域接触带微光(交互反馈层)**:挖掘期,英雄格周界与可挖域(概念见《排雷玩法》动作合法性节;呈现义务见《HUD框架》可挖域指示)的接触面画呼吸微光——正交邻墙 = 该侧整条边,斜向邻墙 = 共享角点 + 向两侧各延伸半条边;光只画英雄格(空间)一侧、不进墙格(与装饰变体零冲突,数字砖心不涉);微光 = 空间的灯光边界(画法规则)。色相 = 蓝族浅一档(§4.2 蓝条;与主角蓝框的分层由样张测试定,§1.4);呼吸 = §2.1 挖掘期唯一在动元素,减动效停格。角点为斜墙与正交墙共享、不逐格归因,逐格指认 = 悬停金框(呈现交互归《HUD框架》悬停指示域)。验收:目检〈微光只画英雄格一侧接触面:正交邻墙整条边、斜向邻墙角点 + 半边〉 ←§2.1;§4.2;P1 -- **墙体**:圆角砌块堆叠,禁尖刺装饰——尖刺专属危险语义,装饰禁止伪装逻辑差异。验收:目检〈墙体无尖刺装饰〉 ←原三 -- **高墙瓦片体系|术语与定义**:未挖格 = **一整块 32×32 自含凸起岩体**;素材语义名——未开格素材 = **墙瓦片**,已开格素材 = **地面瓦片**。瓦片以自含岩体为单位,Tiny Dungeon 拼块套不进自含岩体定义,不作瓦片参考。验收:目检〈未开格为整块凸起岩体,无拼块接缝感〉 ←原三 -- **高墙瓦片体系|边界墨线纪律**:墙瓦片与相邻格分界一律 1px 深墨线、直线贴格边、零锯齿;墨线职责 = 分隔,不追求与地面融合。验收:目检〈分界为 1px 深墨直线贴格边、零锯齿〉 -- **高墙瓦片体系|光照纪律(单一北受光)**:北缘 = 墨线内侧 1px 微亮棱(就地取色提亮,禁宽亮带);西/东缘 = 墨线后直进纹理、不加修饰;南缘 = 墨线 + 立面窄暗条(约 1/3 格高,横层理叠石、禁竖条纹栅栏感)。验收:目检〈北缘亮棱、南缘窄暗条;无宽亮带、无竖条纹栅栏感〉 -- **高墙瓦片体系|凹角收头**:角点单像素落线;南立面侧凹角加 1px 立面收头——带身与转角竖线同列收尾,禁悬空、禁突出。验收:目检〈凹角收头与转角竖线同列,无悬空突出〉 -- **高墙瓦片体系|现行定稿**:`game/assets/art/board/terrain_*`(16 唯一墙瓦片 + 5 地面瓦片变体;词表与查表逻辑落码于 `board_world.gd`)。验收:机检〈terrain_* 素材 16 墙瓦片 + 5 地面变体在库,查表逻辑与词表一致〉 -- **高墙瓦片体系|高墙感 D 投影档与整板悬崖**:南临空墙格 = 立面口沿高光 2px `Color8(196,172,132)` + 双段暗带(0.16/0.25 格边,黑罩 α0.60/0.82);北邻墙地面格 = 三段渐隐投影(顶带 0.22 格边,α0.28 起);整板悬崖 = 板底/右侧 `DEPTH_PX 8px` 暗面 + 列分隔竖缝 + 岩层横纹 + 口沿高光 + 接触 AO + 底线收边 + 悬浮影(游戏 `board_world.gd` 同款结构件,样张脚本与游戏渲染常量同源)。验收:目检〈南临空墙格呈口沿高光+双段暗带;北邻墙地面格呈三段渐隐投影;整板悬崖结构件齐备〉 -- **变异格形状通道**(双通道之"形",与色无关):底砖圆角外轮廓不变,砖面内缘绘一圈定宽专属几何纹带(纹带形全作不与他类共用),砖心留白予数字与旗;验收标准 = **灰度截图下仍可分类**。种类清单 = 地形变异六类型已定稿(地形变异 GDD;荆棘丛/藤蔓缠绕/岩浆/烬覆/落石/矿尘):**纹带形按轴只分两种**——伤害轴(荆棘丛/岩浆/落石)= 刺/缺排斥形,信息轴(藤蔓缠绕/烬覆/矿尘)= 遮蔽/缠绕形;同轴跨图类型只做色板变体(§4.3 `variant_tint` 槽),不做新纹形。岩浆纹带橙红须与危险红互辨(变异格不是雷,色相邻近有混淆风险);信息轴遮蔽呈现禁止侵入数字字形占位区(§1.4 验收 1 延伸)。验收:目检〈六类型纹带灰度下仍可分类,同轴不新增纹形;岩浆纹带与危险红互辨〉 ←原一(纹理位次第 4,让位于数字/小人/旗);原三 +- **雷图棋盘呈现基准**:战棋地块质感 × 正俯视角——取 ITB 参考图(`references/itb_1.jpg`)的地表质感骨架:地块纹理多样、起伏体块感、岛缘/砖缘厚度、格上装饰小立牌;但相机一律正上方垂直向下,格线屏轴对齐,**禁等距/斜视角与菱形格**;形状语言仍守总纲圆角方形。砖缘厚度与岛缘立体面属形体塑造,不属状态/情绪表达,不受 §2 情绪明暗禁令约束(2026-09-29 v3.0:本例外上升为 §2 明度分层允许域通则;顶面格底仍守 §4.1 参数域);装饰小立牌属 ITB 参考的观感元素,本作不采用——装饰变体走氛围层纪律(纹理级/物件级形状家族,见下方装饰变体条),零信息纪律不变。←原二;原三;§2 +- **地砖**:一切环境体由同一圆角方形衍生:未开=完整凸砖,已开=同形缺角碎裂,雷=碎裂加巢穴纹(危险格玩家说法见《排雷玩法》);只以"完整度"叙事,不为状态新造形。←原三;P4 +- **装饰变体(未开格氛围层)**:覆尘完整砖之上叠加裂纹/碎石斑/物件级点缀等装饰;2026-09-29 v3.0 物件级解禁——物件级装饰(小石块/陶片/骨殖等)须走**专属装饰形状家族**(与信息元素形状语言隔离、灰度下不与任何逻辑元素同形),数量配额按图类型定(候选 3–5 种,样张 v3 冻结),一律残缺、内凹、低对比而自然后退(§3.4);零信息纪律(分布随机、与雷/安/类型零相关、灰度下禁止制造新分类特征、分布每板一次跨轮不复掷)以原则三为准,此处不赘。←原三 +- **结果整格表达(已开格)**:已开格按当格结果整格表达(结果清单见原则三);画法——产资源格的家族物件纹样用 §3.1 道具点剪影语言(直线几何体)角嵌于砖缘,砖心与数字字形占位区留白(已开格仍承载数字);危险格巢穴纹较已开格更凶一档,形状+颜色双通道不变。←原三;P3 +- **未开格预读通道(微光单通道)**:类型预读 = 类型微光单通道(色相+明度双码,色带 §4.2);色弱类型主通道 = 微光明度梯度(组粒度——组间互辨;§2 微光明度例外);格内零角嵌/箭头/加号类附加形(2026-09-28 用户裁「微光格只留微光」,退役史见 git 提交史)。←原一;原三 +- **封印印记**:封印动作的叙事呈现 = **封印**(玩家可见专名,术语定义见 [术语表](../gdd/术语表.md)「封印」条;动作面见《排雷玩法》)——封印动作落在格上的印记称封印印记;封印色(§4.1,现行封印黑)与格内优先级栈位(§1.2)不变。封印特效(施印瞬间 + 印记常显形态)资产归清单 §4 演出类;封印印记纹理已落位(现行件=`seal_mark.png`,32×32 可选件)、字形占位退役(迭代 9 卡⑬已实现;原待办清单债务条目已完成移除)。←原一;P2;术语批 2026-09-25;封印紫批 2026-09-26;封印黑批 2026-09-28 +- **可挖域接触带微光(交互反馈层,2026-09-25 用户拍板)**:挖掘期,英雄格周界与可挖域(概念见《排雷玩法》动作合法性节;呈现义务见《HUD框架》可挖域指示)的接触面画呼吸微光——正交邻墙=该侧整条边,斜向邻墙=共享角点+向两侧各延伸半条边;光只画英雄格(空间)一侧、不进墙格(与装饰变体零冲突,数字砖心不涉);微光=空间的灯光边界(画法口径)。色相=蓝族浅一档(§4.2 蓝条;与主角蓝框的分层归样张批);呼吸=§2.1 挖掘期唯一在动元素,减动效停格。角点为斜墙与正交墙共享、不逐格归因,逐格指认=悬停金框(呈现交互归《HUD框架》悬停指示域)。←§2.1;§4.2;P1 +- **墙体**:圆角砌块堆叠,禁尖刺装饰——尖刺专属危险语义,装饰禁止伪装逻辑差异。←原三 +- **高墙瓦片体系(2026-09-26 定稿,样张批追认)**:未挖格=**一整块 32×32 自含凸起岩体**(方案甲核心定义);素材语义名——未开格素材=**墙瓦片**,已开格素材=**地面瓦片**(术语 2026-09-26 用户立)。**边界=墨线纪律**:墙瓦片与相邻格分界一律 1px 深墨线、直线贴格边、零锯齿;墨线职责=分隔,不追求与地面融合(Tiny Dungeon 样图实证调研后否决出局——TD 拼块套不进方案甲自含岩体定义,2026-09-25 终局维持方案甲;记录=git 提交 70d2320)。**光照=单一北受光**:北缘=墨线内侧 1px 微亮棱(就地取色提亮,禁宽亮带);西/东缘=墨线后直进纹理、不加修饰;南缘=墨线+立面窄暗条(约 1/3 格高,横层理叠石、禁竖条纹栅栏感)。**凹角**:角点单像素落线;南立面侧凹角加 1px 立面收头——带身与转角竖线同列收尾,禁悬空、禁突出(v9.1/v9.2 两版返工实证)。现行定稿=`game/assets/art/board/terrain_*`(16 唯一墙瓦片+5 地面瓦片变体;词表与查表逻辑见 `高墙瓦片词表(已落码 board_world.gd 查表,文件已退役)`)。**高墙感 D 投影档+整板悬崖(样张 v2 定稿 2026-09-28,用户 2026-09-27 四变体挑档)**:南临空墙格=立面口沿高光 2px `Color8(196,172,132)`+双段暗带(0.16/0.25 格边,黑罩 α0.60/0.82);北邻墙地面格=三段渐隐投影(顶带 0.22 格边,α0.28 起);整板悬崖=板底/右侧 `DEPTH_PX 8px` 暗面+列分隔竖缝+岩层横纹+口沿高光+接触 AO+底线收边+悬浮影(游戏 `board_world.gd` 同款结构件,样张脚本与游戏渲染常量同源——机检 F7 承载)。←原三;状态记录 164;样张 v2 批 2026-09-28 +- **变异格形状通道**(双通道之"形",与色无关):底砖圆角外轮廓不变,砖面内缘绘一圈定宽专属几何纹带(纹带形全作不与他类共用),砖心留白予数字与旗;验收标准=**灰度截图下仍可分类**。种类清单 = 地形变异六类型已定稿(地形变异 GDD;荆棘丛/藤蔓缠绕/岩浆/烬覆/落石/矿尘):**纹带形按轴只分两种**——伤害轴(荆棘丛/岩浆/落石)= 刺/缺排斥形,信息轴(藤蔓缠绕/烬覆/矿尘)= 遮蔽/缠绕形;同轴跨图类型只做色板变体(§4.3 `variant_tint` 槽),不做新纹形。样张批增查:岩浆纹带橙红须与危险红互辨(变异格不是雷,色相邻近混淆风险);信息轴遮蔽呈现禁止侵入数字字形占位区(§1.4 验收 1 延伸)。←原一(纹理位次第 4,让位于数字/小人/旗);原三 -### 3.3 UI 形状语法(方向指导,细则见 §7.1) +### 3.3 UI 形状语法(方向指导,细则迁 §7.1) -**方向指导**:半拟物——容器穿戏服、数据素面;圆角 = 可触摸的物件,直角 = 须精确的信息(总纲「圆角为世,直线为信」的 UI 侧落法)。画法细则(面板/按钮/蜡封/数字色牌/参考分界)成文于 **§7.1**。验收:方向性 ←原二;§2.4 +**方向指导**:半拟物——容器穿戏服、数据素面;圆角=可触摸的物件,直角=须精确的信息(总纲「圆角为世,直线为信」的 UI 侧落法)。画法细则(面板/按钮/蜡封/数字色牌/参考分界)成文于 **§7.1**。←原二;§2.4 ### 3.4 主角形状与对比预算 -显著度 = 完整度:**每格至多一个"完整凸形"**(数字/小人/旗,依 1.2 优先级让位;装饰变体贴砖缘分布、不构成第二完整凸形——§4.6;数字色牌随数字一体,不作独立焦点;类型微光为底效泛光,不作焦点、不抢完整凸形预算),底砖与装饰一律残缺、内凹、低对比而自然后退;危险用刺与缺(排斥形),收益用满与圆(吸引形),同表预算、此消彼长。焦点分配双机制:**完整度**(形状,本条上文)+ **明度带**(§2 明度分层——信息面层恒亮于世界层,焦点天然落信息面);明暗只做层间分离,层内禁明暗竞争(不靠明暗抢焦)。验收:目检〈任一格至多一个完整凸形;信息面层恒亮于世界层〉 ←原二;P1;P3 +显著度=完整度:**每格至多一个"完整凸形"**(数字/小人/旗,依 1.2 优先级让位;装饰变体贴砖缘分布、不构成第二完整凸形——§4.6;数字色牌随数字一体,不作独立焦点;类型微光为底效泛光,不作焦点、不抢完整凸形预算),底砖与装饰一律残缺、内凹、低对比而自然后退;危险用刺与缺(排斥形),收益用满与圆(吸引形),同表预算、此消彼长。焦点分配双机制(2026-09-29 v3.0):**完整度**(形状,本条上文)+**明度带**(§2 明度分层——信息面层恒亮于世界层,焦点天然落信息面);明暗只做层间分离,层内禁明暗竞争(不靠明暗抢焦纪律不变)。←原二;P1;P3 --- ## 4. 色彩系统(Color System) -> 尾注「←」同第 3 节:P1–P4=支柱(P1 推理的掌控感/P2 规划的延迟兑现/P3 风险与收益同源/P4 地牢下探隐喻,语义见 §1.2「锚定」注);原一/二/三=1.2 支撑原则;§x=对应节。本节只定用色;凡涉具体变异、状态种类之处一律「待 GDD 定夺」,只规定"若有,则如何用色"。 +> 尾注「←」同第 3 节:P1–P4=支柱(P1 推理的掌控感/P2 规划的延迟兑现/P3 风险与收益同源/P4 地牢下探隐喻,语义见 §1.2「锚定」注);原一/二/三=1.2 支撑原则;§x=对应节。本节只定用色;凡涉具体变异、状态种类之处一律「待 GDD 定夺」,只规定"若有,则如何用色"。 -**总纲|彩为世,墨为信**:逻辑信息(数字、UI 数据、物件轮廓)一律用**近黑暖墨族**;世界与物件(砖、封印面、金、氛围)用彩。墨管可读,彩管气质。验收:目检〈逻辑信息用近黑暖墨族,世界与物件用彩〉 ←原一/原二;§3.3 +**总纲|彩为世,墨为信**:逻辑信息(数字、UI 数据、物件轮廓)一律用**近黑暖墨族**;世界与物件(砖、封印面、金、氛围)用彩。墨管可读,彩管气质。←原一/原二;§3.3 ### 4.1 主色板 -- **掩尘 `#C9AF83`**|未开格底色,覆尘完整砖:全作的"未知",画面最大沉默面。验收:机检〈未开格底色 = #C9AF83〉 ←原二;§2.1 -- **裸土 `#B39A78`**|已开格底色,碎裂裸露:更收更退;明度分层——已开格(挖出的空间)走**世界底层档**,明度低于未开格墙体一档(与 §8.5 图底分离同轴),L 0.08–0.14 带(更高明度底实测达不到 4.5:1,故取本带;样张已冻结,§1.4)。验收:机检〈已开格底 L 落在 0.08–0.14,对亮墨数字 ≥4.5:1〉 ←原三 -- **数字墨双色制**:1–8 **不做八色分色**,按密度分三档色温(冷 = 稀疏,暖 = 密集);**字形为唯一数值载体**,色温只作密度氛围。验收:目检〈1–8 数字按密度呈三档色温而非八色〉 ←原一/原二;P3 -- **棋盘数字呈现 = 按板自适应三态**:暗纹理板(dungeon 系)= **亮族**三档 1–3 冷亮青白 #D8ECF0/4–5 暖白 #F0E2C8/6–8 金亮 #F8E3A8 直画(判据 = 族最弱成员对 p90 ≥4.5:1 且对均值 ≥2.5:1);双峰纹理板(underdark/deep_underdark 系)= **色牌垫底**(素牌 DIGIT_PLATE + 深墨 #33261B 12:1,纹理免疫——双峰纹理板上任何墨族直画都不可读,渲染帧取证;判据 = 亮深两族皆不过门,深族均值门 3.0);缺盘平色回退态(#B39A78 中明度底)默认深墨直画。选模式管线落码单点 `board_view._digit_ink_mode`(三态:亮直/深直/落牌),机检面 = `terrain_tile_sets_test` 三关呈现模式断言;目视验证面 = board_shot_probe 渲染帧取证(机检≠看得清)。验收:机检〈terrain_tile_sets_test 三关呈现模式断言全绿,board_shot_probe 渲染帧目视复核〉 -- **垫牌与 UI 数据墨 = 深墨族 `#33261B` 系**(L≤0.027,§7.2)。验收:机检〈垫牌与 UI 数据墨取深墨族,L≤0.027〉 -- **八色弃用**:八色必与红/金/蓝/紫赤语义冲突,故弃。验收:方向性 -- **封印黑 `#000000`**|封印宣告色,无色相:玩家的宣告"此格封印"。封印现行 = 无色相黑——紫色与微光蓝族(魔法微光 256° 等)色相冲突,黑色无色相天然避开 300°–345° 禁入带与全部微光色带;未开格墙顶明度 ≈113 对纯黑对比 ≥4.4:1,旗杆米白保可读。旗朱与封印紫已不用于棋盘封印旗,红族棋盘侧只剩危险红;人设背包旗 = 旗朱红矩形旗,与棋盘封印色脱钩(人设侧见 character-design 背包旗条)。验收:机检〈封印宣告色 = #000000,未开格墙顶对其对比 ≥4.4:1〉 ←原一;P2 -- **危险红 `#A8321F`**|危险色:雷 = 被惊动的巢穴,兑现档红,更深更沉。验收:机检〈危险语义色 = #A8321F〉 ←原三;P2 -- **奖励金 `#F0A828`**|已兑现的收益:GP/宝箱/蜡封,全作唯一亮金。验收:机检〈收益语义色 = #F0A828〉 ←P2;§2.3/2.4 -- **小人蓝 `#2E86DE`**|小人主色:棋盘唯一"己方",与光标准线同族。验收:机检〈己方语义色 = #2E86DE〉 ←P1;§2.1 -- **数字色牌 `#F5EAD3`**|数字底具,**后备手段**:素色暖纸白直角小牌,仅复杂格底(物件纹样/变异纹带/微光叠加致直画对比不足)垫于数字正下方;素面无纹理、不随图类型/装饰变体变。验收:机检〈色牌 L≥0.70,仅复杂格底垫用〉 ←原二 -- **收益金亮档 `#E0B860`(GOLD_BRIGHT)**|UI 徽标可读性亮档 token:敌顺位徽标①②③与重甲徽标对舞台底 6.47:1(GOLD_DEEP 3.26:1 不达 §7.2 带,故另立亮档);金族亮档,与奖励金 #F0A828 同族不同岗——奖励金 = 已兑现收益语义(§4.2 金条),本 token 只承载徽标可读性、不占收益语义。GOLD_DEEP/HERO_BLUE 常量本尊不动(standee 意图判据等语义岗现行使用)。验收:机检〈GOLD_BRIGHT 对舞台底 ≥4.5:1〉 -- **小人蓝亮档 `#7EBAF0`(HERO_BLUE_BRIGHT)**|魔法护甲徽标对舞台底 5.88:1(HERO_BLUE 3.23:1 不达带,故另立亮档);蓝族亮档,与小人蓝 #2E86DE 同族不同岗——本 token 只承载徽标可读性,不占用「蓝只说己方」的语义。验收:机检〈HERO_BLUE_BRIGHT 对舞台底 ≥4.5:1〉 -- **图标槽柔和档 `#806A4E`(SLOT_BACKDROP_SOFT)**|BCard 图标槽底:对米色卡面 PANEL_DATA 3.69:1(深档 SLOT_BACKDROP #3E362C 8.53:1 过强、像硬贴补丁,故槽底另立柔和档),槽内白图标 5.14:1,满足 ≥3:1 图形参考线硬约束;仅 BCard 图标槽换档,B 区状态槽仍用深档 SLOT_BACKDROP。验收:机检〈槽内白图标对槽底 ≥3:1〉 +- **掩尘 `#C9AF83`**|未开格底色,覆尘完整砖:全作的"未知",画面最大沉默面。←原二;§2.1 +- **裸土 `#B39A78`**|已开格底色,碎裂裸露:更收更退;2026-09-29 v3.0 明度分层——已开格(挖出的空间)走**世界底层档**,明度低于未开格墙体一档(与 §8.5 图底分离同轴),候选 L 0.08–0.14 带(样张 v3 实测修正:L0.20+ 底配纯白也仅 3.4:1,数学不可达 4.5:1),样张 v3 冻结。←原三 +- **数字墨双色制(2026-09-29 v3.0)**|1–8 **不做八色分色**,按密度分三档色温(冷=稀疏,暖=密集);**字形为唯一数值载体**,色温只作密度氛围。①**棋盘数字呈现=按板自适应三态**(2026-10-01 v3.4 修订——v3.3 深墨族方案经渲染帧取证证伪:underdark 系地板近黑斑与近白筋并存〔双峰纹理〕,深墨直画融黑斑同样隐形,任何墨族直画不可救):暗纹理板(dungeon 系)=**亮族**三档 1–3 冷亮青白 #D8ECF0/4–5 暖白 #F0E2C8/6–8 金亮 #F8E3A8 直画(样张 v3 冻结原样;判据=族最弱成员对 p90 ≥4.5:1 且对均值 ≥2.5:1);双峰纹理板(underdark/deep_underdark 系)=**色牌垫底**(素牌 DIGIT_PLATE + 深墨 #33261B 12:1,纹理免疫——v2 色牌形制在双峰板复役;判据=亮深两族皆不过门,深族均值门 3.0)。选模式管线落码单点 `board_view._digit_ink_mode`(三态:亮直/深直/落牌),机检面=`terrain_tile_sets_test` 三关呈现模式断言;**目视验证面=board_shot_probe 渲染帧取证**(机检≠看得清,2026-10-01 用户教训);缺盘平色回退态(#B39A78 中明度底)默认深墨直画;②**垫牌与 UI 数据墨=深墨族 `#33261B` 系**(L≤0.027 不变,§7.2)。八色必与红金蓝紫赤语义冲突,故弃。←原一/原二;P3 +- **封印黑 `#000000`**|封印(插旗)宣告色,无色相:玩家的宣告"此格封印"(色史:旗朱 `#C63D22` →封印紫 `#7C3AA8` 2026-09-26→**封印黑 2026-09-28 用户拍板**——紫色与微光蓝族〔魔法微光 256° 等〕色相冲突,黑色无色相天然避开 300°–345° 禁入带与全部微光色带;未开格墙顶明度 ≈113 对纯黑对比 ≥4.4:1,旗杆米白保可读)。旗朱随封印紫批退役〔棋盘侧〕,红族棋盘侧自此只剩危险红(2026-09-28 起人设背包旗=旗朱红矩形旗——旗色脱钩拍板,人设侧详见 character-design 背包旗条)。←原一;P2;封印紫批 2026-09-26;封印黑批 2026-09-28 +- **危险红 `#A8321F`**|危险色:雷=被惊动的巢穴,兑现档红,更深更沉(色值不变)。←原三;P2 +- **奖励金 `#F0A828`**|已兑现的收益:GP/宝箱/蜡封,全作唯一亮金。←P2;§2.3/2.4 +- **小人蓝 `#2E86DE`**|小人主色:棋盘唯一"己方",与光标准线同族(样张 v2 定稿 2026-09-28)。←P1;§2.1 +- **数字色牌 `#F5EAD3`**|数字底具,2026-09-29 v3.0 降级为**后备手段**:素色暖纸白直角小牌,仅复杂格底(物件纹样/变异纹带/微光叠加致直画对比不足)垫于数字正下方;素面无纹理、不随图类型/装饰变体变。←原二 +- **收益金亮档 `#E0B860`(GOLD_BRIGHT)**|UI 徽标可读性亮档 token(2026-10-14 迭代 26 反哺③登记转正):敌顺位徽标①②③与重甲徽标对舞台底 6.47:1(旧 GOLD_DEEP 3.26:1 越 §7.2 带);金族亮档,与奖励金 #F0A828 同族不同岗——奖励金=已兑现收益语义(§4.2 金条),本 token 只承载徽标可读性、不占收益语义。语义岗冲撞已裁:GOLD_DEEP/HERO_BLUE 常量本尊不动(standee 意图判据等语义岗在役) +- **小人蓝亮档 `#7EBAF0`(HERO_BLUE_BRIGHT)**|魔法护甲徽标对舞台底 5.88:1(旧 HERO_BLUE 3.23:1 越带);蓝族亮档,与小人蓝 #2E86DE 同族不同岗——本 token 只承载徽标可读性,「蓝只说己方」语义不变 +- **图标槽柔和档 `#806A4E`(SLOT_BACKDROP_SOFT)**|BCard 图标槽底(2026-10-14 迭代 26 T5):对米色卡面 PANEL_DATA 3.69:1(旧深档 SLOT_BACKDROP #3E362C 8.53:1——过强像硬贴补丁),槽内白图标 5.14:1 ≥3:1 图形参考线硬约束;仅 BCard 图标槽换档,B 区状态槽仍用深档 SLOT_BACKDROP -**对比硬指标(可测)**:数字可读性由**格底明度分档**承载——棋盘数字亮墨 **L≥0.75** 对**已开格底 L 0.08–0.14** ⇒ 直画对比 **≥4.5:1 恒成立**(WCAG AA,样张逐组合抽测冻结);**色牌后备**:色牌素色恒定 **L≥0.70**、牌上数字深墨 **L≤0.027** ⇒ 任意牌墨组合 ≥4.5:1(实测样例:#33261B/#F5EAD3 = 12.3:1)。未开格底槽(含全部图类型)相对亮度 **L≥0.30**(墙顶面受光档)、HSV 饱和 **≤0.55**、避 Boss 禁入色相带;已开格底走世界底层档(见上)。物件识别不依赖格底(§5.1 轮廓通道),旗面、金色不作识别依赖。验收:机检〈亮墨对已开格底 ≥4.5:1;未开格底 L≥0.30、饱和 ≤0.55;任意牌墨组合 ≥4.5:1〉 ←原二;地形变异 AC-9 +**对比硬指标(可测;2026-09-29 v3.0 改写)**:数字可读性由**格底明度分档**承载——棋盘数字亮墨 **L≥0.55**(候选)对**已开格底 L 0.20–0.32**(候选)⇒ 直画对比 **≥4.5:1 恒成立**(WCAG AA,样张 v3 逐组合抽测冻结);**色牌后备**:色牌素色恒定 **L≥0.70**、牌上数字深墨 **L≤0.027** ⇒ 任意牌墨组合 ≥4.5:1(实测样例:#33261B/#F5EAD3 = 12.3:1)。未开格底槽(含全部图类型)相对亮度 **L≥0.30**(墙顶面受光档)、HSV 饱和 **≤0.55**、避 Boss 禁入色相带;已开格底走世界底层档(上见)。物件识别不依赖格底(§5.1 轮廓通道),旗面、金色不作识别依赖。←原二;地形变异 AC-9 -**色板增量纪律(数值状态 = 待定现用)**:全作色板硬上限 **48 色**(防色板无限膨胀的封口条款;额度余量以色板检查器实报为准);新色入板须同时过两道门:①§4.2 语义检查(不占语义色段/禁入带);②**与既有任一色的 RGB 三通道差 ≥8/255**——反例实证:#3A2E22 ↔ #362B20 三通道差均 <5,肉眼不可辨,同板共存只增噪。不过门即触发「换色合并」(把意图色并进最近既有色),**禁加色绕行**;加色义务同时登记于本表修订。**「待定现用」态释义**:数值**现行即执法**(色板检查器按此拦,§8.3)——非候选(不存在"等冻结"空窗)、非死规(后续需求引入时可变);调整走本节修订并登记,改值 = 修订不是违约。验收:机检〈色板总数 ≤48;新色入板过两道门〉 +**色板增量纪律(2026-10-06 P2 对账批新增;数值状态 = **待定现用**)**:全作色板硬上限 **48 色**(现行 ≈45,余量 3——2026-10-14 迭代 26 新入 GOLD_BRIGHT/HERO_BLUE_BRIGHT/SLOT_BACKDROP_SOFT 三枚 UI 亮档/柔和档 token,均已过 §4.2 语义检查〔不占语义色段〕与 RGB 三通道差 ≥8/255 两道门;审计"色板逼近失控线"的封口条款);新色入板须同时过两道门:①§4.2 语义检查(不占语义色段/禁入带);②**与既有任一色的 RGB 三通道差 ≥8/255**——反例即审计实证:#3A2E22 ↔ #362B20 三通道差均 <5,肉眼不可辨,同板共存只增噪。不过门即触发「换色合并」(把意图色并进最近既有色),**禁加色绕行**;加色义务顺带登记于本表修订。**「待定现用」态释义(2026-10-06 用户立第三态)**:数值**现行即执法**(色板检查器按此拦,§8.3)——非候选(不存在"等冻结"空窗)、非死规(后续需求引入时可变);调整走本节修订并登记,改值 = 修订不是违约。 -- **色板分层**:色板分两层管理——① **语义/UI token 板**:全作共享,硬上限 **48 色**,管 UI 语义色、墨色、材质色,跨屏恒定;② **场景环境子板**:**每屏一套,≤32 色,允许跨屏不共享**(环境色 = 天空、地面、植被、石材、火焰等随场景走的颜色)。新增环境色仍须过两道门(§4.2 语义检查 + 与既有任一色 RGB 三通道差 ≥8/255),但**不占用 48 token 额度**;不过门照旧走「换色合并」,**禁加色绕行**。单张 PNG 的像素域色数上限 **≤32 色**另由 `standee_asset_check` 执法(按单图像素域取值方式计数,与本表 token 计数互不干扰)。额度不足时向作者报告,由作者决定压缩或合并既有色。验收:机检〈token 板 ≤48;每屏环境子板 ≤32〉 +- **色板分层(2026-10-22 作者裁)**:色板分两层管理——① **语义/UI token 板**:全作共享,硬上限 **48 色**,管 UI 语义色、墨色、材质色,跨屏恒定;② **场景环境子板**:**每屏一套,≤32 色,允许跨屏不共享**(环境色=天空、地面、植被、石材、火焰等随场景走的颜色)。新增环境色仍须过两道门(§4.2 语义检查 + 与既有任一色 RGB 三通道差 ≥8/255),但**不占用 48 token 额度**;不过门照旧走「换色合并」,**禁加色绕行**。单张 PNG 的像素域色数上限 **≤32 色**另由 `standee_asset_check` 执法(像素域口径,与本表 token 口径互不干扰)。额度不足时呈报作者,由作者决定压缩或合并既有色。 ### 4.2 语义色彩规则 -> **适用域**:本节「X 只说 Y」约束**信息层**(格内信息/UI 数据/预告/语义标识)与**语义物件**(GP=收益金、血条=危险红等);**纯环境氛围**(背景/装饰变体/远景/非语义物件)不受色相独占约束,改受 §3.4 焦点预算与低饱和后退约束(不抢读即可用色)。禁入带 300°–345°:信息层与语义物件禁入不变;环境层可入但限低饱和(高饱和紫赤仍 Boss 独占,§2.6)。验收:目检〈信息层与语义物件不落禁入带、不占语义独占色相;环境层用色低饱和〉 +> **适用域(2026-09-29 v3.0 立)**:本节「X 只说 Y」约束**信息层**(格内信息/UI 数据/预告/语义标识)与**语义物件**(GP=收益金、血条=危险红等);**纯环境氛围**(背景/装饰变体/远景/非语义物件)不受色相独占约束,改受 §3.4 焦点预算与低饱和后退约束(不抢读即可用色)。禁入带 300°–345°:信息层与语义物件禁入不变;环境层可入但限低饱和(高饱和紫赤仍 Boss 独占,§2.6)。 -- **红只说危险**:危险红 = 兑现档,红族单档。禁(信息层域):红禁止用于奖励、装饰性标识与常规 UI(微光群伤组除外——见「类型微光色带」条,其色相刻意错开语义红)。验收:目检〈信息层域红色仅出现在危险语义与微光群伤组〉 ←原三;P2;§2.1 -- **金只说收益与兑现**(奖励、结算、胜利)。禁(信息层与语义物件域):金禁止作危险障碍色、不上格底、不作日常 UI 强调(防通胀;微光通用增益组的芥末黄属「类型微光色带」条色带,非收益语义金)。验收:目检〈信息层与语义物件域金色仅承载收益与兑现〉 ←P2;P3;§2.2–2.4 -- **蓝只说己方与冷静信息**(小人、光标、工具信息——含可挖域接触带呼吸微光,蓝族浅一档 `#D6ECFF`)。禁(信息层域):蓝禁止表示敌方危险;暮蓝 `#5A6C8C` 为失败独占(§2.5),只作全屏氛围,不作物件色。验收:目检〈信息层域蓝色仅承载己方与冷静信息;敌方危险无蓝色〉 ←P1;§2.5 -- **紫赤只说 Boss 与精英** `#8F2F6A`(色相 323°):郑重来自色相独占,不来自压暗。禁:**HSV 色相 300°–345° 为信息层与语义物件禁入带**——UI、粒子、类型微光、语义物件及图类型色板信息面禁止落入;环境层(背景/装饰)可入但限低饱和,高饱和紫赤仍 Boss 独占。验收:机检〈信息层与语义物件色值色相不落 300°–345°;高饱和紫赤仅 Boss 战〉 ←§2.6;原三 -- **绿只说自然生机**(环境植被;恢复类状态若有则用绿——待增益状态定夺)。**例外:类型微光治疗组**(见下条「类型微光色带」)。禁(信息层域):绿禁止承载逻辑状态(微光色带除外),禁止与红构成唯一区分对。验收:目检〈信息层域绿色仅微光治疗组;绿红不构成唯一区分对〉 ←原三 -- **类型微光色带 = 格面类型先验专用逻辑色带**(未开格类型预读的色彩通道;概念定义、可见范围与生命周期见《HUD框架》「未开格类型微光」节):未开格类型微光的信息色彩按以下五组执行——**通用增益组**(attack/defense/weaken/action/gp 五族)芥末黄 `#D6C13A`(≈52°)、**治疗组**(heal)黄绿 `#5CB83A`(≈104°)、**穿刺组**(pierce)青 `#2E9E9E`(≈180°)、**魔法组**(magic)紫罗兰 `#7B5BD6`(≈256°)、**群伤组**(blast)绯红 `#FF2D55`(≈349°,刻意避开危险红色相段与 300°–345° 禁入带)。验收:机检〈类型微光五组色值与本表一致〉 -- **微光呈现参数**:内缘带状泛光 α170、带宽 cell÷8(亮度不压格底明度档,§2 微光例外);组间互辨以抽检核对。验收:机检〈微光泛光 α=170、带宽=cell÷8〉 -- **微光分域纪律与双码**:微光色相只承载类型先验,不迁移复用为物件/阵营/UI 语义色;微光 = **色相+明度双码**——明度梯度承载分组(组间五色相、组内不细分:通用增益组五族同色相、不承诺组内分型;§2 微光明度例外:梯度禁止压破格底明度档与三态分类);色弱玩家的类型主通道 = 微光双码(组粒度:组间五色相 + 各组一档明度互辨)。验收:目检〈五组微光两两互辨,明度梯度不压破格底明度档与三态分类〉 ←§4.6.3 -- **金/红兼作极性色相槽(§4.6)**:极性(概念定义与符号矩阵见《地图战斗资源》极性提案)的视觉取色只在这两族间——有利兑付 = 金族,有害兑付 = 红族,不新增语义色。验收:目检〈极性视觉取色仅金/红两族〉 ←§4.6;原二 +- **红只说危险**:危险红=兑现档(旗朱预告档随封印紫批退役,红族单档)。禁(信息层域):红禁止用于奖励、装饰性标识与常规 UI(微光群伤组除外——见「类型微光色带」条,其色相刻意错开语义红)。←原三;P2;§2.1;封印紫批 2026-09-26 +- **金只说收益与兑现**(奖励、结算、胜利)。禁(信息层与语义物件域):金禁止作危险障碍色、不上格底、不作日常 UI 强调(防通胀;微光通用增益组的芥末黄属「类型微光色带」条色带,非收益语义金)。←P2;P3;§2.2–2.4 +- **蓝只说己方与冷静信息**(小人、光标、工具信息——含可挖域接触带呼吸微光,蓝族浅一档 `#D6ECFF`〔样张 v2 定稿 2026-09-28〕,2026-09-25 拍板)。禁(信息层域):蓝禁止表示敌方危险;暮蓝 `#5A6C8C` 为失败独占(§2.5),只作全屏氛围,不作物件色。←P1;§2.5 +- **紫赤只说 Boss 与精英** `#8F2F6A`(色相 323°):郑重来自色相独占,不来自压暗。禁:**HSV 色相 300°–345° 为信息层与语义物件禁入带**——UI、粒子、类型微光、语义物件及图类型色板信息面禁止落入;环境层(背景/装饰)可入但限低饱和,高饱和紫赤仍 Boss 独占(2026-09-29 v3.0 域限定)。←§2.6;原三 +- **绿只说自然生机**(环境植被;恢复类状态若有则用绿——待增益状态定夺)。**例外:类型微光治疗组**(见下条「类型微光色带」)。禁(信息层域):绿禁止承载逻辑状态(微光色带除外),禁止与红构成唯一区分对。←原三;T1 裁决 2026-09-22 +- **类型微光色带 = 格面类型先验专用逻辑色带**(未开格类型预读的色彩通道;概念定义、可见范围与生命周期见《HUD框架》「未开格类型微光」节):未开格类型微光的信息色彩按以下五组执行——**通用增益组**(attack/defense/weaken/action/gp 五族)芥末黄 `#D6C13A`(≈52°)、**治疗组**(heal)黄绿 `#5CB83A`(≈104°)、**穿刺组**(pierce)青 `#2E9E9E`(≈180°)、**魔法组**(magic)紫罗兰 `#7B5BD6`(≈256°)、**群伤组**(blast)绯红 `#FF2D55`(≈349°,刻意避开危险红色相段与 300°–345° 禁入带)——**样张 v2 定稿(2026-09-28)**,组间互辨抽检随机检报告;v2 呈现参数=内缘带状泛光 α170/带宽 cell÷8(亮度不压格底明度档,§2 微光例外)。分域纪律:微光色相只承载类型先验,不迁移复用为物件/阵营/UI 语义色;微光 = **色相+明度双码**(2026-09-28 角嵌退役批起明度梯度承载分组(组间五色相、组内不细分——通用增益组五族同色相、不再承诺组内分型;§2 微光明度例外:梯度禁止压破格底明度档/三态分类);色弱玩家的类型主通道 = 微光双码(组粒度:组间五色相 + 各组一档明度互辨)。←T1 裁决 2026-09-22;角嵌退役批 2026-09-28;§4.6.3 +- **金/红兼作极性色相槽(§4.6)**:极性(概念定义与符号矩阵见《地图战斗资源》极性提案)的视觉取色只在这两族间——有利兑付 = 金族,有害兑付 = 红族,不新增语义色。←§4.6;原二 ### 4.3 图类型色板换皮规则(board_type) -**换皮只换四槽,永不变信**。模板按棋盘生成产物 `board_type` 标签直接消费(键名与词表事实源 = 棋盘生成 GDD 与 `board_gen.tres`:dungeon / underdark / deep_underdark);清单与词表待 关卡节点地图/地形变异/地图战斗资源 定稿后归其所有,本节只定模板。验收:机检〈换皮仅动下列四槽,其余槽位与不变槽恒定〉 +**换皮只换四槽,永不变信**。模板按棋盘生成产物 `board_type` 标签直接消费(键名与词表事实源 = 棋盘生成 GDD 与 `board_gen.tres`:dungeon / underdark / deep_underdark);清单与词表 owner 待 关卡节点地图/地形变异/地图战斗资源,本节只定模板: -- `tile_closed` 未开底:L 与饱和同 §4.1 硬指标,避禁入色相带。验收:机检〈各图类型 tile_closed 底色 L 与饱和落 §4.1 未开格底带〉 -- `tile_open` 已开底:世界底层档(L 带见 §4.1)、饱和 ≤0.55,避禁入色相带。验收:机检〈各图类型 tile_open 底色 L 落 §4.1 已开格底带〉 -- `wall_ambient` 墙雾氛围 tint:可带主题色相,L≥0.30。验收:机检〈wall_ambient tint L≥0.30〉 -- `variant_tint` 变异纹带染槽:只改色相不改纹形(纹带形按轴两分,见 §3.2 变异格形状通道;种类 = 地形变异六类型已定稿)。验收:目检〈同纹带形跨图类型仅色相不同〉 +- `tile_closed` 未开底:L 与饱和同 4.1 硬指标(墙顶面受光档 L≥0.30、饱和 ≤0.55),避禁入色相带 +- `tile_open` 已开底:世界底层档(2026-09-29 v3.0,候选 L 0.08–0.14,样张 v3 冻结)、饱和 ≤0.55,避禁入色相带 +- `wall_ambient` 墙雾氛围 tint:可带主题色相,L≥0.30 +- `variant_tint` 变异纹带染槽:只改色相不改纹形(纹带形按轴两分,见 §3.2 变异格形状通道;种类 = 地形变异六类型已定稿) -**不变槽**:棋盘数字墨亮族三档、数字色牌(后备件)、封印黑、危险红、奖励金、小人蓝、紫赤、绿、类型微光五组——**信息与语义层全图类型恒定**;换挡动色相饱和,格底两档明度关系(墙亮/空间暗)全图类型恒定(§2 明度分层)。新增色板须重跑 §1.4 格子样张(灰度可辨+对比抽测)。验收:机检〈换皮前后不变槽色值逐项一致〉 ←原三/原二;地形变异 AC-9 +**不变槽**:棋盘数字墨亮族三档、数字色牌(后备件)、封印黑、危险红、奖励金、小人蓝、紫赤、绿、类型微光五组——**信息与语义层全图类型恒定**;换挡动色相饱和,格底两档明度关系(墙亮/空间暗)全图类型恒定(§2 明度分层)。新增色板须重跑 §1.4 格子样张(灰度可辨+对比抽测)。←原三/原二;地形变异 AC-9 -### 4.4 UI 色板(方向指导,细则迁 §7.2) +### 4.4 UI 色板(方向指导,细则迁 §7.2) -**方向指导**:数据素面的墨来自世界的墨——UI 数字/进度/准线一律主墨 `#33261B` 系;容器按场景穿材质色(营地全作最暖、战斗中性石);UI 不新增语义色,强调沿用 §4.2。色板细则成文于 **§7.2**。验收:方向性 ←原一;§2.8 +**方向指导**:数据素面的墨来自世界的墨——UI 数字/进度/准线一律主墨 `#33261B` 系;容器按场景穿材质色(营地全作最暖、战斗中性石);UI 不新增语义色,强调沿用 §4.2。色板细则成文于 **§7.2**。←原一;§2.8 ### 4.5 色弱安全清单 -依赖色相的语义对逐对点名(冗余通道:形/图标/位置/运动): +依赖色相的语义对逐对点名(冗余通道:形/图标/位置/运动): -1. 已开 vs 未开:§3.2 完整/碎裂形承载,无需另加。验收:目检〈色弱模拟下两态以完整度可辨〉 -2. 已开 vs 变异格:纹带形状通道,灰度截图可辨,无需另加。验收:目检〈灰度截图下变异格可辨〉 -3. 封印印记 vs 巢穴(危险格):印记形物件对砖面巢穴纹,无需另加。验收:目检〈色弱模拟下印记与巢穴纹可辨〉 -4. 奖励金 vs 危险红(色弱下皆偏褐):§3.1 道具点两类剪影 + §3.4 满圆对刺缺 + 金粒运动,无需另加。验收:目检〈色弱模拟下奖励金与危险红可辨〉 -5. Boss 紫赤 vs 其他敌:§5.1 形状独占,无需另加。验收:目检〈色弱模拟下 Boss 可辨〉 -6. 小人蓝 vs 青墨数字:明度悬殊 + 小人轮廓与底盘,无需另加。验收:目检〈色弱模拟下小人与数字可辨〉 -7. **UI 红/金强调对:纹带不管面板——需补图标+文案冗余,且破坏性确认禁止与常规确认同位(位置通道)**。验收:目检〈UI 红/金强调位有图标+文案冗余,破坏性确认与常规确认不同位〉 ←原三 -8. 失败暮蓝 vs 小人蓝:状态互斥,不承载区分,无需冗余。验收:方向性 -9. 图标键族(§4.6):图标身份全靠纹样形状通道(一键一纹样,色相不承载身份);未开格类型预读 = **类型微光单通道**(五组色带,§4.2;色相+明度双码——明度档间互辨承载色弱场景);极性金/红对由框形满/缺冗余——色相全失效时图标仍可逐一辨认与定极性。验收:目检〈色相全失效时图标仍可逐一辨认与定极性〉 ←原三;增益状态×4② +1. 已开 vs 未开:§3.2 完整/碎裂形承载,无需另加 +2. 已开 vs 变异格:纹带形状通道,灰度截图可辨,无需另加 +3. 封印印记 vs 巢穴(危险格):印记形物件对砖面巢穴纹,无需另加 +4. 奖励金 vs 危险红(色弱下皆偏褐):§3.1 道具点两类剪影 + §3.4 满圆对刺缺 + 金粒运动,无需另加 +5. Boss 紫赤 vs 其他敌:§5.1 形状独占,无需另加 +6. 小人蓝 vs 青墨数字:明度悬殊 + 小人轮廓与底盘,无需另加 +7. **UI 红/金强调对:纹带不管面板——需补图标+文案冗余,且破坏性确认禁止与常规确认同位(位置通道)** ←原三 +8. 失败暮蓝 vs 小人蓝:状态互斥,不承载区分,无需冗余 +9. 图标键族(§4.6):图标身份全靠纹样形状通道(一键一纹样,色相不承载身份);未开格类型预读 = **类型微光单通道**(五组色带,§4.2;2026-09-28 角嵌退役批起色相+明度双码——明度档间互辨承载色弱场景);极性金/红对由框形满/缺冗余——色相全失效时图标仍可逐一辨认与定极性。←原三;增益状态×4②;T1 裁决 2026-09-22 -### 4.6 图标键契约(Icon Key Contract,键与通道分配已冻结) +### 4.6 图标键契约(Icon Key Contract,2026-09-13 冻结) -> **白话**:图标键 = "哪个数据行配哪枚图标"的固定对照表——效果、奖励、格面类型、敌种各自的数据表主键(effect_id / reward_id / cell_type / enemy_id)直接作图标键,一枚键配一枚专属纹样。**本节冻结的是键与通道的分配,不冻结终稿美术**:纹样造型、具体色值、动画帧归格子样张(§1.4)与资产规格文档定稿。先行冻结是为了 HUD框架/战斗表现开工不必等美术终稿;映射一经冻结**全局恒定、跨 run 禁重掷**(enemy_id / reward_id 键恒定同款纪律),增键走本节修订,禁运行时换图。验收:机检〈各键族图标与数据表主键一一对应、跨 run 不换图〉 +> **白话**:图标键 = "哪个数据行配哪枚图标"的固定对照表——效果、奖励、格面类型、敌种各自的数据表主键(effect_id / reward_id / cell_type / enemy_id)直接作图标键,一枚键配一枚专属纹样。**本节冻结的是键与通道的分配,不冻结终稿美术**:纹样造型、具体色值、动画帧归格子样张(§1.4)与 asset-spec 批。先行冻结是为了 HUD框架/战斗表现开工不必等美术终稿;映射一经冻结**全局恒定、跨 run 禁重掷**(enemy_id / reward_id 键恒定同款纪律),增键走本节修订,禁运行时换图。 > -> 承接义务:增益状态×4②(effect_id→icon 映射键契约)· 地图战斗资源×5①(奖励身份恒定可读)· 敌人系统×4①②(敌种图像线索清单成文 + 全局恒定)· 极性提案 §3(类型图标/极性视觉语言)。引用方:HUD框架、战斗表现、教学引导、地图战斗资源库存呈现。 +> 承接义务:增益状态×4②(effect_id→icon 映射键契约)· 地图战斗资源×5①(奖励身份恒定可读)· 敌人系统×4①②(敌种图像线索清单成文 + 全局恒定)· 极性提案 §3(类型图标/极性视觉语言)。引用方:HUD框架、战斗表现、教学引导、地图战斗资源库存呈现。 -**通道分配(全键族通用,双通道之"形+色")**: +**通道分配(全键族通用,双通道之"形+色")**: -- **身份 = 形状纹样键**:一键一纹样,全作不复用;**色相不承载身份**(七效果不七色——防色相爆炸,亦防色弱失效)。验收:目检〈同族键共图、异族键纹样可辨〉 -- **极性 = 色相槽 + 框形**:有利兑付 = 金族(§4.2"金只说收益"),有害兑付 = 红族("红只说危险")——零新增语义色;框形冗余:满框对缺框(§3.4"收益用满与圆,危险用刺与缺")。极性判定的数据源 = 战斗结算 SettlementTable 效果语义(非 magnitude 符号——增益状态×4③),本契约只锁视觉通道、不锁数据映射。验收:目检〈正负极性以金满框/红缺框可辨〉 -- **格面身份预读 = 类型微光;徽章 = 附属小图标**:格面的类型身份预读画在格上——类型走类型微光(§3.2/§4.2,底效泛光不入遮挡栈),禁止构成格内第二焦点(§3.4);徽章小图标族用于库存条与结算明细。角嵌位已不存在。验收:目检〈格面类型预读仅微光且非第二焦点;徽章仅出现于库存条与结算明细〉 -- **未开格预读 = 类型微光单通道**(色相+明度双码):微光五组色带见 §4.2「类型微光色带」。只表达类型、不呈现极性、不泄雷/安(信息定义见《HUD框架》「未开格类型微光」节;约束出处见《地图战斗资源》极性提案 R3);兑付面与库存徽章可用语义族色,终稿归格子样张(§1.4)。验收:目检〈未开格类型预读仅微光,无极性与雷/安信息〉 +- **身份 = 形状纹样键**:一键一纹样,全作不复用;**色相不承载身份**(七效果不七色——防色相爆炸,亦防色弱失效)。 +- **极性 = 色相槽 + 框形**:有利兑付 = 金族(§4.2"金只说收益"),有害兑付 = 红族("红只说危险")——零新增语义色;框形冗余:满框对缺框(§3.4"收益用满与圆,危险用刺与缺")。极性判定的数据源 = 战斗结算 SettlementTable 效果语义(非 magnitude 符号——增益状态×4③),本契约只锁视觉通道、不锁数据映射。 +- **格面身份预读 = 类型微光;徽章 = 附属小图标**:格面的类型身份预读画在格上——类型走类型微光(§3.2/§4.2,底效泛光不入遮挡栈),禁止构成格内第二焦点(§3.4);徽章小图标族用于库存条与结算明细。〔角嵌位随 2026-09-28 退役批消失〕 +- **未开格预读 = 类型微光单通道**(色相+明度双码;2026-09-22 T1 裁决立双通道→2026-09-28 角嵌退役批改单通道):微光五组色带见 §4.2「类型微光色带」。只表达类型、不呈现极性、不泄雷/安(信息口径见《HUD框架》「未开格类型微光」节;约束出处见《地图战斗资源》极性提案 R3);兑付面与库存徽章可用语义族色,终稿归样张批。 -#### 4.6.1 效果图标键(九行 = 增益状态现行实态;键集事实源归增益状态 GDD 与 `status_effects.tres`,本表键名随其修订自动跟随) +#### 4.6.1 效果图标键(九行 = 增益状态现行实态,2026-09-24 纹样系统定稿;键集事实源归增益状态 GDD 与 `status_effects.tres`,本表键名随其修订自动跟随) -| 键(effect_id) | display_name | 纹样描述 | 极性 | +| 键(effect_id) | display_name | 纹样描述(2026-09-24 用户定稿) | 极性 | |---|---|---|---| | attack_up | 增伤 | 白色长剑为主体(大,斜向、剑尖朝右上),右下角一枚小绿色向上箭头 | 有利 | | ac_up | 盾牌加护 | 正面白色盾牌为主体(大),右下角一枚小绿色向上箭头 | 有利 | @@ -280,24 +274,24 @@ | empower | 蓄力 | 同 attack_up 白剑主体,右下角两枚小绿色向上箭头(叠放:攻击提升两档) | 有害 | | fortify | 硬化 | 与 ac_up 完全同图(同为防御提升族:防御提升一档) | 有害 | -- **纹样系统**:主体符号表达属性轴——白剑=攻击轴、白盾=防御轴、白眼=警觉特例;主体右下角的小箭头表达方向与幅度——绿↑=提升一档、红↓=削弱一档、双绿↑=提升两档(蓄力)。同族键共用同图:attack_up=enrage、weaken=sapped、ac_up=fortify;图标不区分归属方,主角/敌方由 HUD 状态槽挂载位置表达。极性列保留为数据语义。验收:目检〈九行纹样按表落图——同族键同图、异族键可辨、箭头数表幅度〉 +- **纹样系统(2026-09-24 用户定稿)**:主体符号表达属性轴——白剑=攻击轴、白盾=防御轴、白眼=警觉特例;主体右下角的小箭头表达方向与幅度——绿↑=提升一档、红↓=削弱一档、双绿↑=提升两档(蓄力)。同族键共用同图:attack_up=enrage、weaken=sapped、ac_up=fortify;图标不区分归属方,主角/敌方由 HUD 状态槽挂载位置表达。极性列保留为数据语义;原九行「语义锚」文字描述(向上的力/护盾壁垒/戒备甲亮等)随本批退役。 -- 呈现位 = HUD 状态槽(增益状态×4①);堆叠层数呈现归 HUD框架/战斗表现,不入本契约。验收:目检〈效果图标呈现于 HUD 状态槽〉 +- 呈现位 = HUD 状态槽(增益状态×4①);堆叠层数呈现归 HUD框架/战斗表现,不入本契约。 -#### 4.6.2 奖励徽章键(九型字典正负 18 行 + 清图槽 1 行 = 地图战斗资源现行实态;键集事实源归地图战斗资源 GDD 与 `reward.tres`,本表键名随其修订自动跟随) +#### 4.6.2 奖励徽章键(九型字典正负 18 行 + 清图槽 1 行 = 地图战斗资源现行实态,2026-09-22 核;键集事实源归地图战斗资源 GDD 与 `reward.tres`,本表键名随其修订自动跟随) | 键(reward_id) | 型/极性 | 徽章语义锚 | 极性视觉 | |---|---|---|---| -| attack_up | attack 正向 | 增伤增益(与 §4.6.1 同语义域共族、键分立) | 金族满框 | +| attack_up | attack 正向 | 增伤增益(与 §4.6.1 同语义域共族、键分立) | 金族满框 | | enrage | attack 负向 | 敌方增伤 | 红族缺框 | | ac_up | defense 正向 | 盾牌加护 | 金族满框 | -| off_guard | defense 负向 | 主角失衡(与 §4.6.1 共族、键分立) | 红族缺框 | +| off_guard | defense 负向 | 主角失衡(与 §4.6.1 共族、键分立) | 红族缺框 | | heal_minor | heal 正向 | 小额回复 | 金族满框 | -| heal_backlash | heal 负向 | 恢复倒扣(直伤) | 红族缺框 | +| heal_backlash | heal 负向 | 恢复倒扣(直伤) | 红族缺框 | | spark_aoe | blast 正向 | 爆破群伤 | 金族满框 | -| enemy_alert | blast 负向 | 警觉(引用 alert 状态行,与 §4.6.1 alert 共族) | 红族缺框 | -| weaken | weaken 正向 | 削弱(与 §4.6.1 共族) | 金族满框 | -| sapped | weaken 负向 | 乏力(与 §4.6.1 共族) | 红族缺框 | +| enemy_alert | blast 负向 | 警觉(引用 alert 状态行,与 §4.6.1 alert 共族) | 红族缺框 | +| weaken | weaken 正向 | 削弱(与 §4.6.1 共族) | 金族满框 | +| sapped | weaken 负向 | 乏力(与 §4.6.1 共族) | 红族缺框 | | pierce_strike | pierce 正向 | 穿刺弹——锐锥 | 金族满框 | | pierce_snare | pierce 负向 | 穿刺陷阱 | 红族缺框 | | magic_bolt | magic 正向 | 魔法矢——秘法/星辰 | 金族满框 | @@ -308,12 +302,13 @@ | gp_lose | gp 负向 | 破财 | 红族缺框 | | clear_blast | 清图槽 | 清图群伤——blast 族、整图作用域 | 按正向视觉 | -- 负向徽标用于踩雷反噬的结算呈现;权重与摇号语义见《地图战斗资源》。验收:目检〈踩雷反噬结算以红族缺框徽标呈现〉 -- 库存常读条义务与兑付/库存语义见《地图战斗资源》。验收:方向性 -- 格面预读按**家族类型**微光呈现(§4.6.3;呈现粒度决策见《地图战斗资源》极性提案 R1)。验收:目检〈格面预读仅到家族类型粒度〉 -- **资产契约**:19 枚 = `ui/icons/reward_.png`(键 = 本表 reward_id,正向/负向/清图槽同模式;状态图标 §4.6.1 同款「键→文件名→尺寸」模式),源 40×40、显示 20px(格面小记号级)/40px(明细面)双档,尺寸为候选,样张验收时冻结;极性视觉按本表「极性视觉」列(金族满框/红族缺框 = 形状+色彩双通道纪律)。本族现无终稿图,以程序化占位承载;出图工单归 art-assets-checklist/PRD,交付后同步登记 art-status-checklist ✅。验收:机检〈reward_*.png 文件名键与本表 reward_id 一致,源 40×40〉 +- 负向徽标用于踩雷反噬的结算呈现;权重与摇号语义见《地图战斗资源》。 +- 库存常读条义务与兑付/库存语义见《地图战斗资源》。 +- 格面预读按**家族类型**微光呈现(§4.6.3;呈现粒度决策见《地图战斗资源》极性提案 R1)。 +- **2026-09-22 勘误**:修订指导底稿曾记 gp 组键名为 gp_gain/gp_loss,以 `reward.tres` 实际键 **gp_coin/gp_lose** 为准(配置是事实源)。 +- **资产契约(2026-10-06 P1-2 批——审计「19 枚格面徽章 0 张」缺口完成;状态图标 §4.6.1 模式复刻「键→文件名→尺寸」;尺寸候选,样张批冻结)**:19 枚 = `ui/icons/reward_.png`(键 = 本表 reward_id,正向/负向/清图槽同模式),源 40×40、显示 20px(格面小记号级)/40px(明细面)双档候选;极性视觉按本表「极性视觉」列(金族满框/红族缺框 = 形状+色彩双通道纪律)。**契约立前此族 0 张为已知缺口**(审计 P1-2),程序化占位在役;出图工单归 art-assets-checklist/PRD,交付后 art-status-checklist 顺带 ✅。 -#### 4.6.3 格面类型图标键(九类 = 地图战斗资源现行实态;键契约已冻结,终稿归格子样张 §1.4) +#### 4.6.3 格面类型图标键(九类——地图战斗资源现行实态;键契约冻结,终稿归样张批) | 键(cell_type) | 类型 | 纹样语义锚 | |---|---|---| @@ -327,229 +322,229 @@ | action | 行动族 | 行动点/疾步 | | gp | GP 族 | 钱币/财货 | -- **呈现约束**:未开格呈现**类型微光单通道**(五组色带见 §4.2「类型微光色带」,色相+明度双码;可见范围与时机见《HUD框架》「未开格类型微光」节);不呈现极性、不呈现雷/安(信息定义见该权威定义节);微光组间互辨抽测含明度档互辨(误读率指标出处见《地图战斗资源》极性提案)。验收:目检〈未开格仅微光呈现,无极性与雷/安信息;五组互辨含明度档〉 -- **状态声明**:键集九值封闭(attack/defense/heal/blast/weaken/pierce/magic/action/gp,与 RewardTable 现行 cell_type 词表一致),扩键走本节修订。验收:机检〈cell_type 键集九值与 RewardTable 词表一致〉 -- 与 §4.6.1 的关系:同语义域的键(attack↔attack_up 等)可共族设计,键分立、纹样须可区分(格面小记号级对槽内图标级)。验收:目检〈同语义域两键纹样同级可辨〉 -- **资产契约**:纹带族 = `board/band_.png`(键 = 变异类型 id,键集事实源 = [地形变异](../gdd/systems/排雷循环/地形变异.md)「变异框架与类型清单」region 六行〔荆棘丛/藤蔓缠绕/岩浆/烬覆/落石/矿尘〕+ 地形变异文档的池门槛域;通配 global 型〔贫矿脉〕无格子布置域、**不在纹带族**),源 64×64 四边无缝(叠于已开格底)、显示 1× = 格边长,尺寸为候选,样张验收时冻结。呈现锚 = 「变异格未开时无预告呈现(遭遇式)」——纹带是**已开变异格**的标识层(地形变异 GDD 视觉标识条款的素材承载),半透明叠加、禁止侵入数字字形占位区(§4.1 砖心留白同理)。现以程序化占位承载(◐);出图工单归 PRD,交付后同步登记 art-status-checklist ✅。验收:机检〈band_*.png 文件名键与六类型 id 一致,源 64×64〉 +- **呈现约束(2026-09-28 角嵌退役批更新)**:未开格呈现**类型微光单通道**(五组色带,§4.2「类型微光色带」,色相+明度双码;样张 v2 定稿 2026-09-28;可见范围与时机见《HUD框架》「未开格类型微光」节);不呈现极性、不呈现雷/安(信息口径见该权威定义节);样张批微光组间互辨抽测含明度档互辨(误读率指标出处见《地图战斗资源》极性提案)。 +- **状态声明**:键集九值封闭(attack/defense/heal/blast/weaken/pierce/magic/action/gp,与 RewardTable 现行 cell_type 词表一致),扩键走本节修订。 +- 与 §4.6.1 的关系:同语义域的键(attack↔attack_up 等)可共族设计,键分立、纹样须可区分(格面小记号级对槽内图标级)。 +- **资产契约(2026-10-06 P1-2 批——审计「6 类地形变异纹带 0 张」缺口完成;尺寸候选,样张批冻结)**:纹带族 = `board/band_.png`(键 = 变异类型 id,键集事实源 = [地形变异](../gdd/systems/排雷循环/地形变异.md)「变异框架与类型清单」region 六行〔荆棘丛/藤蔓缠绕/岩浆/烬覆/落石/矿尘〕+ 地形变异文档的池门槛域;通配 global 型〔贫矿脉〕无格子布置域、**不在纹带族**),源 64×64 四边无缝(叠于已开格底)、显示 1× = 格边长。呈现锚 = 「变异格未开时无预告呈现(遭遇式)」——纹带是**已开变异格**的标识层(地形变异 GDD「视觉标识归表现层升级批」的素材承载),半透明叠加、禁止侵入数字字形占位区(§4.1 砖心留白同理)。程序化纹带在役 = 占位(◐);出图工单归 PRD,交付后 art-status-checklist 顺带 ✅。 -#### 4.6.4 敌种线索键(映射键 = enemy_id;键集事实源归敌人系统 GDD 与 `data/tables/enemy.tres`) +#### 4.6.4 敌种线索键(映射键 = enemy_id;键集事实源归敌人系统 GDD 与 `data/tables/enemy.tres`,2026-09-22 核) | 线索通道 | 冻结内容 | |---|---| -| 意图姿态 | **动画三档**:① 普通攻击 = 常态待机(零独立资产);② 攻击技能 = 特殊准备动画(放光/高举武器);③ 重击/硬化类 = 蓄力动作或防御姿态。不显示文字与技能名(信息定义见《敌人系统》意图线索节);三档在灰度/剪影层可辨(双通道纪律) | -| 强度档位 | 体量通道:§5.1 archetype 体量/垂直延伸("大个子多半有重击");护甲类型(light/ward/heavy)视觉区分方案已落 §5.3(候选,样张验收时冻结) | -| 预告时序 | 预告语义见《敌人系统》意图线索节,呈现义务(挖掘期全程可见)见《HUD框架》;本表只锁「结算不另设独立意图播片」的动画形态 | -| 恒定义务 | enemy_id 键映射跨 run 恒定、禁重掷;图像不报数——精确数值的明细承载见《战斗结算》。验收:机检〈enemy_id 键映射跨 run 恒定、禁重掷〉 | +| 意图姿态 | **动画三档**(2026-09-18 用户拍板):① 普通攻击 = 常态待机(零独立资产);② 攻击技能 = 特殊准备动画(放光/高举武器);③ 重击/硬化类 = 蓄力动作或防御姿态。不显示文字与技能名(信息口径见《敌人系统》意图线索节);三档在灰度/剪影层可辨(双通道纪律) | +| 强度档位 | 体量通道:§5.1 archetype 体量/垂直延伸("大个子多半有重击");护甲类型(light/ward/heavy)视觉区分方案已落 §5.3(候选,样张批冻结) | +| 预告时序 | 预告语义见《敌人系统》意图线索节,呈现义务(挖掘期全程可见)见《HUD框架》;本表只锁「结算不另设独立意图播片」的动画形态 | +| 恒定义务 | enemy_id 键映射跨 run 恒定、禁重掷;图像不报数——精确数值的明细承载见《战斗结算》 | -- **敌种键集 = 八敌种**:goblin 地精(light)/ bugbear 熊地精(light;**L2 熊地精 = bugbear 复用**,第二关三行显示名熊地精系,同剪影换色板,非独立键)/ ogre_chief 食人魔酋长(light,第一关关底 Boss)/ giant_spider 巨蜘蛛(light,群行 [2,3])/ drider 蛛化卓尔精灵(ward,第二关 Boss)/ duergar 灰矮人(heavy)/ hook_horror 钩爪恐魔(light,群行)/ duergar_king 灰矮人石君王(heavy,第三关 Boss)。键数据与甲档实态见《敌人系统》与 `enemy.tres`。验收:机检〈八敌种键集与 enemy.tres 一致〉 -- **资产需求下限三类**(敌人系统登记,呈现归 HUD框架/战斗表现):受击反馈(可共用一组)/ 死亡表现(**灰化+碎裂退场,禁止直接消失**)/ 顺位标记(敌多时可读)。验收:目检〈三类呈现齐备,死亡为灰化+碎裂退场非直接消失〉 -- Boss 表现约束:§2.6 紫赤色相独占 + §5.1 Boss 形状独占双通道;键集扩容时随内容修订入本表。验收:目检〈Boss 呈现同时满足色相与形状双独占〉 -- 剪影通用性:剪影禁止借特定格底色成立(§5.1)——棋盘/结算/任一战斗舞台同一套。验收:目检〈同一剪影在棋盘与结算两舞台均成立〉 +- **敌种键集 = 八敌种**(registry enemy mvp_content,2026-09-25 核):goblin 地精(light)/ bugbear 熊地精(light;**L2 熊地精 = bugbear 复用**,第二关三行显示名熊地精系,同剪影换色板,非独立键)/ ogre_chief 食人魔酋长(light,第一关关底 Boss)/ giant_spider 巨蜘蛛(light,群行 [2,3])/ drider 蛛化卓尔精灵(ward,第二关 Boss)/ duergar 灰矮人(heavy)/ hook_horror 钩爪恐魔(light,群行)/ duergar_king 灰矮人石君王(heavy,第三关 Boss)。键数据与甲档实态见《敌人系统》与 `enemy.tres`。 +- **资产需求下限三类**(敌人系统登记,呈现归 HUD框架/战斗表现):受击反馈(可共用一组)/ 死亡表现(**灰化+碎裂退场,禁止直接消失**)/ 顺位标记(敌多时可读)。 +- Boss 表现约束:§2.6 紫赤色相独占 + §5.1 Boss 形状独占双通道;键集扩容随内容批入本表。 +- 剪影通用性:剪影禁止借特定格底色成立(§5.1)——棋盘/结算/任一战斗舞台同一套。 --- ## 5. 角色设计(Character Design) -> **本节内容已整体迁至独立文档 [`character-design.md`](character-design.md)**(小节号 §5.1–§5.6 在该文档中沿用不变)。迁移动机:人设高频改动,独立成档免动圣经;本存根保证既有 "art-bible §5.x" 式引用(§4.1/§4.5/§4.6.4、art-assets-checklist、战斗表现)经此仍可达,新引用直指该文档。 +> **本节内容已整体迁至独立文档 [`character-design.md`](character-design.md)**(2026-09-23 v1.7 抽离批;小节号 §5.1–§5.6 在该文档中沿用不变)。迁移动机:人设高频改动,独立成档免动圣经;本存根保证既有 "art-bible §5.x" 式引用(§4.1/§4.5/§4.6.4、art-assets-checklist、战斗表现)经此仍可达,新引用直指该文档。 -内容速览:§5.1 角色剪影与轮廓通道 · §5.2 头身比与体量档位 · §5.3 护甲类型视觉区分(候选,样张冻结终稿) · §5.4 主角三态规格(形象锚 = 蓝兜帽披肩的暖装小冒险者:圆头挖掘铲 + 冒险背包插朱红矩形旗(与棋盘封印色脱钩) + 护目镜腰灯) · §5.5 八敌种逐卡 · §5.6 意图准备动画三档。 +内容速览:§5.1 角色剪影与轮廓通道 · §5.2 头身比与体量档位 · §5.3 护甲类型视觉区分(候选,样张批冻结) · §5.4 主角三态规格(形象锚 = 蓝兜帽披肩的暖装小冒险者:**圆头挖掘铲 + 冒险背包插旗朱红矩形旗(2026-09-28 与棋盘封印色脱钩) + 护目镜腰灯**,2026-09-23 特征批〔旗色史:旗朱→封印紫→封印黑 2026-09-28〕) · §5.5 八敌种逐卡 · §5.6 意图准备动画三档。 --- ## 6. 环境与场景(Environment & Scenes) -> 本节定格面之外的场景容器怎么画:战斗舞台背景、营地场景、三关图类型色板方向、关卡间下探过渡、棋盘视野迷雾遮蔽(§6.5)。格内画法归 §3.2,情绪与能量归 §2,换色模板归 §4.3;本节不新增信息通道、不承载玩法语义。屏级分区布局(三区 B/C/D)的裁决权归 battle.md,本节只定环境资产的视觉规格。 +> 本节定格面之外的场景容器怎么画:战斗舞台背景、营地场景、三关图类型色板方向、关卡间下探过渡、棋盘视野迷雾遮蔽(§6.5)。格内画法归 §3.2,情绪与能量归 §2,换色模板归 §4.3;本节不新增信息通道、不承载玩法语义。屏级分区布局(三区 B/C/D)的裁决权归 battle.md,本节只定环境资产的视觉规格。 -### 6.1 战斗舞台背景(B 区环境) +### 6.1 战斗舞台背景(B 区环境) -- **双舞台常驻下的 B 区环境**:战斗画面区(站桩立绘所在)的背景 = 明亮地窟,与 §1 风格基调同源——暖倾向、无压抑;背景整体**低对比自然后退**(§3.4 焦点预算延伸):背景禁止构成完整凸形、禁止抢 B 区立绘与 D 区数字的焦点,识别层级恒为 立绘 > 背景。验收:目检〈背景无完整凸形、立绘最抢眼〉 -- **舞台正面目标**:B 区 = **地窟看台**——立绘脚下地台/落影承载(世界中层),背景环境层低饱和后退、明度走世界底层档,允许光源氛围(洞顶光柱/暖光晕候选);角色禁止直接贴平色底。规格与终值归整屏样张闸(§1.4)冻结。验收:目检〈立绘脚下有地台/落影,非贴平色底〉 -- **明度分层适用**:背景氛围的情绪表达仍禁止以压暗/渐晕实现(§2 禁令);空间纵深与洞穴感走明度分层允许域——世界底层档、光照氛围、色相饱和与形体承载(§3.2 同款逻辑)。验收:方向性 -- **图类型联动**:背景 tint 走 §4.3 `wall_ambient` 槽,随三关换挡(方向候选见 §6.3);紫赤仅 Boss 战允许短暂进入背景氛围纹样(§2.6 色相独占的舞台侧延伸,高饱和独占不变),非 Boss 战高饱和紫赤禁入。验收:机检〈背景吸色无非 Boss 高饱和紫赤〉 -- **资产渠道**:✦ 插画级(演出类场景贴图,清单 §4 联动登记);战斗一屏样张(§1.4 清单 1.2)含背景出样。**资产契约(尺寸候选,样张冻结终稿)**:基准一张 = `stage/stage_bg.png`(源 1280×270,B 区全高含明细带底;三关换挡走 §4.3 `wall_ambient` tint 槽,不按板出三张——换皮成本复用棋盘同款逻辑),显示 1×;呈现约束按本节各条(低对比后退/明度世界底层档/光柱氛围保留)。程序化平色+光柱现行使用 = 占位;插画工单归 PRD,交付后 art-status-checklist 同步 ✅。 +- **双舞台常驻下的 B 区环境**:战斗画面区(站桩立绘所在)的背景 = 明亮地窟,与 §1 风格基调同源——暖倾向、无压抑;背景整体**低对比自然后退**(§3.4 焦点预算延伸):背景禁止构成完整凸形、禁止抢 B 区立绘与 D 区数字的焦点,识别层级恒为 立绘 > 背景。 +- **舞台正面目标(2026-09-29 v3.0 新增——原条只有负面约束,病根 6)**:B 区 = **地窟看台**——立绘脚下地台/落影承载(世界中层),背景环境层低饱和后退、明度走世界底层档,允许光源氛围(洞顶光柱/暖光晕候选);角色禁止直接贴平色底。规格与终值归整屏样张闸(§1.4)冻结。 +- **明度分层适用(2026-09-29 v3.0 改条)**:背景氛围的情绪表达仍禁止以压暗/渐晕实现(§2 禁令);空间纵深与洞穴感走明度分层允许域——世界底层档、光照氛围、色相饱和与形体承载(§3.2 同款逻辑)。 +- **图类型联动**:背景 tint 走 §4.3 `wall_ambient` 槽,随三关换挡(方向候选见 §6.3);紫赤仅 Boss 战允许短暂进入背景氛围纹样(§2.6 色相独占的舞台侧延伸,高饱和独占不变),非 Boss 战高饱和紫赤禁入。 +- **资产渠道**:✦ 插画级(演出类场景贴图,清单 §4 联动登记);战斗一屏样张(§1.4 清单 1.2)含背景出样。**资产契约(2026-10-06 P1-2 批——审计「B 区舞台背景插画 0 张」缺口完成;尺寸候选,样张批冻结)**:基准一张 = `stage/stage_bg.png`(源 1280×270,B 区全高含明细带底;三关换挡走 §4.3 `wall_ambient` tint 槽,不按板出三张——换皮成本复用棋盘同款逻辑),显示 1×;呈现约束按本节各条(低对比后退/明度世界底层档/光柱氛围保留)。程序化平色+光柱在役 = 占位(◐);插画工单归 PRD,交付后 art-status-checklist 顺带 ✅。 ### 6.2 营地场景 -- **情绪与色域**:承载 §2.8——全作最暖:烛光黄 `#F6C667` × 木色 `#8A5A34`(§7.2),中等饱和,无棋盘式高对比。 -- **场景构成**:一张可点击的营地场景图(1280×800,与工程档同幅面)。远景=天空与远山;中景=木围墙(密排竖木桩、桩顶削尖、不透缝无横木)与**半开的营地大门**(门洞内一条通向远处的小路);近景=中央篝火、左侧锻炉铁砧、右侧帐篷与宝箱、帐篷旁的笔记本,另有木桩与挂在围墙上的布告板。**中景带自底部往上约 2/3 处起**;**近景物件可覆盖围墙(围墙是纯背景板),唯一例外=中间门洞必须留空、不被任何物件遮挡**。七个可点物件的功能绑定归 `camp.md`,本节只定画法。 -- **交付形态**:**背景 1 层 + 七个物件各 1 层**(**帐篷**为承载件、不可点),共 8 张透明 PNG + 一份坐标表(贴图矩形/命中矩形/z 序)。**篝火例外**:命中矩形只取下半(火堆),上半(火焰)只作特效展示。 -- **色彩与氛围依据**:营地场景的冷暖分工(冷的大面积后退、暖的小面积前进)、空气透视三档与大面积用色规则 = `reports/提案-营地色彩与氛围脚本-2026-10-23.md`。本节不复述色值;新增环境色按 §4.1 场景环境子板条款登记(每屏一套、≤32 色、过 §4.2 语义检查与 RGB 三通道差 ≥8/255 两道门)。 -- **交互四态(零新素材,全部程序化)**:悬停=**轮廓亮边 + 浮动**(浮动须整像素吸附);按下=位移 1px;**禁用/未解锁=降明度 + 去饱和**(§2 已缩窄至情绪域,状态表达不受限),**仅锻炉(锻造工坊)与宝箱(秘宝匣)两件会有此态**;已解锁=常态。 -- **钥石余额**:常驻画面顶部的一行小 HUD 栏(高 48px,**浮层覆盖,不挤压画面**)。 -- **事实源边界**:解锁条件/价格/存档写入归元进度 GDD 与 `MetaProgression`;素材命名与路径归 `asset-path-map`;清单登记归 `art-assets-checklist` §3.5。 -- **待裁(本节不填数,由作者定)**:① **营地环境子板色值**——色彩脚本只给定色方向,落地色值属 §4.1 单点,须过 §4.2 语义检查与 RGB 三通道差 ≥8/255 两道门;② **门扇与门洞的高宽比例**——中景带自底部往上约 2/3 处起之后高度有限,门扇高度远超围墙带,两者关系待作者重定;③ **帐篷的落地尺寸**——帐篷体量大于被替换的旧承载件,尺寸待作者重定;坐标草案的现行出口见 `camp.md` 的指路句。 +- **情绪与色域**:承载 §2.8——全作最暖:烛光黄 `#F6C667` × 木色 `#8A5A34`(§7.2),中等饱和,无棋盘式高对比。 +- **场景构成**:一张可点击的营地场景图(1280×800,与工程档同幅面)。远景=天空与远山;中景=木围墙(密排竖木桩、桩顶削尖、不透缝无横木)与**半开的营地大门**(门洞内一条通向远处的小路);近景=中央篝火、左侧锻炉铁砧、右侧帐篷与宝箱、帐篷旁的笔记本,另有木桩与挂在围墙上的布告板。**中景带自底部往上约 2/3 处起**;**近景物件可覆盖围墙(围墙是纯背景板),唯一例外=中间门洞必须留空、不被任何物件遮挡**。七个可点物件的功能绑定归 `camp.md`,本节只定画法。 +- **交付形态**:**背景 1 层 + 七个物件各 1 层**(**帐篷**为承载件、不可点),共 8 张透明 PNG + 一份坐标表(贴图矩形/命中矩形/z 序)。**篝火例外**:命中矩形只取下半(火堆),上半(火焰)只作特效展示。 +- **色彩与氛围依据**:营地场景的冷暖分工(冷的大面积后退、暖的小面积前进)、空气透视三档与大面积用色口径 = `reports/提案-营地色彩与氛围脚本-2026-10-23.md`。本节不复述色值;新增环境色按 §4.1 场景环境子板条款登记(每屏一套、≤32 色、过 §4.2 语义检查与 RGB 三通道差 ≥8/255 两道门)。 +- **交互四态(零新素材,全部程序化)**:悬停=**轮廓亮边 + 浮动**(浮动须整像素吸附);按下=位移 1px;**禁用/未解锁=降明度 + 去饱和**(§2 已缩窄至情绪域,状态表达不受限),**仅锻炉(锻造工坊)与宝箱(秘宝匣)两件会有此态**;已解锁=常态。 +- **钥石余额**:常驻画面顶部的一行小 HUD 栏(高 48px,**浮层覆盖,不挤压画面**)。 +- **事实源边界**:解锁条件/价格/存档写入归元进度 GDD 与 `MetaProgression`;素材命名与路径归 `asset-path-map`;清单登记归 `art-assets-checklist` §3.5。 +- **待裁(本节不填数,由作者定)**:① **营地环境子板色值**——色彩脚本只给定色方向,落地色值属 §4.1 单点,须过 §4.2 语义检查与 RGB 三通道差 ≥8/255 两道门;② **门扇与门洞的高宽比例**——中景带自底部往上约 2/3 处起之后高度有限,门扇高度远超围墙带,两者关系待作者重定;③ **帐篷的落地尺寸**——帐篷体量大于被替换的旧承载件,尺寸待作者重定;坐标草案的现行出口见 `camp.md` 的指路句。 -### 6.3 三关图类型色板候选(board_type 换挡方向) +### 6.3 三关图类型色板候选(board_type 换挡方向) -- **模板与不变槽**:换皮只换 §4.3 四槽(`tile_closed`/`tile_open`/`wall_ambient`/`variant_tint`),信息与语义层全图类型恒定——本节只给三关**方向性候选**,终值按 §4.3 现行规则重跑 §1.4 格子样张冻结。 -- **深度轴叙事**(§2.7 下探过渡):色相层进预告「更深更险」,明度档不动—— - - **dungeon 地精洞窟**(第一关):现行掩尘系暖石棕(`#C9AF83` 基)即基准档,`wall_ambient` 暖棕; - - **underdark 幽暗地域**(第二关;方向:**冷调幽暗**):主调**灰白蓝**候选(低饱和蓝灰域,`tile_closed`),少量紫黑倾向点缀——紫系取紫罗兰域(≈256°)候选,禁入带 300°–345° 不变;`wall_ambient` 冷蓝灰; - - **deep_underdark 灰矮人矿城**(第三关;方向:**灰黑主调 + 矿脉金属色点缀**候选,守 §4.1 明度地板 L≥0.30):矿脉点缀走金属质感族(冷银/铜灰候选)——「金属色」以材质感承载,奖励金亮金仍禁上格底(§4.2),不占用收益语义金;`wall_ambient` 铁灰。 -- **共同纪律**:三套候选色相均须与五组类型微光色带(§4.2)抽测互辨(格底与微光同在未开格面,邻近色相有吞没风险);避 300°–345° 禁入带;L≥0.30、饱和 ≤0.55(§4.1 硬指标)。 +- **模板与不变槽**:换皮只换 §4.3 四槽(`tile_closed`/`tile_open`/`wall_ambient`/`variant_tint`),信息与语义层全图类型恒定——本节只给三关**方向性候选**,终值按 §4.3 现行规则重跑 §1.4 格子样张冻结。 +- **深度轴叙事**(§2.7 下探过渡):色相层进预告「更深更险」,明度档不动—— + - **dungeon 地精洞窟**(第一关):现行掩尘系暖石棕(`#C9AF83` 基)即基准档,`wall_ambient` 暖棕; + - **underdark 幽暗地域**(第二关;2026-09-23 用户裁方向:**冷调幽暗**):主调**灰白蓝**候选(低饱和蓝灰域,`tile_closed`),少量紫黑倾向点缀——紫系取紫罗兰域(≈256°)候选,禁入带 300°–345° 不变;`wall_ambient` 冷蓝灰; + - **deep_underdark 灰矮人矿城**(第三关;2026-09-23 用户裁方向):**灰黑主调 + 矿脉金属色点缀**候选(守 §4.1 明度地板 L≥0.30);矿脉点缀走金属质感族(冷银/铜灰候选)——「金属色」以材质感承载,奖励金亮金仍禁上格底(§4.2),不占用收益语义金;`wall_ambient` 铁灰。 +- **共同纪律**:三套候选色相均须与五组类型微光色带(§4.2)抽测互辨(格底与微光同在未开格面,邻近色相有吞没风险);避 300°–345° 禁入带;L≥0.30、饱和 ≤0.55(§4.1 硬指标)。 ### 6.4 关卡间下探过渡 -- **视觉承载**(§2.7 情绪的画法面):竖井下移镜头,两侧砖层色板渐变,预演下一层主色——渐变取色 = 下一关 `tile_closed`/`wall_ambient` 槽候选值入画,只动色相/饱和;明度带随深度微移候选归 §2.7(空间纵深表达,非情绪)。 -- **资产与排期**:程序化色板渐变(⚙ 脚本管线),无独立插画需求(候选);演出编排与时序归冒险结构/战斗表现 GDD,本节只定渐变取色与明度纪律。 +- **视觉承载**(§2.7 情绪的画法面):竖井下移镜头,两侧砖层色板渐变,预演下一层主色——渐变取色 = 下一关 `tile_closed`/`wall_ambient` 槽候选值入画,只动色相/饱和;明度带随深度微移候选归 §2.7(空间纵深表达,非情绪)。 +- **资产与排期**:程序化色板渐变(⚙ 脚本管线),无独立插画需求(候选);演出编排与时序归冒险结构/战斗表现 GDD,本节只定渐变取色与明度纪律。 -### 6.5 视野迷雾与黑暗区域渲染(棋盘灯光区) +### 6.5 视野迷雾与黑暗区域渲染(棋盘灯光区,2026-10-03 立节) -> **白话**:战斗棋盘不再整板可见——只有以角色为中心的菱形灯光区看得清,区外没开过的格子沉入全黑,开过的格子留下灰阶的「记忆」。像在洞穴里举灯探索。机制规则(可见性/生命周期/纯呈现纪律)的现行事实源 =《HUD框架》「视野迷雾:菱形灯光区与黑暗边界」节(全项目唯一事实源,引用只指路),该节点名「黑暗区域渲染规格(暗淡态明度、迷雾过渡)归 art-bible」,本节即其承载——只管"怎么画",不复述机制。纯呈现层:格态、数字真值、清图判定零影响;零素材新增(全部程序化遮罩/调制);类型微光语义零改动。 +> **白话**:战斗棋盘不再整板可见——只有以角色为中心的菱形灯光区看得清,区外没开过的格子沉入全黑,开过的格子留下灰阶的「记忆」。像在洞穴里举灯探索。机制口径(可见性/生命周期/纯呈现纪律)的现行事实源 =《HUD框架》「视野迷雾:菱形灯光区与黑暗边界」节(全项目唯一事实源,引用只指路),该节点名「黑暗区域渲染规格(暗淡态明度、迷雾过渡)归 art-bible」,本节即其承载——只管"怎么画",不复述机制。纯呈现层:格态、数字真值、清图判定零影响;零素材新增(全部程序化遮罩/调制);类型微光语义零改动。 -- **菱形灯光区(遮罩几何)**:灯光区 = 以角色所在格为中心、**曼哈顿距离 ≤ LIGHT_RADIUS** 的格集,区内照常渲染。LIGHT_RADIUS 走表值/常量参数化,**基准 8**——现行事实源「灯光区起步半径=微光环 8 格」的落码取值(半径数值沿微光「周围 8 格」的 8 起步,实测后可调)。**两圈分立注**:类型微光作用圈 = 英雄贴身 8 邻格(与可挖域同圈,现行事实源《HUD框架》「未开格类型微光」节),**不随灯光区扩**(权威文档配套裁决)——灯光区只裁可见性,微光锚仍是可挖域,两圈几何分立、半径参数互不读写。提灯插值位:FOG_LANTERN_BONUS = **+2**(flat 整数档,权威文档配套裁决;落码常量名 `board_view.gd FOG_LANTERN_BONUS`),**已接效果入库**(载体=秘宝·同调位 fog_lantern「魔法提灯」行,组合根每战装配链显式重算接线;呈现画法零改动——本节只管"怎么画",机制现行事实源仍归《HUD框架》)。边界随角色格位逐格重算,无半径插值动画。←现行事实源;§2 光照氛围允许域 -- **区外未开格:全黑不渲染(不透明档,照抄现行事实源)**:现行事实源原文「区外**未开格=全黑不渲染**」——不透明纯黑(L=0),**非高遮蔽半透档**。理由注记(为何不用高遮蔽档):①三档信息阶梯干净——未开格不可见=零信息、记忆格=弱信息、活格=全信息,叠读不打架;②高遮蔽档下砖面纹理隐约残留,与「类型微光=未开格唯一信息通道」(§4.2/§4.6.3)冲突——纹理残影泄格面观感;③现行事实源验收线「区外未开格不可见」须二值态才能逐格断言。←现行事实源;§4.2 -- **区外已开格:暗淡记忆态(灰显可读档)**:画法 = **地板纹理原样 + 灰罩压顶**——纹理直画后 `FOG_MEMORY_PLATE #3A3A3A` α0.66 灰罩盖顶,素材零改动(原「程序化素色记忆板平涂」已弃用);格内元素灰阶随罩隐现:**记忆墨 #C4C4C4(中性灰 L≈0.55)**。双峰板色牌格在记忆态**退牌归板**——牌面并入记忆态、数字以记忆墨直画。**旗不在记忆态面**:旗只插在未开格,区外未开格全黑——旗印记随格隐没、灯光区移回复显(纯呈现,旗数据零影响,现行事实源的显式推论)。←HUD框架 可访问性基线;§4.1;§3.2 -- **记忆态可读性判据**:数字对合成底**归一后(真实渲染面)三关 ≥4.5:1**(WCAG 硬指标;合成底 L1 #4C4038/L2 #3F4147/L3 #3E4048;行值与素材随动,测点=ui_contrast_probe 迷雾段)。**归一注**:装载亮度归一是现行生产路径(`board_view._normalize_floor_luma`),原图均值≠渲染面,复算须落归一后底。可辨性自查(三通道,随样张/实机自检):①对全黑未开格——合成底 #3E3731(L≈0.036)对 L0,实机目检常光档可分(暗房档一并抽检);②对灯光区内活态已开格——主判据=数字墨灰度差(记忆墨 L0.55 对亮墨三档 L≥0.75 / 色牌 #F5EAD3 L≈0.83,灰度 ΔL≥0.20)+ 色相通道(记忆态近中性灰,对活态暖底暖墨);③格态三形在记忆态保持灰度可辨——形通道经纹理+灰罩形态不变,沿 §1.4 验收 2 同源判据。验收:机检〈ui_contrast_probe 迷雾段记忆墨对比 ≥4.5:1〉←§1.4 -- **迷雾过渡边:硬边(逐格二值)**:灯光区边界 = **整格二值切换**(区内全渲染 / 区外全黑或记忆态),**不做渐变过渡带、不做半透明晕边**。理由:①边界是信息边界不是氛围晕——渐变中间态 =「看得见但看不清」,破数字可读硬指标与记忆态明度档;②现行事实源验收断言(边界格呈态、两态逐位一致)须格级二值可判;③与砖面整格表达(§3.2 高墙瓦片体系)、ITB 格内信息分层气质同轴。无动画,减动效无涉。←现行事实源;§1.2 原则一;§3.2 -- **与结算期去饱和的叠加共存**:结算期去饱和偏移(settlement_fx_view,同族先例)与迷雾调制叠加规则 = **记忆态为终态档**——区外已开格在结算期保持记忆态档、**不再叠加**结算去饱和(灰显已含去饱和语义,二次调制无信息增量且明度不可控);灯光区内格照常吃结算去饱和;全黑格两态无差。叠加态随实施批共存校验 + 探针断言(provisional,实机目检可代拍)。验收:机检〈叠加态下记忆态灰显不被二次调制〉 -- **§2 明度禁令自查**:迷雾遮蔽与记忆态灰显属 §2 明度分层**光照氛围允许域**(洞穴举灯的空间光照呈现,与 Moonlighter 取列「亮地板/暗深渊/发光体」光照层次同族),非情绪压暗(§9.2 Darkest 反面条款不触——那禁的是情绪表达)、非状态明暗编码(格态语义仍由形+色相承载,灰度下三态可辨不受扰)。←§2;§9.1;§9.2 +- **菱形灯光区(遮罩几何)**:灯光区 = 以角色所在格为中心、**曼哈顿距离 ≤ LIGHT_RADIUS** 的格集,区内照常渲染。LIGHT_RADIUS 走表值/常量参数化,**基准 8**——现行事实源「灯光区起步半径=微光环 8 格」的落码口径(半径数值沿微光「周围 8 格」的 8 起步,实测后可调)。**两圈分立注**:类型微光作用圈 = 英雄贴身 8 邻格(与可挖域同圈,现行事实源《HUD框架》「未开格类型微光」节),**不随灯光区扩**(权威文档配套拍板④)——灯光区只裁可见性,微光锚仍是可挖域,两圈几何分立、半径参数互不读写。提灯插值位:FOG_LANTERN_BONUS = **+2**(flat 整数档,权威文档配套拍板;落码常量名 `board_view.gd FOG_LANTERN_BONUS`——本条旧记 LIGHT_RADIUS_LANTERN_BONUS 系文档侧拟名,随迭代 19 卡④入库对齐实名),**已接效果入库**(2026-10-08 迭代 19 卡④:载体=秘宝·同调位 fog_lantern「魔法提灯」行,组合根每战装配链显式重算接线;呈现画法零改动——本节只管"怎么画",机制现行事实源仍归《HUD框架》)。边界随角色格位逐格重算,无半径插值动画。←现行事实源;§2 光照氛围允许域 +- **区外未开格:全黑不渲染(不透明档,照抄现行事实源)**:现行事实源原文「区外**未开格=全黑不渲染**」——不透明纯黑(L=0),**非高遮蔽半透档**。理由注记(为何不用高遮蔽档):①三档信息阶梯干净——未开格不可见=零信息、记忆格=弱信息、活格=全信息,叠读不打架;②高遮蔽档下砖面纹理隐约残留,与「类型微光=未开格唯一信息通道」(§4.2/§4.6.3)冲突——纹理残影泄格面观感;③现行事实源验收线「区外未开格不可见」须二值态才能逐格断言。←现行事实源;§4.2 +- **区外已开格:暗淡记忆态(灰显可读档,2026-10-06 UI 设计对齐批 P10 乙换装)**:画法 = **地板纹理原样 + 灰罩压顶**(demo 口径换装——原「程序化素色记忆板平涂」退役;纹理直画后 `FOG_MEMORY_PLATE #3A3A3A` α0.66 灰罩盖顶,素材零改动)+ 格内元素灰阶随罩隐现:**记忆墨 #C4C4C4(中性灰 L≈0.55)**;数字对合成底实测 **归一后(真实渲染面)三关 5.71–5.93:1 ≥4.5:1**(WCAG 硬指标;合成底 L1 #4C4038/L2 #3F4147/L3 #3E4048;行值与素材随动,测点=ui_contrast_probe 迷雾段)。**归一口径注**:装载亮度归一是现行生产路径(`board_view._normalize_floor_luma`),原图均值≠渲染面,复算须落归一后底。双峰板色牌格在记忆态**退牌归板**——牌面并入记忆态、数字以记忆墨直画。可辨性自查判据(三通道,落码后随样张/实机自检):①对全黑未开格——合成底 #3E3731(L≈0.036)对 L0,实机目检常光档可分(暗房档一并抽检);②对灯光区内活态已开格——主判据=数字墨灰度差(记忆墨 L0.55 对亮墨三档 L≥0.75 / 色牌 #F5EAD3 L≈0.83,灰度 ΔL≥0.20)+ 色相通道(记忆态近中性灰,对活态暖底暖墨);格底明度差为辅助通道;③格态三形在记忆态保持灰度可辨——形通道经纹理+灰罩形态不变,沿 §1.4 验收 2 同源判据。**旗不在记忆态面**:旗只插在未开格,区外未开格全黑——旗印记随格隐没、灯光区移回复显(纯呈现,旗数据零影响,现行事实源口径的显式推论)。←HUD框架 可访问性基线;§4.1;§1.4;§3.2 +- **迷雾过渡边:硬边(逐格二值)**:灯光区边界 = **整格二值切换**(区内全渲染 / 区外全黑或记忆态),**不做渐变过渡带、不做半透明晕边**。理由:①边界是信息边界不是氛围晕——渐变中间态 =「看得见但看不清」,破数字可读硬指标与记忆态明度档;②现行事实源验收断言(边界格呈态、两态逐位一致)须格级二值可判;③与砖面整格表达(§3.2 高墙瓦片体系)、ITB 格内信息分层气质同轴。无动画,减动效无涉。←现行事实源;§1.2 原则一;§3.2 +- **与结算期去饱和的叠加共存**:结算期去饱和偏移(settlement_fx_view,同族先例)与迷雾调制叠加口径 = **记忆态为终态档**——区外已开格在结算期保持记忆态档、**不再叠加**结算去饱和(灰显已含去饱和语义,二次调制无信息增量且明度不可控);灯光区内格照常吃结算去饱和;全黑格两态无差。叠加态随实施批共存校验 + 探针断言(provisional,实机目检可代拍)。←需求卡片 T3/T4 +- **§2 明度禁令自查**:迷雾遮蔽与记忆态灰显属 §2 明度分层**光照氛围允许域**(洞穴举灯的空间光照呈现,与 Moonlighter 取列「亮地板/暗深渊/发光体」光照层次同族),非情绪压暗(§9.2 Darkest 反面条款不触——那禁的是情绪表达)、非状态明暗编码(格态语义仍由形+色相承载,灰度下三态可辨不受扰)。←§2;§9.1;§9.2 --- ## 7. UI 设计(UI Design) -> **白话**:这一章管 UI 长什么样式,UI 的具体选择都在本章定义。布局落在哪里、占多少像素,屏级权威在 battle.md、系统契约在 HUD框架;本章节内容——形状语法与色板细则(自 §3.3/§4.4 迁入)、布局栅格、字体字号、营地界面画法、引擎原生控件与按钮状态规格。 +> **白话**:这一章管 UI 长什么样式,UI 的具体选择都在本章定义。布局落在哪里、占多少像素,屏级权威在 battle.md、系统契约在 HUD框架;本章节内容——形状语法与色板细则(自 §3.3/§4.4 迁入)、布局栅格、字体字号、营地界面画法、引擎原生控件与按钮状态规格。 -### 7.1 形状语法细则(自 §3.3 迁入) +### 7.1 形状语法细则(自 §3.3 迁入) -**半拟物:容器穿戏服,数据素面。**面板=石板圆角矩形(与地砖同圆角率,全 UI 恒定),按钮=凸圆角的"可按的砖"(按压态=下沉一档,运动手段 §2),仪式性确认(胜利等)=圆形蜡封(呼应 §2.4);数字框、准线、进度条一律直角细线、无纹理;**数字色牌 = 直角小素牌**(后备件)——宽随数字位、无纹理无装饰,仅复杂格底兜底时垫于数字正下方一层(色值 §4.1),属"须精确的信息"侧、走直角律。判据:圆角=可触摸的物件,直角=须精确的信息。与参考分界:取 ITB 的信息分层,弃其冷硬斜切 HUD;取 Moonlighter 的木石亲和,不做全拟物。验收:目检〈面板圆角/按钮凸圆角/数据直角三态可辨〉 +**半拟物:容器穿戏服,数据素面。**面板=石板圆角矩形(与地砖同圆角率,全 UI 恒定),按钮=凸圆角的"可按的砖"(按压态=下沉一档,运动手段 §2),仪式性确认(胜利等)=圆形蜡封(呼应 §2.4);数字框、准线、进度条一律直角细线、无纹理;**数字色牌 = 直角小素牌**(2026-09-29 v3.0 后备件)——宽随数字位、无纹理无装饰,仅复杂格底兜底时垫于数字正下方一层(色值 §4.1),属"须精确的信息"侧、走直角律。判据:圆角=可触摸的物件,直角=须精确的信息。与参考分界:取 ITB 的信息分层,弃其冷硬斜切 HUD;取 Moonlighter 的木石亲和,不做全拟物。 -- **极性强调冗余**(§4.5⑦):UI 红/金强调对必须补图标+文案冗余;破坏性确认与常规确认禁止同位(位置通道)。验收:目检〈红/金强调位有图标+文案,破坏性确认独立位〉 -- **圆角率单点注记**:容器/按钮圆角值收敛为全站唯一 `RADIUS_OBJECT = 5`(ui_kit 单点;面板与按钮同圆角率即满足本节「全 UI 恒定圆角率」);数据框/数字牌/AP 点阵/准线/血条维持 0 直角;**例外三件保留**=标题屏底座椭圆 17/结算金印 200(圆形蜡封)/状态脉冲环 100(圆形)。验收:机检〈ui_kit 圆角取值仅 RADIUS_OBJECT 与例外三件〉 -- **九宫格切片注记**:panel 石皮 `PANEL_SLICE = 16`(button 维持 8、仪式框维持 12——素材 panel_stone/button_stone 均 48×48 不动);`panel_skinned` 工厂内 **content margin clamp**=低于切片的分量自动抬到切片(石框纹理不吃内容;工厂单点,调用点零改)。验收:机检〈九宫格面板切片 16、边角无错位〉 -- **资产渠道**:♻ 九宫格面板/按钮改色过复用闸优先(清单 §0),⚙ 兜底;样式终稿随 §1.4 样张闸后定稿。验收:方向性 -- **占位完成条款**:程序化占位 = **临时态**,任何屏的终稿闸 = §1.4 整屏样张过闸;占位态禁止跨里程碑。验收:目检〈过闸屏无程序化占位残留〉 -- **实现侧边界**:样式收敛 ui_kit;ui_kit 的工程契约归《HUD框架》,本节不重述。 +- **极性强调冗余**(§4.5⑦):UI 红/金强调对必须补图标+文案冗余;破坏性确认与常规确认禁止同位(位置通道)。 +- **圆角率单点注记(2026-10-06 UI 设计对齐批 P5 甲)**:容器/按钮圆角值收敛为全站唯一 `RADIUS_OBJECT = 5`(ui_kit 单点;与主/危按钮原 5px 同值,面板与按钮同圆角率即满足本节「全 UI 恒定圆角率」);数据框/数字牌/AP 点阵/准线/血条维持 0 直角;**例外三件保留**=标题屏底座椭圆 17/结算金印 200(圆形蜡封)/状态脉冲环 100(圆形)。收敛前实测 0/2/3/4/5/6/8/17/100/200 十档并存。 +- **九宫格切片注记(2026-10-06 UI 设计对齐批 P4 乙+Q1 丙)**:panel 石皮 `PANEL_SLICE = 16`(原 8→16,与原型 .plate9 slice16 对齐;button 维持 8、仪式框维持 12——素材 panel_stone/button_stone 均 48×48 不动);`panel_skinned` 工厂内 **content margin clamp**=低于切片的分量自动抬到切片(石框纹理不吃内容;工厂单点,调用点零改)。本节原无切片条款,此为新增注记。 +- **资产渠道**:♻ 九宫格面板/按钮改色过复用闸优先(清单 §0),⚙ 兜底;样式终稿随 §1.4 样张闸后定稿。 +- **占位完成条款(2026-09-29 v3.0 新增,病根 8)**:程序化占位 = **临时态**,任何屏的终稿闸 = §1.4 整屏样张过闸;占位态禁止跨里程碑。 +- **实现侧边界**:样式收敛 ui_kit;ui_kit 的工程契约归《HUD框架》,本节不重述。 -### 7.2 UI 色板细则(自 §4.4 迁入) +### 7.2 UI 色板细则(自 §4.4 迁入) -**全局文字可读性硬指标**:**一切文字**(含菜单/说明/tooltip/按钮/底栏,无区域例外)对其直接背景 **≥4.5:1**(WCAG AA);大字(≥18px 或加粗档)底线 3:1、目标 4.5:1;**文字禁直接压纹理底**——垫容器或罩层。核验闸 = §1.4 整屏样张。验收:机检〈ui_contrast_probe 全文字位 ≥4.5:1〉 +**全局文字可读性硬指标(2026-09-29 v3.0 新增,病根 7)**:**一切文字**(含菜单/说明/tooltip/按钮/底栏,无区域例外)对其直接背景 **≥4.5:1**(WCAG AA);大字(≥18px 或加粗档)底线 3:1、目标 4.5:1;**文字禁直接压纹理底**——垫容器或罩层(现行菜单说明 2.58:1、C 区属性行 1.31:1 式违例自此有条款可依)。核验闸 = §1.4 整屏样张。 -**数据素面的墨来自世界的墨**:UI 数字、进度、准线一律主墨 `#33261B` 系——棋盘与面板读同一种墨;容器穿木石材质色,数据只穿墨。**营地与棋盘**:菜单容器背景用全作最暖的烛光黄 `#F6C667` 与木色 `#8A5A34`(§2.8;场景构成 §6.2);战斗容器转中性石色,防暖色渗入棋盘分走数字对比预算——氛围色向可读色让路。UI 不新增语义色,一切强调沿用 §4.2。**暗底呈现态双色墨制**(样张冻结):暗底系(已过/未来/候选/Boss 方块、暗数据框)图标与读数一律暖白 `#F0E2C8`(WARM)、亮底(当前金 `#F0A828`)转深墨 `#181410`(INK_ROUTE)——各态对比 ≥4.5:1;已过节点暗金冻结 `GOLD_DONE #68562C`(专用呈现态值;旧 GOLD_DIM 对双色墨不达标、已退出该呈现位);Boss 描边 = 紫赤 2px + 外缘 1px 深墨环(灰度冗余通道——§4.2 色相独占本色不变,§4.5 灰度可分类承载)。验收:机检〈暗底/亮底各态文字对比 ≥4.5:1〉 +**数据素面的墨来自世界的墨**:UI 数字、进度、准线一律主墨 `#33261B` 系——棋盘与面板读同一种墨;容器穿木石材质色,数据只穿墨。**营地与棋盘**:菜单容器背景用全作最暖的烛光黄 `#F6C667` 与木色 `#8A5A34`(§2.8;场景构成 §6.2);战斗容器转中性石色,防暖色渗入棋盘分走数字对比预算——氛围色向可读色让路。UI 不新增语义色,一切强调沿用 §4.2。**暗底呈现态双色墨制**(关卡画面样张 v1 冻结 2026-09-28):暗底系(已过/未来/候选/Boss 方块、暗数据框)图标与读数一律暖白 `#F0E2C8`(WARM)、亮底(当前金 `#F0A828`)转深墨 `#181410`(INK_ROUTE)——各态对比 ≥4.5:1;已过节点暗金冻结 `GOLD_DONE #68562C`(专用呈现态值,旧 GOLD_DIM `#8C743C` 对双色墨均不达标退役出该呈现位);Boss 描边 = 紫赤 2px + 外缘 1px 深墨环(灰度冗余通道——§4.2 色相独占本色不变,§4.5 灰度可分类承载)。 -- **琥珀双岗注记**:主操作色与收益金分轨——主按钮/候选节点/钥石徽章底用烛光黄 `#F6C667`(CANDLE,独立 token),奖励数字/钥石读数/地图「当前位置」仍独占 §4.1 奖励金 `#F0A828`。烛光黄族身兼「主操作 + 收益邻近氛围」双岗系主动取舍,非疏忽,防回改。底色三件套:BG_ROOT `#3A2E22`/BG_OVERLAY `#362B20`/BG_BRIGHT `#453723`(配色总纲「提暖提亮禁近黑」);暖米族 WARM_SOFT `#E8D5A8`/WARM_FAINT `#CDB88C`(「米色去灰」)。验收:方向性 -- **色板五件对齐注记(来源 = 原型 demo)**:数据板 `PANEL_DATA #E9D9B8`/板线 `PANEL_BORDER #786E58`/状态槽底 `SLOT_BACKDROP #3E362C`/血条深底 `HP_BAR_BACK #282220`/营地头带 `CAMP_HEADER_BAND #6E4526` 五 token 取值向原型 v2.1 对齐(Δ≤6/255 目视不可辨)。色板唯一来源 = ui_kit 单点不变;原型文件只读不改(artwork 红线)。验收:机检〈ui_kit 五 token 与本表一致〉 +- **琥珀双岗注记(2026-10-04 UI 落码批 M0)**:主操作色与收益金分轨——主按钮/候选节点/钥石徽章底用烛光黄 `#F6C667`(CANDLE,独立 token),奖励数字/钥石读数/地图「当前位置」仍独占 §4.1 奖励金 `#F0A828`。烛光黄族身兼「主操作 + 收益邻近氛围」双岗系主动取舍(原型评审 C 组留痕),非疏忽,防回改。底色三件套同步换代:BG_ROOT `#3A2E22`/BG_OVERLAY `#362B20`/新增 BG_BRIGHT `#453723`(配色总纲「提暖提亮禁近黑」);暖米族 WARM_SOFT `#E8D5A8`/WARM_FAINT `#CDB88C`(「米色去灰」)。 +- **色板五件对齐注记(2026-10-06 UI 设计对齐批 P7 乙——demo=唯一来源)**:数据板 `PANEL_DATA #E9D9B8`/板线 `PANEL_BORDER #786E58`/状态槽底 `SLOT_BACKDROP #3E362C`/血条深底 `HP_BAR_BACK #282220`/营地头带 `CAMP_HEADER_BAND #6E4526` 五 token 取值换代向原型 v2.1 对齐(Δ≤6/255 目视不可辨;代码与文档同批双侧写入文档)。色板唯一来源 = ui_kit 单点不变;原型文件只读不改(artwork 红线)。 ### 7.3 布局栅格 -- **结构**:双舞台三区(B 战斗画面/C 数据框/D 棋盘;字母保持不重编)为结构冻结项,分区尺寸与像素级裁决归 battle.md(HUD框架承载 1280×800 基座锚点值,如扫雷图区 1036×504、结算明细带展开 84px)。验收:机检〈三区结构常驻〉 -- **栅格规则(候选)**:面板间距/内边距按 4px 步进档(8/12/16/24 候选),全 UI 恒定;数据区直角细线不占圆角预算。终值归样张。验收:目检〈间距落在步进档〉 +- **结构**:双舞台三区(B 战斗画面/C 数据框/D 棋盘;字母保持不重编)为结构冻结项,分区尺寸与像素级裁决归 battle.md(HUD框架承载 1280×800 基座锚点值,如扫雷图区 1036×504、结算明细带展开 84px)。 +- **栅格口径(候选)**:面板间距/内边距按 4px 步进档(8/12/16/24 候选),全 UI 恒定;数据区直角细线不占圆角预算。终值归样张批。 ### 7.4 字体与字号 -- **字体**:全局一套像素字,单套即终稿——缝合像素字体 Fusion Pixel(OFL,已接入 `game/assets/fonts/ui_font_fusion_pixel_zh_hans.ttf`),拉丁字符与数字沿用其自带拉丁字形,不另设拉丁专件(现状即终稿;旧拉丁候选 Kenney Fonts 已弃;生产工单=清单 §3.6);墨色走主墨系(§7.2);换字体 = 全局决策。验收:机检〈主题字体指向 Fusion Pixel 件〉 -- **字号**:像素字体**整数倍缩放,禁非整数缩放**(像素模糊);下限(1280×800 基座):棋盘数字 ≥18px(公式 = `max(18, ceil(格边长 × 0.5))`不变)、**正文/明细 ≥16px、tooltip ≥14px**(1280×800 低于 1080p 基准,同字号实际更小)。验收:机检〈静态文字位点字号 ≥ 档位表值〉 -- **字号档位表(六档)**:全站 UI 文字字号只经 `UiKit.FS_*` 单点取值,裸数字字号弃用——`FS_CAPTION 12`/`FS_HINT 14`/`FS_BODY 16`/`FS_SUB 18`/`FS_TITLE 24`/`FS_H1 34`。旧实测档按就近归档收编(10/11/12→CAPTION、13/14→HINT、15/16→BODY、17/18/20→SUB、21/22/24/26→TITLE、32/40→H1;静态位点全归档,数据驱动动态字号位点除外);棋盘数字公式 `maxi(18, ceil(格边长×0.5))` 与站桩血数 11px(条带内读数豁免)不入表。注:六档相邻比 12→14→16→18 三段 <1.25×,系裁决值优先于字重纪律的档差参考线,该参考线对新层级设计仍有效。验收:机检〈全站无裸数字字号,位点走 FS_*〉 -- **小字号豁免条款(合并单条不开两口子)**:下款两类**豁免于本节下限**——①**条带内读数**:压在窄条带(血条/进度条等 ≤12px 高带)内居中的数字,受条带高度物理约束,维持 11px 下限,且必须按 §7.2 垫素色牌后计对比(实装=B 区站桩血数 11px 压 HP_BAR_BACK 牌 14.3:1);②**装饰性 overline/caption**:不承载须精确读取的信息、仅作分区标题或氛围小字者,可至 **12px**。**豁免逐处清单(撤除史归 git 提交史,余清单如下)**:条带内读数=B 区站桩血数一处;装饰 caption 档(FS_CAPTION)在册面=棋盘底操作提示条/营地柜台说明文与结构位预留注记/图例 RAP 词条说明/设置警示文两处/关卡地图底栏组标(生命/属性/特性)与分隔系/站桩消散提示/B 区状态槽微计数(×N●○)/bcard 脚注/RAP 反馈行/刷新余量徽标——清单外 11-13px 文字一律按正文/tooltip 下限提档,新增 caption 位须同批增补本清单。验收:机检〈豁免文字位均可对上本清单,清单外无 <14px〉 -- **字重纪律(字重两档不启用)**:层级手段为两件——**字号档差(相邻层级 ≥1.25×)+ 墨色深浅**;全局单字重,不上第二个字重(重开钩子:战斗一屏样张设计轮若实证标题层级不足,字重议题随该轮重开,不另立条目);数字禁描边/阴影伪加粗不变(字形为唯一数值载体,§4.1 同源纪律)。验收:机检〈主题仅注册单字重〉 +- **字体**:全局一套像素字,单套即终稿——缝合像素字体 Fusion Pixel(OFL,已接入 `game/assets/fonts/ui_font_fusion_pixel_zh_hans.ttf`),拉丁字符与数字沿用其自带拉丁字形,不另设拉丁专件(2026-10-24 用户裁:现状即终稿,旧拉丁候选 Kenney Fonts 退役不再接入;生产工单=清单 §3.6);墨色走主墨系(§7.2);换字体 = 全局决策。 +- **字号**:像素字体**整数倍缩放,禁非整数缩放**(像素模糊);下限(1280×800 基座;2026-09-29 v3.0 对齐 ux-designer PC 规范):棋盘数字 ≥18px(公式 = max(18, ceil(格边长 × 0.5))不变)、**正文/明细 ≥16px、tooltip ≥14px**(旧 14/13px 下限退役——1280×800 低于 1080p 基准,同字号实际更小)。 +- **字号档位表(2026-10-06 UI 设计对齐批 P3 甲——WB G1 六档)**:全站 UI 文字字号只经 `UiKit.FS_*` 单点取值,裸数字字号退役——`FS_CAPTION 12`/`FS_HINT 14`/`FS_BODY 16`/`FS_SUB 18`/`FS_TITLE 24`/`FS_H1 34`。旧 15 实测档按就近归档收编(10/11/12→CAPTION、13/14→HINT、15/16→BODY、17/18/20→SUB、21/22/24/26→TITLE、32/40→H1;T0 普查 107 静态位点全归档,数据驱动动态字号位点除外);棋盘数字公式 `maxi(18, ceil(格边长×0.5))` 与站桩血数 11px(条带内读数豁免)不入表。注:六档相邻比 12→14→16→18 三段 <1.25×,系 P3 裁决值优先于字重纪律的档差参考线,档差参考线对新层级设计仍有效。 +- **小字号豁免条款(2026-10-06 UI 设计对齐批 P11+Q5 甲,合并单条不开两口子)**:下款两类**豁免于本节下限**——①**条带内读数**:压在窄条带(血条/进度条等 ≤12px 高带)内居中的数字,受条带高度物理约束,维持 11px 下限,且必须按 §7.2 垫素色牌后计对比(实装=B 区站桩血数 11px 压 HP_BAR_BACK 牌 14.3:1);②**装饰性 overline/caption**:不承载须精确读取的信息、仅作分区标题或氛围小字者,可至 **12px**。**豁免逐处清单(2026-10-06 T6 判定落定,2026-10-22 随迭代 33 卡② 主菜单三入口化撤除「主菜单分组 overline×2」两处——组帽随旧钮组退役,余清单不动)**:条带内读数=B 区站桩血数一处;装饰 caption 档(FS_CAPTION)在册面=棋盘底操作提示条/营地柜台说明文与结构位预留注记/图例 RAP 词条说明/设置警示文两处/关卡地图底栏组标(生命/属性/特性)与分隔系/站桩消散提示/B 区状态槽微计数(×N●○)/bcard 脚注/RAP 反馈行/刷新余量徽标——清单外 11-13px 文字一律按正文/tooltip 下限提档,新增 caption 位须同批增补本清单。 +- **字重纪律(2026-09-29 v3.0 修订;2026-10-24 用户裁:字重两档不启用)**:层级手段为两件——**字号档差(相邻层级 ≥1.25×)+ 墨色深浅**;全局单字重,不上第二个字重(重开钩子:战斗一屏样张批设计轮若实证标题层级不足,字重议题随该批设计轮重开,不另立条目);数字禁描边/阴影伪加粗不变(字形为唯一数值载体,§4.1 同源纪律)。 ### 7.5 营地界面 -- 营地界面=**一张可点击的场景图 + 七物件点选**:点画面里的物件进入对应界面(冒险/锻造工坊/秘宝匣/生涯记录/天赋树/图鉴/离开营地)。场景构成、七个物件清单、画法、分层与交互四态的唯一出处 = §6.2,本节不复述;色域走 §7.2 最暖一档;钥石余额常驻顶部一行小 HUD 栏。界面视觉终稿以表现层样张过审为前置(§1.4 整屏样张闸)。验收:目检〈七物件可点、门洞留空〉 +- 营地界面=**一张可点击的场景图 + 七物件点选**:点画面里的物件进入对应界面(冒险/锻造工坊/秘宝匣/生涯记录/天赋树/图鉴/离开营地)。七个物件=半开的**大门门扇**、挂在木围墙上的**布告板**、**锻炉·铁砧**、**木桩**、**篝火**、**宝箱**、**帐篷旁的笔记本**(**帐篷**为承载件、不可点;它的体量与落地尺寸待作者定)。画面纵深=远景天空与远山/中景木围墙与营地大门/近景篝火与左右物件;**近景物件允许压住中景围墙(围墙是纯背景板),唯一例外=中间门洞必须留空、不被任何物件遮挡**(围墙形态与分带起点的现行口径见 §6.2)。场景图的画法、分层与四态见 §6.2;色域走 §7.2 最暖一档;钥石余额常驻顶部一行小 HUD 栏。界面视觉终稿以表现层样张过审为前置(§1.4 整屏样张闸)。 -### 7.6 引擎原生控件与按钮状态规格 +### 7.6 引擎原生控件与按钮状态规格(2026-10-14 迭代 25/26 反哺写入——原无条款,两批实现=临时权威,本批按呈报转正) -> **来源与边界**:本节由迭代实现批按呈报写入(引擎原生控件皮肤、按钮五态矩阵与全局纹理过滤),现为现行规格。**定值单点纪律(§8 头注)遵守**:本节只锁形态/尺寸档/对比度底线等规则,色值一律指路——主题色值事实源 = `game/assets/theme/README.md` 溯源表(与 ui_kit 常量双源,见本节末注),本节不复述色值。 +> **来源与边界**:迭代 25(UI 全局主题批)立引擎原生控件皮肤、迭代 26(UI 交互与观感批)立按钮五态矩阵与全局纹理过滤——此前 §7 无相应条款,两批实现系临时权威,用户 2026-10-14 裁「全部按呈报写入文档」转正为本节。**定值单点纪律(§8 头注)遵守**:本节只锁形态/尺寸档/对比度底线等规则,色值一律指路——主题色值事实源 = `game/assets/theme/README.md` 溯源表(与 ui_kit 常量双源,见本节末注),本节不复述色值。 -**(一)按钮五态矩阵(冒烟五态断言在闸)** +**(一)按钮五态矩阵(迭代 26 T1;冒烟 V1/V2 35 断言在闸)** -- **五态齐全**:全站按钮(普通/主/危险三厂)必须五态非空——常态 / 悬停 / 按下 / 禁用 / 键盘焦点;任一态缺档即违本条。验收:机检〈冒烟五态断言全绿〉 -- **悬停 = 常态图 + modulate 提亮**:提亮档 `Color(1.06,1.06,1.06)`,零素材;主钮承 `CANDLE.lightened(0.06)` 同纪律。验收:机检〈悬停态样式非空且非常态图直贴〉 -- **按下 = 本族下沉**:石纹钮 `button_stone_pressed` 九宫格;危险钮补本族 pressed 深档(深红 `darkened(0.18)`)。验收:机检〈三厂 pressed 样式各归本族〉 -- **禁用 = 本族底 + α0.45 幽灵化**:flat 两厂 bg+border 同 α 全幽灵化;文字禁用色同档。(注:本条管 **UI 按钮**;营地场景图里的**物件**禁用态另按 §6.2——降明度 + 去饱和,不走 α 幽灵化。)验收:机检〈禁用态非按压图、α0.45〉 -- **键盘焦点 = INK 2px 内缩 1px 环**:负 expand margin 实现内缩;引擎 focus 样式盒叠加绘制、不遮钮底;逐实例 override、**主题层零新增词条**(冒烟双红线在闸)。验收:机检〈焦点样式在位、主题词条数不增〉 -- 新增按钮位须同批过五态清单;验收 = §1.4 样张 + 冒烟五态断言。验收:机检〈新增按钮位入冒烟五态清单〉 +- **五态齐全**:全站按钮(普通/主/危险三厂)必须五态非空——常态 / 悬停 / 按下 / 禁用 / 键盘焦点;任一态缺档即违本条(旧态:悬停填常态图=无反馈、禁用错配按压图、焦点全站空白)。 +- **悬停 = 常态图 + modulate 提亮**(乙案):提亮档 `Color(1.06,1.06,1.06)`,零素材;主钮承 `CANDLE.lightened(0.06)` 同纪律。 +- **按下 = 本族下沉**:石纹钮 `button_stone_pressed` 九宫格;危险钮补本族 pressed 深档(深红 `darkened(0.18)`——旧承石纹按压图错配退役)。 +- **禁用 = 本族底 + α0.45 幽灵化**(甲案):flat 两厂 bg+border 同 α 全幽灵化,取代旧「disabled=按压图」错配;文字禁用色同档。 (注:本条管 **UI 按钮**;营地场景图里的**物件**禁用态另按 §6.2——降明度 + 去饱和,不走 α 幽灵化。) +- **键盘焦点 = INK 2px 内缩 1px 环**(甲案):负 expand margin 实现内缩;引擎 focus 样式盒叠加绘制、不遮钮底(「三类钮底可辨性实比」设计语言成立);逐实例 override、**主题层零新增词条**(冒烟双红线在闸)。 +- 新增按钮位须同批过五态清单;验收 = §1.4 样张 + 冒烟五态断言。 -**(二)引擎原生控件组件规格(15 类型词条全覆盖,预盖件零实役)** +**(二)引擎原生控件组件规格(迭代 25 T0;15 类型词条全覆盖,预盖件零实役)** -- **滚动条**:V/HScrollBar 的 scroll 槽(直角)+ grabber 三态;**引擎事实 = grabber 走 StyleBox 非 Icon**(4.7.2 源码实证),滚动条无位图、无箭头按钮(increment/decrement 默认主题未定义);粗细 = content margin 4(8px)、柄最小 8px。验收:目检〈滚动条非引擎默认灰〉 -- **滑轨**:HSlider/VSlider 的 slider(槽轨)+ grabber_area(填充段)两段必须**一眼可辨**(§7.2 可读性硬条款的滑轨侧落法——判据 = 拖到中点两段可辨,机检对 ≥4.5:1 文字位不覆盖该面,归样张目检);抓子三态位图;H/V 边距为旋转换位。验收:目检〈拖到中点两段可辨〉 -- **悬停气泡(Tooltip)**:TooltipPanel/TooltipLabel 深底 + 边 + 暖字,标题屏观感承旧值原样迁入;字号走 §7.4 既有档(tooltip 14)。验收:目检 -- **输入件**:LineEdit(数据框惯例:牌色底 + 板线 + `RADIUS_OBJECT` 圆角 + 占位色)、SpinBox(updown 合并箭头位图 + 箭头底 hover/pressed 覆盖)、H/VSeparator(单线 1px,默认灰 #808080 实证在位须接管)。验收:目检 -- **预盖件**(零实役,备案待启用时复查):CheckBox/CheckButton 共用一套 `checked`/`unchecked`(+_disabled) 四态图标(**引擎事实 = 4.7 无 on/off 词条,两类同名同套——否则预盖静默空转**)、OptionButton(button_stone 九宫格 + 箭头位图)、PopupMenu(panel + hover 高亮 + 字色字号)。验收:方向性 -- **字体防御**:主题 `default_font` 显式指向全局像素字(与 project.godot `theme/custom_font` 同文件),冒烟双断言(主题非空 + Label 实取非空);`default_font_size` 不设——全站字号走 §7.4 `UiKit.FS_*` 逐实例 override。验收:机检〈冒烟字体双断言全绿〉 -- **组件新增位**:新引擎控件入 UI 须同批在本节登记规格(尺寸档/圆角/对比度底线),禁裸用引擎默认灰圆角现代风。验收:目检〈新控件无默认灰样式〉 +- **滚动条**:V/HScrollBar 的 scroll 槽(直角)+ grabber 三态;**引擎事实 = grabber 走 StyleBox 非 Icon**(4.7.2 源码实证),滚动条无位图、无箭头按钮(increment/decrement 默认主题未定义);粗细 = content margin 4(8px)、柄最小 8px。 +- **滑轨**:HSlider/VSlider 的 slider(槽轨)+ grabber_area(填充段)两段必须**一眼可辨**(§7.2 可读性硬条款的滑轨侧落法——判据 = 拖到中点两段可辨,机检对 ≥4.5:1 文字位不覆盖该面,归样张目检);抓子三态位图;H/V 边距为旋转换位。 +- **悬停气泡(Tooltip)**:TooltipPanel/TooltipLabel 深底 + 边 + 暖字,标题屏观感承旧值原样迁入;字号走 §7.4 既有档(tooltip 14)。 +- **输入件**:LineEdit(数据框惯例:牌色底 + 板线 + `RADIUS_OBJECT` 圆角 + 占位色)、SpinBox(updown 合并箭头位图 + 箭头底 hover/pressed 覆盖)、H/VSeparator(单线 1px,默认灰 #808080 实证在位须接管)。 +- **预盖件**(零实役,备案待启用时复查):CheckBox/CheckButton 共用一套 `checked`/`unchecked`(+_disabled) 四态图标(**引擎事实 = 4.7 无 on/off 词条,两类同名同套——否则预盖静默空转**)、OptionButton(button_stone 九宫格 + 箭头位图)、PopupMenu(panel + hover 高亮 + 字色字号)。 +- **字体防御**:主题 `default_font` 显式指向全局像素字(与 project.godot `theme/custom_font` 同文件),冒烟双断言(主题非空 + Label 实取非空);`default_font_size` 不设——全站字号走 §7.4 `UiKit.FS_*` 逐实例 override。 +- **组件新增位**:新引擎控件入 UI 须同批在本节登记规格(尺寸档/圆角/对比度底线),禁裸用引擎默认灰圆角现代风(迭代 25 全屏巡检即此判据)。 -**(三)氛围层平滑件与抖动派并存** +**(三)氛围层平滑件与抖动派并存(迭代 26 反哺④——Q5 已裁保平滑)** -- vignette / gradient_base / beams 三件程序化平滑渐变件**保平滑连续 alpha**(已裁),与卡顶类型色带 DitherBand 的 Bayer 有序抖动派**并存不一致**——两派统一裁决留**整屏样张**(§1.4;战斗一屏样张未开工,待办清单在册),本节只登记并存事实与裁决去向,不提前统一。验收:方向性 +- vignette / gradient_base / beams 三件程序化平滑渐变件**保平滑连续 alpha**(2026-10-13 grilling Q5 裁),与卡顶类型色带 DitherBand 的 Bayer 有序抖动派**并存不一致**——两派统一裁决留**整屏样张批**(§1.4;战斗一屏样张 P0 未开工,待办清单在册),本节只登记并存事实与裁决去向,不提前统一。 -> **主题色值双源注记**:主题色值现存两处——`main_theme.tres` 字面量与 ui_kit 常量(README 溯源表缓解)。长期收敛候选 = 程序化 Theme 生成回代码单点,属重构面、未排期;新增色值仍按现状双落并同批登 README 溯源表。 +> **主题色值双源注记(迭代 25 反哺④)**:主题色值现存两处——`main_theme.tres` 字面量与 ui_kit 常量(卡片立项时已知代价,README 溯源表缓解)。长期收敛候选 = 程序化 Theme 生成回代码单点,属重构面、未排期;新增色值仍按现状双落并同批登 README 溯源表。 --- ## 8. 资产与技术规范(Asset & Technical Specs) -> **白话**:这一章是给所有出图者(人与脚本)的技术底线——多大的画布、什么格式、怎么命名。屏级格边长定值的裁决权在 battle.md;本节锁资产侧:分辨率档位、像素基准与缩放纪律、导出与命名、帧数规格。生产工单视图 = art-assets-checklist。 +> **白话**:这一章是给所有出图者(人与脚本)的技术底线——多大的画布、什么格式、怎么命名。屏级格边长定值的裁决权在 battle.md;本节锁资产侧:分辨率档位、像素基准与缩放纪律、导出与命名、帧数规格。生产工单视图 = art-assets-checklist。 -> **定值单点纪律**:全文数字定值只落在**两张表**——**§4.1 主色板**(全部色值+明度/饱和边界)与 **§8.2**(画布/体量/立绘源档/圆角率/缩放);其余章节一律**指路、禁复述数字**(修订改值只动单点处);character-design §5.5 为档位**语义**表(普通/精英/Boss + 例外),数值权威 = §8.2。 +> **定值单点纪律(2026-10-06 审计 §07-1 落规)**:全文数字定值只落在**两张表**——**§4.1 主色板**(全部色值+明度/饱和边界)与 **§8.2**(画布/体量/立绘源档/圆角率/缩放);其余章节一律**指路、禁复述数字**(修订改值只动单点处);character-design §5.5 为档位**语义**表(普通/精英/Boss + 例外),数值权威 = §8.2;存量双写复述随整备批(待办清单挂账)逐步收编。 -### 8.1 分辨率档位与格边长 +### 8.1 分辨率档位与格边长口径 -- **两档定案**:1280×800(基座)+ 1920×1080,窗口不可调;后续调整走修订。验收:机检〈工程分辨率配置与两档一致〉 -- **格边长取值**:按档位与板形计算——固定档位下格子量增加则格边长相应变小;公式、单位与现行算例的唯一出处 = [battle.md](../gdd/ux/battle.md)「棋盘格尺寸公式」,本节不复述数值。资产侧锁两条比例:**瓦片整格铺满**(32×32 源随格边长缩放,允许非整数倍——瓦片不适用 §8.2 甲类整数倍纪律)、**棋盘小人显示占位 ≤ 0.75× 格边长**。(1920 档已裁决全局等比缩放)验收:机检〈瓦片显示尺寸随格边长、小人占位 ≤0.75×〉 +- **两档定案**:1280×800(基座)+ 1920×1080,窗口不可调;后续调整走修订。 +- **格边长口径**:按档位与板形计算——固定档位下格子量增加则格边长相应变小;公式、单位与现行算例的唯一出处 = [battle.md](../gdd/ux/battle.md)「棋盘格尺寸公式」,本节不复述数值。资产侧锁两条比例:**瓦片整格铺满**(32×32 源随格边长缩放,允许非整数倍——瓦片不适用 §8.2 甲类整数倍纪律)、**棋盘小人显示占位 ≤ 0.75× 格边长**。(1920 档已裁决全局等比缩放——迭代 9 卡⑮落码) ### 8.2 像素基准与画布 -- **缩放纪律(硬规则,按资产两类分级;不做个案豁免)**: - - **甲类·文本与语义件**(数字、图标、旗、格面纹样):维持严格整数倍缩放硬规则——可读性与语义精确性优先,**禁非整数缩放不变**。砖面 tile 不属本类,随格边长整格铺满(见 §8.1)。验收:机检〈甲类件显示尺寸为源整数倍〉 - - **全局纹理过滤与平滑渐变件例外**:`project.godot` `default_texture_filter=0`(Nearest)全局——像素风 UI 皮(九宫格/主题石纹样式盒/滑轨抓子/勾选/箭头图标)翻 Nearest=意图面;**显式 LINEAR 豁免清单(有意例外)= vignette / gradient_base / beams 三件程序化平滑渐变件**——保平滑连续 alpha 的程序化氛围件(§7.6 三已裁);新增平滑件须同批入册本清单,禁静默挂 LINEAR。翻转面枚举与冒烟断言(6 条)在闸。验收:机检〈过滤面枚举与 LINEAR 清单逐项一致〉 - - **显示档登记例外(64 源 0.5×,待素材批消除)**:柜台四枚 `icon_forge/workshop/atlas/keystone`(64×64 源)显示档统一 **32px = 0.5×**;状态九枚(32×32 源)mini(站桩)档 **16px = 0.5×**。两处均系「显示尺寸侧唯一可调、素材一字节禁改」红线下的人工检整数半倍档,登记为**待消除例外**——素材设计出 32px 源后回归 1×。常规档显示一律 1×(状态九枚 32px、手绘十枚 20px/40px 双档、BCard 图标槽 40px=2×)。验收:机检〈显示档与登记表一致〉 - - **乙类·角色与单位精灵**(有机形体、非文字):允许运行时非整数倍缩放(NEAREST 过滤不变),容忍轻度像素宽窄不均;判据 = 实机目检,可感不适即回整数档。乙类免逐案豁免。验收:目检〈立绘无可感拉花〉 - - **站桩立绘槽位=高定宽随**:立绘/精灵槽位只定「高度的整数倍档」,宽度随源图宽高比自适应——**禁止把全部立绘塞进统一方框强拉**(纵横倍率必须恒等)。反面实例:统一方框把窄条生物源(细长本体裁掉透明边)按主角比例横拉压扁发花。乙类的非整数豁免只覆盖**等比**缩放,不含纵横异倍变形。验收:机检〈立绘纵横倍率恒等〉 - - **1920×1080 档策略**:全局等比(stretch=canvas_items+aspect **keep**)——16:10 设计在 16:9 窗口=等比 1.35×+左右黑边各 ~96px,构图与 1280×800 基准档完全一致;甲类 1.35 非整数缩放例外注记:轻度宽窄不均系本档位接受代价(像素游戏 shipped 常态),实机目检可感不适→回乙案(1920 独立 2× 基准)重议;乙类同前款。验收:目检〈1920 档构图与 1280 档一致〉 -- **画布基准(暂定值)**: - - **主角人设原图 = 48×72**(1× 基准,暂定;与站姿降采样可读性验证一致); - - **棋盘小人 = 32×32 二头身派生版(暂定,允许更小)**——独立资产,自人设定稿压缩派生(character-design §5.4),不吞数字位、占位 ≤ 0.75× 格边长;板越小占比摩擦越大(13×13 / 14×14 更小,算例见 [battle.md](../gdd/ux/battle.md)「棋盘格尺寸公式」),32px 源档沿「独立小源档」路线在样张定终值; - - **站桩立绘源档 = 八敌逐键定档**:主角 48 不变;八敌源档高 = goblin 36 / giant_spider 38 / duergar 46 / hook_horror 58 / bugbear 62 / ogre_chief 74 / drider 90 / duergar_king 94,显示一律 **×2 整数档(NEAREST)** = 72–188。定档依据 = 生物体型三约束(钩爪恐魔>灰矮人、灰矮人<熊地精、食人魔>熊地精——体量=物种体型,非强度线性映射,见 character-design §5.2);可读性实测门槛 = 源高 ≥32(24 糊/32 可读/48 清楚/64 细节完整),全阶梯 36–94 达标;派生管线 = pixel-1.png 紧裁 → 等比缩至档高 → dom_block_resize 块内众数降采样(tools/pixelize_candidates.py `STANDEE_TIER_H`,`SIZES` 降格为管线上游中间分辨率表);体量存在感经整屏对比校验(B 区 270/立绘显示上限 188)。验收:机检〈standee_asset_check 逐键档高校验〉 - - **站桩立绘排版**:① 区高 B 区 270(≈1/3)/C 区 530(≈2/3),**扫雷盘 438×438 原尺寸不缩、格子不变**(区高事实源 battle.md 区表;压缩由 C 区面板空档吸收);② 站桩内部重排——立绘双脚落石台上缘(弃盒内居中)、**血条+状态/名字行落底排**(立绘→血条→状态 顺序,全场血条对齐一线;血条嵌石板位、台缘线留作地面线、落影保留接地感),状态空时该行退化为名字行;③ 大体档(显示 >150px 级:drider/duergar_king)**允许向上溢出盒顶 N px**(N 由实装批按实测尺寸算定,**与背景/光柱无关**——现行溢出区恰为临时光柱氛围层〔§6.1 方向稿四柱,x 基准 110/900/1055/1210@1280,`battle_stage_view.gd:55-63`〕,正式舞台背景插画 `stage/stage_bg.png` 到位后该层由背景绘制吸收,**溢出判定不随之变更**;横向禁止压到顶部中央轮次·阶段指示,溢出上限由实装批用真图复算 x 区间定),血条仍落底(落底排的第二个收益=头部探顶不带出血条);意图徽标悬浮头顶上方(探顶档压胸);④ 「血条悬浮头顶+立绘盒内居中」排式已弃——盒顶固定 y 与盒内居中在高盒下拉开血条-立绘空洞。验收:目检〈血条一线对齐、大体档溢出不压轮次指示〉 +- **缩放纪律(硬规则,2026-09-30 v3.1 起按资产两类分级——2026-09-26 用户裁「更改规则,不做个案豁免」)**: + - **甲类·文本与语义件**(数字、图标、旗、格面纹样):维持严格整数倍缩放硬规则——可读性与语义精确性优先,**禁非整数缩放不变**。砖面 tile 不属本类,随格边长整格铺满(见 §8.1)。 + - **全局纹理过滤与平滑渐变件例外(2026-10-14 迭代 26 反哺②写入文档)**:`project.godot` `default_texture_filter=0`(Nearest)全局——像素风 UI 皮(九宫格/主题石纹样式盒/滑轨抓子/勾选/箭头图标)翻 Nearest=意图面;**显式 LINEAR 豁免清单(有意例外,与状态图标抗锯齿例外同族先例)= vignette / gradient_base / beams 三件程序化平滑渐变件**——保平滑连续 alpha 的程序化氛围件,Q5 已裁(§7.6 三);新增平滑件须同批入册本清单,禁静默挂 LINEAR。翻转面枚举与冒烟断言(6 条)在闸。 + - **显示档登记例外(2026-10-06 UI 设计对齐批 P6 甲——64 源 0.5×,待素材批消除)**:柜台四枚 `icon_forge/workshop/atlas/keystone`(64×64 源)显示档统一 **32px = 0.5×**;状态九枚(32×32 源)mini(站桩)档 **16px = 0.5×**。两处均系「显示尺寸侧唯一可调、素材一字节禁改」红线下的人工检整数半倍档,登记为**待消除例外**——素材批出 32px 源后回归 1×。常规档显示一律 1×(状态九枚 32px、手绘十枚 20px/40px 双档、BCard 图标槽 40px=2×)。 + - **乙类·角色与单位精灵**(有机形体、非文字):允许运行时非整数倍缩放(NEAREST 过滤不变),容忍轻度像素宽窄不均;判据 = 实机目检,可感不适即回整数档。乙类今后免逐案豁免(豁免蔓延断根)。 + - **站桩立绘槽位=高定宽随(2026-10-04 原型审计教训落规)**:立绘/精灵槽位只定「高度的整数倍档」,宽度随源图宽高比自适应——**禁止把全部立绘塞进统一方框强拉**(纵横倍率必须恒等)。实例=UI 原型 88×92 方框把 23×48 地精源横拉 3.83×/纵拉 1.92×、26×48 熊地精 3.38×/1.92× 压扁发花,46×48 主角近方恰等比故独活——方框是按主角比例定的,窄条生物(细长本体裁掉透明边)进主角的框必变形。乙类的非整数豁免只覆盖**等比**缩放,不含纵横异倍变形。 + - **1920×1080 档策略(2026-09-30 三裁决案甲落定——三案呈报原件已随中间文档退役,删除批见 commit 8e02dcf0)**:全局等比(stretch=canvas_items+aspect **keep**)——16:10 设计在 16:9 窗口=等比 1.35×+左右黑边各 ~96px,构图与 1280×800 基准档完全一致(逻辑布局 1920/1280 逐键一致实测);甲类 1.35 非整数缩放例外注记:轻度宽窄不均系本档位接受代价(像素游戏 shipped 常态),实机目检可感不适→回乙案(1920 独立 2× 基准)重议;乙类同前款。 +- **画布基准(2026-09-23 用户暂定值)**: + - **主角人设原图 = 48×72**(1× 基准,暂定;与站姿降采样可读性验证一致); + - **主角战斗状态图(含 B 区站桩侧向)= 48×72**(同上暂定;v1.9 待决注记「主角立绘档空缺」由此完成); + - **棋盘小人 = 32×32 二头身派生版(暂定,允许更小)**——独立资产,自人设定稿压缩派生(人物设计档 §5.4),不吞数字位、占位 ≤ 0.75× 格边长;板越小占比摩擦越大(13×13 / 14×14 更小,算例见 [battle.md](../gdd/ux/battle.md)「棋盘格尺寸公式」),32px 源档的去留(v1.9 待决注记①)沿「独立小源档」路线在样张批定终值; + - **站桩立绘源档 = 八敌逐键定档(2026-10-06 定档批,用户确认定档总图;原三档 48/64/96 候选与 v3.0 档位上调方向候选一并完成)**:主角 48 不变;八敌源档高 = goblin 36 / giant_spider 38 / duergar 46 / hook_horror 58 / bugbear 62 / ogre_chief 74 / drider 90 / duergar_king 94,显示一律 **×2 整数档(NEAREST)** = 72–188。定档依据 = 生物体型三约束(用户拍板:钩爪恐魔>灰矮人、灰矮人<熊地精、食人魔>熊地精——体量=物种体型,非强度线性映射,人物设计档 §5.2 顺带修订);可读性实测门槛 = 源高 ≥32(24 糊/32 可读/48 清楚/64 细节完整),全阶梯 36–94 达标;派生管线 = pixel-1.png 紧裁 → 等比缩至档高 → dom_block_resize 块内众数降采样(tools/pixelize_candidates.py `STANDEE_TIER_H`,`SIZES` 降格为管线上游中间分辨率表);存在感问题(v3.0 病根 6)经方案二整屏对比(B 区 270/立绘显示上限 188)解决。 + - **站桩立绘排版 = 方案二(2026-10-06 用户拍板)**:① 区高 B 区 270(≈1/3)/C 区 530(≈2/3),**扫雷盘 438×438 原尺寸不缩、格子不变**(区高事实源 battle.md 区表;压缩由 C 区面板空档吸收);② 站桩内部重排——立绘双脚落石台上缘(弃盒内居中)、**血条+状态/名字行落底排**(立绘→血条→状态 顺序,全场血条对齐一线;血条嵌石板位、台缘线留作地面线、落影保留接地感),状态空时该行退化为名字行;③ 大体档(显示 >150px 级:drider/duergar_king)**允许向上溢出盒顶 N px**(2026-10-07 用户裁甲案;N 由实装批 C0 按实测尺寸算定,**与背景/光柱无关**——现行溢出区恰为临时光柱氛围层〔§6.1 方向稿四柱,x 基准 110/900/1055/1210@1280,`battle_stage_view.gd:55-63`〕,正式舞台背景插画 `stage/stage_bg.png` 到位后该层退役/吸收进背景绘制,**溢出判定不随之变更**;横向不得压到顶部中央轮次·阶段指示,溢出上限由 C0 用真图复算 x 区间定),血条仍落底(落底排的第二个收益=头部探顶不带出血条);意图徽标悬浮头顶上方(探顶档压胸);④ 原「血条悬浮头顶+立绘盒内居中」排式退役——盒顶固定 y 与盒内居中在高盒下拉开血条-立绘空洞(2026-10-06 用户点名否决)。 ### 8.3 导出格式与命名规范 -- **归档纪律**:图片素材**不入 git 版本管理**——仓库只保留运行时引用资产(game/assets);生成链路原始产物(AI 原图/中间产物/候选版本)归档至 `archive/` 并择机上对象存储备份;主文档(圣经/GDD/清单)**禁引用 artwork/ 散文件路径**——定稿成图以 game/assets 为唯一事实源,过程稿引用一律用归档包路径或描述性出处。验收:机检〈主文档 grep 无 artwork/ 散文件路径〉 -- **入包与对账检查器(挂点已立,实现随 PRD 工具批)**:①**色板检查器**——扫描新素材色板,报新增色/近色告警/超 §4.1 上限(48)拦截,挂素材流入包步(asset-path-map §2 第 3 步);②**路径核对器**——扫描 asset-path-map 声明的全部路径,悬空/未清即报,随大批与审计例行跑;③**standee_asset_check 硬闸升级规格**——按 §8.2 逐键定档校验体量与档高、像素域色数 ≤32、无非整数倍缩放痕迹、描边亮度达标(亮描边明度档);把「高定宽随」教训变成代码闸而非文字。验收:机检〈三检查器在闸可跑〉 -- **交付格式**:PNG 透明底;动画 = 序列帧或引擎精灵表(SpriteFrames),帧尺寸恒定、横向等宽排列(候选)。验收:目检〈交付件透明底无杂边〉 -- **命名规范(候选草案)**:`<域>_<对象键>_<状态|变体>[_<序号>].png`,全小写 snake_case;**对象键 = 数据表主键**(effect_id/reward_id/cell_type/enemy_id——图标键契约 §4.6 的文件侧延伸:资产文件名键 = 图标键,全局恒定禁运行时换图);图类型变体后缀 = board_type 键(`_dungeon`/`_underdark`/`_deep_underdark`);候选评审件与终稿分目录(`artwork/candidates/` 承载评审件)。验收:机检〈命名与目录检查器零红〉 +- **归档纪律(2026-10-06 P0-1 用户口径写入文档)**:图片素材**不入 git 版本管理**——仓库只保留运行时引用资产(game/assets);生成链路原始产物(AI 原图/中间产物/候选版本)归档至 `archive/` 并择机上对象存储备份;主文档(圣经/GDD/清单)**禁引用 artwork/ 散文件路径**——定稿成图以 game/assets 为唯一事实源,过程稿引用一律用归档包路径或描述性出处。 +- **入包与对账检查器(2026-10-06 P2 固化挂点——本批只立挂点,实现随 PRD 工具批)**:①**色板检查器**——扫描新素材色板,报新增色/近色告警/超 §4.1 上限(48)拦截,挂素材流入包步(asset-path-map §2 第 3 步);②**路径核对器**——扫描 asset-path-map 声明的全部路径,悬空/退役未清即报,随大批与审计例行跑(2026-10-06 三处悬空即此类失守);③**standee_asset_check 硬闸升级规格**(审计 §07-2/3)——按 §8.2 逐键定档校验体量与档高(现「宽 1–75」虚设区间退役)、像素域色数 ≤32、无非整数倍缩放痕迹、描边亮度达标(亮描边明度档,§8.2 描边层级);把 v3.6「高定宽随」教训变成代码闸而非文字。 +- **交付格式**:PNG 透明底;动画 = 序列帧或引擎精灵表(SpriteFrames),帧尺寸恒定、横向等宽排列(候选)。 +- **命名规范(候选草案)**:`<域>_<对象键>_<状态|变体>[_<序号>].png`,全小写 snake_case;**对象键 = 数据表主键**(effect_id/reward_id/cell_type/enemy_id——图标键契约 §4.6 的文件侧延伸:资产文件名键 = 图标键,全局恒定禁运行时换图);图类型变体后缀 = board_type 键(`_dungeon`/`_underdark`/`_deep_underdark`);候选评审件与终稿分目录(`artwork/candidates/` 承载评审件)。 -### 8.4 帧数规格(样张冻结) +### 8.4 帧数规格(样张 v2 冻结 2026-09-28) -- **帧数规格(样张冻结)**:待机循环 4 帧;攻击/技能准备 6 帧单次;受击 2 帧;死亡 = 灰化+碎裂退场(§4.6.4 语义硬规则),序列帧归 ✦ 缺料清单;帧时长与结算节拍对齐(预告时序见 §4.6.4)。验收:机检〈序列帧数与档位一致〉 -- **方向集**:棋盘小人=**4 向**(下/上/侧,侧向左右镜像共用一份画稿);斜向移动/挖掘按**主导轴吸附最近正交向**呈现,不出 45° 斜向帧(斜向信息由位移轨迹与目标格预告承载,ITB 实态同款)。镜像与「旗面恒画右」纪律(character-design.md 冒险背包条)的相容归属:棋盘小人默认镜像豁免、旗右纪律限立绘侧——代拟规则,样张确认。验收:目检〈四向帧齐、侧向镜像无破绽〉 +- **帧数规格(样张 v2 冻结,2026-09-28)**:待机循环 4 帧;攻击/技能准备 6 帧单次;受击 2 帧;死亡 = 灰化+碎裂退场(§4.6.4 语义硬规则),序列帧归 ✦ 缺料清单;帧时长与结算节拍对齐(预告时序见 §4.6.4)。 +- **方向集(2026-09-24 用户拍板)**:棋盘小人=**4 向**(下/上/侧,侧向左右镜像共用一份画稿);斜向移动/挖掘按**主导轴吸附最近正交向**呈现,不出 45° 斜向帧(斜向信息由位移轨迹与目标格预告承载,ITB 实态同款)。镜像与「旗面恒画右」纪律(character-design.md 冒险背包条)的相容归属:棋盘小人默认镜像豁免、旗右纪律限立绘侧——**代拟口径,样张批追认**。 -### 8.5 结构件完整性与参考核验 +### 8.5 结构件完整性与参考核验(2026-09-24 用户立规) -> **白话**:格子读感里的立体结构——顶面、缘件、角件、周界轮廓——必须是素材里**画出来的结构件**;素材缺件就停下报缺,拿调色/渐变/滤镜顶替不是变通,是违规。 +> **白话**:格子读感里的立体结构——顶面、缘件、角件、周界轮廓——必须是素材里**画出来的结构件**;素材缺件就停下报缺,拿调色/渐变/滤镜顶替不是变通,是违规。 -- **结构件完整性**:格面立体感(高墙顶面/坡缘/角件/周界轮廓)只能来自**素材本体绘制的结构件拼装**(样例=Necesse 式缘件:专属底色+手绘细节+暗轮廓线的独立贴图);禁止以调色、渐变、明暗滤镜、跨格投影等生成手段伪造素材中不存在的结构。样稿暴露缺件时,正确动作=停下报告缺口与「换素材/自产缺件」选项,由用户裁决后执行。验收:目检〈立体感逐件来自素材本体,无滤镜伪造〉 -- **参考核验**:宣称「参考 X 游戏」前,必须取得并实际查看 X 的截图证据(像素级放大核验其实现方式),证据存 `artwork/mockups/` 或 `design/art/references/`;仅凭文字资料、记忆或宏观印象作出「X 是这样做」的断言无效。验收:目检〈参考结论有截图证据在库〉 -- **图底分离**:已开地面的明度须低于未开格墙体,由素材选型承载(选更暗的地板砖),不靠渲染压暗实现。验收:机检〈已开格底明度低于墙顶面〉 -- **生产分工**:格面地形类素材=**材质归模型、结构归程序**——AI 生图模型只出无缝填充纹理(无边框/无亮边/均匀光照/方形);一切结构件(缘件/轮廓/立面/角件/收头)一律程序化绘制或手修,因为 autotile 任意拼接的边界像素约束超出 2D 生图模型能力(实证:生图 5×5 瓦片表每枚砖自带啃咬边框与宽亮带,提示词无法根除)。**镜像规则**:东西对称结构件只许水平翻转(flip_h)复用、**禁旋转**——北受光下东西侧缘光照对称,镜像无损真;旋转会把北受光转到东边。验收:目检〈瓦片表无自带边框/亮带;对称件仅 flip_h〉 +- **结构件完整性**:格面立体感(高墙顶面/坡缘/角件/周界轮廓)只能来自**素材本体绘制的结构件拼装**(样例=Necesse 式缘件:专属底色+手绘细节+暗轮廓线的独立贴图);禁止以调色、渐变、明暗滤镜、跨格投影等生成手段伪造素材中不存在的结构。样稿暴露缺件时,正确动作=停下呈报缺口与「换素材/自产缺件」选项,由用户裁决后执行。 +- **参考核验**:宣称「参考 X 游戏」前,必须取得并实际查看 X 的截图证据(像素级放大核验其实现方式),证据存 `artwork/mockups/` 或 `design/art/references/`;仅凭文字资料、记忆或宏观印象作出「X 是这样做」的断言无效。(2026-10-07 路径核对器 B3 首跑发现更正——原 `production/mockups/` 系废路径,v3.9 批已更正asset-path-map 等四处,本条漏网) +- **图底分离**:已开地面的明度须低于未开格墙体,由素材选型承载(选更暗的地板砖),不靠渲染压暗实现。 +- **生产分工(2026-09-26 用户拍板)**:格面地形类素材=**材质归模型、结构归程序**——AI 生图模型只出无缝填充纹理(无边框/无亮边/均匀光照/方形);一切结构件(缘件/轮廓/立面/角件/收头)一律程序化绘制或手修,因为 autotile 任意拼接的边界像素约束超出 2D 生图模型能力(实证:2026-09-25 生图 5×5 瓦片表每枚砖自带啃咬边框与宽亮带,提示词无法根除)。**镜像规则**:东西对称结构件只许水平翻转(flip_h)复用、**禁旋转**——北受光下东西侧缘光照对称,镜像无损真;旋转会把北受光转到东边。 --- ## 9. 参考与反参考(References & Anti-References) -> **白话**:正面参考 = 向谁抄什么;反面参考 = 避什么。两者同等效力——方案违反反面条款时,禁止以「参考了某游戏」辩护。参考图库存于 `design/art/references/`(点名池三款×2 张,与头注点名池一一对应)。 +> **白话**:正面参考 = 向谁抄什么;反面参考 = 避什么。两者同等效力——方案违反反面条款时,禁止以「参考了某游戏」辩护。参考图库存于 `design/art/references/`(点名池三款×2 张,与头注点名池一一对应)。 -### 9.1 正面参考(点名池,用户审阅选定) +### 9.1 正面参考(点名池,用户审阅选定) | 参考 | 取 | 弃 | |---|---|---| -| **Into the Breach** | 格内信息分层、攻击预告上格范式、战棋地块质感骨架(§1.2 原则一;§3.2) | 冷暗军事色调、压暗信息板、斜切 HUD(原则二;§3.3) | -| **Moonlighter** | 暖质地砖与砖缝质感、亲和冒险者气质、木石暖色道具(原则二/原则三);**光照层次与明度纵深**(亮地板/暗深渊/发光体三档,§2 明度分层) | 全拟物倾向(§3.3 半拟物边界);Darkest 式情绪压暗(§9.2) | -| **Peglin** | 明快全年龄洞穴色彩、胖体量亲和怪(§5.1 普通怪原型) | 色块依赖——先读轮廓后读颜色(§5.1) | +| **Into the Breach** | 格内信息分层、攻击预告上格范式、战棋地块质感骨架(§1.2 原则一;§3.2) | 冷暗军事色调、压暗信息板、斜切 HUD(原则二;§3.3) | +| **Moonlighter** | 暖质地砖与砖缝质感、亲和冒险者气质、木石暖色道具(原则二/原则三);**光照层次与明度纵深**(2026-09-29 v3.0 起取——亮地板/暗深渊/发光体三档,§2 明度分层) | 全拟物倾向(§3.3 半拟物边界);Darkest 式情绪压暗(§9.2) | +| **Peglin** | 明快全年龄洞穴色彩、胖体量亲和怪(§5.1 普通怪原型) | 色块依赖——先读轮廓后读颜色(§5.1) | -- **反面条款已成文**(§9.2);反面参考**图证**待本作素材生成后增补。验收:方向性 +- **反面条款已成文**(§9.2);反面参考**图证**待本作素材生成后另批增补。 -### 9.2 反面参考(每条绑定违反条款) +### 9.2 反面参考(每条绑定违反条款) | 反面参考 | 违反条款 | 立法理由 | |---|---|---| -| **Darkest Dungeon** 式压暗 | §2 情绪明暗禁令(明度分层下仍禁) | 状态与情绪只走色相/饱和/运动/元素四手段,不以变暗表达情绪(失败重开,世界无光暗记忆) | -| **传统扫雷白牌/贴纸式数字承载** | §1.2 原则二(明度分档为骨) | 数字由格底明度分档承载、直画入世界;色牌仅复杂格底后备——满屏白牌=电子表格观感,非游戏画面 | -| **Enter the Gungeon** 式粒子与色相爆炸 | §3.4 焦点预算;§4.2 色相纪律 | 信息层禁止被特效与杂色吞没,特效入画先过焦点预算 | -| **Hades** 式特效密度 | §2.2 兑现爽感的上限 | 爽感靠逐行因果归因,不靠满屏特效;承载元素以 §2.2 清单为界 | -| **传统扫雷 1–8 八色分色** | §4.1 数字墨三档 | 八色必撞红/金/蓝/紫赤语义色段;数字只走墨族三档色温,字形为唯一数值载体 | +| **Darkest Dungeon** 式压暗 | §2 情绪明暗禁令(明度分层下仍禁) | 状态与情绪只走色相/饱和/运动/元素四手段,不以变暗表达情绪(失败重开,世界无光暗记忆) | +| **传统扫雷白牌/贴纸式数字承载**(2026-09-29 v3.0 新增) | §1.2 原则二(明度分档为骨) | 数字由格底明度分档承载、直画入世界;色牌仅复杂格底后备——满屏白牌=电子表格观感,非游戏画面 | +| **Enter the Gungeon** 式粒子与色相爆炸 | §3.4 焦点预算;§4.2 色相纪律 | 信息层禁止被特效与杂色吞没,特效入画先过焦点预算 | +| **Hades** 式特效密度 | §2.2 兑现爽感的上限 | 爽感靠逐行因果归因,不靠满屏特效;承载元素以 §2.2 清单为界 | +| **传统扫雷 1–8 八色分色** | §4.1 数字墨三档 | 八色必撞红/金/蓝/紫赤语义色段;数字只走墨族三档色温,字形为唯一数值载体 | --- diff --git a/design/art/art-prompts.md b/design/art/art-prompts.md index 346b3c57..1f2ea871 100644 --- a/design/art/art-prompts.md +++ b/design/art/art-prompts.md @@ -2,7 +2,7 @@ > **性质**:美术清单生图通道(✦ 用户自跑,现走线上 API)的执行侧文件——每资产一段**自包含完整提示词**:正向 = 通用底座+主体+动作已拼妥、负向 = 通用底座+增补已并妥,整段复制直接贴进工作流即可,**不需要读其他文件、不需要拼装**。人物规格转写自 [character-design.md](character-design.md)(人物设计 v1.0,自 art-bible §5 迁出,圣经 §5 为存根),风格/色彩条款转写自 [art-bible](art-bible.md)(§4.1 主色板 / §4.2 语义色规则);冲突时人物规格以 character-design 为准、色彩以 art-bible 为准。 > **边界**:本文件只管**角色级**资产的生图通道。图标/格面/样张走脚本管线(⚙ 代理出),规格在 art-bible §4.6 与 [art-assets-checklist](art-assets-checklist.md),不需要提示词。**场景级资产(营地场景图的背景层与八个物件层)另立 [camp-scene-prompts](camp-scene-prompts.md),本文件不覆盖**;两卡的角色/场景分工以「谁的资产谁出卡」为界,同一段提示词不两处维护。意图动画三档是动画资产,等一屏样张锁方案后另立规格卡,本文件不覆盖。 -> **状态**:提示词卡成文,出图未开始;按「美术圣经 §5 现行」重生成(正序:B 路线),继承 seed 纪律 / 色相禁入带 / `#F2F2F2` 生成底色;沿革见 git 提交史。 +> **前世**:v1.0(2026-09-20,主角三态+六敌种)于 2026-09-22 经裁决移除;本版按「美术圣经 §5 落地后重生成」拍板重写(正序:B 路线),继承 seed 纪律 / 色相禁入带 / `#F2F2F2` 生成底色。**建立**:2026-09-22 · 状态:提示词卡成文,出图未开始 --- @@ -252,14 +252,14 @@ Pixel art style. true pixel art game sprite drawn on a tiny 48x72 pixel canvas, 5. **头身**:2–3 头身之内,与站姿样张同世界观。 6. **同形象**:主角三态并排、L2 熊地精对熊地精、熊地精 A/B 对——兜帽/装备/剪影不串戏。 -## 4bis. 地形填充纹理(材质归模型、结构归程序) +## 4bis. 地形填充纹理(2026-09-26 定稿,材质归模型、结构归程序) > 范围注:本节为地形类资产首张卡(库原定位=角色级,地形类同库登记)。分工纪律=art-bible §8.5「生产分工」:**模型只出无缝填充纹理,结构件(缘件/轮廓/立面/角件)全部程序化**——autotile 拼接约束超出 2D 生图能力,勿让模型画瓦片结构(实证:5×5 瓦片表每枚必带边框亮框)。 -**对话式生图控制纪律(实证)**: +**对话式生图控制纪律(2026-09-25/26 实证)**: 1. 同对话续写=对上一张图的**编辑模式**——变体必须各开新对话;同一对话里"same as X but ..."会被当成对最近一张的增量编辑。 2. 英文 prompt 服从度明显高于中文(Gemini/Nano Banana 语料结构使然),提示词一律英文。 -3. **地形瓦片出图制式 = 拼表制主选(操作单见 remote-gen-prompts §3)**——一张整图容纳全部瓦片变体,后处理裁切取用:风格统一、一张管住全族。画框风险的缓解 = ①出图贴程序占位样图作参考(模型有具体锚定就不乱画框)②裁切时每格内缩 2–3% 切掉边框③统一降采样(结构归程序)。**分张(一张一跑)降为回退路径**:仅当拼表连续画框失败时改用。〔实证教训保留:裸拼表(无参考图)必诱画框——教训归因于缺参考锚定,非拼表制本身〕 +3. **地形瓦片出图制式 = 拼表制主选(2026-10-06 用户拍板,操作单见 remote-gen-prompts §3)**——一张整图容纳全部瓦片变体,后处理裁切取用:风格统一、一张管住全族。画框风险的缓解 = ①出图贴程序占位样图作参考(模型有具体锚定就不乱画框)②裁切时每格内缩 2–3% 切掉边框③统一降采样(结构归程序)。**分张(一张一跑)降为回退路径**:仅当拼表连续画框失败时改用。〔实证教训保留:2026-09-25/26 裸拼表(无参考图)必诱画框——教训归因于缺参考锚定,非拼表制本身〕 4. 差异特征写第一句并给否定约束(如碎石斑变体须写明 `no cracks, no dark lines`);尺寸不写死(模型自定,原图越大裁切余量越足,后处理统一降采样)。 **①墙顶基面(无缝岩石)**: @@ -282,7 +282,7 @@ Same as the rock surface texture but with a few tiny lighter stone speckles spar Seamless tileable pixel art texture of dry dirt ground, top-down, for a dungeon board game. Flat 2D texture only — not an object, no perspective. Pattern fills the entire square evenly and wraps perfectly at all four edges. No border, no frame, no edge highlight, no vignette, no cast shadow, uniform directionless lighting. Dark warm brown palette around #B39A78 — clearly darker than tan rock. A few thin cracks and tiny pebble speckles, sparse. Clean retro pixel art look, limited palette, no gradients, no text, square image. ``` -后处理(程序侧,生成器脚本归档 zip 内〔archive 备选案-terrain-walls〕):周期对齐无缝裁剪→BOX 降采样 32×32→环绕混合→量化(12 色)→结构件合成;②③亦可由①程序加纹理级裂纹/浅斑替代(实证更稳)。 +后处理(程序侧,生成器 `_tools/gen_terrain_walls.py`,归档 zip 内):周期对齐无缝裁剪→BOX 降采样 32×32→环绕混合→量化(12 色)→结构件合成;②③亦可由①程序加纹理级裂纹/浅斑替代(2026-09-26 实证更稳)。 ## 5. 明确不在本文件 diff --git a/design/art/art-status-checklist.md b/design/art/art-status-checklist.md index f2351613..cb9ebd80 100644 --- a/design/art/art-status-checklist.md +++ b/design/art/art-status-checklist.md @@ -2,7 +2,7 @@ > **性质**:纯看板——每条素材只记三件事:**素材名 × 是否完成 × 需求文档指针**。要什么、怎么做、验收标准一概不记:需求规格归 [art-assets-checklist.md](art-assets-checklist.md),风格/规范定义处归各 owner 文档(第三列)。 > **更新纪律**:顺带只改「状态」列;做完不删行,改 ✅。条目编号与 art-assets-checklist.md 的 P0–P3 一一对应。 -> **建档**:状态列为建档当日快照,全量对账后与实机在役状态核对更新;数字以表内实况为准。 +> **建档**:2026-09-24,状态列为当日快照。**2026-10-06 P1-3 对账批**:按实机在役状态与圣经 v3.7 冻结记录全量复核更新(审计发现看板反超条目已对齐;对账后 = ✅ 4 / ◐ 8 / ⬜ 3,共 15 条)。 **状态图例**:⬜ 未开始 | ◐ 占位在役(程序化占位,够试玩不达标) | ◑ 候选已出待审 | ✅ 已交付 @@ -10,7 +10,7 @@ | 素材 | 状态 | 需求文档 | |---|---|---| -| 格子样张 | ✅ 样张 v3 冻结(2026-09-30 机检 17/17 全绿,art-bible §1.4 v3.2 记录;v2/v3 两次冻结通过;产物原件已丢失、未入 git) | [art-bible](art-bible.md) §1.4 | +| 格子样张 | ✅ 样张 v3 冻结(2026-09-30 机检 17/17 全绿,art-bible §1.4 v3.2 记录;v2/v3 两次冻结通过;产物 `artwork/mockups/sample-batch-v3-final-2026-09-30.png`) | [art-bible](art-bible.md) §1.4 | | 战斗一屏样张 | ⬜ | [战斗表现](../gdd/systems/战斗循环/战斗表现.md)(一屏样张验收标准)+ [art-bible](art-bible.md) §5.1 | ## P1 角色级(样张通过后开工) @@ -18,7 +18,7 @@ | 素材 | 状态 | 需求文档 | |---|---|---| | 主角战斗小人(待机/攻击/受击三态) | ✅ B区站桩三态全接(2026-09-29 迭代 6 T3:normal/attack/hit 48px 档入包+挂事件切换+结算回 idle,短驻自回;棋盘 1.5×/脚锚25%/椭圆影在役不变;hit 态源=new-hit.pxo 已程序导出) | [character-design](character-design.md) + [art-prompts](art-prompts.md) §2 | -| 八敌种形象(L2 熊地精=bugbear 复用,不单列) | ✅ B区站桩八敌真图服役(2026-09-29 迭代 6 T1/T2:pixel-1 源→48px 档统一比例/底中锚/众数投票降采样,character_enemy_<键>.png 按常量入位,字形回退退役为缺位兜底;素材不入 git 本地在盘)。八敌源档已定(36–94,art-bible §8.2),旧统一 48 档为过渡形态,新档重产落位归 PRD(tools/pixelize_candidates.py `STANDEE_TIER_H` 已改,产出与定档总图一致) | [art-bible](art-bible.md) §5 + [art-prompts](art-prompts.md) §3 | +| 八敌种形象(L2 熊地精=bugbear 复用,不单列) | ✅ B区站桩八敌真图服役(2026-09-29 迭代 6 T1/T2:pixel-1 源→48px 档统一比例/底中锚/众数投票降采样,character_enemy_<键>.png 按常量入位,字形回退退役为缺位兜底;素材不入 git 本地在盘)。**2026-10-06 v3.7 定档批注记**:八敌源档已定(36–94,art-bible §8.2),旧统一 48 档在役过渡,新档重产落位归 PRD(tools/pixelize_candidates.py `STANDEE_TIER_H` 已改,产出与定档总图一致) | [art-bible](art-bible.md) §5 + [art-prompts](art-prompts.md) §3 | | 意图准备动画三档 | ◐ | [art-bible](art-bible.md) §4.6.4 | ## P2 图标与格面(可与样张并行) @@ -28,7 +28,7 @@ | 格面资源徽章 19 枚 | ◐ | [art-bible](art-bible.md) §4.6 | | 关卡节点图图标组 | ◐ | [art-assets-checklist](art-assets-checklist.md) §3.2(正式 UI 设计未成文) | | 雷/封印印记/数字砖面全套 | ◐ | [art-bible](art-bible.md) §1.4/§3.2(前置:格子样张) | -| 增益状态效果图标 9 枚 | ✅ 已接入(2026-09-24) | [art-bible](art-bible.md) §4.6.1(候选原稿:状态图标批 2026-09-23 远程出图,归 candidates 档——路径描述性引用,归档纪律见 art-bible §8.3) | +| 增益状态效果图标 9 枚 | ✅ 已接入(2026-09-24 状态记录 156) | [art-bible](art-bible.md) §4.6.1(候选原稿:状态图标批 2026-09-23 远程出图,归 candidates 档——路径描述性引用,归档纪律见 art-bible §8.3) | | 营地场景图 | ◐ | [art-bible](art-bible.md) §6.2 | | 字体(全局 UI 像素字,Fusion Pixel 单套单字重) | ✅ 已定稿在役(2026-09-27 接入;2026-10-24 用户裁现状即终稿——不另接拉丁专件、不上第二字重) | [art-bible](art-bible.md) §7.4 | @@ -36,10 +36,10 @@ | 素材 | 状态 | 需求文档 | |---|---|---| -| 受击反馈 / 死亡表现 / 顺位标记 | ◐ 程序化演出在役(受击闪白+打击火花、灰化+碎裂退场〔禁瞬消〕、顺位徽标 ①②③ 程序绘制;帧级素材未产,§8.4 帧数规格候选) | [战斗表现](../gdd/systems/战斗循环/战斗表现.md) | +| 受击反馈 / 死亡表现 / 顺位标记 | ◐ 程序化演出在役(受击闪白+打击火花、灰化+碎裂退场〔禁瞬消〕、顺位徽标 ①②③ 程序绘制——2026-09-29 迭代 6 T4/T5;帧级素材未产,§8.4 帧数规格候选) | [战斗表现](../gdd/systems/战斗循环/战斗表现.md) | | 连锁坍塌显形演出 | ⬜ | [art-assets-checklist](art-assets-checklist.md) §4 | -| 封印特效(动作=封印,两件:施印特效+印记常显) | ⬜ | [art-bible](art-bible.md) §3.2 + [art-assets-checklist](art-assets-checklist.md) §4 | -| 弹道 / 退场等动画密度 | ◐ 程序化弹道在役(play_projectile 0.16s+0.19s 到达拍调度;密度终稿归战斗表现) | [art-assets-checklist](art-assets-checklist.md) §4(战斗表现输入清单) | +| 封印特效(插旗动作=封印,两件:施印特效+印记常显) | ⬜ | [art-bible](art-bible.md) §3.2 + [art-assets-checklist](art-assets-checklist.md) §4 | +| 弹道 / 退场等动画密度 | ◐ 程序化弹道在役(play_projectile 0.16s+0.19s 到达拍调度,2026-09-29 迭代 6;密度终稿归战斗表现批) | [art-assets-checklist](art-assets-checklist.md) §4(战斗表现输入清单) | --- diff --git a/design/art/asset-path-map.md b/design/art/asset-path-map.md index 0ae326cc..e915bc9f 100644 --- a/design/art/asset-path-map.md +++ b/design/art/asset-path-map.md @@ -1,16 +1,16 @@ # 素材路径分工约定(asset-path-map) -- 定立:2026-09-26(用户立约指令「把哪些路径、哪些位置是干什么的确定好」) +- 定立:2026-09-26(用户立约指令「把哪些路径、哪些位置是干什么的确定好」——状态记录 161) - 效力:全仓素材批的路径判据单点;新素材批开工前对照,违例=实施缺陷 ## 1. 目录分工总表 | 路径 | 角色 | 入 git | 游戏运行时引用 | |---|---|---|---| -| `artwork/candidates/**` | 候选原图与母版源档(PNG/.pxo/GIF/对比图/验收留底)——**素材的原产地,游戏禁直接引用**;随 `artwork/` 整目录不入 git(`design/art/candidates/` 已弃用,路径列入熵增闸退役名单) | 否(`.gitignore` 三行规则;`*.md` 指引例外) | **禁**(res:// 路径禁指 candidates) | +| `artwork/candidates/**` | 候选原图与母版源档(PNG/.pxo/GIF/对比图/验收留底)——**素材的原产地,游戏禁直接引用**;随 `artwork/` 整目录不入 git(`design/art/candidates/` 已作废,路径列入熵增闸退役名单) | 否(`.gitignore` 三行规则;`*.md` 指引例外) | **禁**(res:// 路径禁指 candidates) | | `game/assets/art/**` | **游戏运行时素材唯一落地处**(res://assets/art/...)——定稿素材从 `artwork/candidates/` 拷贝入此,改名按圣经 §8.3 | 否(untracked,勿 git add;worktree 间需脚本拷贝同步) | **唯一合法引用源** | -| `game/assets/ui/icons/**` | UI 图标(状态图标批特例——唯一已提交入 git 的素材目录) | **是**(状态图标入库特例) | 是 | -| `artwork/mockups/**` | 混合样张/版式对比图(设计评审产物,非游戏素材;样张、关卡地图样张等实存于此) | 否 | 禁 | +| `game/assets/ui/icons/**` | UI 图标(状态图标批特例——唯一已提交入 git 的素材目录) | **是**(2026-09-24 批特例) | 是 | +| `artwork/mockups/**` | 混合样张/版式对比图(设计评审产物,非游戏素材;样张 v3、关卡地图样张等实存于此)——2026-10-06 对账更正:原写的 `production/mockups/**` 系废路径 | 否 | 禁 | ## 2. 素材流(新素材批标准路径) @@ -19,7 +19,7 @@ 3. **入包**:拷贝 → `game/assets/art/<语义子目录>/`,改名 `§8.3` 域键式(如 `character_hero_portrait.png`) 4. **接线**:代码 `res://assets/art/...` 引用(路径常量单点归 `UiKit.TEX_*`);装载一律 `UiKit.load_tex`(exists 守卫——素材不入 git,缺位树回退),**禁 preload** 5. **提交**:只提交代码/文档;PNG 与 `.import`(全局忽略)均不入 git -6. **整理(脚本链第四段:生成→验证→整理→入包)**:定稿入包后当批清 `artwork/candidates/` 子目录——保留**定稿源 + 最相邻候选 2 版**(默认策略,可按资产调整),其余打包归 `archive/`;非素材残留(.url/.ogg/.ai/.swf 等)剔出;批量执行一律 **dry-run 清单先行**,经用户确认再动文件 +6. **整理(2026-10-06 P2-1 固化段——脚本链第四段:生成→验证→整理→入包)**:定稿入包后当批清 `artwork/candidates/` 子目录——保留**定稿源 + 最相邻候选 2 版**(默认策略,可按资产调整),其余打包归 `archive/`;非素材残留(.url/.ogg/.ai/.swf 等)剔出;批量执行一律 **dry-run 清单先行**,经用户确认再动文件(2026-10-06 用户惯例) ## 3. game/assets/art 语义子目录 @@ -27,11 +27,11 @@ |---|---|---| | `character/` | 角色棋盘帧 + B 区立绘 | character_hero_idle/walk(棋盘 32×32 朝左基准)、character_hero_portrait(B 区立绘 46×48)+ character_enemy_<键> 八敌 | | `fx/` | 特效帧 | shovel_swing_f0/f1/f2(挥铲三帧,握把锚 (18,12)) | -| `scene/` | **场景图**(背景层 + 可点物件层;营地场景图首批) | `scene_camp_bg`(背景)+ `scene_camp_gate / forge / chest / notebook / dummy / board / campfire`(七个可点物件)+ `scene_camp_tent`(承载件,不可点)——登记超前:营地场景图批落地前盘上未建 | -| `board/` | 砖面/封印印记(三关换皮 + 封印印记件) | terrain_floor__.png(三关地面系)、seal_mark.png | +| `scene/` | **场景图**(背景层 + 可点物件层;2026-10-22 新增,营地场景图首批) | `scene_camp_bg`(背景)+ `scene_camp_gate / forge / chest / notebook / dummy / board / campfire`(七个可点物件)+ `scene_camp_tent`(承载件,不可点) | +| `board/` | 砖面/封印印记(三关换皮 + 封印印记件;2026-10-06 对账更正——旧 `board_floor/wall/pillar_dungeon_*` 命名退役) | terrain_floor__.png(三关地面系)、seal_mark.png | ## 4. 坐标/朝向等工程侧约定备查 - 素材基准朝向:角色/铲帧一律朝**左**入库,右向=运行时 `flip_h`(镜像帧不入包,right 变体留 `artwork/candidates/`) - 挥铲握把锚:画布 (18,12)(三帧同位——以手为圆心旋转的预烤相位) -- 缩放纪律:圣经 §8.2 整数缩放为一般规则;棋盘小人 1.5× 系用户明示裁决的登记例外(注记见 art-bible §8.2) +- 缩放纪律:圣经 §8.2 整数缩放为一般规则;棋盘小人 1.5× 系 2026-09-26 用户明示裁决(豁免注记归 art-bible 批另落) diff --git a/design/art/character-design.md b/design/art/character-design.md index d7a1a50c..9d64296b 100644 --- a/design/art/character-design.md +++ b/design/art/character-design.md @@ -1,82 +1,82 @@ -# 人物设计文档(Character Design)— Dungeon Sweeper +# 人物设计文档(Character Design)— Dungeon Sweeper -> **性质**:美术圣经 §5(角色设计)的独立承载文档——**人设高频改动、独立成档免动圣经**;圣经 §5 自此为引用存根指向本文件,**小节号 §5.1–§5.6 沿用不变**(跨文档引用经圣经 §5 存根仍可达,新引用直指本文件)。 -> **边界**:本文件只定角色呈现;角色承载的机制语义(护甲克制矩阵、技能与状态)以 GDD 为准(敌人系统/增益状态/战斗结算)。色彩系统、形状语言总纲引用美术圣经 [art-bible](art-bible.md) §4/§3,冲突以圣经为准。提示词执行侧(可直贴串)归 [art-prompts](art-prompts.md),本文件是它的规格上游。 +> **性质**:美术圣经 §5(角色设计)的独立承载文档——2026-09-23 抽离批自 art-bible v1.6 §5 迁出成文,动机是**人设高频改动、独立成档免动圣经**;圣经 §5 自此为引用存根指向本文件,**小节号 §5.1–§5.6 沿用不变**(跨文档引用经圣经 §5 存根仍可达,新引用直指本文件)。 +> **边界**:本文件只定角色呈现;角色承载的机制语义(护甲克制矩阵、技能与状态)以 GDD 为准(敌人系统/增益状态/战斗结算)。色彩系统、形状语言总纲引用美术圣经 [art-bible](art-bible.md) §4/§3,冲突以圣经为准。提示词执行侧(可直贴串)归 [art-prompts](art-prompts.md),本文件是它的规格上游。 --- -## 5.1 角色剪影与轮廓通道(自圣经 §3.1 迁入) +## 5.1 角色剪影与轮廓通道(自圣经 §3.1 迁入) -硬规则:**所有角色自带轮廓通道**——亮色描边(暖白族,禁黑描边)+落影底盘,识别不依赖所在背景。敌人不占格,剪影禁止借格底色成立;同一套剪影因此在棋盘、结算画面与任一战斗舞台通用,不为战斗表现未决的舞台问题付重画成本。←已锁事实 +硬规则:**所有角色自带轮廓通道**——亮色描边(暖白族,禁黑描边)+落影底盘,识别不依赖所在背景。敌人不占格,剪影禁止借格底色成立;同一套剪影因此在棋盘、结算画面与任一战斗舞台通用,不为战斗表现未决的舞台问题付重画成本。←已锁事实;「禁黑描边」= 2026-09-20 提示词卡实践顺带固化 -**主角描边例外(显式例外,非漂移)**:主角四态硬描边 = **深褐 `(40,19,13)`**,不入暖白族——深描边在浅砖面格底上对比更高、主角更突出;暖白达标率 0–28% 属例外内允许(敌人仍暖白族,实测达标 27–69%,不变)。素材不随本条修改;样张(圣经 §1.4)终审复核例外成立性,复核义务登记待办清单。 +**主角描边例外(2026-10-06 待决项 B 用户拍板——显式例外,非漂移)**:主角四态硬描边 = **深褐 `(40,19,13)`**,不入暖白族——深描边在浅砖面格底上对比更高、主角更突出;暖白达标率 0–28% 属例外内允许(敌人仍暖白族,实测达标 27–69%,不变)。素材不随本条修改;样张批(圣经 §1.4)终审复核例外成立性,复核义务登记待办清单。 -四类角色 archetype 以高宽比/轮廓凹凸数/锐角数一眼区分(道具点——唯一非生物剪影——留在圣经 §3.1): +四类角色 archetype 以高宽比/轮廓凹凸数/锐角数一眼区分(道具点——唯一非生物剪影——留在圣经 §3.1): -- **小人**:窄、直立、对称,凹凸 ≤2;剪影在 2~3 头身两档下均成立(比例见 §5.2);格内占位最窄,不吞数字位。←原一;圣经 §2.1 -- **普通怪**:近圆墩形,宽≥高,零锐角。取 Peglin 的胖体量,弃其色块依赖——先读轮廓,后读颜色。←P4 -- **精英**:普通怪形加恰一处垂直延伸(角/冠/棘),底盘不变。←圣经 §2.6 郑重感的低配档 -- **Boss**:敌类中唯一允许锐角与不对称者,体量最大,与紫赤色相独占并列"形状独占"。←圣经 §2.6 +- **小人**:窄、直立、对称,凹凸 ≤2;剪影在 2~3 头身两档下均成立(比例见 §5.2);格内占位最窄,不吞数字位。←原一;圣经 §2.1 +- **普通怪**:近圆墩形,宽≥高,零锐角。取 Peglin 的胖体量,弃其色块依赖——先读轮廓,后读颜色。←P4 +- **精英**:普通怪形加恰一处垂直延伸(角/冠/棘),底盘不变。←圣经 §2.6 郑重感的低配档 +- **Boss**:敌类中唯一允许锐角与不对称者,体量最大,与紫赤色相独占并列"形状独占"。←圣经 §2.6 ## 5.2 头身比与体量档位 -- **头身比**:全角色 2~3 头身(chibi)两档——小人剪影须在两档下均成立(§5.1);敌种按 archetype 取档,同一敌种恒定一档(enemy_id 键恒定纪律,圣经 §4.6.4)。←风格基调 -- **体量档位 = 生物体型通道(原「强度先验·普通 < 精英 < Boss 单调递增」读法已弃用)**:体量按**物种天性**定,禁按强度档位线性映射——小体型精英与大体型普通怪并存(锁定实例:钩爪恐魔 > 灰矮人、灰矮人 < 熊地精、食人魔酋长 > 熊地精);「大个子多半有重击」的强度先验读法改由意图徽标(§5.6)与甲档(§5.3)承担,体量不再报强度;图像不报数,精确数值只在结算明细。 -- 像素画布/精灵尺寸**不在本节**——归圣经 §8 资产规范;本节只锁比例关系与档位次序。 +- **头身比**:全角色 2~3 头身(chibi)两档——小人剪影须在两档下均成立(§5.1);敌种按 archetype 取档,同一敌种恒定一档(enemy_id 键恒定纪律,圣经 §4.6.4)。←风格基调 2026-09-10 +- **体量档位 = 生物体型通道(2026-10-06 用户三约束修订,原「强度先验·普通 < 精英 < Boss 单调递增」读法退役)**:体量按**物种天性**定,禁按强度档位线性映射——小体型精英与大体型普通怪并存(锁定实例:钩爪恐魔 > 灰矮人、灰矮人 < 熊地精、食人魔酋长 > 熊地精);「大个子多半有重击」的强度先验读法改由意图徽标(§5.6)与甲档(§5.3)承担,体量不再报强度;图像不报数,精确数值只在结算明细。 +- 像素画布/精灵尺寸**不在本节**——归圣经 §8 资产规范(D1,另批);本节只锁比例关系与档位次序。 -## 5.3 护甲类型视觉区分(候选方案,样张冻结终稿) +## 5.3 护甲类型视觉区分(候选方案,样张批冻结) -> **白话**:敌方有三种护甲类型——轻甲(light)/魔法护甲(ward)/重甲(heavy),克制博弈需要玩家从画面读出"这只怪穿什么甲";本节定三档甲怎么画。键与命名事实源 = `data/schemas/enemy_table.gd` 的 `armor_type` 枚举与 [术语表](../gdd/术语表.md)(轻甲/魔法护甲/重甲)。 +> **白话**:敌方有三种护甲类型——轻甲(light)/魔法护甲(ward)/重甲(heavy),克制博弈需要玩家从画面读出"这只怪穿什么甲";本节定三档甲怎么画。键与命名事实源 = `data/schemas/enemy_table.gd` 的 `armor_type` 枚举与 [术语表](../gdd/术语表.md)(轻甲/魔法护甲/重甲,2026-09-15 命名统一批)。 -- **通道 = 甲面覆盖率 × 甲材质色**,不触 §5.1 剪影 archetype(底盘与体量轴不动)——「重甲语义走色板不作形状独占」的落法:甲档不新增独占通道(独占 = Boss 双通道专属),装备配件画法不属独占。 -- **轻甲 light**:甲面覆盖率≈0——皮条/布带点缀或裸露,体表以物种本色为主。 -- **魔法护甲 ward**:甲面覆**符纹饰带**——发光符纹走轮廓描边同族暖白,不占任何语义色(圣经 §4.2),覆盖率居中。 -- **重甲 heavy**:**金属甲板**覆盖肩/胸/盾沿,冷灰金属材质色(与全作暖底成材质对比),覆盖率最大。 -- **天然甲壳/毛皮属体表,不计甲档**(巨蜘蛛/钩爪恐魔的几丁质是皮肤不是装备)——甲档只读装备甲板/符纹,防群行小体量敌种被误读为重甲。 -- **灰度通道**:色弱玩家按**甲面覆盖率**三档可辨(无甲板→符纹带→大面积甲板)——形状+色彩双通道纪律。 -- 完整克制读数 = HUD 状态图标(alert/fortify 等,圣经 §4.6.1)+ 本节甲面视觉辅助;克制矩阵数值不在画面上报数。←敌人系统 armor_type +- **通道 = 甲面覆盖率 × 甲材质色**,不触 §5.1 剪影 archetype(底盘与体量轴不动)——「重甲语义走色板不作形状独占」的落法:甲档不新增独占通道(独占 = Boss 双通道专属),装备配件画法不属独占。 +- **轻甲 light**:甲面覆盖率≈0——皮条/布带点缀或裸露,体表以物种本色为主。 +- **魔法护甲 ward**:甲面覆**符纹饰带**——发光符纹走轮廓描边同族暖白,不占任何语义色(圣经 §4.2),覆盖率居中。 +- **重甲 heavy**:**金属甲板**覆盖肩/胸/盾沿,冷灰金属材质色(与全作暖底成材质对比),覆盖率最大。 +- **天然甲壳/毛皮属体表,不计甲档**(巨蜘蛛/钩爪恐魔的几丁质是皮肤不是装备)——甲档只读装备甲板/符纹,防群行小体量敌种被误读为重甲。 +- **灰度通道**:色弱玩家按**甲面覆盖率**三档可辨(无甲板→符纹带→大面积甲板)——形状+色彩双通道纪律。 +- 完整克制读数 = HUD 状态图标(alert/fortify 等,圣经 §4.6.1)+ 本节甲面视觉辅助;克制矩阵数值不在画面上报数。←敌人系统 armor_type;营地扩展前置批 ## 5.4 主角三态规格 -- **形象锚** = 站姿样张先例改定:**蓝兜帽冒险者(兜帽取下披于颈肩,似短披肩非垂背)、暖米白上衣+深棕工装裤(弃全蓝)、白皙肤色、棕色短发、皮革背带腰带、圆头挖掘铲(待机插地扶柄)、结构式冒险背包插朱红矩形旗(1.5× 体宽)、额前护目镜、腰挂提灯、半指手套与卷裤脚皮护膝**;三态必须同形象(同 seed 起步只换动作短语,提示词卡承载)。 -- **兜帽披肩**:兜帽**不戴在头上**,取下披于肩上——露出棕色短发与整脸(表情可读),蓝色兜帽披在肩头仍是最近的"己方"识别点;三态同款披肩。 -- **圆头挖掘铲(武器)**:持铲不持剑——**铲 = 本作核心动词"挖安全格"的角色化**,挖掘与攻击共用同一动作语义(攻击表现即挥铲,后续攻击类表现优先围绕铲展开)。画法:圆头铲刃 + 木柄(木柄走木色族 `#8A5A34`,铲刃浅铁灰、不做冷灰重甲质感);**待机态 = 铲插立在地上、一只手扶在铲柄顶端**;攻击/受击态仍为手持。刀光/铲击光效/弹道不进精灵图(归战斗演出另立资产)。 -- **冒险背包与背包旗(身份件)**:背负**结构式冒险背包**——翻盖+皮扣带的帆布背包(暖卡其布面,皮革件走木色族),**宽 ≈1.5× 体宽**:双肩背负、两侧各略超出身形一点;**禁圆麻袋化**(措辞弃"鼓胀掳获袋")、**禁特里式 3× 超宽**(反面参照:旷野之息特里;正面参照:合金弹头双主角大背包、超级马里奥奇诺比奥队长)。**旗杆插在背包一侧**(不要求完整可见,**被头部遮挡一部分亦可**),旗子**尺寸大于头部**,短杆**矩形**旗旗面 = **旗朱红 `#C63D22`**(人设背包旗与棋盘封印色**脱钩**——封印色仅作用于棋盘格封印旗,色史归 art-bible §4.1;人设旗形为矩形非三角,以 normal/hit 定稿成图为基准),把"宣告落旗"的角色化焊在人设上,主角即背包旗持有者(**动作呈现名 = 封印**,见 art-bible §3.2;背包旗**不**视作封印印记的携带形态——旗面旗朱红矩形,与棋盘封印黑旗形色均不同,仅保留"主角即背包旗持有者"的身份叙事)。三条纪律:①旗面红=旗朱语义**仅旗非服饰**(负向 `no red clothing` 不拦截正向 `red flag`——红服饰禁令与红旗的边界由正向句显式锁色值;封印黑仍为棋盘格封印旗专属色,圣经 §4.1 不变);②背包+旗 = 主角对 §5.1"对称"的**唯一破例组**(语义配件非装饰),旗面朝向恒定画右、跨素材一致;③轮廓凹凸预算按"背包+旗"整组记账,样张(圣经 §1.4)验证背负后剪影仍窄、仍可辨、不超格宽。 -- **护目镜与腰灯**:**护目镜推在额前**(兜帽披肩后直接戴于额上,不戴眼上——眼睛是表情载体;皮革带+圆镜片,防尘挖掘者的职业信号);**提灯挂腰间,定案不手持**(小号铜色提灯,**不点亮**;空间光照属圣经 §2 明度分层允许域,**提灯点亮为候选**,点亮形制(光晕半径/暖色值/与舞台光照关系)归整屏样张重议;头部饰品唯一化,头灯方案已弃)。 -- **衣着细节**:**棕色半指手套**(挖土护手,露指保灵巧读感)+ **裤脚卷起、棕色皮护膝**(挖掘跪地的工装感)——小件走皮革木色族,不占剪影凹凸预算,不新增语义色。 -- **三态**:① 待机 = 窄、直立、对称(§5.1 小人原型,含背包、旗与提灯记账);② 攻击 = 允许破对称,整体仍窄、不超格宽;③ 受击 = 无血红、无伤害数字(伤害数字归 HUD 层,不进精灵图)。 -- **随身件 = 秘宝形象候选池**:铲、旗、背包、提灯四件随身件是未来秘宝设计的**形象候选池**——道具实装可借用主角身上已有的同款(彩蛋闭环:玩家在道具栏认出主角身上的物件);具体道具语义/数值归秘宝 GDD,人设侧义务 = 四件画得出来、认得出。 -- **配色(弃全蓝,蓝只在明显处)**:**小人蓝 `#2E86DE` 只保留在兜帽**(披肩状态仍可辨,与棋盘小人/光标识别一致,"全作唯一蓝色"语义不变);上衣改**暖米白 `#EFE3C8`**(数字色牌同族暖纸白)、长裤改**深棕工装 `#6B4A2E`**(木色族深化)——与掩尘棋盘同域协调、靠明度差保持身份识别;皮革件木色族 `#8A5A34`;眼部用数字墨 `#33261B` 族。(色值均候选,样张圣经 §1.4 冻结终稿)←圣经 §4.1 -- **语义约束**:蓝只说己方(圣经 §4.2)——主角是全作唯一蓝色角色,敌方与物件主色禁止入蓝族;禁入带 300°–345° 对全角色生效(Boss 独占例外,§5.5)。 -- **生成顺序**:先人设定稿(本档 §5.4 + 提示词卡待机态),定稿后再逐状态出图——战斗画面的**侧身面向怪物**形象属状态图,随表现层设计另行生成;状态图需侧/背身时,该卡负向提示词中的 `side view / back view` 条目按卡摘除(人设立绘卡仍保留禁侧背)。 -- **棋盘小人**:棋盘格上的主角小人 = **主角人设图压缩至二头身后的简化版像素小人**——随人设定稿派生(同款装备剪影:铲/背包/旗可辨),二头身在 §5.2 两档内;派生工艺(最近邻降采样+手修简化)归整屏样张与脚本管线定。 +- **形象锚** = 站姿样张先例(2026-09-16 脚本管线产物)经 2026-09-23 特征批+同日配装修订改定:**蓝兜帽冒险者(兜帽取下披于颈肩,似短披肩非垂背)、暖米白上衣+深棕工装裤(弃全蓝)、白皙肤色、棕色短发、皮革背带腰带、圆头挖掘铲(待机插地扶柄)、结构式冒险背包插旗朱红矩形旗(1.5× 体宽;2026-09-28 旗色脱钩拍板)、额前护目镜、腰挂提灯、半指手套与卷裤脚皮护膝**;三态必须同形象(同 seed 起步只换动作短语,提示词卡承载)。 +- **兜帽披肩(2026-09-23 用户拍板)**:兜帽**不戴在头上**,取下披于肩上——露出棕色短发与整脸(表情可读),蓝色兜帽披在肩头仍是最近的"己方"识别点;三态同款披肩。 +- **圆头挖掘铲(武器,2026-09-23 用户拍板)**:持铲不持剑——**铲 = 本作核心动词"挖安全格"的角色化**,挖掘与攻击共用同一动作语义(攻击表现即挥铲,后续攻击类表现优先围绕铲展开)。画法:圆头铲刃 + 木柄(木柄走木色族 `#8A5A34`,铲刃浅铁灰、不做冷灰重甲质感);**待机态 = 铲插立在地上、一只手扶在铲柄顶端**(2026-09-23 修订,原"拄于身侧"退役);攻击/受击态仍为手持。刀光/铲击光效/弹道不进精灵图(归战斗演出批另立资产)。 +- **冒险背包与插旗(身份件,2026-09-23 用户拍板,同日尺寸修正)**:背负**结构式冒险背包**——翻盖+皮扣带的帆布背包(暖卡其布面,皮革件走木色族),**宽 ≈1.5× 体宽**:双肩背负、两侧各略超出身形一点;**禁圆麻袋化**(措辞弃"鼓胀掳获袋")、**禁特里式 3× 超宽**(反面参照:旷野之息特里;正面参照:合金弹头双主角大背包、超级马里奥奇诺比奥队长)。**旗杆插在背包一侧**(不要求完整可见,**被头部遮挡一部分亦可**),旗子**尺寸大于头部**(2026-09-23 修订,弃"背包顶小旗"旧稿),短杆**矩形**旗旗面 = **旗朱红 `#C63D22`**(2026-09-28 用户拍板:**人设背包旗与棋盘封印色脱钩**——封印色沿革〔旗朱 #C63D22→封印紫 2026-09-26→封印黑 2026-09-28〕**仅作用于棋盘格封印旗**;人设旗维持旗朱红、形为矩形非三角,以 normal/hit 定稿成图为基准),把"插旗宣告"的角色化焊在人设上,主角即插旗者(**插旗动作呈现名 = 封印**,2026-09-25 术语批,见 art-bible §3.2;2026-09-28 脱钩拍板后背包旗**不再**视作封印印记的携带形态——旗面旗朱红矩形,与棋盘封印黑旗形色均不同,仅保留"主角即插旗者"的身份叙事)。三条纪律:①旗面红=旗朱语义**仅旗非服饰**(负向 `no red clothing` 不拦截正向 `red flag`——红服饰禁令与红旗的边界由正向句显式锁色值;封印黑仍为棋盘格封印旗专属色,圣经 §4.1 不变);②背包+旗 = 主角对 §5.1"对称"的**唯一破例组**(语义配件非装饰),旗面朝向恒定画右、跨素材一致;③轮廓凹凸预算按"背包+旗"整组记账,样张批(圣经 §1.4)验证背负后剪影仍窄、仍可辨、不超格宽。 +- **护目镜与腰灯(2026-09-23 用户拍板,同日定案)**:**护目镜推在额前**(兜帽披肩后直接戴于额上,不戴眼上——眼睛是表情载体;皮革带+圆镜片,防尘挖掘者的职业信号);**提灯挂腰间,定案不手持**(小号铜色提灯,**不点亮**——原守圣经 §2 明度恒定;2026-09-29 圣经 v3.0 明度分层后,空间光照属允许域,**提灯点亮解禁为候选**,点亮形制(光晕半径/暖色值/与舞台光照关系)归整屏样张批重议,本批不改成图;头部饰品唯一化,头灯方案退役)。 +- **衣着细节(2026-09-23 用户拍板)**:**棕色半指手套**(挖土护手,露指保灵巧读感)+ **裤脚卷起、棕色皮护膝**(挖掘跪地的工装感)——小件走皮革木色族,不占剪影凹凸预算,不新增语义色。 +- **三态**:① 待机 = 窄、直立、对称(§5.1 小人原型,含背包、旗与提灯记账);② 攻击 = 允许破对称,整体仍窄、不超格宽;③ 受击 = 无血红、无伤害数字(伤害数字归 HUD 层,不进精灵图)。 +- **随身件 = 秘宝形象候选池(2026-09-23 用户提案登记)**:铲、旗、背包、提灯四件随身件是未来秘宝批的**形象候选池**——道具实装可借用主角身上已有的同款(彩蛋闭环:玩家在道具栏认出主角身上的物件);具体道具语义/数值归秘宝批 GDD,人设侧义务 = 四件画得出来、认得出。 +- **配色修订(2026-09-23 用户拍板:弃全蓝,蓝只在明显处)**:**小人蓝 `#2E86DE` 只保留在兜帽**(披肩状态仍可辨,与棋盘小人/光标识别一致,"全作唯一蓝色"语义不变);上衣改**暖米白 `#EFE3C8`**(数字色牌同族暖纸白)、长裤改**深棕工装 `#6B4A2E`**(木色族深化)——与掩尘棋盘同域协调、靠明度差保持身份识别;皮革件木色族 `#8A5A34`;眼部用数字墨 `#33261B` 族。(色值均候选,样张批圣经 §1.4 冻结终稿)←圣经 §4.1 +- **语义约束**:蓝只说己方(圣经 §4.2)——主角是全作唯一蓝色角色,敌方与物件主色禁止入蓝族;禁入带 300°–345° 对全角色生效(Boss 独占例外,§5.5)。 +- **生成顺序口径(2026-09-23 用户确认)**:先人设定稿(本档 §5.4 + 提示词卡待机态),定稿后再逐状态出图——战斗画面的**侧身面向怪物**形象属状态图,随表现层批次另行生成;状态图需侧/背身时,该卡负向提示词中的 `side view / back view` 条目按卡摘除(人设立绘卡仍保留禁侧背)。 +- **棋盘小人(2026-09-23 用户拍板,原开放项定案)**:棋盘格上的主角小人 = **主角人设图压缩至二头身后的简化版像素小人**——随人设定稿派生(同款装备剪影:铲/背包/旗可辨),二头身在 §5.2 两档内;派生工艺(最近邻降采样+手修简化)归一屏样张批与脚本管线定。 -## 5.5 八敌种逐卡(键集与甲档事实源 = `data/tables/enemy.tres`,本表随其修订自动跟随) +## 5.5 八敌种逐卡(键集与甲档事实源 = `data/tables/enemy.tres`,本表随其修订自动跟随,2026-09-25 甲档核对回填) -| 键(enemy_id) | 显示名 | archetype 档 | 甲档 | 主色域与特征锚 | +| 键(enemy_id) | 显示名 | archetype 档 | 甲档 | 主色域与特征锚 | |---|---|---|---|---| -| goblin | 地精 | 普通怪 | 轻甲 | 橄榄绿皮、圆耳墩形,木棒;Peglin 式胖体量,先读轮廓后读颜色 | -| bugbear | 熊地精 | 普通/精英两版候选出齐供拍板 | 轻甲 | 棕黄蓬毛、獠牙下颚;粗陋皮条无甲板无符纹(轻甲语义,§5.3);精英版 = 恰一处垂直冠棘;**L2 熊地精 = 本键复用**(见下注) | -| ogre_chief | 食人魔酋长 | 精英(第一关关底 Boss 战,形状仍守精英档) | 轻甲 | 暖褐皮、肚圆顶冠;甲面覆盖率≈0——皮条/骨饰点缀、裸身为主(轻甲语义,§5.3) | -| giant_spider | 巨蜘蛛 | 普通怪(群行 [2,3]) | 轻甲 | 炭棕毛+蜜色腹纹;腿收短收粗,小体量可辨 | -| drider | 蛛化卓尔精灵 | Boss(第二关) | 魔法护甲 | 紫赤 `#8F2F6A` 色相独占+形状独占双通道;卓尔刺客上体+蛛腹;符纹饰带(魔法甲语义,§5.3) | -| duergar | 灰矮人 | 精英 | 重甲 | 灰皮灰辫须;冷灰金属甲板+高盔冠(恰一处垂直延伸) | -| hook_horror | 钩爪恐魔 | 普通怪(群行) | 轻甲 | 骨灰几丁质、圆钩双爪(圆钩非锐角);小体量可辨 | -| duergar_king | 灰矮人石君王 | Boss(第三关) | 重甲 | 紫赤独占+形状独占双通道;岩石板甲+不对称王冠,大地/岩石主题(承载技能语义见敌人系统) | +| goblin | 地精 | 普通怪 | 轻甲 | 橄榄绿皮、圆耳墩形,木棒;Peglin 式胖体量,先读轮廓后读颜色 | +| bugbear | 熊地精 | 普通/精英两版候选出齐供拍板 | 轻甲 | 棕黄蓬毛、獠牙下颚;粗陋皮条无甲板无符纹(轻甲语义,§5.3);精英版 = 恰一处垂直冠棘;**L2 熊地精 = 本键复用**(见下注) | +| ogre_chief | 食人魔酋长 | 精英(第一关关底 Boss 战,形状仍守精英档) | 轻甲 | 暖褐皮、肚圆顶冠;甲面覆盖率≈0——皮条/骨饰点缀、裸身为主(轻甲语义,§5.3) | +| giant_spider | 巨蜘蛛 | 普通怪(群行 [2,3]) | 轻甲 | 炭棕毛+蜜色腹纹;腿收短收粗,小体量可辨 | +| drider | 蛛化卓尔精灵 | Boss(第二关) | 魔法护甲 | 紫赤 `#8F2F6A` 色相独占+形状独占双通道;卓尔刺客上体+蛛腹;符纹饰带(魔法甲语义,§5.3) | +| duergar | 灰矮人 | 精英 | 重甲 | 灰皮灰辫须;冷灰金属甲板+高盔冠(恰一处垂直延伸) | +| hook_horror | 钩爪恐魔 | 普通怪(群行) | 轻甲 | 骨灰几丁质、圆钩双爪(圆钩非锐角);小体量可辨 | +| duergar_king | 灰矮人石君王 | Boss(第三关) | 重甲 | 紫赤独占+形状独占双通道;岩石板甲+不对称王冠,大地/岩石主题(承载技能语义见敌人系统) | -- **主色域 = 物种本色**,不受圣经 §4.2 语义色禁则约束(该禁则管格面与 UI 的逻辑色,角色身体主色豁免);**两例外**:禁入带 300°–345° 全角色禁入(仅 drider/duergar_king 两 Boss 独占)、蓝族禁入敌方主色(§5.4 同款)。 -- **L2 熊地精 = bugbear 复用**(第二关三行,显示名熊地精系,非独立键):同剪影**换色板不换形**(色板替换走脚本管线——原则三"先换色板"的角色侧应用);变体主色避小人蓝族与禁入带。 -- 每敌种出 2–3 版候选(清单 §2.2);受击反馈/死亡表现/顺位标记三类呈现下限见圣经 §4.6.4 资产需求行。 -- **立绘源档高(定值单点 = 圣经 §8.2 立绘源档表,本档指路不复述)**:八敌 36–94 逐键定档 + 主角 48 不变、显示一律 ×2 整数档(NEAREST)——**数值权威一律圣经 §8.2**(定值单点纪律,圣经 §8 头注);定档经三体型约束校验(§5.2)确认;派生管线与排版规范同归圣经 §8.2。 -- **钩爪恐魔「宽≥高」豁免登记(待样张终审)**:定档源 58 高窄条束状剪影(长臂圆钩),宽高比 <1,与 §5.1 普通怪「近圆墩形宽≥高」冲突——按体量=生物天性(§5.2)登记豁免:以长臂束状轮廓为识别锚,不强行压成墩形;样张(圣经 §1.4)终审复验灰度剪影可辨性。 +- **主色域 = 物种本色**,不受圣经 §4.2 语义色禁则约束(该禁则管格面与 UI 的逻辑色,角色身体主色豁免——提示词卡 2026-09-20 成例);**两例外**:禁入带 300°–345° 全角色禁入(仅 drider/duergar_king 两 Boss 独占)、蓝族禁入敌方主色(§5.4 同款)。 +- **L2 熊地精 = bugbear 复用**(第二关三行,显示名熊地精系,非独立键):同剪影**换色板不换形**(色板替换走脚本管线——原则三"先换色板"的角色侧应用);变体主色避小人蓝族与禁入带。 +- 每敌种出 2–3 版候选(清单 §2.2);受击反馈/死亡表现/顺位标记三类呈现下限见圣经 §4.6.4 资产需求行。 +- **立绘源档高(2026-10-06 定档批;定值单点 = 圣经 §8.2 立绘源档表,本档指路不复述)**:八敌 36–94 逐键定档 + 主角 48 不变、显示一律 ×2 整数档(NEAREST)——**数值权威一律圣经 §8.2**(定值单点纪律,圣经 §8 头注);定档经用户三体型约束校验(§5.2)与定档总图确认;派生管线与排版规范同归圣经 §8.2。 +- **钩爪恐魔「宽≥高」豁免登记(2026-10-06,待样张批终审)**:定档源 58 高窄条束状剪影(长臂圆钩),宽高比 <1,与 §5.1 普通怪「近圆墩形宽≥高」冲突——按体量=生物天性(§5.2 修订)登记豁免:以长臂束状轮廓为识别锚,不强行压成墩形;样张批(圣经 §1.4)终审复验灰度剪影可辨性。 -## 5.6 意图准备动画三档(规格承载节) +## 5.6 意图准备动画三档(2026-09-18 用户拍板,规格承载节) -> **白话**:敌人下一拍做什么,不用文字预告——用三档动作动画让玩家"看姿态就知道要有事"。 +> **白话**:敌人下一拍做什么,不用文字预告——用三档动作动画让玩家"看姿态就知道要有事"。 -- **三档**:① 普通攻击 = 常态待机(待机即预示,零独立资产);② 攻击技能 = 特殊准备动画(放光/高举武器等"摇号感"动作);③ 重击/硬化类 = 蓄力动作或防御姿态。 -- **双通道纪律**:三档在灰度/剪影层可辨(色弱安全,圣经 §4.5 同款要求)。 -- **呈现纪律**:不显示文字与技能名(文字与技能名彻底退出预告);映射按 enemy_id **全局恒定、跨 run 禁重掷**;预告时序 = 下一拍,挖掘期全程可见,结算不另设独立意图播片。契约锚 = 圣经 §4.6.4 意图姿态行。 -- **终稿帧规格**(帧数/时长/结算节拍对齐)归圣经 §8 资产规范与战斗一屏样张(清单 §2.3);占位符号(—/⚔/✦/蓄力)现行使用,动画到位后原位替换。 +- **三档**:① 普通攻击 = 常态待机(待机即预示,零独立资产);② 攻击技能 = 特殊准备动画(放光/高举武器等"摇号感"动作);③ 重击/硬化类 = 蓄力动作或防御姿态。 +- **双通道纪律**:三档在灰度/剪影层可辨(色弱安全,圣经 §4.5 同款要求)。 +- **呈现纪律**:不显示文字与技能名(文字与技能名彻底退出预告);映射按 enemy_id **全局恒定、跨 run 禁重掷**;预告时序 = 下一拍,挖掘期全程可见,结算不另设独立意图播片。契约锚 = 圣经 §4.6.4 意图姿态行。 +- **终稿帧规格**(帧数/时长/结算节拍对齐)归圣经 §8 资产规范与战斗一屏样张批(清单 §2.3);占位符号(—/⚔/✦/蓄力)现行在役,动画到位后原位替换。 --- diff --git a/design/art/remote-gen-prompts.md b/design/art/remote-gen-prompts.md index 29ed0880..2612a874 100644 --- a/design/art/remote-gen-prompts.md +++ b/design/art/remote-gen-prompts.md @@ -1,8 +1,7 @@ # 远程生图提示词单(对话式生图操作单) -> **渠道定位**:生图渠道 = **远程对话式生图为主,本地 ComfyUI 为备选**;本文件是**对话操作单**(怎么开对话/贴什么参考图/怎么续写),提示词正文词库归 [art-prompts](art-prompts.md)——分工:词库改词库、操作单改流程,同一段正文不双处维护。 -> **冲突优先级**:画法与色彩以 [art-bible](art-bible.md) 为准,人物规格以 [character-design](character-design.md) 为准;本单为执行侧操作单,不另立规格。 -> **出图登记义务(复现性)**:每张产出登记 **模型名 + 分辨率 + seed(可取则取)+ 对话/日期**,随候选原图同存 `artwork/candidates/<资产名>/`;缺登记的候选不入正稿遴选。 +> **渠道定位(2026-10-06 拍板)**:生图渠道 = **远程对话式生图为主,本地 ComfyUI 为备选**(v2.7「彻底关闭」系当时口径,现行更正为备选);本文件是**对话操作单**(怎么开对话/贴什么参考图/怎么续写),提示词正文词库归 [art-prompts](art-prompts.md)——分工:词库改词库、操作单改流程,同一段正文不双处维护。 +> **出图登记义务(2026-10-06 P1-1 复现性)**:每张产出登记 **模型名 + 分辨率 + seed(可取则取)+ 对话/日期**,随候选原图同存 `artwork/candidates/<资产名>/`;缺登记的候选不入正稿遴选。 > 开新对话出图:同一角色 2–3 版候选各开新对话(同对话续写=修图);同角色换姿势在同对话续写。分辨率开大档;候选原图存 `artwork/candidates/<资产名>/`。 ## 1. 主角战斗立绘 @@ -79,10 +78,10 @@ Pixel art style. true pixel art game sprite drawn on a tiny 48x72 pixel canvas, ## 3. 瓦片纹理 -**制式 = 拼表制主选——三步流程**: +**制式 = 拼表制主选(2026-10-06 用户拍板)——三步流程**: 1. **程序占位出样图**:现行程序化格面(占位在役)截一张作参考样图; 2. **整图生成**:开新对话,贴样图 + 发下方整段,一张 2×2 出全部瓦片变体(风格统一、一张管全族); -3. **裁切取用**:按 50% 坐标切图、每格内缩 2–3% 切掉可能的边框 → 统一降采样 → 成片瓦片 21 枚由程序从切出的材质合成(查表已落码 `board_world.gd`)。 +3. **裁切取用**:按 50% 坐标切图、每格内缩 2–3% 切掉可能的边框 → 统一降采样 → 成片瓦片 21 枚由程序从切出的材质合成(词表:高墙瓦片词表(已落码 board_world.gd 查表,文件已退役))。 ``` Seamless tileable pixel art texture sheet for a dungeon board game, arranged as a clean 2x2 grid of four equal square quadrants filling the entire image edge to edge, with no gaps, no dividing lines, no borders, no frames, no grid lines, no labels and no text. Top-left quadrant: warm tan rock surface with subtle darker seams between stone patches and small lighter facets. Top-right quadrant: the same warm tan rock surface but with a few thin darker crack lines scattered across it. Bottom-left quadrant: the same warm tan rock surface but with a few tiny lighter stone speckles sparsely scattered. Bottom-right quadrant: dry dirt ground in a dark warm brown palette around #B39A78, clearly darker than the tan rock, with a few thin cracks and tiny pebble speckles, sparse. Every quadrant is a flat 2D top-down texture — not an object, no perspective — and each quadrant must wrap seamlessly at its own four edges. Uniform directionless lighting across all four quadrants, no vignette, no cast shadow, no bevel, no edge highlight anywhere. Warm tan-brown palette around #C9AF83 for the three rock quadrants. Clean retro pixel art look, limited palette, no gradients, no text, square image. diff --git a/design/engineering/process-conventions.md b/design/engineering/process-conventions.md index 32335b8f..660701d0 100644 --- a/design/engineering/process-conventions.md +++ b/design/engineering/process-conventions.md @@ -54,7 +54,7 @@ 11. **文档同步闸(实现批禁写文档)**——实现批**禁写 `design/` 整个目录与根规则文件**,要往外交代的东西一律落 `reports/`:实现报告 `reports/iter-实现报告-<日期>.md`(各卡状态+实机目检总清单+遗留读数义务+实现决定记录——超白名单/超卡片明列范围事项逐条记「决了什么/为什么/代价/撤销方式」,全部按卡执行时只写一行「零拍板」)、GDD 反哺点清单 `reports/iter-反哺点-<日期>.md`(每条写清哪个文件哪一节、改成什么、依据是什么)。设计权威文档(GDD/art-bible)**任何批都不直改**:用户在会话中逐点裁决后,由工程/文档设计改动批(直改类)写入 `design/`(执行入口=doc-design 技能),并把反哺条目从 `reports/` 清掉(吸收即死;退役执行=batch-closure 技能)。机械面文档(roadmap 行/术语表/README 工具注)同样归工程/文档设计改动批在合并前更新。反哺点未落地期间相关 GDD 节视为待反哺,读前先查 `reports/` 与待办清单;需求卡片必列「关联文档同步面」按两层标注 12. **实机目检归用户,只登记不阻塞**——真人检测项登记进实现报告目检总清单与体验验证清单即完成批内义务;禁把等目检当停工理由或完成前置 13. **中间产物吸收即死**——细则见 AGENTS.md 硬性红线与 doc-conventions 第 11 节 -14. **改动级验收闭合**——写卡期列需求侧对照表,实现期补实际对照表(测试文件:行),每个行为改动的验收断言从真实入口进入、在当轮真实跑批中执行并通过(判据绑定面口径=test-conventions §8.4;禁变异类测试与撤销类取证,2026-10-24/28 用户两裁);合回前独立验收审计。细则=test-conventions §8 +14. **改动级验收闭合**——写卡期列需求侧对照表,实现期补实际对照表(测试文件:行),每个行为改动做撤销证明;合回前独立验收审计。细则=test-conventions §8 ## 4. 测试执行两入口铁规(细则) diff --git a/design/engineering/test-conventions.md b/design/engineering/test-conventions.md index fd19c4cf..76642a5a 100644 --- a/design/engineering/test-conventions.md +++ b/design/engineering/test-conventions.md @@ -97,7 +97,7 @@ - **反向**:diff 里每个行为改动至少一行;没有对应行的改动,必须写明「非行为改动」或「由第 X 行覆盖」。 - **测试侧**:新增或改写的每条断言至少挂一行;挂不上的孤儿断言删掉或补挂到需求点。 -**能力清单维护锚(2026-10-28 用户裁,验证与清单体系整改阶段 1;阶段 2 后定稿)**:逐断言账本 `game/tests/验证集清单.md` 已随阶段 2「存量断言精简」完成退役——冻结对账基准(基点 `b9260138`/1587 条)与精简对账结果(33 站点并为 7、1587→1561,合回 `87d241c1`)留 git 历史。覆盖索引由 `game/tests/README.md`「能力清单 v1」唯一承担:每行一个能力,给真实入口与 2–3 个代表用例(`文件:函数`)。维护义务——**每批收口核对本批受影响能力行**:代表用例可 grep、在本批真实跑批中执行、真实入口未绕过改动点,并刷新「最后验证批次」;新增能力加行,能力退役删行。改动—验收对照表纪律不变:新增或改写的每条断言仍须挂一行(上文「测试侧」);断言改判仍受 §8.4 真实入口口径约束。 +**验证集清单维护锚(2026-10-22 用户裁)**:`game/tests/验证集清单.md` 是迁移断言全体的冻结参照清单(机器提取生成、只剪切禁改写、出口逐条核对,机制见该文件头注;清单自断言迁移批第 0 步建立时起存在)——清单在册后,任何断言**新增、删除或改判**必须同步该清单,与改动—验收对照表同一条纪律:清单没跟上的断言改动视同未完成。 ### 8.4 真实入口断言(验收闭合的主判据) diff --git a/design/gdd/02-系统清单.md b/design/gdd/02-系统清单.md index 2f74f45f..01a69a23 100644 --- a/design/gdd/02-系统清单.md +++ b/design/gdd/02-系统清单.md @@ -15,7 +15,7 @@ | `术语表.md` | 全库专用术语的唯一对照表——危险格/安全格/同调位/RAP/GP/真视等定义+退役词对照(2026-10-11 建立,v1.23) | | `待办清单.md`(仓库根,不属本树) | 全项目待办事项的唯一登记处;各系统文档的「待办」节只留指路存根 | | `体验验证清单.md`(仓库根,不属本树) | 需要真人试玩观测的验证项登记处(与待办清单分工:待办清单是"要拍板的事",本篇是"要玩过才知道的事");每条注明结论回流路径 | -| `08-Alpha版本标准.md` | Alpha 版本包含哪些东西的清单——里程碑 M3 的完成标准;只列条目不写内容,定义一律指路各权威文档(**已定稿 v1.19**,2026-10-13) | +| `08-Alpha版本标准.md` | Alpha 版本包含哪些东西的清单——里程碑 M3 的完成标准;只列条目不写内容,定义一律指路各权威文档(**已定稿 v1.17**,2026-10-13) | ## systems/ 五个循环 diff --git a/design/gdd/08-Alpha版本标准.md b/design/gdd/08-Alpha版本标准.md index ea7461dd..f4bc9b92 100644 --- a/design/gdd/08-Alpha版本标准.md +++ b/design/gdd/08-Alpha版本标准.md @@ -2,7 +2,7 @@ > **一句话定位**:本文定义这个项目的 **Alpha 版本包含哪些东西**——它是里程碑 M3 的完成标准,也是判断"还差什么"的对照表。 > **本文只列条目,不写内容**:每样东西是什么、规则怎么走,一律归各自的权威文档,本文只给指路链接。想知道某样东西的定义,请点链接,不要在本文里找解释。 -> **状态**:**已定稿(v1.19)**——2026-10-13 用户令定稿,历次升版明细见 git 提交史。Alpha 的内容范围自此冻结:增删条目一律须经用户裁决。 +> **状态**:**已定稿(v1.18)**——2026-10-13 用户令定稿,历次升版明细见 git 提交史。Alpha 的内容范围自此冻结:增删条目一律须经用户裁决。 > **成文**:2026-10-13。 ## 本文怎么用 @@ -48,7 +48,7 @@ Alpha 内以玩法循环本身的内容与表现为主(棋盘、关卡、战 | 冒险结构 | [冒险结构](systems/冒险循环/冒险结构.md) | 在役 | | 关卡节点地图 | [关卡节点地图](systems/冒险循环/关卡节点地图.md) | 在役(信息迷雾类型可见性已随迭代 34 卡④ 实装,2026-10-22) | | 关卡节点图重构(增加可选节点) | [关卡节点地图](systems/冒险循环/关卡节点地图.md)+[关卡节点图生成](systems/冒险循环/关卡节点图生成.md) | 在役(2026-10-25 迭代 35 卡①② 合回:图源切为参数档×种子生成图、模板链路退役;真人目检选路项已登记体验验证清单,登记不阻塞) | -| 关卡节点图生成 | [关卡节点图生成](systems/冒险循环/关卡节点图生成.md)(参数化长图:产出规格与生成参数) | 在役(2026-10-24 迭代 35 卡① 生成器核心合回;enemy.tres 与 board_gen.tres 已覆盖到第三关深度 13;生成图 L3 深度域 0–13 超出关卡地图屏画布屏预算约 10 层——等比缩放/滚动视口两升级路径未设计待裁,[待办清单](../../待办清单.md)「超预算图策略升级路径」条在册) | +| 关卡节点图生成 | [关卡节点图生成](systems/冒险循环/关卡节点图生成.md)(参数化长图:产出规格与生成参数) | 在役(2026-10-24 迭代 35 卡① 生成器核心合回;enemy.tres 与 board_gen.tres 已覆盖到第三关深度 13) | | 难度曲线与 Boss | [难度曲线与Boss](systems/冒险循环/难度曲线与Boss.md) | 在役 | | 奇物 | [奇物](systems/冒险循环/奇物.md) | 在役 | diff --git a/design/gdd/术语表.md b/design/gdd/术语表.md index 710b1364..fc970207 100644 --- a/design/gdd/术语表.md +++ b/design/gdd/术语表.md @@ -1,7 +1,7 @@ # 术语表 > 全库专用术语的唯一对照表(2026-10-11 用户令建立)。规矩:新术语先登记本表、再在文档使用;退役词见文末对照表,文档正文一律使用现行词。文档中遇到看不懂的词,查本表。用词纪律(禁用词、替换值、豁免范围)见「用语规范」节。 -> 状态:**现行 v1.24**——2026-10-11 建立;历次升版明细见 git 提交史。 +> 状态:**现行 v1.23**——2026-10-11 建立;历次升版明细见 git 提交史。 ## 格与棋盘 @@ -97,20 +97,6 @@ | `seal_missed` | 封印落空事件(原 `flag_missed` 换代):封印动作落在安全格上时发出,订阅面据此做限次与豁免判定(迭代 31 卡② 落地) | | `forgive_seal_miss` | 本场首次封印落空豁免——专长「完美保险」的效果类型,`domain=battle` 首例;有效误标=误标总数 − 豁免数,豁免不改写误标账(迭代 31 卡② 落地) | -## 美术表现 - -| 术语 | 定义 | -|---|---| -| 样张 | 按美术圣经参数画成的风格试验图,是砖面形态等视觉定稿的验收闸(规格见[美术圣经](../art/art-bible.md) §1.4) | -| 整屏样张 | 整屏级样张——战斗/关卡/菜单/营地屏各出一张,验屏级文字对比度、明度分层与焦点预算 | -| 掩尘 | 未开格底色的专色名——覆尘完整砖的「未知」面色 | -| 裸土 | 已开格底色的专色名——碎裂裸露空间的沉暗面色 | -| 色牌 | 垫在数字正下方的素色直角小牌,复杂格底时保数字可读的后备手段 | -| 图类型 | 棋盘生成产物 `board_type` 的中文名——三关换皮的单位(同款地砖换色板) | -| 方向稿 | 冻结前的方向性候选稿,供气质目检;过目检后才进机检冻结 | -| 站桩 | 战斗画面区(B 区)里角色直立立绘的显示形态(配血条与状态行) | -| 徽章 | 库存条与结算明细用的小图标(与格面类型微光预读相区分) | - ## 开发流程 | 术语 | 定义 | diff --git a/game/core/action_economy.gd b/game/core/action_economy.gd index 17a48750..a90a66ff 100644 --- a/game/core/action_economy.gd +++ b/game/core/action_economy.gd @@ -30,10 +30,7 @@ var _rap := 0 var rap: int: get: return _rap var _last_round_end: Dictionary[String, String] = {} # {path_tag, continuation_hint}——排雷玩法 组包 round_end 的合法数据源 -var last_round_end: Dictionary[Variant, Variant]: - get: return _last_round_end var _settlement_consumed := false # AC-14 门:notify_settlement_done 消费后才受理 round_start -var _next_round_mode := "" # 调试观测量(同板续轮 / 换新图) var _pending_ap_delta := 0 # 下轮行动点修正排队(action 型 ±1 排队,轮开始兑现后清零) # 蓄力流运行态:转蓄力=round_end grant_ap(mode:transfer) 订阅(finish_round_stop 定夺)、 # 深挖对价=dig_paid counter_spend 订阅(request_dig 施加面);charge_layers=蓄力层账本 @@ -44,7 +41,6 @@ var charge_layers: int: var _fx: EffectEngine = null var _ctx: EffectContext = null var _last_stop_ap := 0 # 停手侧剩余 AP 记账(转存决策在 round_end 发射点——grant_ap(transfer) 订阅定夺) -var _ap_exhausted_last := false # 上轮 AP 耗尽封印(一滴不剩 round_start when 面) func set_fx(fx: EffectEngine, ctx: EffectContext) -> void: @@ -204,9 +200,9 @@ func request_refresh() -> bool: if _phase != Phase.ACTIVE: _reject("phase") return false - if _rap < db.action_economy.rap_refresh_cost: + if _rap < db.run.action_economy.rap_refresh_cost: return false # EC13 资源不足拒绝——合法相位内资源拒绝,非编排错误 - _rap -= db.action_economy.rap_refresh_cost + _rap -= db.run.action_economy.rap_refresh_cost return true @@ -216,7 +212,6 @@ func notify_settlement_done(next_round_mode: String) -> bool: _reject("phase") return false _settlement_consumed = true - _next_round_mode = next_round_mode return true @@ -249,7 +244,7 @@ func notify_round_start() -> bool: else: _reject("phase") return false - _ap = maxi(0, db.action_economy.ap_per_round + _pending_ap_delta) # 每轮基线 + 排队修正(下限 0) + _ap = maxi(0, db.run.action_economy.ap_per_round + _pending_ap_delta) # 每轮基线 + 排队修正(下限 0) _pending_ap_delta = 0 _phase = Phase.ACTIVE _settlement_consumed = false @@ -272,13 +267,19 @@ func _finish_round(path_tag: String, continuation_hint: String) -> void: assert(path_tag != "ap_exhausted" or _ap == 0, "耗尽轮剩余行动点恒为 0(实现层断言兜底)") assert(path_tag != "cleared" or _ap == 0, "清图轮剩余行动点当场清零(不进储备池)") _last_round_end = {"path_tag": path_tag, "continuation_hint": continuation_hint} - _ap_exhausted_last = path_tag == "ap_exhausted" # 一滴不剩 when 面(轮始发射读数) _phase = Phase.WAITING -# 上轮 AP 是否耗尽(round_start 发射 payload 读数面;轮始兑现后保持到本轮末) +# 上轮组包读数({path_tag, continuation_hint}——排雷玩法 组包 round_end 的合法数据源; +# 属性→方法 K5 换形,消费方 minesweeper_rules 单点改址) +func last_round_end() -> Dictionary[String, String]: + return _last_round_end + + +# 上轮 AP 是否耗尽(round_start 发射 payload 读数面;轮始兑现后保持到本轮末)—— +# 派生读(同源同刻:_last_round_end 组包点即派生原点,K5 派生合并——存储成员退役) func ap_exhausted_last_round() -> bool: - return _ap_exhausted_last + return _last_round_end.get("path_tag", "") == "ap_exhausted" func _reject(source: String) -> void: diff --git a/game/core/adventure_runner.gd b/game/core/adventure_runner.gd index 80a1afec..44ccda53 100644 --- a/game/core/adventure_runner.gd +++ b/game/core/adventure_runner.gd @@ -105,7 +105,7 @@ func start_adventure(seed_val: int, params: Dictionary = {}) -> bool: _stats.armor_type = String(params.get("armor_type", "")) summary = {} _reset_curio_state() - levels_total = db.level_count() # 表访问收口(关数读 level 表行数) + levels_total = db.run.level.level_count() # 表访问收口(关数读 level 表行数) level_index = 1 _pending_next_level = false _shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 缺省 legacy(off 对照零扰动) @@ -120,7 +120,7 @@ func start_adventure(seed_val: int, params: Dictionary = {}) -> bool: # 刷新练习开局注入(refresh_stock_grant 装配即得一次性加仓: # 装配集 kind 分发扫描;只入存量账、临时账不动=V8 契约;resume 不重注入〔存量随断点已在〕) for iid_v2 in _attunement: - var rp_row: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v2)) + var rp_row: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v2)) if (rp_row.get("effect_kinds", []) as Array).has("refresh_stock_grant"): _refresh_bank.add_refresh_stock(int(rp_row.get("magnitude", 0))) # 特性开局注入(MC 成型画像载体:params.relics_seed 非空=开局持有集〔七流派 @@ -184,7 +184,7 @@ func _resume_ledger(bp: Dictionary, params: Dictionary) -> void: _wallet.restore_fragment(bp, params) _journal.restore_fragment(bp, params) summary = {} - levels_total = db.level_count() # 表访问收口(关数读 level 表行数) + levels_total = db.run.level.level_count() # 表访问收口(关数读 level 表行数) level_index = int(bp.get("level_index", 1)) _pending_next_level = false @@ -223,7 +223,7 @@ func _resume_params(bp: Dictionary, params: Dictionary) -> void: #(如调试浮窗调高期写出的值,实例:hero_hp=28 > 基础 26)——恢复后钳到生效上限, # 否则首战 spawn「hero_hp 入参越界」拒收 → 「敌人系统 spawn 被拒」fatal。钳制式单点归 # 数值账 clamp_hp_to_resume_cap(保守口径不含加算区——与原钳制式逐字同)。 - _stats.clamp_hp_to_resume_cap(int(db.hero.max_hp)) + _stats.clamp_hp_to_resume_cap(int(db.combat.hero.max_hp)) _shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 续玩参数面同 start_adventure(缺省 legacy) _treasure_unlocked = params.get("treasure_unlocked", {}) if params.get("treasure_unlocked", {}) is Dictionary else {} _attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive @@ -330,7 +330,7 @@ func present_node_curio(candidates: Array) -> void: func _attune_refresh_x_total() -> int: var total := 0 for iid_v in _attunement: - var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v)) + var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v)) if (arow.get("effect_kinds", []) as Array).has("bonus_refresh"): total += int(arow.get("magnitude", 0)) return total @@ -369,8 +369,8 @@ func _loadout_on_level_inventory() -> void: func _boss_keystone_drop(li: int) -> void: var first := true if _save_manager != null: - first = not _save_manager.boss_first_kill_marked(li) - var row: Dictionary[Variant, Variant] = db.meta_boss_keystone_row(li) + first = not _save_manager.meta_state.career_state().boss_first_kill.has(str(li)) + var row: Dictionary[Variant, Variant] = db.crossrun.meta.boss_keystone_row(li) var amount: int = int(row.get("first_kill", 0)) if first else int(row.get("repeat_kill", 0)) _journal.record_keystone_drop({"level": li, "first": first, "amount": amount}) # 逐杀明细+累计单写原语 diff --git a/game/core/attune_loadout.gd b/game/core/attune_loadout.gd index bd6fcf4a..55fabc8e 100644 --- a/game/core/attune_loadout.gd +++ b/game/core/attune_loadout.gd @@ -33,7 +33,7 @@ func item_ids() -> Array: func seed_for_adventure(inventory: TreasureItems) -> void: for iid_v in _ids: var iid := String(iid_v) - var arow: Dictionary[Variant, Variant] = db.treasure_item_row(iid) + var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(iid) if String(arow.get("form", "")) != "active_charged": continue _ensure_rows(iid, maxi(0, int(arow.get("charges_per_adventure", 0))), inventory) @@ -44,7 +44,7 @@ func seed_for_adventure(inventory: TreasureItems) -> void: func reset_cycle(cycle: String, inventory: TreasureItems) -> void: for iid_v in _ids: var iid := String(iid_v) - var arow: Dictionary[Variant, Variant] = db.treasure_item_row(iid) + var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(iid) if String(arow.get("form", "")) != "active_charged": continue if String(arow.get("charge_reset", "adventure")) != cycle: @@ -94,7 +94,7 @@ func breakpoint_fragment(inventory: TreasureItems) -> Dictionary[Variant, Varian var charges: Dictionary[Variant, Variant] = {} for iid_v in _ids: var iid := String(iid_v) - var arow: Dictionary[Variant, Variant] = db.treasure_item_row(iid) + var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(iid) if String(arow.get("form", "")) == "active_charged": charges[iid] = charges_left(iid, inventory) return {"attunement_charges": charges} diff --git a/game/core/attunement.gd b/game/core/attunement.gd index f426f60c..b3a66a56 100644 --- a/game/core/attunement.gd +++ b/game/core/attunement.gd @@ -12,6 +12,6 @@ static func slots(db: ConfigDb, save: Variant) -> int: return 0 var n := 0 for i in range(1, 4): - if save.tree_level("att_socket%d" % i) >= 1: + if save.meta_state.tree_state().nodes.get("att_socket%d" % i, 0) >= 1: n += 1 return n diff --git a/game/core/battle_fx_host.gd b/game/core/battle_fx_host.gd index 5ffa6955..a4a3bc42 100644 --- a/game/core/battle_fx_host.gd +++ b/game/core/battle_fx_host.gd @@ -175,5 +175,5 @@ func _fx_fact(fact: String) -> Variant: # 基线 = enc 口径,-1(表值兜底态)时解析为表值后叠加,守恒口径与 begin_battle 注入一致) func _sync_hero_ac() -> void: if int(ac.base) < 0: - ac.base = int(db.hero.armor_class) + ac.base = int(db.combat.hero.armor_class) damage.set_hero_ac_override(int(ac.base) + int(ac.round) + int(ac.battle)) diff --git a/game/core/battle_orchestrator.gd b/game/core/battle_orchestrator.gd index fbaed186..c47203a3 100644 --- a/game/core/battle_orchestrator.gd +++ b/game/core/battle_orchestrator.gd @@ -180,7 +180,7 @@ func attune_has(item_id: String) -> bool: func attune_param_sum(kind: String) -> int: var total := 0 for iid_v in attunement: - var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v)) + var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v)) if (arow.get("effect_kinds", []) as Array).has(kind): total += int(arow.get("magnitude", 0)) return total @@ -190,7 +190,7 @@ func attune_param_sum(kind: String) -> int: # 首行语义=装配集内至多一件积点制〔试炼镐〕) func _attune_charge_safe_row() -> Dictionary: for iid_v in attunement: - var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v)) + var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v)) if (arow.get("effect_kinds", []) as Array).has("ap_charge_safe"): return arow return {} @@ -301,10 +301,8 @@ func _begin_battle_accept(enc: Dictionary) -> bool: _fatal("敌人表 行集解析为空(%d,%d,%s)" % [int(enc.level_index), int(enc.node_depth), String(enc.get("node_type", "battle"))]) return false # E4 收口:加成经 encounter 显式携带(hero_atk/hero_max_hp;缺省 = 表值——冒烟/工具直呼路径) - _hero_atk = int(enc.get("hero_atk", int(db.hero.atk))) - if not _enemy_system.spawn(int(enc.level_index), int(enc.node_depth), int(enc.hero_hp), dice[0], - _hero_atk, int(enc.get("hero_max_hp", -1)), dice[1], - String(enc.get("attack_type", "")), String(enc.get("armor_type", "")), String(enc.get("node_type", "battle"))): # 两根类型轴(空串=锻造配置默认值回退,行为连续)+行类维(battle/boss) + _hero_atk = int(enc.get("hero_atk", int(db.combat.hero.atk))) + if not _enemy_system.spawn({"level_index": int(enc.level_index), "node_depth": int(enc.node_depth), "hero_hp": int(enc.hero_hp), "unit_count_overrides": dice[0], "hero_atk_override": _hero_atk, "hero_max_hp_override": int(enc.get("hero_max_hp", -1)), "enemy_atk_overrides": dice[1], "hero_attack_type": String(enc.get("attack_type", "")), "hero_armor_type": String(enc.get("armor_type", "")), "row_kind": String(enc.get("node_type", "battle"))}): # 两根类型轴(空串=锻造配置默认值回退,行为连续)+行类维(battle/boss) _fatal("敌人系统 spawn 被拒") return false return true @@ -317,7 +315,7 @@ func _begin_battle_accept(enc: Dictionary) -> bool: # 返回 [count_overrides, atk_overrides];行集为空 = 返回 [](fail 判据交受理段 _fatal)。 func _begin_battle_dice(enc: Dictionary) -> Array: # 行类维度:节点身份 battle/boss 决定行集键域(缺省 battle 只服务测试直呼面——生产路径恒传 node_type) - var erowset_pre: Array[Variant] = db.resolve_enemy_rowset(int(enc.level_index), int(enc.node_depth), String(enc.get("node_type", "battle"))) + var erowset_pre: Array[Variant] = db.combat.enemy.resolve_rowset(int(enc.level_index), int(enc.node_depth), String(enc.get("node_type", "battle"))) if erowset_pre.is_empty(): return [] var needs_count := false @@ -462,7 +460,7 @@ func _install_new_board(during_round := false) -> bool: func _resolve_global_variant(board: BoardData) -> void: board.board_variants.clear() _reward_economy.board_skew = 0 - var row: Dictionary[Variant, Variant] = db.global_variant_row(int(encounter.level_index), String(board.board_type), + var row: Dictionary[Variant, Variant] = db.combat.variation.global_variant_row(int(encounter.level_index), String(board.board_type), String(encounter.get("node_type", ""))) if row.is_empty(): return @@ -482,7 +480,7 @@ func _resolve_global_variant(board: BoardData) -> void: func _place_variants(board: BoardData) -> void: if not board.board_variants.is_empty(): return - var pool: Array[Variant] = db.variant_pool(int(encounter.level_index), String(board.board_type)) + var pool: Array[Variant] = db.combat.variation.variant_pool(int(encounter.level_index), String(board.board_type)) if pool.is_empty(): return var r0 := {} @@ -496,8 +494,8 @@ func _place_variants(board: BoardData) -> void: return var stream: RandomNumberGenerator = rng.open_stream("variant_place") # 一板恰一 scope(换图/刷新图各取号一次) var vrow: Dictionary[Variant, Variant] = pool[stream.randi_range(0, pool.size() - 1)] - var total: int = int(round(float(candidates.size()) * float(db.variation.percent_of_cells) / 100.0)) \ - + (int(encounter.level_index) - 1) * int(db.variation.per_level_bonus) + var total: int = int(round(float(candidates.size()) * float(db.combat.variation.percent_of_cells) / 100.0)) \ + + (int(encounter.level_index) - 1) * int(db.combat.variation.per_level_bonus) total = mini(total, candidates.size()) _place_variants_apply(board, candidates, stream, vrow, total) @@ -506,7 +504,7 @@ func _place_variants(board: BoardData) -> void: func _place_variants_apply(board: BoardData, candidates: Array[Vector2i], stream: RandomNumberGenerator, vrow: Dictionary, total: int) -> void: var placed := {} while placed.size() < total: - var pack_size: int = mini(total - placed.size(), stream.randi_range(2, int(db.variation.pack_max))) + var pack_size: int = mini(total - placed.size(), stream.randi_range(2, int(db.combat.variation.pack_max))) var free: Array[Vector2i] = [] for c in candidates: # 新片种子/生长格禁与其他已布片 8 邻贴接——否则两片粘连成一个超 pack_max 的连通分量 @@ -638,7 +636,7 @@ func _harmful_variants(vids: Array) -> Array: var out: Array[Variant] = [] for vid_v in vids: var vid := String(vid_v) - var vrow: Dictionary[Variant, Variant] = db.variation.row_by_id(vid) + var vrow: Dictionary[Variant, Variant] = db.combat.variation.row_by_id(vid) if String(vrow.get("effect", "")) == "flat_damage": out.append(vid) return out @@ -677,7 +675,7 @@ func set_treasure_unlocked(ids: Dictionary) -> void: # 统一流:behavior_roll 无子流派生向量(位置交错,单点定位由事件日志承载)。 # 摇定后、兑现前死亡 → 本拍技能作废。 func _roll_round_actions() -> void: - var pity_m: int = int(db.skill.pity_threshold) + var pity_m: int = int(db.combat.skill.pity_threshold) # facts 组包(开轮摇号前一次成型——last_round_end 空字典首轮映射 none 唯一落点;hero 事实不进 facts, # 容器内直读主角行) var facts := { @@ -692,7 +690,7 @@ func _roll_round_actions() -> void: # 技能名提示〔轻量 toast 非演出——D8 呈现面〕。门=确定性谓词,机制面零随机,摇号随机 # 消费仍在 behavior_roll 既有流——同种子逐位复现不破坏) for sid in _enemy_system.check_trigger_unlocks(u, facts): - var srow_u: Dictionary[Variant, Variant] = db.skill_row(sid) + var srow_u: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(sid) _emit_row({"type": "highlight", "source_class": "enemy_action", "origin_cell": Vector2i(-1, -1), "highlight": "skill_unlock"}, round_index, "%s 解锁了 %s" % [String(u.display_name), String(srow_u.get("display_name", sid))], 0, {"skill_id": sid}) @@ -733,7 +731,7 @@ func _defender_ac(unit: Dictionary) -> int: func _pool_effective(effect_id: String) -> int: var side := "hero" - var row: Dictionary[Variant, Variant] = db.status_effect_row(effect_id) + var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(effect_id) if not row.is_empty(): side = String(row.target_side) for eff in _status_effects.query_active(side): @@ -818,9 +816,9 @@ func _exit_distribution() -> void: # 呈现层查询面(HUD 刷新按钮可用性)——挖掘期唯一通道 func can_refresh() -> bool: return battle_result == "" \ - and _refresh_used_this_round < int(db.action_economy.refresh_cap_per_round) \ + and _refresh_used_this_round < int(db.run.action_economy.refresh_cap_per_round) \ and action_economy.phase == action_economy.PHASE_ACTIVE \ - and action_economy.rap >= int(db.action_economy.rap_refresh_cost) + and action_economy.rap >= int(db.run.action_economy.rap_refresh_cost) # 玩家经 HUD框架发起、战斗结算 代理调用(禁止 RAP 满自动刷)。 @@ -870,14 +868,14 @@ func _battle_over(result: String) -> void: # + level_gp_bonus(level_index) + escrow。掷定行缺位 = 数据面缺行(fail 报错按 0 兑付——不哑掉)。 func _roll_gp_drop() -> int: var tier := "boss" if String(encounter.get("node_type", "")) == "boss" else "normal" - var drops: Dictionary[Variant, Variant] = db.shop_gp_drop_row(tier) + var drops: Dictionary[Variant, Variant] = db.run.shop.gp_drop_row(tier) var roll := 0 if drops.is_empty(): push_error("[战斗结算] gp_drops 缺行 tier=%s——掉落掷定按 0 兑付" % tier) else: var stream: RandomNumberGenerator = rng.open_stream("gp_drop") roll = stream.randi_range(int(drops.get("roll_min", 0)), int(drops.get("roll_max", 0))) - var drop := roll + db.shop_level_gp_bonus(int(encounter.get("level_index", 0))) + var drop := roll + db.run.shop.level_gp_bonus_at(int(encounter.get("level_index", 0))) # 财富之道·聚(战斗掉落 GP 乘算·入账前:只乘战斗掉落两成分〔掷定+关卡加成〕, # 店内 escrow 不乘〔范围=仅战斗掉落渠道〕;本函数返回值=结算框「GP 增量」行 # 显示源,落在入账前=显示即实得;乘数 provisional 150%) @@ -899,14 +897,14 @@ func presentation_snapshot() -> Dictionary: # 敌方增益投影(side-wide 状态——蓄力/硬化/削弱/暴走 percent 行 + 警觉 ac 行) var enemy_effects: Array[Dictionary] = [] for eff in _status_effects.query_active("enemy"): - var erow: Dictionary[Variant, Variant] = db.status_effect_row(eff.effect_id) + var erow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff.effect_id) if not erow.is_empty() and String(erow.unit) in ["percent", "ac"]: enemy_effects.append({"effect_id": eff.effect_id, "display_name": String(eff.display_name), "layers": int(eff.layers)}) for i in all.size(): var u: Dictionary[Variant, Variant] = all[i] var alive: bool = bool(u.get("alive", false)) var pending: String = String(u.get("pending_skill", "")) if alive else "" - var pending_row: Dictionary[Variant, Variant] = db.skill_row(pending) + var pending_row: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(pending) enemies.append({ "order": i + 1, "display_name": String(u.display_name), diff --git a/game/core/board_generator.gd b/game/core/board_generator.gd index 9e338fe6..accfd631 100644 --- a/game/core/board_generator.gd +++ b/game/core/board_generator.gd @@ -22,12 +22,19 @@ static func target_mine_count(params: Dictionary) -> int: # rng_stream = 一次 open_stream("board_gen") 实例(一张图的全部取数;重试共用——attempt 边界非 scope 事件)。 # db 参数不标具体类型原因:冒烟工具注入 FakeDB 桩(experiment_density.gd 等)——typed 形参会拒收桩表 func generate(L: int, D: int, rng_stream: RandomNumberGenerator, db) -> BoardData: # type-hint: ignore(测量面 _BoardRowStub 冒烟夹具鸭子型注入——typed 参拒 stub) - var row: Dictionary[Variant, Variant] = db.resolve_board_gen(L, D) + var row: Dictionary[Variant, Variant] = db.combat.board_gen.resolve_row(L, D) if row.is_empty(): _fatal("BoardGen 行缺失 (%d,%d)" % [L, D]) return null - # 生成期表值单次读取快照(防热重载撕裂 attempt 语义) - var params := { + var params := _board_params(row, L, D) + var outcome := _attempt_loop(params, rng_stream, db) + _finalize(outcome["board"], params, outcome["attempts"], outcome["mode"], outcome["r0_rej"]) + return outcome["board"] + + +## 生成期表值单次读取快照(防热重载撕裂 attempt 语义;零 RNG) +func _board_params(row: Dictionary, L: int, D: int) -> Dictionary: + return { "L": L, "D": D, "w": int(row.board_width), "h": int(row.board_height), "density": float(row.mine_density), @@ -41,10 +48,11 @@ func generate(L: int, D: int, rng_stream: RandomNumberGenerator, db) -> BoardDat "shave_r": int(row.get("shave_ring_radius", 1)), "r0_cap": int(row.get("r0_max_cap", 0)), } - var best_legal: BoardData = null - var best_legal_score := -1.0 - var best_bad: BoardData = null - var best_badness := [999999, 999999, 999999] + + +## 重试主循环:逐尝试评分择优,产出三分出口结果(board/mode/attempts/r0_rej) +func _attempt_loop(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Dictionary: # type-hint: ignore(同 generate——测量面 stub 注入) + var best := {"legal": null, "legal_score": -1.0, "bad": null, "badness": [999999, 999999, 999999]} var attempts_used := 0 var accepted: BoardData = null var accept_mode := "" @@ -53,35 +61,37 @@ func generate(L: int, D: int, rng_stream: RandomNumberGenerator, db) -> BoardDat attempts_used = k var b := _attempt(params, rng_stream, db) var badness := _badness(b, params) - if best_bad == null or _lex_less(badness, best_badness): - best_bad = b - best_badness = badness + if best["bad"] == null or _lex_less(badness, best["badness"]): + best["bad"] = b + best["badness"] = badness # R₀≤cap 重掷闸触发计数(迭代14① T3——尝试事实,与合法性判定独立记录;占比=T5 闸触发率读数) if int(params.r0_cap) > 0 and b.compute_r0().size() > int(params.r0_cap): r0_cap_rejected += 1 if legality(b, params): - var m: Dictionary[Variant, Variant] = measure_inferability(b) # 求解器每合法板恰一次(O3 收口——此前重复调用两次) - var inf: float = m["inferability"] - if best_legal == null or inf > best_legal_score: - best_legal = b - best_legal_score = inf + var inf: float = measure_inferability(b)["inferability"] # 求解器每合法板恰一次(O3 收口——此前重复调用两次) + if best["legal"] == null or inf > best["legal_score"]: + best["legal"] = b + best["legal_score"] = inf if inf >= params.target: # 单一判据(棋盘生成:现行规则已回归"可推理率 ≥ 及格线") accepted = b accept_mode = "first_pass" break if accepted == null: - if best_legal != null: - accepted = best_legal - accept_mode = "accept_best" - accepted.degraded_product = true # 降级标记(独立于 illegal_product——三分出口表要求标记随产物上抛) - push_warning("[棋盘生成] 重试耗尽无达标——接受最优出图(强告警埋点,target=%.2f best=%.3f)" % [params.target, best_legal_score]) - else: - accepted = best_bad - accept_mode = "accept_worst" - accepted.illegal_product = true - push_error("[棋盘生成] attempts 耗尽零合法——接受最不坏出图 + 非法产物标记(玩家永不卡死)") - _finalize(accepted, params, attempts_used, accept_mode, db, r0_cap_rejected) - return accepted + var fallback := _accept_fallback(params, best["legal"], best["legal_score"], best["bad"]) + accepted = fallback[0] + accept_mode = fallback[1] + return {"board": accepted, "mode": accept_mode, "attempts": attempts_used, "r0_rej": r0_cap_rejected} + + +## 三分出口(重试耗尽):接受最优(降级标记)或接受最不坏(非法产物标记——玩家永不卡死) +func _accept_fallback(params: Dictionary, best_legal: BoardData, best_legal_score: float, best_bad: BoardData) -> Array: + if best_legal != null: + best_legal.degraded_product = true # 降级标记(独立于 illegal_product——三分出口表要求标记随产物上抛) + push_warning("[棋盘生成] 重试耗尽无达标——接受最优出图(强告警埋点,target=%.2f best=%.3f)" % [params.target, best_legal_score]) + return [best_legal, "accept_best"] + best_bad.illegal_product = true + push_error("[棋盘生成] attempts 耗尽零合法——接受最不坏出图 + 非法产物标记(玩家永不卡死)") + return [best_bad, "accept_worst"] # ==================== 五阶段:单次尝试 ==================== @@ -94,8 +104,20 @@ func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Boar b.board_type = str(params.board_type) b.level_index = params.L b.node_depth = params.D - # ② 布雷:N = round(d×(W×H−9)) 先定数;域 = 全格 − 出生 3×3;均匀布雷后"先布后删" - # (−9 = 出生区格数,常量单点 BoardData.SPAWN_AREA_CELLS) + # ② 布雷:N = round(d×(W×H−9)) 先定数;域 = 全格 − 出生 3×3;均匀布雷后"先布后删"(_populate_mines 承载——Fisher-Yates 取号段整体随迁) + var removed := _populate_mines(b, params, rng_stream) + # ③ 变异布置(地形变异 框架面):布置约束与算法随首批变异类型定稿(生成期布置接口结构位), + # 定稿前零布置——清单为空是合法态(P0),静默产出无变异图。 + b.metrics = { + "metric_version": METRIC_VERSION, + "ring_removed": removed, + } + return b + + +## 布雷与剃雷域:域 = 全格 − 出生 3×3;均匀布雷(部分 Fisher-Yates,确定性,消费 board_gen 流) +## 后剃出生环雷,返回 ring_removed(指标语义不变——现值变小=收窄批预期) +func _populate_mines(b: BoardData, params: Dictionary, rng_stream: RandomNumberGenerator) -> int: var spawn := {} for c in b.spawn_cells(): spawn[c] = true @@ -104,7 +126,7 @@ func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Boar if not spawn.has(c): domain.append(c) var n_mines: int = target_mine_count(params) - # 部分 Fisher-Yates(确定性,消费 board_gen 流) + # 部分 Fisher-Yates(确定性,消费 board_gen 流)——取号序冻结(契约:同种子逐位一致) for i in range(0, mini(n_mines, domain.size())): var j := rng_stream.randi_range(i, domain.size() - 1) var tmp = domain[i] @@ -116,9 +138,14 @@ func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Boar # 先布后删:剃雷域 = 距 spawn_center 切比雪夫距离 ≤1+r 的格(迭代14①收窄批 T2——原「R₀ flood 域∪ # 外扩 1 环」全域剃雷=开局开阔地巨大根因〔10×10 板 R₀ 均值 68.6=板面 68.6%〕;收窄后开阔地= # 出生邻域外的自然 flood。r 读表 shave_ring_radius(r=1 → 5×5=25 格;r=0 即只剃出生 3×3)。 - # 只减不增、无迭代语义不变;ring_removed 指标语义不变(现值变小=预期);零 RNG 消费变化—— - # 同种子布雷序列逐位一致,仅移除域收窄。 - var shave_r: int = int(params.get("shave_r", 1)) + # 只减不增、无迭代语义不变;零 RNG 消费变化。 + var removed := _shave_ring(b, int(params.get("shave_r", 1))) + _compute_digits(b) + return removed + + +## 剃雷环域(零 RNG):距出生心切比雪夫 ≤1+r 的环内剃雷,返回剃除数 +func _shave_ring(b: BoardData, shave_r: int) -> int: var ring := {} for c in b.all_cells(): if maxi(absi(c.x - b.spawn_center.x), absi(c.y - b.spawn_center.y)) <= 1 + shave_r: @@ -128,14 +155,7 @@ func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Boar if b.mines.has(c): b.mines.erase(c) removed += 1 - _compute_digits(b) - # ③ 变异布置(地形变异 框架面):布置约束与算法随首批变异类型定稿(生成期布置接口结构位), - # 定稿前零布置——清单为空是合法态(P0),静默产出无变异图。 - b.metrics = { - "metric_version": METRIC_VERSION, - "ring_removed": removed, - } - return b + return removed func _compute_digits(b: BoardData) -> void: @@ -164,49 +184,12 @@ func measure_inferability(b: BoardData) -> Dictionary: while changed: changed = false # 算子① 连锁:0 格的 8 邻安全格全入 R - var frontier: Array[Variant] = r.keys() - for x in frontier: - if safe_set.has(x) and b.digit_of(x) == 0: - for n in b.neighbors8(x): - if safe_set.has(n) and not r.has(n): - r[n] = true - changed = true + changed = _apply_chain_rule(b, r, safe_set) or changed # 信息源:数字约束 single + subset(solver_rules=single_subset 冻结域) var constraints := _build_constraints(b, r, known_mines, safe_set) - # single 规则 - for con in constraints: - if con.mines == 0: - for c in con.unknown: - if not r.has(c): - r[c] = true - changed = true - elif con.mines == con.unknown.size(): - for c in con.unknown: - if not known_mines.has(c): - known_mines[c] = true - changed = true - # subset 规则:U1 ⊂ U2 ⇒ (d2−d1, U2∖U1) - var derived := [] - for i in constraints.size(): - for j in range(i + 1, constraints.size()): - var a: Dictionary[Variant, Variant] = constraints[i] - var bb: Dictionary[Variant, Variant] = constraints[j] - var sub := _subset_of(a.unknown, bb.unknown) - if sub == 1: - derived.append(_diff_constraint(a, bb)) - elif sub == 2: - derived.append(_diff_constraint(bb, a)) - for con in derived: - if con.mines == 0: - for c in con.unknown: - if not r.has(c): # 同 no-peek 口径:推导结论不再用 safe_set 复核(约束本身可靠) - r[c] = true - changed = true - elif con.mines == con.unknown.size(): - for c in con.unknown: - if not known_mines.has(c): - known_mines[c] = true - changed = true + changed = _apply_single_rules(constraints, r, known_mines) or changed + var derived := _derived_constraints(constraints) + changed = _apply_derived_rules(derived, r, known_mines) or changed var r_inf := r.keys() var s_count := safe_set.size() var inferability: float = float(r_inf.size()) / float(maxi(1, s_count)) @@ -217,6 +200,68 @@ func measure_inferability(b: BoardData) -> Dictionary: } +## 算子① 连锁域:0 格的 8 邻安全格全入 R(返回本轮是否有新增) +func _apply_chain_rule(b: BoardData, r: Dictionary, safe_set: Dictionary) -> bool: + var changed := false + var frontier: Array[Variant] = r.keys() + for x in frontier: + if safe_set.has(x) and b.digit_of(x) == 0: + for n in b.neighbors8(x): + if safe_set.has(n) and not r.has(n): + r[n] = true + changed = true + return changed + + +## single 规则域:零雷约束开格/满雷约束标雷(返回本轮是否有新增) +func _apply_single_rules(constraints: Array, r: Dictionary, known_mines: Dictionary) -> bool: + var changed := false + for con in constraints: + if con.mines == 0: + for c in con.unknown: + if not r.has(c): + r[c] = true + changed = true + elif con.mines == con.unknown.size(): + for c in con.unknown: + if not known_mines.has(c): + known_mines[c] = true + changed = true + return changed + + +## subset 派生域:U1 ⊂ U2 ⇒ (d2−d1, U2∖U1)(对约束对做包含判定,产出派生约束集) +func _derived_constraints(constraints: Array) -> Array: + var derived := [] + for i in constraints.size(): + for j in range(i + 1, constraints.size()): + var a: Dictionary[Variant, Variant] = constraints[i] + var bb: Dictionary[Variant, Variant] = constraints[j] + var sub := _subset_of(a.unknown, bb.unknown) + if sub == 1: + derived.append(_diff_constraint(a, bb)) + elif sub == 2: + derived.append(_diff_constraint(bb, a)) + return derived + + +## 派生规则域:派生约束的零雷开格/满雷标雷(同 no-peek 口径:推导结论不再用 safe_set 复核——约束本身可靠) +func _apply_derived_rules(derived: Array, r: Dictionary, known_mines: Dictionary) -> bool: + var changed := false + for con in derived: + if con.mines == 0: + for c in con.unknown: + if not r.has(c): + r[c] = true + changed = true + elif con.mines == con.unknown.size(): + for c in con.unknown: + if not known_mines.has(c): + known_mines[c] = true + changed = true + return changed + + func _build_constraints(b: BoardData, r: Dictionary, known_mines: Dictionary, safe_set: Dictionary) -> Array: var out: Array[Dictionary] = [] for c in r.keys(): @@ -337,7 +382,7 @@ func _neighbor_mine_count(b: BoardData, c: Vector2i) -> int: # ==================== 产出上报(生成期指标集) ==================== -func _finalize(b: BoardData, params: Dictionary, attempts_used: int, accept_mode: String, _db, r0_cap_rejected: int = 0) -> void: # type-hint: ignore(同 generate——测量面 stub 注入) +func _finalize(b: BoardData, params: Dictionary, attempts_used: int, accept_mode: String, r0_cap_rejected: int = 0) -> void: var m := measure_inferability(b) # 产物断言(棋盘生成 出生区安全与反退化布雷):实际雷数 ≤ 目标雷数 N——先布后删只减不增,失败即致命 var n_target: int = target_mine_count(params) diff --git a/game/core/config_db.gd b/game/core/config_db.gd index e6370402..bd535c90 100644 --- a/game/core/config_db.gd +++ b/game/core/config_db.gd @@ -1,6 +1,8 @@ ## VERTICAL SLICE - NOT FOR PRODUCTION -## 数值表单点访问入口:全部 .tres 表的加载、校验诊断与类型化访问器(垂直切片验证:改表即改玩法,数值零硬编码)。 -## 玩法代码禁止绕过本单点自建 load();消费面一律走访问器,禁直读行集。 +## 数值表单点访问入口:全部 .tres 表的加载、校验诊断与门面(垂直切片验证:改表即改玩法,数值零硬编码)。 +## 玩法代码禁止绕过本单点自建 load();行查询归各表类(表管自己的行——2026-10-27 全清设计定稿), +## 表持有按域收进三容器(combat 战斗编排域/run 冒险 runner 域/crossrun 跨局元进度域), +## 消费面经 db.<容器>.<表> 直连。「经 ConfigDB 单点读取」=经其持有的容器与表对象(架构 :88)。 ## headless --script 模式无 autoload——冒烟脚本显式实例化本类;加载失败 is_ready=false 加 fatal 封印,调用方须查。 class_name ConfigDb extends Node @@ -29,39 +31,23 @@ const TABLE_MANIFEST := { "forge": "forge.tres", # ForgeTable——两根类型轴默认值+六路线行族;攻防所有权切分唯一数据面 } -var action_economy: ActionEconomyTable -var variation: VariationTable -var status_effects: StatusEffectTable -var board_gen: BoardGenTable -var enemy: EnemyTable -var skill: SkillTable -var hero: HeroTable -var reward: RewardTable -var settlement: SettlementTable -var level: LevelTable -var difficulty: DifficultyTable -var level_gen_params: LevelGenParams # 参数档类型化持用(生成器只吃参数档、不认关卡号——卡① 契约 6) -# ShopTable 类型化持用(schema class_name 已已入库; -# 消费面一律走下方 shop_* 访问器,禁直读行集) -var shop: ShopTable = null -# MetaTable 类型化持用(schema 与表同源已入库) -var meta: MetaTable -var meta_tree: MetaTreeTable # 类型化持用(消费面走 tree_node 访问器) -var treasure_items: TreasureItemTable # 类型化持用(消费面走 treasure_* 访问器) -var adventure: AdventureTable -var feats: FeatTable # 类型化持用(消费面走 feat_* 访问器) -var forge: ForgeTable # 锻造工坊配置(默认两轴+六路线行族——消费面走 forge_* 访问器) +var combat: ConfigDomainCombat # 战斗编排域七表(board_gen/variation/status_effects/enemy/skill/hero/settlement) +var run: ConfigDomainRun # 冒险 runner 域七表(level/difficulty/level_gen_params/reward/adventure/action_economy/shop) +var crossrun: ConfigDomainCrossrun # 跨局元进度域五表(meta/meta_tree/treasure_items/feats/forge) var is_ready := false var _diagnostics: Array[Dictionary] = [] # {severity, category, message} -var _resolver: ConfigDbResolver # R1 难度行解析协作类(门面公有签名不变) +var _resolver: ConfigDbResolver # R1 难度行解析协作类(门面公有签名不变;load_tables 装配段注入三表) var _validator := ConfigDbCrossValidator.new() # 跨表校验协作类(无状态——声明期即建) var _reload_gate := false # 数据配置 EC8——战斗结算 唯一调用方 func _init() -> void: + combat = ConfigDomainCombat.new() + run = ConfigDomainRun.new() + crossrun = ConfigDomainCrossrun.new() _resolver = ConfigDbResolver.new(push_diag, - func() -> Variant: return board_gen.rows, func() -> Variant: return enemy.rows, func() -> Variant: return difficulty) # 协作类持门面引用(诊断/致命通道回呼) + func() -> Variant: return combat.board_gen.rows, func() -> Variant: return combat.enemy.rows, func() -> Variant: return run.difficulty) # 协作类持门面引用(诊断/致命通道回呼) func push_diag(severity: String, category: String, message: String) -> void: @@ -132,312 +118,46 @@ func load_tables(root: String, collect_only: bool) -> bool: if has_errors and not collect_only: # 游戏通道:致命(Rule 6)——is_ready 恒 false,调用方(主场景)中止构建 return false - action_economy = loaded.get("action_economy") - variation = loaded.get("variation") - status_effects = loaded.get("status_effects") - board_gen = loaded.get("board_gen") - enemy = loaded.get("enemy") - skill = loaded.get("skill") - hero = loaded.get("hero") - reward = loaded.get("reward") - settlement = loaded.get("settlement") - level = loaded.get("level") - difficulty = loaded.get("difficulty") - level_gen_params = loaded.get("level_gen_params") - shop = loaded.get("shop") - meta = loaded.get("meta") - treasure_items = loaded.get("treasure_items") - adventure = loaded.get("adventure") - feats = loaded.get("feats") - forge = loaded.get("forge") - meta_tree = loaded.get("meta_tree") + # 容器装配(19 表成员按域收进三容器——2026-10-27 全清设计定稿) + combat.board_gen = loaded.get("board_gen") + combat.variation = loaded.get("variation") + combat.status_effects = loaded.get("status_effects") + combat.enemy = loaded.get("enemy") + combat.skill = loaded.get("skill") + combat.hero = loaded.get("hero") + combat.settlement = loaded.get("settlement") + run.level = loaded.get("level") + run.difficulty = loaded.get("difficulty") + run.level_gen_params = loaded.get("level_gen_params") + run.reward = loaded.get("reward") + run.adventure = loaded.get("adventure") + run.action_economy = loaded.get("action_economy") + run.shop = loaded.get("shop") + crossrun.meta = loaded.get("meta") + crossrun.meta_tree = loaded.get("meta_tree") + crossrun.treasure_items = loaded.get("treasure_items") + crossrun.feats = loaded.get("feats") + crossrun.forge = loaded.get("forge") + # resolver 协作件注入三表(resolve 委托承载——resolver 文件与键零搬移) + combat.board_gen._resolver = _resolver + combat.enemy._resolver = _resolver + run.difficulty._resolver = _resolver is_ready = not has_errors return not has_errors -# ---- relic 访问器(消费面一律走本组,禁直读行集)---- - -func feat_row(feat_id: String) -> Dictionary: - return feats.row_by_id(feat_id) if feats != null else {} - - -# 规则改写型特性带令牌构建源(池接线——CurioDeck.build 消费;行 rarity→投放档映射) -func feat_rows() -> Array: - return feats.rows if feats != null else [] - - -# 天赋树节点行(MetaProgression 树段/营地树 UI 取数口) -func tree_node(node_id: String) -> Dictionary: - return meta_tree.row_by_id(node_id) if meta_tree != null else {} - - -func tree_rows() -> Array: - return meta_tree.rows if meta_tree != null else [] - - # 跨表校验(悬空引用 / 池护栏——全部加载期致命)本体: # ConfigDbCrossValidator(core/config_db_cross_validator.gd)承载,门面公有签名不变。 func _cross_validate(loaded: Dictionary) -> void: _validator.cross_validate(loaded, push_diag) -# 池模板 × 技能行跨表校验(冒烟直验独立入口——签名与消费面零变化,实现归协作类) -func validate_skill_pool_links(skill_table: SkillTable, enemy_table: EnemyTable, status_table: StatusEffectTable = null) -> Array: +# 池模板 × 技能行跨表校验(冒烟直验独立入口——签名与消费面零变化,实现归协作类;门面私有—— +# 消费面经门面者改 _ 前缀直调,cross_validator 直验入口公开面不变) +func _validate_skill_pool_links(skill_table: SkillTable, enemy_table: EnemyTable, status_table: StatusEffectTable = null) -> Array: return _validator.validate_skill_pool_links(skill_table, enemy_table, status_table) -# ---- R1 难度行解析(ceil 向上取整保证偏难;域越界与内容缺口 Fatal 退出)---- -# 解析本体:ConfigDbResolver(core/config_db_resolver.gd)承载—— -# 含 _resolve_progress/_ceil_key 与 R1 告警去重注册表;门面同名方法委托,越域 Fatal 语义一致。 -func resolve_board_gen(L: int, D: int) -> Dictionary: - return _resolver.resolve_board_gen(L, D) - - -func resolve_enemy_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary]: - return _resolver.resolve_enemy_rowset(L, D, row_kind) # 行集数组形态(非单行 Dictionary;D 维带寻址) - - -# 难度曲线与Boss DifficultyTable 消费面(敌人系统.spawn 调用):(L,D) 精确匹配修饰乘数,越域值 clamp(GDD Edge Case——组合事故非配置事故) -func resolve_difficulty_modifiers(L: int, D: int) -> Dictionary: - return _resolver.resolve_difficulty_modifiers(L, D) - - -# ---- 地形变异 variant_pool_gate:候选池门控(∅ board_types = 通配;字段名对齐 -# board_type 词表,无 biome_tags)---- -# 门控第三维 = 节点身份(全局作用域变异):scope=global 行仅 boss/elite 节点入池, -# 其余节点(battle 等)静默跳过——门控兜底 P0 口径(零报错零警告);elite 系前向词位(首发零表行)。 -# node_type 缺省 "" = 旧双参调用口径(global 行跳过)——既有测试/冒烟调用零扰动。 - -func variant_pool(level_index: int, board_type: String, node_type: String = "") -> Array: - var pool: Array[Dictionary] = [] - for row in variation.rows: - if String(row.get("scope", "region")) == "global" \ - and node_type != "boss" and node_type != "elite": - continue - if int(row.get("unlock_level", 999)) > level_index: - continue - var tags: Array[Variant] = row.get("board_types", []) - if tags.is_empty(): - pool.append(row) - continue - if tags.has(board_type): - pool.append(row) - return pool - - -# 全局型单行决议(战斗编排装板期消费——访问器纪律:消费面禁直扫表): -# boss/elite 节点取候选池内首个 scope=global 行(首发批全局池恒恰一行;多行为配置越界态, -# 按表序取首行确定性兜底);空 = 无全局型(调用方回落 region 布置——兜底回落合法态) -func global_variant_row(level_index: int, board_type: String, node_type: String) -> Dictionary: - if node_type != "boss" and node_type != "elite": - return {} - for row in variant_pool(level_index, board_type, node_type): - if String(row.get("scope", "region")) == "global": - return row - return {} - - -func status_effect_row(effect_id: String) -> Dictionary: - return status_effects.row_by_id(effect_id) - - -# ---- 关卡行集访问器(表访问收口——level_runner/meta_progression/adventure_runner -# 不直扫 db.level.rows;消费面一律走本访问器,行集解析单点归 LevelTable)---- - -# 关卡行单点读取(键 = level_index,缺行返回空字典——消费方按既有空行防御路径处理) -func level_row(level_index: int) -> Dictionary: - return level.row_by_level_index(level_index) if level != null else {} - - -# 参数档单点读取(节点图生成器核心批 卡①——键 = level_index,缺行返回空字典;调用方据此产图,生成器本体不读关卡号) -func level_gen_params_row(level_index: int) -> Dictionary: - return level_gen_params.row_by_level_index(level_index) if level_gen_params != null else {} - - -# 序列总关数(AdventureRunner levels_total / MetaProgression 关卡完成累加域的取数口径) -func level_count() -> int: - return level.rows.size() if level != null else 0 - - -# ---- ShopTable 消费面(行集解析单点——runner/战斗编排禁自扫表)---- -# ── 秘宝访问器(消费面一律走访问器,禁直读行集)── -# 活行集(非 retired 非 deferred)——投放校验/奖励节点池/呈现可用性的唯一取数口径 -func treasure_item_row(item_id: String) -> Dictionary: - return treasure_items.row_by_id(item_id) if treasure_items != null else {} - - -# 过渡渠道活行(form 空 = 切片期过渡形态)——掉落投放/停点池/工坊解锁取数口径;同调行(form 非空) -# 永不入过渡渠道 -func treasure_transitional_rows() -> Array: - return treasure_items.transitional_rows() if treasure_items != null else [] - - -# 同调行集(form 非空——装配覆盖层/效果接线的取数口径) -func treasure_attunement_rows() -> Array: - return treasure_items.attunement_rows() if treasure_items != null else [] - - -# 活行词表(掉落投放 effect_id 白名单——过渡渠道口径) -# ── 同调槽位数=树同调位节点点亮数,判定源=Attunement.slots(无 attunement_growth 谓词表访问器); -# boss_kill_count/clear_count 计数域保留——非槽位硬消费在册 ── - - -# 行族四轴(数据契约,行集归 shop.tres):gp_drops {tier, mean, roll_min, roll_max} · -# level_gp_bonus {level_index, bonus} · products {product_id, display_name, attr, flat_value, -# base_price, price_increment} · price_level_bonus {level_index, bonus}。 -# 行族经 ShopTable 同名数组属性读取(.get 容缺——表已入库前查询恒空,掉落按 0 兑付的兜底在调用方)。 - -func shop_gp_drop_row(tier: String) -> Dictionary: - for row in _shop_rows("gp_drops"): - if String(row.get("tier", "")) == tier: - return row - return {} - - -func shop_level_gp_bonus(level_index: int) -> int: - for row in _shop_rows("level_gp_bonus"): - if int(row.get("level_index", -1)) == level_index: - return int(row.get("bonus", 0)) - return 0 - - -func shop_price_level_bonus(level_index: int) -> int: - for row in _shop_rows("price_level_bonus"): - if int(row.get("level_index", -1)) == level_index: - return int(row.get("bonus", 0)) - return 0 - - -func shop_products() -> Array: - return _shop_rows("products") - - -func shop_product_row(product_id: String) -> Dictionary: - for row in _shop_rows("products"): - if String(row.get("product_id", "")) == product_id: - return row - return {} - - -# ── 共享池货架访问器(M-4——消费面一律走访问器,禁直读行集)── - -# 货架行族({kind_id, display_name, base_price}——行序即抽签/呈现稳定序) -func shop_pool_rows() -> Array: - return _shop_rows("pool_shelf") - - -func shop_pool_row(kind_id: String) -> Dictionary: - for row in _shop_rows("pool_shelf"): - if String(row.get("kind_id", "")) == kind_id: - return row - return {} - - -# 乘法价格因子(provisional 投值——0 = 表未投值,消费方按 fail-fast 域校验拦截) -func shop_price_a() -> float: - return float(shop.price_a) if shop != null else 0.0 - - -func shop_price_b() -> float: - return float(shop.price_b) if shop != null else 0.0 - - -# 行族数组读取(ShopTable 属性名 = 行族名;表未已入库/属性缺失 = 空集非错误态) -func _shop_rows(family: String) -> Array: - if shop == null: - return [] - var arr = shop.get(family) - return arr if arr is Array else [] - - -# ---- MetaTable 消费面(行集解析单点——MetaProgression/呈现层禁自扫表)---- -# 行族四轴(数据契约,行集归 meta.tres,数值全 provisional): -# forge_products {attr, display_name, flat_value, base_price, price_ratio_pct} · -# boss_keystone {level_index, first_kill, repeat_kill} · -# workshop_items {item_id, display_name, clear_threshold} · -# conversion {attr, per_level_shift_pct}。 - -func meta_boss_keystone_row(level_index: int) -> Dictionary: - for row in _meta_rows("boss_keystone"): - if int(row.get("level_index", -1)) == level_index: - return row - return {} - - -# 秘宝解锁混合制挂 treasure_items 行内(无 meta_workshop_items 访问器); -# 秘宝解锁投影面=MetaProgression.treasure_shop_status - - -func meta_conversion_row(attr: String) -> Dictionary: - for row in _meta_rows("conversion"): - if String(row.get("attr", "")) == attr: - return row - return {} - - -# Y 刷新升级行单点读取(键 = 等级,缺行返回空字典=未购/已满级合法态) -func meta_attune_refresh_row(level: int) -> Dictionary: - for row in _meta_rows("attune_refresh_upgrade"): - if int(row.get("level", -1)) == level: - return row - return {} - - -# 行族数组读取(MetaTable 属性名 = 行族名;表缺失/属性缺失 = 空集非错误态) -func _meta_rows(family: String) -> Array: - if meta == null: - return [] - var arr = meta.get(family) - return arr if arr is Array else [] - - -# ---- 锻造工坊(ForgeTable 消费面:两根类型轴默认值+六路线行族;禁自扫行集)---- -# 设计权威 design/gdd/systems/跨局循环/锻造工坊.md v0.6:默认值归本表常驻持有(hero.tres 两轴已删—— -# 契约 2);路线行族=攻防数值成长唯一数据面(契约 1)。数值全 provisional 归 M4。 - -# 两根类型轴默认值(表未已入库=裸体态兜底 normal/light——与 ForgeTable 缺省值同口径) -func forge_default_attack_type() -> String: - return String(forge.default_attack_type) if forge != null else "normal" - - -func forge_default_armor_type() -> String: - return String(forge.default_armor_type) if forge != null else "light" - - -# 六路线行族(副本——调用方禁改) -func forge_routes() -> Array: - return forge.routes.duplicate() if forge != null else [] - - -# 路线行单点(键 route_id;缺行=空字典) -func forge_route(route_id: String) -> Dictionary: - for row in forge_routes(): - if String(row.get("route_id", "")) == route_id: - return row - return {} - - -# 按类型轴反查武器路线(attack_type → 路线行;甲路线 id=甲型同值的显式化入口,避免消费侧依赖该巧合) -func forge_weapon_route_by_type(attack_type: String) -> Dictionary: - for row in forge_routes(): - if String(row.get("kind", "")) == "weapon" and String(row.get("type_axis", "")) == attack_type: - return row - return {} - - -# 按类型轴反查护甲路线(armor_type → 路线行——入战 AC 三层之「甲型基础 AC」取数口) -func forge_armor_route_by_type(armor_type: String) -> Dictionary: - for row in forge_routes(): - if String(row.get("kind", "")) == "armor" and String(row.get("type_axis", "")) == armor_type: - return row - return {} - - -# 技能行单点读取(敌人技能池批——单位容器执行上报 / 战斗结算兑现的消费面) -func skill_row(skill_id: String) -> Dictionary: - return skill.row_by_id(skill_id) - - # ---- 数据配置 EC8:debug 热重载门(战斗结算 唯一调用方;切片实现 = 门标志 + 拒绝语义)---- func set_reload_gate(gated: bool) -> void: diff --git a/game/core/config_db_cross_validator.gd b/game/core/config_db_cross_validator.gd index 8bcc0fa7..0ca4c90d 100644 --- a/game/core/config_db_cross_validator.gd +++ b/game/core/config_db_cross_validator.gd @@ -11,21 +11,43 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void: var st_tab: SettlementTable = loaded.get("settlement") if st == null or rt == null or st_tab == null: return # 单表校验已报错,不连锁 + # ①c F2 投放维度覆盖校验不启用:挖格不掉道具,秘宝 + # 唯一来路=三选一共享池〔节点/关卡完成〕;共享池为「抽到道具位再动态掷定具体道具」,维度覆盖不可 + # 静态校验。 + var effect_ids := _effect_ids_of(st) + _cv_reward_dangling(rt, effect_ids, push_diag) # ① 增益词表悬空 + _cv_reward_treasure(loaded, rt, push_diag) # ①b aux 投放行双表 + _cv_poolmap(st_tab, effect_ids, push_diag) # ② 池映射悬空 + _cv_source_labels(st_tab, push_diag) # ③ 来源标签键集 + _cv_skill_pool(loaded, push_diag) # ⑤ 技能池引用完整性 + _cv_shelf_pool(loaded, push_diag) # ⑥ 商店货架 × 冒险共享池 + _cv_counter_closure(loaded, push_diag) # ⑦ 计数器引用闭合 + + +## 增益状态词表全集(effect_id → true) +func _effect_ids_of(st: StatusEffectTable) -> Dictionary: var effect_ids := {} for row in st.rows: effect_ids[row.get("effect_id", "")] = true - # ① RewardTable effect_id 悬空(percent 行引用 StatusEffectTable——悬空加载致命)。 - # 例外:gp_gain/gp_loss = GP 战斗内缓冲(escrow)通道的路由键 - # (战斗结算 _apply_gp_row 兑付——不入增益状态容器),词表封闭按字面豁免悬空引用校验。 + return effect_ids + + +## ① RewardTable effect_id 悬空(percent 行引用 StatusEffectTable——悬空加载致命)。 +## 例外:gp_gain/gp_loss = GP 战斗内缓冲(escrow)通道的路由键 +## (战斗结算 _apply_gp_row 兑付——不入增益状态容器),词表封闭按字面豁免悬空引用校验。 +func _cv_reward_dangling(rt: RewardTable, effect_ids: Dictionary, push_diag: Callable) -> void: for row in rt.rows: if String(row.get("cell_type", "")) == RewardTable.TREASURE_ITEM_TYPE: continue # aux 行 effect_id=item_id——归 ①b 双表校验(reward_dangling_treasure),不入增益词表面 var eff: String = row.get("effect_id", "") if eff != "" and eff != "gp_gain" and eff != "gp_loss" and not effect_ids.has(eff): push_diag.call("error", "dangling_effect", "RewardTable %s 引用不存在的 effect_id: %s" % [row.get("reward_id"), eff]) - # ①b RewardTable aux 投放行双表校验(reward_dangling_treasure):aux 行 effect_id = item_id, - # 须命中 TreasureItemTable 活行(非 retired 非 deferred——罗盘退役/失义件不投放)。retired 行被引用=退役 - # 后仍投放(连带错误),同拦。 + + +## ①b RewardTable aux 投放行双表校验(reward_dangling_treasure):aux 行 effect_id = item_id, +## 须命中 TreasureItemTable 活行(非 retired 非 deferred——罗盘退役/失义件不投放)。retired 行被引用=退役 +## 后仍投放(连带错误),同拦。 +func _cv_reward_treasure(loaded: Dictionary, rt: RewardTable, push_diag: Callable) -> void: var treasure_live := {} var treasure_tab = loaded.get("treasure_items") if treasure_tab != null: @@ -39,27 +61,36 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void: continue if not treasure_live.has(treasure_eff): push_diag.call("error", "reward_dangling_treasure", "RewardTable %s aux 行引用无效道具 %s(retired/deferred/不存在): %s" % [row.get("reward_id"), treasure_eff, "双表校验"]) - # ①c F2 投放维度覆盖校验不启用:挖格不掉道具,秘宝 - # 唯一来路=三选一共享池〔节点/关卡完成〕;共享池为「抽到道具位再动态掷定具体道具」,维度覆盖不可 - # 静态校验。 - # ② SettlementTable.effect_pool_map 悬空 + + +## ② SettlementTable.effect_pool_map 悬空 +func _cv_poolmap(st_tab: SettlementTable, effect_ids: Dictionary, push_diag: Callable) -> void: for eff in st_tab.core.effect_pool_map.keys(): if not effect_ids.has(eff): push_diag.call("error", "poolmap_dangling", "SettlementTable.effect_pool_map 悬空 effect_id: %s" % eff) - # ③ source_label_map 键集封闭性(键 = 增益状态 source_class ∪ {enemy_action, hero_attack, seal_miss}) + + +## ③ source_label_map 键集封闭性(键 = 增益状态 source_class ∪ {enemy_action, hero_attack, seal_miss}) +func _cv_source_labels(st_tab: SettlementTable, push_diag: Callable) -> void: var required_sources := ["safe_cell", "seal_hit", "mine_hit", "clear_reward", "hero_attack", "enemy_action", "seal_miss"] for s in required_sources: if not st_tab.core.source_label_map.has(s): push_diag.call("error", "segorder_violation", "source_label_map 缺必需键: %s" % s) - # ⑤ 技能池引用完整性(敌人技能池批——敌人表池 × 技能表;独立入口 validate_skill_pool_links 供冒烟直验) + + +## ⑤ 技能池引用完整性(敌人技能池批——敌人表池 × 技能表;独立入口 validate_skill_pool_links 供冒烟直验) +func _cv_skill_pool(loaded: Dictionary, push_diag: Callable) -> void: var en: EnemyTable = loaded.get("enemy") var skt = loaded.get("skill") if skt != null and en != null: for e in validate_skill_pool_links(skt, en, loaded.get("status_effects")): push_diag.call("error", e.category, e.message) - # ⑥ 商店货架 × 冒险共享池跨表校验(M-4——次数上限域): - # pool_shelf kind 须命中 adventure.CURIO_KINDS 且为有限库存种(cap ∈ [1,99]——gp 无上限种/牌库外种 - # 入货架 = 库存账本无锚,加载致命;gp 袋负交易切边/规则改写型特性三轴切边由 ShopTable 词表封闭另拦) + + +## ⑥ 商店货架 × 冒险共享池跨表校验(M-4——次数上限域): +## pool_shelf kind 须命中 adventure.CURIO_KINDS 且为有限库存种(cap ∈ [1,99]——gp 无上限种/牌库外种 +## 入货架 = 库存账本无锚,加载致命;gp 袋负交易切边/规则改写型特性三轴切边由 ShopTable 词表封闭另拦) +func _cv_shelf_pool(loaded: Dictionary, push_diag: Callable) -> void: var adv = loaded.get("adventure") var shp = loaded.get("shop") if adv != null and shp != null: @@ -78,34 +109,42 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void: if quota < 1: push_diag.call("error", "shelf_zero_quota", "ShopTable.pool_shelf kind %s 令牌配额为 0(牌库空种入货架 = 库存无锚)" % kind) - # ⑦ 计数器引用闭合(卡② V9——读点 id 必命中同名写入行,否则加载致命): - # 声明集 = feats + treasure 各行 triggers 内 kind=counter_add 的 do.counter_id; - # 读点 = when fact=counter 的 id、do.value_counter、kind=counter_spend 的 counter_id。 - var feats_tab = loaded.get("feats") + +## ⑦ 计数器引用闭合(卡② V9——读点 id 必命中同名写入行,否则加载致命): +## 声明集 = feats + treasure 各行 triggers 内 kind=counter_add 的 do.counter_id; +## 读点 = when fact=counter 的 id、do.value_counter、kind=counter_spend 的 counter_id。 +func _cv_counter_closure(loaded: Dictionary, push_diag: Callable) -> void: var trig_rows: Array[Variant] = [] + var feats_tab = loaded.get("feats") if feats_tab != null: for r in feats_tab.rows: trig_rows.append(r) + var treasure_tab = loaded.get("treasure_items") if treasure_tab != null: for r in treasure_tab.rows: trig_rows.append(r) + var counter_decls := _counter_decls_of(trig_rows) + _cv_counter_reads(trig_rows, counter_decls, push_diag) + + +## 计数器声明集收集:任一 do.counter_id 行(counter_add 直写、forgive_seal_miss 经 _apply_counter_hit 写——皆写入行) +func _counter_decls_of(trig_rows: Array) -> Dictionary: var counter_decls := {} for row in trig_rows: for tr_v in (row.get("triggers", []) as Array): var dd: Dictionary[Variant, Variant] = (tr_v as Dictionary).get("do", {}) as Dictionary[Variant, Variant] - # 声明集=任一 do.counter_id 行(counter_add 直写、forgive_seal_miss 经 _apply_counter_hit 写——皆写入行) if dd.has("counter_id"): counter_decls[String(dd.counter_id)] = true + return counter_decls + + +## 计数器读点校验:when counter id/do.value_counter/counter_spend 的 counter_id 必命中声明集 +func _cv_counter_reads(trig_rows: Array, counter_decls: Dictionary, push_diag: Callable) -> void: for row in trig_rows: var rid := String(row.get("feat_id", row.get("item_id", ""))) for tr_v in (row.get("triggers", []) as Array): var tr: Dictionary[Variant, Variant] = tr_v as Dictionary[Variant, Variant] - for c_v in (tr.get("when", []) as Array): - var c: Dictionary[Variant, Variant] = c_v as Dictionary[Variant, Variant] - if String(c.get("fact", "")) == "counter" and c.has("id"): - var rd := String(c.id) - if not counter_decls.has(rd): - push_diag.call("error", "counter_dangling_ref", "%s when counter 读点无同名写入行: %s(引用闭合)" % [rid, rd]) + _check_counter_when(rid, tr, counter_decls, push_diag) var do2: Dictionary[Variant, Variant] = tr.get("do", {}) as Dictionary[Variant, Variant] if do2.has("value_counter") and not counter_decls.has(String(do2.value_counter)): push_diag.call("error", "counter_dangling_ref", "%s value_counter 读点无同名写入行: %s" % [rid, String(do2.value_counter)]) @@ -113,6 +152,16 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void: push_diag.call("error", "counter_dangling_ref", "%s counter_spend 无同名写入行: %s" % [rid, String(do2.counter_id)]) +## when counter 读点校验(fact=counter 条件的 id 必命中声明集) +func _check_counter_when(rid: String, tr: Dictionary, counter_decls: Dictionary, push_diag: Callable) -> void: + for c_v in (tr.get("when", []) as Array): + var c: Dictionary[Variant, Variant] = c_v as Dictionary[Variant, Variant] + if String(c.get("fact", "")) == "counter" and c.has("id"): + var rd := String(c.id) + if not counter_decls.has(rd): + push_diag.call("error", "counter_dangling_ref", "%s when counter 读点无同名写入行: %s(引用闭合)" % [rid, rd]) + + # 池模板 × 技能行跨表校验(加载期致命;返回错误列表——ConfigDB 诊断通道与冒烟直验共用): # skill_id 悬空致命 + 池构成纪律(每池恰一行普攻、增益技 ≤1、妨害技 ≤1——proposal §2.1 结构保证 + # proposal-status-vocabulary §1.2)+ 施放类 side 守卫(增益引用 enemy 侧 / 妨害引用 hero 侧状态行)。 @@ -141,33 +190,25 @@ func validate_skill_pool_links(skill_table: SkillTable, enemy_table: EnemyTable, errs.append_array(_pool_cast_side_errs(skill_table, status_table)) # 施放类 side 守卫(buff→敌方侧/debuff→主角侧,防反向挂接——全表恰一次) for row in enemy_table.rows: var eid: String = String(row.get("enemy_id", "")) - var basic_n := 0 - var buff_n := 0 - var debuff_n := 0 - var uncond_special_n := 0 + var pool_n := {"basic": 0, "buff": 0, "debuff": 0, "uncond_special": 0} for entry in row.get("pool", []): var sid: String = String(entry.get("skill_id", "")) if not skill_types.has(sid): errs.append({"category": "dangling_skill", "message": "EnemyTable %s 池引用不存在的 skill_id: %s(悬空致命)" % [eid, sid]}) continue - match skill_types[sid]: - "basic": - basic_n += 1 - "buff": - buff_n += 1 - "debuff": - debuff_n += 1 + if pool_n.has(skill_types[sid]): + pool_n[skill_types[sid]] += 1 # 无条件特殊技计数(普攻不计;trigger 缺省行计数——触发条件批 2026-10-26 池构成纪律) - if skill_types[sid] != "basic" and not (skill_table.row_by_id(sid) if skill_table.has_method("row_by_id") else {}).is_empty(): + if skill_types[sid] != "basic": var srow_c: Dictionary[Variant, Variant] = skill_table.row_by_id(sid) if skill_table.has_method("row_by_id") else {} - if not srow_c.has("trigger"): - uncond_special_n += 1 - if basic_n != 1: - errs.append({"category": "pool_composition", "message": "EnemyTable %s 池普攻行数 ≠ 1 (%d)——普攻冷却恒 0 = 空池不可能的结构前提" % [eid, basic_n]}) - if buff_n > 1: - errs.append({"category": "pool_composition", "message": "EnemyTable %s 池增益技 > 1 (%d)——每池增益 ≤1 纪律(纠偏三)" % [eid, buff_n]}) - if debuff_n > 1: - errs.append({"category": "pool_composition", "message": "EnemyTable %s 池妨害技 > 1 (%d)——每池妨害 ≤1 纪律(新效应词汇批 2026-09-19)" % [eid, debuff_n]}) - if uncond_special_n < 1: + if not srow_c.is_empty() and not srow_c.has("trigger"): + pool_n["uncond_special"] += 1 + if pool_n["basic"] != 1: + errs.append({"category": "pool_composition", "message": "EnemyTable %s 池普攻行数 ≠ 1 (%d)——普攻冷却恒 0 = 空池不可能的结构前提" % [eid, pool_n["basic"]]}) + if pool_n["buff"] > 1: + errs.append({"category": "pool_composition", "message": "EnemyTable %s 池增益技 > 1 (%d)——每池增益 ≤1 纪律(纠偏三)" % [eid, pool_n["buff"]]}) + if pool_n["debuff"] > 1: + errs.append({"category": "pool_composition", "message": "EnemyTable %s 池妨害技 > 1 (%d)——每池妨害 ≤1 纪律(新效应词汇批 2026-09-19)" % [eid, pool_n["debuff"]]}) + if pool_n["uncond_special"] < 1: errs.append({"category": "pool_composition", "message": "EnemyTable %s 池无条件特殊技行数 = 0——每池至少一行无条件特殊技纪律(触发条件批 2026-10-26)" % [eid]}) return errs diff --git a/game/core/config_db_resolver.gd b/game/core/config_db_resolver.gd index 34852bdd..37668e44 100644 --- a/game/core/config_db_resolver.gd +++ b/game/core/config_db_resolver.gd @@ -43,11 +43,7 @@ func resolve_enemy_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary] return [] var rows: Array[Dictionary] = [] rows.assign(_enemy_rows_get.call()) - var level_keys: Array[int] = [] - for row in rows: - var lv := int(row.get("level_index", 0)) - if not level_keys.has(lv): - level_keys.append(lv) + var level_keys: Array[int] = _level_keys_of(rows) if level_keys.is_empty(): _diag.call("fatal", "structural", "enemy 行集为空——锚行校验应已拦截") return [] @@ -58,12 +54,7 @@ func resolve_enemy_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary] _warn_resolve("enemy", "resolve_level_clamp", "enemy 请求 (%d,%d) clamp 解析(超出最大关卡取最大值)→ 关卡 %d" % [L, D, l_star], l_star, D) elif l_star != L: _warn_resolve("enemy", "resolve_level_ceil", "enemy 请求 (%d,%d) ceil 解析(向上取整保证偏难)→ 关卡 %d" % [L, D, l_star], l_star, D) - var band_keys: Array[int] = [] - for row in rows: - if int(row.get("level_index", 0)) == l_star and String(row.get("row_kind", "")) == row_kind: - var bv := int(row.get("depth_band", -1)) - if not band_keys.has(bv): - band_keys.append(bv) + var band_keys: Array[int] = _band_keys_of(rows, l_star, row_kind) if band_keys.is_empty(): return [] band_keys.sort() @@ -73,6 +64,32 @@ func resolve_enemy_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary] _warn_resolve("enemy", "resolve_depth_clamp", "enemy 请求 (%d,%d) clamp 解析(超出顶带取顶带)→ (%d,band %d)" % [L, D, l_star, d_star], l_star, d_star) elif d_star != D: _warn_resolve("enemy", "resolve_depth_ceil", "enemy 请求 (%d,%d) ceil 解析(向上取整保证偏难)→ (%d,band %d)" % [L, D, l_star, d_star], l_star, d_star) + return _band_rows(rows, l_star, row_kind, d_star) + + +## 行集关卡键收集(纯行扫描——告警发射序不变) +func _level_keys_of(rows: Array) -> Array[int]: + var level_keys: Array[int] = [] + for row in rows: + var lv := int(row.get("level_index", 0)) + if not level_keys.has(lv): + level_keys.append(lv) + return level_keys + + +## 带键收集(纯行扫描——l_star × row_kind 的 depth_band 去重集) +func _band_keys_of(rows: Array, l_star: int, row_kind: String) -> Array[int]: + var band_keys: Array[int] = [] + for row in rows: + if int(row.get("level_index", 0)) == l_star and String(row.get("row_kind", "")) == row_kind: + var bv := int(row.get("depth_band", -1)) + if not band_keys.has(bv): + band_keys.append(bv) + return band_keys + + +## 带行收集(纯行扫描——命中带全部行、按表文件行序) +func _band_rows(rows: Array, l_star: int, row_kind: String, d_star: int) -> Array[Dictionary]: var all_out: Array[Dictionary] = [] for row in rows: if int(row.get("level_index", 0)) == l_star and String(row.get("row_kind", "")) == row_kind \ @@ -108,11 +125,7 @@ func _resolve_progress(rows: Array, L: int, D: int, table_id: String) -> Variant if L < 1 or D < 0: _diag.call("fatal", "request_oob", "%s 请求越域 (%d,%d)——进度坐标低于合法域(关卡序号≥1、节点深度≥0)" % [table_id, L, D]) return {} - var level_keys: Array[int] = [] - for row in rows: - var lv := int(row.get("level_index", 0)) - if not level_keys.has(lv): - level_keys.append(lv) + var level_keys: Array[int] = _level_keys_of(rows) if level_keys.is_empty(): _diag.call("fatal", "structural", "%s 行集为空——锚行校验应已拦截" % table_id) return {} @@ -121,40 +134,59 @@ func _resolve_progress(rows: Array, L: int, D: int, table_id: String) -> Variant var l_clamped := l_star < 0 if l_clamped: l_star = int(level_keys[-1]) # 超出最大行 → 取最大行封顶(可取范围内向上取整,超出用最大值) + var depth_keys: Array[int] = _depth_keys_of(rows, l_star) + depth_keys.sort() + var d_star := _ceil_key(depth_keys, D) + var d_clamped := d_star < 0 + if d_clamped: + d_star = int(depth_keys[-1]) + var missing := _missing_label(l_clamped, d_clamped, l_star != L, d_star != D) + if missing != "": + _emit_missing_warning(table_id, missing, [l_star, d_star], [L, D]) + for row in rows: + if int(row.get("level_index", 0)) == l_star and int(row.get("node_depth", 0)) == d_star: + return row + return {} + + +## 层键收集(纯行扫描——l_star 行的 node_depth 去重集) +func _depth_keys_of(rows: Array, l_star: int) -> Array[int]: var depth_keys: Array[int] = [] for row in rows: if int(row.get("level_index", 0)) == l_star: var dv := int(row.get("node_depth", 0)) if not depth_keys.has(dv): depth_keys.append(dv) - depth_keys.sort() - var d_star := _ceil_key(depth_keys, D) - var d_clamped := d_star < 0 - if d_clamped: - d_star = int(depth_keys[-1]) - var missing := "" + return depth_keys + + +## 解析偏移形态标签(clamp/ceil × level/depth/both 六形态——空串 = 精确命中; +## l_ceil/d_ceil = 解析值 ≠ 请求值) +func _missing_label(l_clamped: bool, d_clamped: bool, l_ceil: bool, d_ceil: bool) -> String: + var label := "" if l_clamped and d_clamped: - missing = "both_clamp" + label = "both_clamp" elif l_clamped: - missing = "level_clamp" + label = "level_clamp" elif d_clamped: - missing = "depth_clamp" - elif l_star != L and d_star != D: - missing = "both_ceil" - elif l_star != L: - missing = "level_ceil" - elif d_star != D: - missing = "depth_ceil" - if missing != "": - var dedup_key := "%s|%s|%d|%d" % [table_id, missing, l_star, d_star] - if not _warn_dedup.has(dedup_key): - _warn_dedup[dedup_key] = true - var verb := "clamp 解析(超出最大行取最大值)" if missing.contains("clamp") else "ceil 解析(向上取整保证偏难)" - _diag.call("warning", "resolve_" + missing, "%s 请求 (%d,%d) %s → (%d,%d) [%s]——2026-09-18 拍板" % [table_id, L, D, verb, l_star, d_star, missing]) - for row in rows: - if int(row.get("level_index", 0)) == l_star and int(row.get("node_depth", 0)) == d_star: - return row - return {} + label = "depth_clamp" + elif l_ceil and d_ceil: + label = "both_ceil" + elif l_ceil: + label = "level_ceil" + elif d_ceil: + label = "depth_ceil" + return label + + +## 偏移告警发射(四元组去重注册表语义逐字——R1 既有去重; +## req/resolved = [L, D]/[l_star, d_star] 进度坐标对) +func _emit_missing_warning(table_id: String, missing: String, resolved: Array, req: Array) -> void: + var dedup_key := "%s|%s|%d|%d" % [table_id, missing, resolved[0], resolved[1]] + if not _warn_dedup.has(dedup_key): + _warn_dedup[dedup_key] = true + var verb := "clamp 解析(超出最大行取最大值)" if missing.contains("clamp") else "ceil 解析(向上取整保证偏难)" + _diag.call("warning", "resolve_" + missing, "%s 请求 (%d,%d) %s → (%d,%d) [%s]——2026-09-18 拍板" % [table_id, req[0], req[1], verb, resolved[0], resolved[1], missing]) # 取 sorted_keys 中第一个 >= request 的键;无(请求高于最大行)返回 -1 → 调用方 Fatal diff --git a/game/core/config_domain_combat.gd b/game/core/config_domain_combat.gd new file mode 100644 index 00000000..3ea1e5f1 --- /dev/null +++ b/game/core/config_domain_combat.gd @@ -0,0 +1,12 @@ +## 战斗域容器(D2 域容器——config_db 表持有按域收敛,2026-10-27 全清设计定稿):战斗编排域七表类型化持有。 +## 零方法零行为——纯持有结构;「经 ConfigDB 单点读取」=经其持有的容器与表对象(架构 :88 定稿注记)。 +class_name ConfigDomainCombat +extends Resource + +var board_gen: BoardGenTable +var variation: VariationTable +var status_effects: StatusEffectTable +var enemy: EnemyTable +var skill: SkillTable +var hero: HeroTable +var settlement: SettlementTable diff --git a/game/core/config_domain_crossrun.gd b/game/core/config_domain_crossrun.gd new file mode 100644 index 00000000..e001d1ba --- /dev/null +++ b/game/core/config_domain_crossrun.gd @@ -0,0 +1,11 @@ +## 跨局域容器(D2 域容器——config_db 表持有按域收敛,2026-10-27 全清设计定稿):跨局元进度域五表类型化持有 +## (跨局循环=单局之外的长线进度域)。零方法零行为——纯持有结构; +## 「经 ConfigDB 单点读取」=经其持有的容器与表对象(架构 :88 定稿注记)。 +class_name ConfigDomainCrossrun +extends Resource + +var meta: MetaTable +var meta_tree: MetaTreeTable +var treasure_items: TreasureItemTable +var feats: FeatTable +var forge: ForgeTable diff --git a/game/core/config_domain_run.gd b/game/core/config_domain_run.gd new file mode 100644 index 00000000..0bdaa330 --- /dev/null +++ b/game/core/config_domain_run.gd @@ -0,0 +1,12 @@ +## 冒险域容器(D2 域容器——config_db 表持有按域收敛,2026-10-27 全清设计定稿):冒险 runner 域七表类型化持有。 +## 零方法零行为——纯持有结构;「经 ConfigDB 单点读取」=经其持有的容器与表对象(架构 :88 定稿注记)。 +class_name ConfigDomainRun +extends Resource + +var level: LevelTable +var difficulty: DifficultyTable +var level_gen_params: LevelGenParams +var reward: RewardTable +var adventure: AdventureTable +var action_economy: ActionEconomyTable +var shop: ShopTable diff --git a/game/core/curio_deck.gd b/game/core/curio_deck.gd index c69c4092..4df30645 100644 --- a/game/core/curio_deck.gd +++ b/game/core/curio_deck.gd @@ -29,13 +29,13 @@ func reset() -> void: # relic 令牌档 = 行 rarity 直映射,词表外 transitional 值回退映射 rarest→rare/其余→excellent) func build() -> Array: var out: Array[Variant] = [] - for kind in db.adventure.CURIO_KINDS: - for tier in db.adventure.TIERS: - for i in int(db.adventure.curio_quota(String(kind), String(tier))): + for kind in db.run.adventure.CURIO_KINDS: + for tier in db.run.adventure.TIERS: + for i in int(db.run.adventure.curio_quota(String(kind), String(tier))): out.append({"kind": String(kind), "tier": String(tier)}) - for row in db.feat_rows(): + for row in db.crossrun.feats.rows: var rraw := String(row.get("rarity", "")) - var rtier := rraw if db.adventure.TIERS.has(rraw) else ("rare" if rraw == "rarest" else "excellent") + var rtier := rraw if db.run.adventure.TIERS.has(rraw) else ("rare" if rraw == "rarest" else "excellent") out.append({"kind": "relic", "tier": rtier, "feat_id": String(row.get("feat_id", ""))}) return out @@ -73,7 +73,7 @@ func pool_stock(kind: String) -> int: # 读数面 = 商店买走 vs 奖励领走 vs 枯竭) func stock_snapshot() -> Dictionary: var out := {} - for row in db.shop_pool_rows(): + for row in db.run.shop.pool_rows(): var kind := String(row.get("kind_id", "")) out[kind] = pool_stock(kind) return out @@ -97,23 +97,23 @@ func consume_deck_token(kind: String, tier: String, feat_id: String = "") -> boo func cap_ok(kind: String, feat_id: String = "") -> bool: var cap := 99 if kind == "relic": - var row: Dictionary[Variant, Variant] = db.feat_row(feat_id) + var row: Dictionary[Variant, Variant] = db.crossrun.feats.row_by_id(feat_id) cap = int(row.get("cap", 1)) if not row.is_empty() else 1 return taken.get("relic:" + feat_id, 0) < cap - if not db.adventure.CURIO_KINDS.has(kind): + if not db.run.adventure.CURIO_KINDS.has(kind): return true - cap = int(db.adventure.curio_cap(kind)) + cap = int(db.run.adventure.curio_cap(kind)) return int(taken.get(kind, 0)) < cap # 专长凭证展示判定(四口径:一局限购一次/稀有只在概率不在价格/计步保底/触发清零。 # 调用即一次展示判定与记账——展示次数计 pity、配额与限购双闸) func feat_ticket_consider(rg: RandomNumberGenerator) -> bool: - if ticket_sold >= int(db.adventure.feat_ticket_limit): + if ticket_sold >= int(db.run.adventure.feat_ticket_limit): return false # 限购满——不再触发(保底冻结) - if ticket_appeared >= int(db.adventure.feat_ticket_quota): + if ticket_appeared >= int(db.run.adventure.feat_ticket_quota): return false # 配额满——本局不再出现 - var offer: bool = ticket_pity >= int(db.adventure.feat_ticket_pity_step) or rg.randi_range(1, 100) <= int(db.adventure.feat_ticket_chance) + var offer: bool = ticket_pity >= int(db.run.adventure.feat_ticket_pity_step) or rg.randi_range(1, 100) <= int(db.run.adventure.feat_ticket_chance) if offer: ticket_pity = 0 ticket_appeared += 1 @@ -160,7 +160,7 @@ func _candidate_pool(channel: String) -> Array[Variant]: func _draw_weighted(pool: Array[Variant], rg: RandomNumberGenerator) -> Array: var out: Array[Variant] = [] var seen: Array[Variant] = [] - var want := int(db.adventure.curio_candidate_count) + var want := int(db.run.adventure.curio_candidate_count) while out.size() < want and not pool.is_empty(): var total := _pool_weight(pool) if total <= 0: @@ -180,13 +180,13 @@ func _draw_weighted(pool: Array[Variant], rg: RandomNumberGenerator) -> Array: func _pool_weight(pool: Array[Variant]) -> int: var total := 0 for tok in pool: - total += db.adventure.boss_feat_weight(String((tok as Dictionary).get("tier", ""))) + total += db.run.adventure.boss_feat_weight(String((tok as Dictionary).get("tier", ""))) return total func _weighted_index(pool: Array[Variant], roll: int) -> int: for i in pool.size(): - roll -= db.adventure.boss_feat_weight(String((pool[i] as Dictionary).get("tier", ""))) + roll -= db.run.adventure.boss_feat_weight(String((pool[i] as Dictionary).get("tier", ""))) if roll <= 0: return i return 0 @@ -198,12 +198,12 @@ func _draw_exposure(pool: Array[Variant], rg: RandomNumberGenerator) -> Array: _shuffle(pool, rg) var sub: Array[Variant] = [] for tok in pool: - if db.adventure.tier_rank(String((tok as Dictionary).get("tier", ""))) >= db.adventure.tier_rank(db.adventure.TIER_EXCELLENT): + if db.run.adventure.tier_rank(String((tok as Dictionary).get("tier", ""))) >= db.run.adventure.tier_rank(db.run.adventure.TIER_EXCELLENT): sub.append(tok) _shuffle(sub, rg) var out: Array[Variant] = [] var seen: Array[Variant] = [] - var want := int(db.adventure.curio_candidate_count) + var want := int(db.run.adventure.curio_candidate_count) var n_full := maxi(1, want - 1) if want >= 2 else want while out.size() < want: var src: Array[Variant] = sub if out.size() >= n_full and not sub.is_empty() else pool diff --git a/game/core/curio_stop.gd b/game/core/curio_stop.gd index 76dec168..fdaaa76a 100644 --- a/game/core/curio_stop.gd +++ b/game/core/curio_stop.gd @@ -56,7 +56,7 @@ func rebind_level_runner(runner: LevelRunner, level_index: int) -> void: func open_level_clear(payload_base: Dictionary) -> void: var hp_before := _stats.hero_hp var max_eff := _stats.effective_max_hp() # 回血封顶口径含调试浮窗 pct(同战斗内上限) - var heal := mini(int(floor(float(max_eff) * float(int(db.adventure.clear_heal_pct)) / 100.0)), max_eff - hp_before) + var heal := mini(int(floor(float(max_eff) * float(int(db.run.adventure.clear_heal_pct)) / 100.0)), max_eff - hp_before) _stats.hero_hp = hp_before + heal _bank.reset_x() # 碰三选一重置 X 余量(不结转) _bank.reset_paid() # 付费刷计数随停点清零(对齐商店进店清零) @@ -109,7 +109,7 @@ func pick_curio(kind: String, tier: String = "", feat_id: String = "") -> bool: return false if kind == "feat_ticket": return _redeem_feat_ticket() # T5 拿到即开——停点候选替换为专长三选一(零摘牌/不进持有集) - var value: int = int(db.adventure.curio_value(kind, tier)) + var value: int = int(db.run.adventure.curio_value(kind, tier)) var hp_before := _stats.hero_hp if not _apply_curio_pick(kind, tier, value, feat_id): push_error("[停点件] pick_curio 拒绝——未知件: %s|%s|%s" % [kind, tier, feat_id]) @@ -122,7 +122,7 @@ func pick_curio(kind: String, tier: String = "", feat_id: String = "") -> bool: func _feat_payload_keys(feat_id: String) -> Dictionary: if feat_id == "": return {} - var row: Dictionary[Variant, Variant] = db.feat_row(feat_id) + var row: Dictionary[Variant, Variant] = db.crossrun.feats.row_by_id(feat_id) return {"feat_id": feat_id, "feat_name": String(row.get("display_name", "")), "feat_archetype": String(row.get("archetype", ""))} @@ -133,7 +133,7 @@ func _feat_payload_keys(feat_id: String) -> Dictionary: # 按挂点消费,档位质变由 FeatsEngine 求值)。兑付即从牌库摘除该令牌(选中踢池; # 未选候选从未摘牌=自动回池)。道具件不入此体——未知件返回 false 由调用方按渠道名报错。 func _apply_curio_pick(kind: String, tier: String, value: int, feat_id: String = "") -> bool: - if kind == "relic" and db.feat_row(feat_id).is_empty(): + if kind == "relic" and db.crossrun.feats.row_by_id(feat_id).is_empty(): push_error("[停点件] relic 兑付失败——表无此件: %s" % feat_id) return false # 先校验后摘牌(令牌零丢失) if not _deck.consume_deck_token(kind, tier, feat_id): @@ -172,7 +172,7 @@ func _apply_resource_pick(kind: String, value: int, feat_id: String) -> bool: # 路由加算段(match 收敛进效果类型:属性值提升四类走 adventure_table.CURIO_APPLY_ROUTES 声明 # 路由(bonus 落点键),加新加算类令牌零代码) func _apply_route_pick(kind: String, value: int) -> bool: - var sink := db.adventure.curio_apply_route(kind) + var sink := db.run.adventure.curio_apply_route(kind) if sink == "": return false _journal.gain_bonus(sink, value, "curio_pick", kind) # 加算区单写原语(获得日记明细+汇总同拍) @@ -229,7 +229,7 @@ func apply_node_curio(kind: String, tier: String = "", feat_id: String = "") -> if _deck.find_token(candidates, kind, tier, feat_id).is_empty(): push_error("[停点件] apply_node_curio 拒绝——令牌不在候选: %s|%s|%s" % [kind, tier, feat_id]) return false - var value: int = int(db.adventure.curio_value(kind, tier)) + var value: int = int(db.run.adventure.curio_value(kind, tier)) if not _apply_curio_pick(kind, tier, value, feat_id): push_error("[停点件] apply_node_curio 拒绝——未知件: %s|%s|%s" % [kind, tier, feat_id]) return false @@ -348,10 +348,10 @@ func _finish_node_bonus(picked: String, value: int, tier: String = "", feat_id: func consume_pool_purchase(kind: String, tier: String) -> bool: if kind == "feat_ticket": return _redeem_feat_ticket() - if not db.adventure.CURIO_KINDS.has(kind): + if not db.run.adventure.CURIO_KINDS.has(kind): push_error("[停点件] 商店购买消费拒绝——件不在池词表: %s" % kind) return false - return _apply_curio_pick(kind, tier, int(db.adventure.curio_value(kind, tier))) + return _apply_curio_pick(kind, tier, int(db.run.adventure.curio_value(kind, tier))) # ---- 冒险级复位(宿主 _reset_curio_state 调用——停点半:池归 deck.reset、停点三态与刷新账清零)---- @@ -376,7 +376,7 @@ func restore_pending(bp_pending: Dictionary) -> void: mig["feat_id"] = String((c as Dictionary).get("feat_id", "")) migrated.append(mig) else: - migrated.append({"kind": String(c), "tier": db.adventure.TIER_COMMON}) + migrated.append({"kind": String(c), "tier": db.run.adventure.TIER_COMMON}) _pending = {"candidates": migrated} var payload: Dictionary[Variant, Variant] = (bp_pending.get("payload", {}) as Dictionary).duplicate(true) curio_choice_presented.emit(payload) diff --git a/game/core/damage_controller.gd b/game/core/damage_controller.gd index 6afbef7b..8d72cb9b 100644 --- a/game/core/damage_controller.gd +++ b/game/core/damage_controller.gd @@ -294,7 +294,7 @@ func _hero_attack_ctx(t: Dictionary) -> Dictionary: func _ctx_rewrites(t: Dictionary, target: Dictionary, armor: String, matchup: String) -> Dictionary: var ac_hits: Array[Variant] = (t.context.get("fx_attack_hits", []) as Array) var roll_read: Dictionary[Variant, Variant] = _armor_roll() - var threshold: int = defender_ac(target) + int(db.settlement.damage_roll.attack_bonus) + var threshold: int = defender_ac(target) + int(db.combat.settlement.damage_roll.attack_bonus) # armor_roll 发射点(敌护甲掷骰判定;免掷恒折扣订阅面) var ar_hits := _fx_hits("armor_roll", {"target_ac": threshold, "roll": int(roll_read.roll)}) var trade_hit := 0 @@ -383,7 +383,7 @@ func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> D var ac_hits: Array[Variant] = ctx["ac_hits"] var roll_read: Dictionary[Variant, Variant] = ctx["roll_read"] var atk_type: String = ctx["atk_type"] - var rolled: int = DamageMath.rolled_component(base_value, int(ctx["discount"]), bool(ctx["crit"]), int(db.settlement.damage_roll.crit_multiplier), int(ctx["cdelta"])) + var rolled: int = DamageMath.rolled_component(base_value, int(ctx["discount"]), bool(ctx["crit"]), int(db.combat.settlement.damage_roll.crit_multiplier), int(ctx["cdelta"])) var payload: Dictionary[Variant, Variant] = {"target": String(target.display_name), "target_id": String(target.get("enemy_id", ""))} payload[atk_type] = rolled # 分量键 = 实际攻击类型(归因字段,单分量模型) payload["roll"] = int(roll_read.roll) @@ -392,7 +392,7 @@ func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> D if bool(ctx["crit"]): payload["crit"] = true var pooled: int = DamageMath.pool_damage(rolled, clamped_pool("hero_offense"), clamped_pool("enemy_defense")) - var landed: int = maxi(int(db.settlement.damage_roll.damage_floor), pooled) + var landed: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), pooled) # 加法族:英雄侧 rider(enemy_skill rider 对称位补齐口径)——后置必中附加,独立于掷点与乘区 # (奥术余烬「普攻命中后恒定结算」语义);猎巢人巢控光环(per nest_count)/矿工的执拗开格加成 # (per dig_streak、cap 封顶)同通道合计;档一质变 rider+1 / 档二(crit 时)rider 乘暴击倍率。 @@ -406,7 +406,7 @@ func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> D hero_rider += contrib if hero_rider > 0: if bool(ctx["crit"]) and bool(inject["rider_tier_crit"]): - hero_rider = int(round(float(hero_rider) * float(db.settlement.damage_roll.crit_multiplier))) + hero_rider = int(round(float(hero_rider) * float(db.combat.settlement.damage_roll.crit_multiplier))) landed += hero_rider payload["hero_rider"] = hero_rider units.apply_damage(target, landed) @@ -432,7 +432,7 @@ func _kill_overflow(t: Dictionary, target: Dictionary, landed: int) -> void: # source_class 用中性名(fx_overflow 泛用通道,不挂件名) var of_t := open_ticket(DamageController.KIND_BOARD_TYPED, "fx_overflow", "贯穿弹射", {"channel": "board", - "base_value": maxi(int(db.settlement.damage_roll.damage_floor), int(landed * int(h.get("value", 50)) / 100)), + "base_value": maxi(int(db.combat.settlement.damage_roll.damage_floor), int(landed * int(h.get("value", 50)) / 100)), "target_rule": DamageController.TARGET_FRONT_ALIVE, "ri": int(t.round_index), "origin_cell": t.origin_cell, "attack_type": "pierce"}) if policy == POLICY_SEGMENT: @@ -492,13 +492,13 @@ func _resolve_enemy_skill(t: Dictionary) -> void: func _enemy_roll(t: Dictionary) -> Dictionary: var hero: Dictionary[Variant, Variant] = units.hero_unit() var roll_read: Dictionary[Variant, Variant] = _armor_roll() - var threshold: int = defender_ac(hero) + int(db.settlement.damage_roll.attack_bonus) + var threshold: int = defender_ac(hero) + int(db.combat.settlement.damage_roll.attack_bonus) var crit: bool = bool(roll_read.crit) var discount: int = _roll_discount(int(roll_read.roll), threshold) var cdelta: int = _counter_delta(String(t.attack_type), String(hero.get("armor_type", "light"))) - var rolled: int = DamageMath.rolled_component(float(t.base_value), discount, crit, int(db.settlement.damage_roll.crit_multiplier), cdelta) + var rolled: int = DamageMath.rolled_component(float(t.base_value), discount, crit, int(db.combat.settlement.damage_roll.crit_multiplier), cdelta) var pooled: int = DamageMath.pool_damage(rolled, clamped_pool("hero_defense"), clamped_pool("enemy_output")) - var total: int = maxi(int(db.settlement.damage_roll.damage_floor), pooled) + int(t.rider) # rider 后置于伤害下限——独立必中加项,不进乘区 + var total: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), pooled) + int(t.rider) # rider 后置于伤害下限——独立必中加项,不进乘区 return {"roll": int(roll_read.roll), "threshold": threshold, "crit": crit, "discount": discount, "total": total} @@ -520,7 +520,7 @@ func _enemy_shield_apply(hits: Array, total: int) -> Dictionary: var set_v = h.get("set", null) EffectEngine.counter_add(_ctx, String(h.get("counter_id", "")), int(h.get("value", 0)) if set_v == null else int(set_v), set_v != null) if shield > 0: - total = maxi(int(db.settlement.damage_roll.damage_floor), total - shield) + total = maxi(int(db.combat.settlement.damage_roll.damage_floor), total - shield) return {"total": total, "shield": shield} @@ -541,7 +541,7 @@ func _resolve_board_typed(t: Dictionary) -> void: # 无模式 delta 才入棋盘窗(mode 路由:clamp_bonus=主角攻击面专享、discount_shift=折扣档面) if String(h.get("kind", "")) == "modify_ticket" and h.has("delta") and String(h.get("mode", "")) == "": cdelta += int(h.delta) - var dealt: int = DamageMath.counter_component(int(t.base_value), cdelta, int(db.settlement.damage_roll.damage_floor)) + var dealt: int = DamageMath.counter_component(int(t.base_value), cdelta, int(db.combat.settlement.damage_roll.damage_floor)) units.apply_damage(target, dealt) emit_row_cb.call({"type": "damage", "source_class": String(t.source_class), "origin_cell": t.origin_cell, "highlight": String(t.highlight)}, int(t.round_index), "%s → %s" % [String(t.display), String(target.display_name)], dealt, @@ -556,7 +556,7 @@ func _resolve_board_blast(t: Dictionary) -> void: _ineffective_row(t, "无存活敌人") _outcomes[int(t.ticket_id)] = "zero" return - var blast_dmg: int = maxi(int(db.settlement.damage_roll.damage_floor), int(t.base_value)) # 伤害下限全来源 + var blast_dmg: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), int(t.base_value)) # 伤害下限全来源 for u in alive: units.apply_damage(u, blast_dmg) # 恒定表值(下限保底),逐个;受击即死——死亡即不动手 emit_row_cb.call({"type": "damage", "source_class": String(t.source_class), "origin_cell": t.origin_cell, "highlight": String(t.highlight)}, int(t.round_index), @@ -567,7 +567,7 @@ func _resolve_board_blast(t: Dictionary) -> void: # 棋盘主角直伤(反噬/治疗包负向/地形变异):无类型必中,下限保底;context 键并入行载荷 func _resolve_board_flat_hero(t: Dictionary) -> void: - var dealt: int = maxi(int(db.settlement.damage_roll.damage_floor), int(t.base_value)) # 伤害下限全来源 + var dealt: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), int(t.base_value)) # 伤害下限全来源 units.apply_damage(units.hero_unit(), dealt) var extra: Dictionary[Variant, Variant] = {"target": "hero"} for k in t.context.keys(): @@ -580,16 +580,16 @@ func _resolve_board_flat_hero(t: Dictionary) -> void: # 护甲等级掷点(armor_roll 封闭流——逐次掷逐次取号、逐位可回放):返回 {roll, crit} func _armor_roll() -> Dictionary: - var stream: RandomNumberGenerator = rng.open_stream(String(db.settlement.damage_roll.armor_roll_stream)) - var roll: int = stream.randi_range(1, int(db.settlement.damage_roll.roll_faces)) - return {"roll": roll, "crit": roll == int(db.settlement.damage_roll.crit_roll)} + var stream: RandomNumberGenerator = rng.open_stream(String(db.combat.settlement.damage_roll.armor_roll_stream)) + var roll: int = stream.randi_range(1, int(db.combat.settlement.damage_roll.roll_faces)) + return {"roll": roll, "crit": roll == int(db.combat.settlement.damage_roll.crit_roll)} # 掷点折扣读数:nat 最大面恒全额(暴击分支前置),其余查档(缺口 = 阈值 − 掷点) func _roll_discount(roll: int, threshold: int) -> int: - if roll == int(db.settlement.damage_roll.crit_roll): + if roll == int(db.combat.settlement.damage_roll.crit_roll): return 100 - return DamageMath.roll_discount_percent(roll, threshold, db.settlement.damage_roll.roll_tier_bounds, db.settlement.damage_roll.roll_tier_discounts) + return DamageMath.roll_discount_percent(roll, threshold, db.combat.settlement.damage_roll.roll_tier_bounds, db.combat.settlement.damage_roll.roll_tier_discounts) # 防守方护甲等级读数(数值轴——与 armor_type 克制属性轴严格分离):敌人 = spawn 期行值快照; @@ -599,15 +599,15 @@ func defender_ac(unit: Dictionary) -> int: if bool(unit.get("is_hero", false)): var total: int = _hero_ac_override if total < 0: - total = int(db.hero.armor_class) + total = int(db.combat.hero.armor_class) for eff in effects.query_active("hero"): - var row: Dictionary[Variant, Variant] = db.status_effect_row(eff.effect_id) + var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff.effect_id) if not row.is_empty() and String(row.unit) == "ac": total += int(eff.effective_value) return total var total_e: int = int(unit.get("armor_class", 0)) for eff_e in effects.query_active("enemy"): - var erow_ac: Dictionary[Variant, Variant] = db.status_effect_row(eff_e.effect_id) + var erow_ac: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff_e.effect_id) if not erow_ac.is_empty() and String(erow_ac.unit) == "ac": total_e += int(eff_e.effective_value) return total_e @@ -618,14 +618,14 @@ func defender_ac(unit: Dictionary) -> int: func _counter_delta(attack_type: String, armor: String) -> int: if attack_type == "": return 0 - var tbl: Dictionary[Variant, Variant] = db.settlement.damage_roll.armor_effectiveness + var tbl: Dictionary[Variant, Variant] = db.combat.settlement.damage_roll.armor_effectiveness var row: Dictionary[Variant, Variant] = tbl.get(attack_type, {}) return int(row.get(armor, 0)) # 折扣档改善(猎弱本能——折扣表向全额方向移 value 档;查不到当前档 = 不动防御口径) func _discount_shift_up(discount: int, shift: int) -> int: - var tbl: Array[Variant] = db.settlement.damage_roll.roll_tier_discounts + var tbl: Array[Variant] = db.combat.settlement.damage_roll.roll_tier_discounts var idx := tbl.find(discount) if idx < 0: return discount @@ -636,12 +636,12 @@ func _discount_shift_up(discount: int, shift: int) -> int: func clamped_pool(pool: String) -> int: var total := 0 for eff in effects.query_active("hero") + effects.query_active("enemy"): - var row: Dictionary[Variant, Variant] = db.status_effect_row(eff.effect_id) + var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff.effect_id) if row.is_empty(): continue - if String(db.settlement.core.effect_pool_map.get(eff.effect_id, "")) == pool and String(row.unit) == "percent": + if String(db.combat.settlement.core.effect_pool_map.get(eff.effect_id, "")) == pool and String(row.unit) == "percent": total += eff.effective_value # 池内加算(L×m 原样——越域不 clamp 由本处收口) - return clampi(total, int(db.settlement.core.pool_clamp_lo), int(db.settlement.core.pool_clamp_hi)) + return clampi(total, int(db.combat.settlement.core.pool_clamp_lo), int(db.combat.settlement.core.pool_clamp_hi)) # 攻击行显示名(掷点档位随行文本可读;重档折扣 = "被压制") diff --git a/game/core/effect_engine.gd b/game/core/effect_engine.gd index db949132..9388a003 100644 --- a/game/core/effect_engine.gd +++ b/game/core/effect_engine.gd @@ -94,58 +94,76 @@ func _init(db_ref: ConfigDb = null) -> void: ## 在 dispatch 的 round_start 特判内整桶 clear,adventure 桶随宿主字典引用跨战持久——无副本搬运)。 func rebuild(feat_ids: Array, item_ids: Array, tree_rules: Array = []) -> void: _triggers.clear() + _load_feat_triggers(feat_ids) + _load_attune_triggers(item_ids) + _push_tree_rules(tree_rules) + + +## 特性订阅装载域(feats 表序→按持有集过滤→行内数组序——装载声明序第一段) +func _load_feat_triggers(feat_ids: Array) -> void: if db != null and db.feats != null: - for row in db.feats.rows: + for row in db.crossrun.feats.rows: var fid := String(row.get("feat_id", "")) if not feat_ids.has(fid): continue for tr in (row.get("triggers", []) as Array): _push_trigger("feat", fid, tr, row) + + +## 同调秘宝订阅装载域(treasure 同调行序→按装备集过滤→行内数组序——装载声明序第二段) +func _load_attune_triggers(item_ids: Array) -> void: if db != null: - for row in db.treasure_attunement_rows(): + for row in db.crossrun.treasure_items.attunement_rows(): var iid := String(row.get("item_id", "")) if not item_ids.has(iid): continue for tr in (row.get("triggers", []) as Array): _push_trigger("attune", iid, tr, row) - # 树规则合成订阅(六键按 v0.8 三组双态):posture 两键=「每关首轮窗」语义 - # 〔battle_index==1 且 round_index==1;battle_index=关卡域战斗序由 enc 携带〕; - # sense 两组=每关限次〔level 桶计数器 lt 上限闸 + 命中即 counter_add 记账双行——同事件双行同 - # 预评估态见闸同记,序=装载序确定;寿命随 LevelRunner.level_counters 每关 new 天然按关清零〕; - # economy 两组零订阅行——消费点=GP 掉落乘算与商品计价折价的 tree_rules 成员判定。数值全 provisional 归 M4。 + + +## 树规则合成订阅域(六键按 v0.8 三组双态——装载声明序第三段):posture 两键=「每关首轮窗」语义 +## 〔battle_index==1 且 round_index==1;battle_index=关卡域战斗序由 enc 携带〕; +## sense 两组=每关限次〔level 桶计数器 lt 上限闸 + 命中即 counter_add 记账双行——同事件双行同 +## 预评估态见闸同记,序=装载序确定;寿命随 LevelRunner.level_counters 每关 new 天然按关清零〕; +## economy 两组零订阅行——消费点=GP 掉落乘算与商品计价折价的 tree_rules 成员判定。数值全 provisional 归 M4。 +func _push_tree_rules(tree_rules: Array) -> void: for key_v in tree_rules: - var rk := String(key_v) - match rk: - "posture_first_strike": - _push_trigger("tree", rk, {"on": "round_start", - "when": [{"fact": "battle_index", "op": "eq", "value": 1}, - {"fact": "round_index", "op": "eq", "value": 1}], - "do": {"kind": "grant_ap", "mode": "queue", "value": 1}}, {}) - "posture_bulwark": - _push_trigger("tree", rk, {"on": "damage_taken", - "when": [{"fact": "battle_index", "op": "eq", "value": 1}, - {"fact": "round_index", "op": "eq", "value": 1}], - "do": {"kind": "shield", "value": 1}}, {}) - "sense_sharp": - # 锋=踩中危险格伤害减半·每关限次——mine_hit 挂点现成(damage_mult 收集→halve_pct, - # 与惊巢反制同路径;伤害在结算战斗段末兑付,探索阶段零改动) - _push_trigger("tree", rk, {"on": "mine_hit", - "when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}], - "do": {"kind": "damage_mult", "value": 50}}, {}) - _push_trigger("tree", rk, {"on": "mine_hit", - "when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}], - "do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_sharp_used", - "value": 1}}, {}) - "sense_guard": - # 守=标错返还行动点·每关限次(契约 12 失误补偿——seal_missed 由 _judge_seal 去重闸后 - # 发射;queue=下一轮开轮兑现,末轮判出无下一轮=合法作废自动成立) - _push_trigger("tree", rk, {"on": "seal_missed", - "when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}], - "do": {"kind": "grant_ap", "mode": "queue", "value": 1}}, {}) - _push_trigger("tree", rk, {"on": "seal_missed", - "when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}], - "do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_guard_used", - "value": 1}}, {}) + _push_tree_rule(String(key_v)) + + +## 单树规则合成行 push(六键 match 分派——词表封闭,未知键零订阅零扰动) +func _push_tree_rule(rk: String) -> void: + match rk: + "posture_first_strike": + _push_trigger("tree", rk, {"on": "round_start", + "when": [{"fact": "battle_index", "op": "eq", "value": 1}, + {"fact": "round_index", "op": "eq", "value": 1}], + "do": {"kind": "grant_ap", "mode": "queue", "value": 1}}, {}) + "posture_bulwark": + _push_trigger("tree", rk, {"on": "damage_taken", + "when": [{"fact": "battle_index", "op": "eq", "value": 1}, + {"fact": "round_index", "op": "eq", "value": 1}], + "do": {"kind": "shield", "value": 1}}, {}) + "sense_sharp": + # 锋=踩中危险格伤害减半·每关限次——mine_hit 挂点现成(damage_mult 收集→halve_pct, + # 与惊巢反制同路径;伤害在结算战斗段末兑付,探索阶段零改动) + _push_trigger("tree", rk, {"on": "mine_hit", + "when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}], + "do": {"kind": "damage_mult", "value": 50}}, {}) + _push_trigger("tree", rk, {"on": "mine_hit", + "when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}], + "do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_sharp_used", + "value": 1}}, {}) + "sense_guard": + # 守=标错返还行动点·每关限次(契约 12 失误补偿——seal_missed 由 _judge_seal 去重闸后 + # 发射;queue=下一轮开轮兑现,末轮判出无下一轮=合法作废自动成立) + _push_trigger("tree", rk, {"on": "seal_missed", + "when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}], + "do": {"kind": "grant_ap", "mode": "queue", "value": 1}}, {}) + _push_trigger("tree", rk, {"on": "seal_missed", + "when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}], + "do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_guard_used", + "value": 1}}, {}) func _push_trigger(source: String, id: String, tr: Dictionary, row: Dictionary) -> void: @@ -203,13 +221,14 @@ func dispatch(ctx: EffectContext, event: String, payload: Dictionary, facts: Cal return hits -## 计数器归属桶解析(前缀规则唯一出处——round_/lvl_/adv_ 显式桶,其余落 battle 缺省桶)。 +## 计数器归属桶解析(前缀规则唯一出处——round_/lvl_/adv_ 显式桶,其余落 battle 缺省桶; +## 前缀判定序 round→lvl→adv→battle 缺省逐字) static func bucket_of(ctx: EffectContext, counter_id: String) -> Dictionary[Variant, Variant]: if counter_id.begins_with("round_"): return ctx.round_bucket - elif counter_id.begins_with("lvl_"): + if counter_id.begins_with("lvl_"): return ctx.level_bucket - elif counter_id.begins_with("adv_"): + if counter_id.begins_with("adv_"): return ctx.adventure_bucket return ctx.battle_bucket @@ -262,41 +281,68 @@ func _cond_ok(ctx: EffectContext, c: Dictionary, payload: Dictionary, facts: Cal var val = c.get("value", null) var actual = _fact_value(ctx, fact, c, payload, facts) match op: - "eq": - return str(actual) == str(val) - "neq": - return str(actual) != str(val) - "gt": - return int(actual) > int(val) - "gte": - return int(actual) >= int(val) - "lt": - return int(actual) < int(val) - "lte": - return int(actual) <= int(val) + "eq", "neq", "gt", "gte", "lt", "lte": + return _cmp_ok(op, actual, val) "ends_with": - if val is Array: - for v in (val as Array): - if String(actual).ends_with(String(v)): - return true - return false - return String(actual).ends_with(String(val)) + return _ends_with_ok(actual, val) "has": - # 在场判定(fact 查询面落法——白名单覆盖):value 缺省=非空判定 - # (terrain_kind fact:伤害型变异集非空=挖开伤害型地形格/邻格有伤害型地形/地形伤种类在场); - # value 给定=数组包含判定(保留泛用面)。 - if c.has("value"): - if actual is Array: - return (actual as Array).has(val) - return false - if actual is Array: - return not (actual as Array).is_empty() - return actual != null and String(actual) != "" # null 安全(敌技伤害载荷无 terrain_kind 时事实面回落 null,判「不在场」而非抛错) + return _has_ok(c, actual) + "mod_zero", "mod_eq", "advantageous", "gte_ref", "lt_ref": + return _counter_op_ok(ctx, c, op, actual) + assert(false, "[效果引擎] when 算子出域: %s" % op) + return false + + +## 比较算子域(eq/neq/gt/gte/lt/lte 六比较——str 逐字与 int 投影语义逐字) +func _cmp_ok(op: String, actual: Variant, val: Variant) -> bool: + var out := false + match op: + "eq": + out = str(actual) == str(val) + "neq": + out = str(actual) != str(val) + "gt": + out = int(actual) > int(val) + "gte": + out = int(actual) >= int(val) + "lt": + out = int(actual) < int(val) + "lte": + out = int(actual) <= int(val) + return out + + +## 后缀匹配域(ends_with——值数组任一命中即真) +func _ends_with_ok(actual: Variant, val: Variant) -> bool: + if val is Array: + for v in (val as Array): + if String(actual).ends_with(String(v)): + return true + return false + return String(actual).ends_with(String(val)) + + +## 在场判定域(has——fact 查询面落法,白名单覆盖):value 缺省=非空判定 +## (terrain_kind fact:伤害型变异集非空=挖开伤害型地形格/邻格有伤害型地形/地形伤种类在场); +## value 给定=数组包含判定(保留泛用面) +func _has_ok(c: Dictionary, actual: Variant) -> bool: + if c.has("value"): + if actual is Array: + return (actual as Array).has(c.get("value")) + return false + if actual is Array: + return not (actual as Array).is_empty() + return actual != null and String(actual) != "" # null 安全(敌技伤害载荷无 terrain_kind 时事实面回落 null,判「不在场」而非抛错) + + +## 计数器与矩阵算子域(mod_zero/mod_eq 取模、advantageous 克制矩阵、gte_ref/lt_ref 计数器比较——读经 ctx) +func _counter_op_ok(ctx: EffectContext, c: Dictionary, op: String, actual: Variant) -> bool: + match op: "mod_zero": - var m := int(val) + var m := int(c.get("value", null)) return m > 0 and int(actual) % m == 0 "mod_eq": - var me_m := int(val) + var me_m := int(c.get("value", null)) return me_m > 0 and int(actual) % me_m == int(c.get("ref", 0)) "advantageous": # 克制矩阵正面判定(matchup = "{attack_type}_vs_{armor}"——矩阵值 > 0 = 克制成立) @@ -305,7 +351,6 @@ func _cond_ok(ctx: EffectContext, c: Dictionary, payload: Dictionary, facts: Cal return int(actual) >= counter(ctx, String(c.get("ref", ""))) "lt_ref": return int(actual) < counter(ctx, String(c.get("ref", ""))) - assert(false, "[效果引擎] when 算子出域: %s" % op) return false @@ -329,7 +374,7 @@ func _counter_delta_of(matchup: String) -> int: var parts := matchup.split("_vs_") if parts.size() != 2: return 0 - var tbl: Dictionary[Variant, Variant] = db.settlement.damage_roll.armor_effectiveness + var tbl: Dictionary[Variant, Variant] = db.combat.settlement.damage_roll.armor_effectiveness var row: Dictionary[Variant, Variant] = tbl.get(String(parts[0]), {}) return int(row.get(String(parts[1]), 0)) @@ -350,44 +395,59 @@ static func validate_trigger_row(rid: String, row: Dictionary, triggers: Array, errs.append({"category": "enum", "message": "%s 订阅事件出域(事件词汇封闭): %s" % [rid, on]}) elif MINE_EVENTS.has(on): mine_covered = true - for c_v in (tr.get("when", []) as Array): - var c: Dictionary[Variant, Variant] = c_v as Dictionary - for k in c.keys(): - if not WHEN_KEYS.has(String(k)): - errs.append({"category": "zero_flat", "message": "%s when 键出白名单: %s" % [rid, String(k)]}) - var fact := String(c.get("fact", "")) - if not FACTS.has(fact): - errs.append({"category": "enum", "message": "%s fact 出域(事实词汇封闭 %d 项): %s" % [rid, FACTS.size(), fact]}) - if not OPS.has(String(c.get("op", "eq"))): - errs.append({"category": "enum", "message": "%s when 算子出域: %s" % [rid, String(c.get("op", ""))]}) - var do_d: Dictionary[Variant, Variant] = tr.get("do", {}) as Dictionary - for k in do_d.keys(): - if not DO_KEYS.has(String(k)): - errs.append({"category": "zero_flat", "message": "%s do 键出白名单: %s(机制轴禁无条件属性加项)" % [rid, String(k)]}) - var kind := String(do_d.get("kind", "")) - if not EFFECT_KINDS.has(kind): - errs.append({"category": "enum", "message": "%s 效果类型出域(解释器闭集): %s" % [rid, kind]}) - # 计数器前缀归属合法性(.tres 行路径——写错前缀加载致命;判据单点 counter_id_legal,与 _push_trigger 同源) - if do_d.has("counter_id"): - var cid := String(do_d.counter_id) - if not counter_id_legal(cid): - errs.append({"category": "enum", "message": "%s counter_id 前缀非法(meta_ 出域/引擎不接存档级): %s" % [rid, cid]}) - # value_from/delta_from/mult_from/cap_from 引用面:行字段存在性(悬空=加载致命)—— - # do 与 when 两侧同面(when value_from=dawn_order effective_rounds 先例) - for vf_k in ["value_from", "delta_from", "mult_from", "cap_from"]: - if do_d.has(vf_k): - var fld := String(do_d.get(vf_k, "")) - if fld != "" and not row.has(fld): - errs.append({"category": "dangling", "message": "%s do.%s 悬空(行无字段 %s)" % [rid, vf_k, fld]}) - for c_v in (tr.get("when", []) as Array): - var c_w: Dictionary[Variant, Variant] = c_v as Dictionary - if c_w.has("value_from"): - var wfld := String(c_w.get("value_from", "")) - if wfld != "" and not row.has(wfld): - errs.append({"category": "dangling", "message": "%s when.value_from 悬空(行无字段 %s)" % [rid, wfld]}) + _vtr_when_keys(rid, tr, errs) + _vtr_do_keys(rid, tr, row, errs) return mine_covered +## when 键域校验:键白名单/fact 词表/算子词表(追加序承原位——when 键→fact→op) +static func _vtr_when_keys(rid: String, tr: Dictionary, errs: Array) -> void: + for c_v in (tr.get("when", []) as Array): + var c: Dictionary[Variant, Variant] = c_v as Dictionary + for k in c.keys(): + if not WHEN_KEYS.has(String(k)): + errs.append({"category": "zero_flat", "message": "%s when 键出白名单: %s" % [rid, String(k)]}) + var fact := String(c.get("fact", "")) + if not FACTS.has(fact): + errs.append({"category": "enum", "message": "%s fact 出域(事实词汇封闭 %d 项): %s" % [rid, FACTS.size(), fact]}) + if not OPS.has(String(c.get("op", "eq"))): + errs.append({"category": "enum", "message": "%s when 算子出域: %s" % [rid, String(c.get("op", ""))]}) + + +## do 键域校验:键白名单(零裸 flat 护栏)/效果类型闭集/计数器前缀合法性+value_from 引用悬空域 +static func _vtr_do_keys(rid: String, tr: Dictionary, row: Dictionary, errs: Array) -> void: + var do_d: Dictionary[Variant, Variant] = tr.get("do", {}) as Dictionary + for k in do_d.keys(): + if not DO_KEYS.has(String(k)): + errs.append({"category": "zero_flat", "message": "%s do 键出白名单: %s(机制轴禁无条件属性加项)" % [rid, String(k)]}) + var kind := String(do_d.get("kind", "")) + if not EFFECT_KINDS.has(kind): + errs.append({"category": "enum", "message": "%s 效果类型出域(解释器闭集): %s" % [rid, kind]}) + # 计数器前缀归属合法性(.tres 行路径——写错前缀加载致命;判据单点 counter_id_legal,与 _push_trigger 同源) + if do_d.has("counter_id"): + var cid := String(do_d.counter_id) + if not counter_id_legal(cid): + errs.append({"category": "enum", "message": "%s counter_id 前缀非法(meta_ 出域/引擎不接存档级): %s" % [rid, cid]}) + _vtr_from_refs(rid, tr, row, errs) + + +## value_from/delta_from/mult_from/cap_from 引用面:行字段存在性(悬空=加载致命)—— +## do 与 when 两侧同面(when value_from=dawn_order effective_rounds 先例) +static func _vtr_from_refs(rid: String, tr: Dictionary, row: Dictionary, errs: Array) -> void: + var do_d: Dictionary[Variant, Variant] = tr.get("do", {}) as Dictionary + for vf_k in ["value_from", "delta_from", "mult_from", "cap_from"]: + if do_d.has(vf_k): + var fld := String(do_d.get(vf_k, "")) + if fld != "" and not row.has(fld): + errs.append({"category": "dangling", "message": "%s do.%s 悬空(行无字段 %s)" % [rid, vf_k, fld]}) + for c_v in (tr.get("when", []) as Array): + var c_w: Dictionary[Variant, Variant] = c_v as Dictionary + if c_w.has("value_from"): + var wfld := String(c_w.get("value_from", "")) + if wfld != "" and not row.has(wfld): + errs.append({"category": "dangling", "message": "%s when.value_from 悬空(行无字段 %s)" % [rid, wfld]}) + + ## 组合规则(需求卡片 §3.4 六族——效果类型的必填属性,发射点施加面引用) ## 加法族(数值相加,可交换):add_ticket_rider、attack_bonus、grant_material、grant_ap(非 transfer)、 diff --git a/game/core/hero_stats.gd b/game/core/hero_stats.gd index aad9753d..dc0390ac 100644 --- a/game/core/hero_stats.gd +++ b/game/core/hero_stats.gd @@ -52,12 +52,12 @@ func _normalize_override(raw: Dictionary) -> Dictionary[Variant, Variant]: # 调试浮窗百分比后乘;pct=0 时乘法恒等——MC 锚零漂移)---- func effective_max_hp() -> int: - var base: int = int(db.hero.max_hp) + int(_base_override.get("max_hp", 0)) + journal.bonus_total("max_hp") + var base: int = int(db.combat.hero.max_hp) + int(_base_override.get("max_hp", 0)) + journal.bonus_total("max_hp") return maxi(1, int(round(float(base) * (100.0 + float(_override[1])) / 100.0))) func effective_atk() -> int: - var base: int = int(db.hero.atk) + int(_base_override.get("atk", 0)) + journal.bonus_total("atk") + var base: int = int(db.combat.hero.atk) + int(_base_override.get("atk", 0)) + journal.bonus_total("atk") return maxi(1, int(round(float(base) * (100.0 + float(_override[0])) / 100.0))) @@ -70,10 +70,10 @@ func effective_ac() -> int: # 甲型基础 AC(三层之第一层——选中甲路线行 base_ac;未注入/缺行=锻造配置默认值路线, # 与 hero 表两轴删除前 armor_class=10 行为连续) func _armor_base_ac() -> int: - var at: String = armor_type if armor_type != "" else db.forge_default_armor_type() - var row: Dictionary[Variant, Variant] = db.forge_armor_route_by_type(at) + var at: String = armor_type if armor_type != "" else db.crossrun.forge.default_armor_type + var row: Dictionary[Variant, Variant] = db.crossrun.forge.armor_route_by_type(at) if row.is_empty(): - return int(db.hero.armor_class) # 表缺失防御兜底(现役基准) + return int(db.combat.hero.armor_class) # 表缺失防御兜底(现役基准) return int(row.get("base_ac", 0)) diff --git a/game/core/level_runner.gd b/game/core/level_runner.gd index cb860f5f..9469a2f0 100644 --- a/game/core/level_runner.gd +++ b/game/core/level_runner.gd @@ -318,7 +318,7 @@ func restore_fragment(resume_state: Dictionary) -> Dictionary: func _find_level_row(level_index: int) -> Dictionary: - return db.level_row(level_index) # 表访问收口(行集直扫归 LevelTable/ConfigDB 访问器) + return db.run.level.row_by_level_index(level_index) # 表访问收口(行集直扫归 LevelTable/ConfigDB 访问器) # ---- 图源装配(关卡节点图生成 v1.1——图 = f(参数档, 种子) 纯函数;无模板池、无兜底图)---- @@ -327,7 +327,7 @@ func _find_level_row(level_index: int) -> Dictionary: # LevelTable.validate_graph(与产出侧自证同源规格、分工不重叠);违规=起关拒绝,不降级不回退。 func _assign_generated_graph(row: Dictionary, seed_val: int) -> bool: var level_index: int = int(row.get("level_index", 0)) - var params: Dictionary[Variant, Variant] = db.level_gen_params_row(level_index) + var params: Dictionary[Variant, Variant] = db.run.level_gen_params.row_by_level_index(level_index) if params.is_empty(): push_error("[关卡节点地图] level %d 无参数档行(level_gen_params 缺行——配置错误)" % level_index) return false @@ -433,7 +433,7 @@ func visited_ids() -> Array: # ---- 类型可见性读面(信息迷雾 v2.1:近处见类型,远处起雾)---- # 可见集 = Boss/终点(type=="boss")∪ 当前候选(下一跳恒可见——生成承诺)∪ -# {id : |层(id) − 锚层| ≤ N};N = db.level.info_visible_layers(禁硬编码); +# {id : |层(id) − 锚层| ≤ N};N = db.run.level.info_visible_layers(禁硬编码); # 锚层 = 当前节点层,入口停点(无当前节点)= 0;层 = 入口 BFS 深度(与呈现层分层同源口径)。 # 纯函数零状态:已过节点不豁免(单圈语义,零累积记忆);断点快照零新增字段。 func type_visible_ids() -> Array[int]: @@ -443,7 +443,7 @@ func type_visible_ids() -> Array[int]: var cur := current_node() if not cur.is_empty(): anchor = int(layers.get(_node_id(cur), 0)) - var n: int = int(db.level.info_visible_layers) + var n: int = int(db.run.level.info_visible_layers) for node in nodes(): var nid := _node_id(node) if String(node.get("type", "")) == "boss" \ diff --git a/game/core/meta_progression.gd b/game/core/meta_progression.gd index 25749848..0d9687c1 100644 --- a/game/core/meta_progression.gd +++ b/game/core/meta_progression.gd @@ -7,6 +7,7 @@ extends Node var db: ConfigDb var save: SaveManagerCore +var meta_state: MetaStateStore # 存档内元进度数据读写唯一层(K3′ 装配成员——架构 :90 直连) # MetaProgression 是 autoload(仿 RngService 先例:类名加 Core 后缀、 @@ -16,6 +17,8 @@ var save: SaveManagerCore func _init(db_ref: ConfigDb = null, save_ref: SaveManagerCore = null) -> void: db = db_ref save = save_ref + if save != null: + meta_state = save.meta_state func _ready() -> void: @@ -23,31 +26,23 @@ func _ready() -> void: db = get_node("/root/ConfigDB") if save == null: save = get_node("/root/SaveManager") - - -func keystone_balance() -> int: - return save.keystone_balance() + meta_state = save.meta_state # ---- 秘宝解锁服务(解锁混合制,契约 6)---- # 解锁条件单一事实源=treasure_items.tres 行内 unlock_keystone_cost/unlock_requires 谓词 -# (C1 逐件分配锁定表)。 - -# 道具解锁读数访问器(组合根/呈现层解锁门控查询面——禁再穿透 meta.save 直读 save_manager) -func is_item_unlocked(item_id: String) -> bool: - return save.item_unlocked(item_id) - +# (C1 逐件分配锁定表)。钥石余额/道具解锁读数消费方直连 meta.meta_state(纯转发读已随件退役)。 # 解锁条件计数读数单点(谓词 key → 生涯计数——封闭枚举=TreasureItemTable.UNLOCK_COUNTER_KEYS; # 词表加键 = 此处增一 match 分支,阈值比较与解锁写档零改动) func _treasure_unlock_progress(key: String) -> int: match key: "adventure_cleared": - return save.adventure_cleared() + return meta_state.career_state().adventure_cleared "boss_kill_count": - return save.boss_kill_count() + return meta_state.career_state().boss_kill_count "vision_used": - return save.vision_used() + return meta_state.career_state().vision_used _: return 0 # 未知计数键恒 0 = 永不解锁(fail-safe 非致命——schema 词表校验兜底,不静默误解锁) @@ -55,15 +50,15 @@ func _treasure_unlock_progress(key: String) -> int: # 钥石购买解锁(营地秘宝匣唯一购买写口):仅购买件(行带 unlock_keystone_cost)可购; # 已解锁/余额不足/条件件误购 = 拒绝且零副作用(spend_keystone 内嵌余额闸,失败不扣币)。 func buy_treasure_unlock(item_id: String) -> bool: - var arow: Dictionary[Variant, Variant] = db.treasure_item_row(item_id) + var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(item_id) var price := int(arow.get("unlock_keystone_cost", 0)) if arow.is_empty() or price < 1 or (arow.get("unlock_requires", []) as Array).size() > 0: return false # 非购买件(条件件/无价行)拒绝 - if save.item_unlocked(item_id): + if meta_state.item_unlocked(item_id): return false # 已解锁重复购买拒绝 - if not save.spend_keystone(price): + if not meta_state.spend_keystone(price): return false # 余额不足(零副作用) - save.unlock_item(item_id) + meta_state.unlock_item(item_id) return save.write_to_disk() @@ -71,9 +66,9 @@ func buy_treasure_unlock(item_id: String) -> bool: # 全部谓词达标才解锁(多谓词=AND——天眼双条件);幂等:已解锁跳过,重复刷新零重复入账。 func refresh_treasure_unlocks() -> Array: var new_unlocks: Array[String] = [] - for row in db.treasure_attunement_rows(): + for row in db.crossrun.treasure_items.attunement_rows(): var iid := String(row.get("item_id", "")) - if save.item_unlocked(iid): + if meta_state.item_unlocked(iid): continue var ureqs: Array[Variant] = row.get("unlock_requires", []) as Array if ureqs.is_empty(): @@ -85,7 +80,7 @@ func refresh_treasure_unlocks() -> Array: met = false break if met: - save.unlock_item(iid) + meta_state.unlock_item(iid) new_unlocks.append(iid) if not new_unlocks.is_empty(): save.write_to_disk() @@ -96,9 +91,9 @@ func refresh_treasure_unlocks() -> Array: # {item_id, display_name, desc, tier, unlocked, mode(keystone|condition|owned), price, reqs:[{key,threshold,progress}]} func treasure_shop_status() -> Array: var out: Array[Dictionary] = [] - for row in db.treasure_attunement_rows(): + for row in db.crossrun.treasure_items.attunement_rows(): var iid := String(row.get("item_id", "")) - var unlocked := save.item_unlocked(iid) + var unlocked := meta_state.item_unlocked(iid) var ureqs: Array[Variant] = row.get("unlock_requires", []) as Array var req_view: Array[Variant] = [] for req_v in ureqs: @@ -128,18 +123,18 @@ func treasure_shop_status() -> Array: # 等级语义:未解锁/未锻造=0 加值(裸体态);锻造第一级=「获得装备」=首个 +1;逐级递进。 # 路线解锁态派生(不落档——初始持有∨直购∨练满前一档;派生单点只此一处) -func forge_route_unlocked(route_id: String) -> bool: - var row: Dictionary[Variant, Variant] = db.forge_route(route_id) +func _forge_route_unlocked(route_id: String) -> bool: + var row: Dictionary[Variant, Variant] = db.crossrun.forge.route(route_id) if row.is_empty(): return false if bool(row.get("initial_owned", false)): return true - if save.forge_unlocked_purchases().has(route_id): + if meta_state.forge_state().unlocked.has(route_id): return true var prereq := String(row.get("prereq_route", "")) if prereq != "": - var prow: Dictionary[Variant, Variant] = db.forge_route(prereq) - if not prow.is_empty() and save.forge_route_level(prereq) >= int(prow.get("cap", 0)): + var prow: Dictionary[Variant, Variant] = db.crossrun.forge.route(prereq) + if not prow.is_empty() and meta_state.forge_state().levels.get(prereq, 0) >= int(prow.get("cap", 0)): return true return false @@ -147,86 +142,60 @@ func forge_route_unlocked(route_id: String) -> bool: # 路线购买(几何价=基础价×150%^已购级数——锚表 20/30/45/67/101;等级+1 即时写盘)。 # 未解锁/已满级/余额不足/未知路线 = 拒绝且零副作用(spend_keystone 内嵌余额闸,失败不扣币不写盘)。 func forge_buy_level(route_id: String) -> bool: - var row: Dictionary[Variant, Variant] = db.forge_route(route_id) - if row.is_empty() or not forge_route_unlocked(route_id): + var row: Dictionary[Variant, Variant] = db.crossrun.forge.route(route_id) + if row.is_empty() or not _forge_route_unlocked(route_id): return false - var lvl := save.forge_route_level(route_id) + var lvl := meta_state.forge_state().levels.get(route_id, 0) if lvl >= int(row.get("cap", 0)): return false var price := ForgeTable.level_price(row, lvl) - if not save.spend_keystone(price): + if not meta_state.spend_keystone(price): return false - save.set_forge_route_level(route_id, lvl + 1) + meta_state.set_forge_route_level(route_id, lvl + 1) return save.write_to_disk() # 一次性解锁价直购(并联双通道②)。已解锁(含练满前一档派生)= 拒绝零收费——两通道互不影响: # 未练满也可直购;直购后练满不重复收(练满通道本就是派生、零收费)。初始持有行无解锁面。 func forge_buy_unlock(route_id: String) -> bool: - var row: Dictionary[Variant, Variant] = db.forge_route(route_id) + var row: Dictionary[Variant, Variant] = db.crossrun.forge.route(route_id) if row.is_empty() or bool(row.get("initial_owned", false)): return false - if forge_route_unlocked(route_id): + if _forge_route_unlocked(route_id): return false var price := int(row.get("unlock_price", 0)) - if price < 1 or not save.spend_keystone(price): + if price < 1 or not meta_state.spend_keystone(price): return false - save.add_forge_unlock(route_id) + meta_state.add_forge_unlock(route_id) return save.write_to_disk() # 当前武器/甲选中切换(免费——只受理已解锁路线;写存档域+即时写盘。冒险开始锁死归参数面快照, -# 契约 8;冒险中无切换入口=UI 面无路径可达本口) -func forge_select_weapon(route_id: String) -> bool: - var row: Dictionary[Variant, Variant] = db.forge_route(route_id) - if row.is_empty() or String(row.get("kind", "")) != "weapon" or not forge_route_unlocked(route_id): +# 契约 8;冒险中无切换入口=UI 面无路径可达本口)。并组入口:kind ∈ {weapon, armor}。 +func forge_select(kind: String, route_id: String) -> bool: + var row: Dictionary[Variant, Variant] = db.crossrun.forge.route(route_id) + if row.is_empty() or String(row.get("kind", "")) != kind or not _forge_route_unlocked(route_id): return false - save.set_forge_weapon(route_id) + if kind == "weapon": + meta_state.set_forge_weapon(route_id) + else: + meta_state.set_forge_armor(route_id) return save.write_to_disk() -func forge_select_armor(route_id: String) -> bool: - var row: Dictionary[Variant, Variant] = db.forge_route(route_id) - if row.is_empty() or String(row.get("kind", "")) != "armor" or not forge_route_unlocked(route_id): - return false - save.set_forge_armor(route_id) - return save.write_to_disk() - - -# 当前选中武器攻击加值 / 当前甲 AC 加值(路线等级×每级增量——0=裸体态/未锻造合法态)。 -# 选中态异常(未解锁/表缺行)防御回退 0——注入面只认合法态,禁把脏选中带进冒险。 -func forge_weapon_atk_bonus() -> int: - var rid := save.forge_weapon() - var row: Dictionary[Variant, Variant] = db.forge_route(rid) - if row.is_empty() or String(row.get("kind", "")) != "weapon" or not forge_route_unlocked(rid): - return 0 - return save.forge_route_level(rid) * int(row.get("per_level_gain", 0)) - - -func forge_armor_ac_bonus() -> int: - var rid := save.forge_armor() - var row: Dictionary[Variant, Variant] = db.forge_route(rid) - if row.is_empty() or String(row.get("kind", "")) != "armor" or not forge_route_unlocked(rid): - return 0 - return save.forge_route_level(rid) * int(row.get("per_level_gain", 0)) - - -# 两根类型轴当前生效值(选中路线类型轴;选中态异常/未解锁=锻造配置默认值——契约 2 回退面)。 +# 两根类型轴当前生效值({attack, armor} 并组承载;选中态异常/未解锁=锻造配置默认值——契约 2 回退面)。 # 默认值与柜台解锁状态解耦:树钥匙未点亮时本函数照常返回默认值(配置常驻可读)。 -func hero_attack_type() -> String: - var rid := save.forge_weapon() - var row: Dictionary[Variant, Variant] = db.forge_route(rid) - if row.is_empty() or String(row.get("kind", "")) != "weapon" or not forge_route_unlocked(rid): - return db.forge_default_attack_type() - return String(row.get("type_axis", "")) - - -func hero_armor_type() -> String: - var rid := save.forge_armor() - var row: Dictionary[Variant, Variant] = db.forge_route(rid) - if row.is_empty() or String(row.get("kind", "")) != "armor" or not forge_route_unlocked(rid): - return db.forge_default_armor_type() - return String(row.get("type_axis", "")) +func hero_type_axes() -> Dictionary: + var fs := meta_state.forge_state() + var wrow: Dictionary[Variant, Variant] = db.crossrun.forge.route(String(fs["weapon"])) + var attack := db.crossrun.forge.default_attack_type + if not wrow.is_empty() and String(wrow.get("kind", "")) == "weapon" and _forge_route_unlocked(String(fs["weapon"])): + attack = String(wrow.get("type_axis", "")) + var arow: Dictionary[Variant, Variant] = db.crossrun.forge.route(String(fs["armor"])) + var armor := db.crossrun.forge.default_armor_type + if not arow.is_empty() and String(arow.get("kind", "")) == "armor" and _forge_route_unlocked(String(fs["armor"])): + armor = String(arow.get("type_axis", "")) + return {"attack": attack, "armor": armor} # ---- 锻造工坊柜台只读投影(呈现层数据源:两区六路线行板;余额联动禁用归视图)---- @@ -235,16 +204,16 @@ func hero_armor_type() -> String: # prereq_level, prereq_cap, prereq_maxed}——纯结构面,禁嵌余额判断(视图按 balance 禁用,同秘宝匣先例)。 func forge_shop_status() -> Array: var out: Array[Dictionary] = [] - for row in db.forge_routes(): + for row in db.crossrun.forge.route_rows(): var rid := String(row.get("route_id", "")) - var lvl := save.forge_route_level(rid) + var lvl := meta_state.forge_state().levels.get(rid, 0) var cap_v := int(row.get("cap", 0)) - var unlocked := forge_route_unlocked(rid) + var unlocked := _forge_route_unlocked(rid) var prereq := String(row.get("prereq_route", "")) - var plvl := save.forge_route_level(prereq) if prereq != "" else 0 - var pcap := int(db.forge_route(prereq).get("cap", 0)) if prereq != "" else 0 - var selected := (String(row.get("kind", "")) == "weapon" and save.forge_weapon() == rid) \ - or (String(row.get("kind", "")) == "armor" and save.forge_armor() == rid) + var plvl := meta_state.forge_state().levels.get(prereq, 0) if prereq != "" else 0 + var pcap := int(db.crossrun.forge.route(prereq).get("cap", 0)) if prereq != "" else 0 + var selected := (String(row.get("kind", "")) == "weapon" and meta_state.forge_state().weapon == rid) \ + or (String(row.get("kind", "")) == "armor" and meta_state.forge_state().armor == rid) out.append({ "route_id": rid, "display_name": String(row.get("display_name", "")), @@ -258,7 +227,7 @@ func forge_shop_status() -> Array: "next_price": ForgeTable.level_price(row, lvl) if (unlocked and lvl < cap_v) else 0, "unlock_price": int(row.get("unlock_price", 0)), "prereq_route": prereq, - "prereq_name": String(db.forge_route(prereq).get("display_name", "")) if prereq != "" else "", + "prereq_name": String(db.crossrun.forge.route(prereq).get("display_name", "")) if prereq != "" else "", "prereq_level": plvl, "prereq_cap": pcap, "prereq_maxed": prereq != "" and plvl >= pcap, @@ -274,10 +243,10 @@ func forge_shop_status() -> Array: # apply_adventure_summary 逐关卡完成关次置位)。本函数只做标记+入账+归档。 func settle_adventure(summary: Dictionary) -> Dictionary: # 首次冒险布尔位置位(关卡节点图生成 v0.4——完成/失败/放弃三结局同经本收束挂点,写入只在收束) - save.mark_first_adventure_done() + meta_state.mark_first_adventure_done() var drops: Array[Variant] = (summary.get("keystone_drops", []) as Array).duplicate(true) - save.apply_adventure_summary(summary, drops) - save.add_vision_used(int(summary.get("vision_used_delta", 0))) # 真视使用计数收束聚合入档(信标网络行为条件) + meta_state.apply_adventure_summary(summary, drops) + meta_state.add_vision_used(int(summary.get("vision_used_delta", 0))) # 真视使用计数收束聚合入档(信标网络行为条件) var new_unlocks := refresh_treasure_unlocks() # 条件解锁刷新 save.write_to_disk() var total := int(summary.get("keystone_total", 0)) @@ -299,51 +268,75 @@ func settle_adventure(summary: Dictionary) -> Dictionary: # 全零 = 白板合法态(无解锁不得改变任何局内规则);override 禁写表(表运行期只读)。 func hero_override() -> Dictionary: + # 锻造路线加值内联(原 forge_weapon_atk_bonus/forge_armor_ac_bonus 单调用点收编——输出逐字: + # 路线等级×每级增量,选中态异常/未解锁防御回退 0) + var fs := meta_state.forge_state() + var atk_bonus := _forge_axis_bonus(fs, "weapon", "weapon") + var ac_bonus := _forge_axis_bonus(fs, "armor", "armor") + # 树淬炼 flat 内联(原 tree_stat_flat 单调用点收编——四线 flat 汇总同轮扫描,三轴输出逐字等价) + var tree_atk := 0 + var tree_hp := 0 + var tree_ac := 0 + var nodes: Dictionary[Variant, Variant] = meta_state.tree_state()["nodes"] + for row in db.crossrun.meta_tree.rows: + var lvl := int(nodes.get(String(row.get("node_id", "")), 0)) + if lvl <= 0: + continue + var fx: Dictionary[Variant, Variant] = row.get("effects", {}) as Dictionary + tree_atk += int(fx.get("atk", 0)) * lvl + tree_hp += int(fx.get("max_hp", 0)) * lvl + tree_ac += int(fx.get("armor_class", 0)) * lvl return { - "atk": forge_weapon_atk_bonus() + tree_stat_flat("atk"), - "max_hp": tree_stat_flat("max_hp"), - "armor_class": forge_armor_ac_bonus() + tree_stat_flat("armor_class"), + "atk": atk_bonus + tree_atk, + "max_hp": tree_hp, + "armor_class": ac_bonus + tree_ac, } +# 锻造轴加值原语(选中路线等级×每级增量;选中态异常(未解锁/表缺行)防御回退 0——注入面只认合法态) +func _forge_axis_bonus(fs: Dictionary, sel_key: String, kind: String) -> int: + var rid := String(fs[sel_key]) + var row: Dictionary[Variant, Variant] = db.crossrun.forge.route(rid) + if row.is_empty() or String(row.get("kind", "")) != kind or not _forge_route_unlocked(rid): + return 0 + return int(fs["levels"].get(rid, 0)) * int(row.get("per_level_gain", 0)) + + # ---- 天赋树段(设计权威 整合定稿 §1.4 一树双币三脉一脊)---- # 全树已购总等级(row-gating 输入——「投入点数累计解锁新行」) -func tree_spent_total() -> int: +func _tree_spent_total() -> int: var total := 0 - for lvl in (save.tree_nodes() as Dictionary).values(): + for lvl in (meta_state.tree_state().nodes as Dictionary).values(): total += int(lvl) return total # 节点可购校验(行门槛 + 显式前置 + 币 + cap;free 节点=钥匙不经购买,彩蛋=觉醒不经购买) func tree_can_buy(node_id: String) -> bool: - var row: Dictionary[Variant, Variant] = db.tree_node(node_id) - if row.is_empty(): - return false + var row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(node_id) # 彩蛋不经购买(觉醒唯一入口=_check_easter_egg 特化判定——V8 反例:手动购亮=红) - if String(row.get("kind", "")) == "easter": - return false - var lvl := save.tree_level(node_id) - if lvl >= int(row.get("cap", 1)): + if row.is_empty() or String(row.get("kind", "")) == "easter": return false + var nodes: Dictionary[Variant, Variant] = meta_state.tree_state()["nodes"] + var lvl := int(nodes.get(node_id, 0)) + var blocked := lvl >= int(row.get("cap", 1)) # 刷新升级节点:cap 主语=Y 账本(单一事实源——树上入口与装配段同 cap 同价,防双账漂移) - if node_id == "ks_refresh" and db.meta_attune_refresh_row(save.attune_refresh_level() + 1).is_empty(): - return false + if node_id == "ks_refresh" and db.crossrun.meta.attune_refresh_row(meta_state.attune_state().refresh_level + 1).is_empty(): + blocked = true # 行门槛(一线一节点:脊柱节点第 N 级需全树总投入 ≥4×(N−1)) - if String(row.get("vein", "")) == "spine" and String(row.get("kind", "")) == "stat": - if tree_spent_total() < db.meta_tree.level_gate(lvl + 1): - return false + if String(row.get("vein", "")) == "spine" and String(row.get("kind", "")) == "stat" and _tree_spent_total() < db.crossrun.meta_tree.level_gate(lvl + 1): + blocked = true for req in (row.get("requires", []) as Array): - if save.tree_level(String(req)) < 1: - return false + if int(nodes.get(String(req), 0)) < 1: + blocked = true # 等级阈值前置(requires_level {node_id: n}:前置节点等级 ≥ n 才可购; # 现役表零配置=空转零行为影响) var req_lv: Dictionary[Variant, Variant] = row.get("requires_level", {}) as Dictionary for req_id in req_lv.keys(): - if save.tree_level(String(req_id)) < int(req_lv[req_id]): - return false - return true + if int(nodes.get(String(req_id), 0)) < int(req_lv[req_id]): + blocked = true + return not blocked # 节点购买(营地树 UI 唯一写口):树全钥石单币。free=工作间钥匙零币点亮 @@ -353,13 +346,13 @@ func tree_can_buy(node_id: String) -> bool: func tree_buy(node_id: String) -> bool: if node_id == "ks_refresh": return buy_attune_refresh() - var row: Dictionary[Variant, Variant] = db.tree_node(node_id) + var row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(node_id) if row.is_empty() or not tree_can_buy(node_id): return false var price := int(row.get("price", 0)) - if not save.spend_keystone(price): + if not meta_state.spend_keystone(price): return false - save.set_tree_level(node_id, save.tree_level(node_id) + 1) + meta_state.set_tree_level(node_id, meta_state.tree_state().nodes.get(node_id, 0) + 1) _check_easter_egg() return save.write_to_disk() @@ -367,45 +360,35 @@ func tree_buy(node_id: String) -> bool: # Fork 切换(营地树 UI——一节点双态:购买解锁组默认 A 态,换态走本口零币;同态重复=零操作; # 未解锁组切换=拒绝——解锁唯一入口=购买面 tree_buy_fork) func tree_toggle_fork(group: String, side: String) -> bool: - if not db.meta_tree.FORK_GROUPS.has(group) or not (side == "a" or side == "b"): + if not db.crossrun.meta_tree.FORK_GROUPS.has(group) or not (side == "a" or side == "b"): return false - var cur := save.fork_side(group) + var cur := meta_state.tree_state().fork_sides.get(group, "") if cur == "" or cur == side: return false # 未解锁走购买面;同态零操作 - save.set_fork_side(group, side) + meta_state.set_fork_side(group, side) return save.write_to_disk() # Fork 节点购买入口(一节点双态——购=解锁开关并置默认 A 态;重复购买由 cap 经 can_buy 拦截) func tree_buy_fork(node_id: String) -> bool: - var row: Dictionary[Variant, Variant] = db.tree_node(node_id) + var row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(node_id) if row.is_empty() or String(row.get("kind", "")) != "fork": return tree_buy(node_id) if not tree_can_buy(node_id): return false var price := int(row.get("price", 0)) - if not save.spend_keystone(price): + if not meta_state.spend_keystone(price): return false - save.set_tree_level(node_id, 1) - save.set_fork_side(String(row.get("fork_group", "")), "a") # 解锁默认 A 态 + meta_state.set_tree_level(node_id, 1) + meta_state.set_fork_side(String(row.get("fork_group", "")), "a") # 解锁默认 A 态 return save.write_to_disk() -# 树效果投影:四线 flat 汇总 + rule 键集(激活 Fork 侧 effects 并入) -func tree_stat_flat(key: String) -> int: - var total := 0 - for row in db.tree_rows(): - var lvl := save.tree_level(String(row.get("node_id", ""))) - if lvl <= 0: - continue - total += int((row.get("effects", {}) as Dictionary).get(key, 0)) * lvl - return total - - +# 树规则键投影(激活 Fork 侧 effects 并入) func tree_rule_keys() -> Array: var out: Array[Variant] = [] - var sides: Dictionary[Variant, Variant] = save.fork_sides() - for row in db.tree_rows(): + var sides: Dictionary[Variant, Variant] = meta_state.tree_state().fork_sides + for row in db.crossrun.meta_tree.rows: if String(row.get("kind", "")) != "fork": continue var active := String(sides.get(String(row.get("fork_group", "")), "")) @@ -418,10 +401,15 @@ func tree_rule_keys() -> Array: return out -# 营地树面投影(CampOverlay _rebuild_tree 数据源——行板只读;账面归本类、拼句归视图同款分工) -func tree_camp_rows() -> Array: +# 营地树面投影(并组入口——{rows, fork_groups};CampOverlay _rebuild_tree 数据源——行板只读;账面归本类、拼句归视图同款分工) +func tree_camp_status() -> Dictionary: + return {"rows": _tree_camp_rows(), "fork_groups": _tree_fork_groups()} + + +## 树行板投影段(原 tree_camp_rows) +func _tree_camp_rows() -> Array: var out: Array[Variant] = [] - for row in db.tree_rows(): + for row in db.crossrun.meta_tree.rows: if String(row.get("kind", "")) == "fork": continue # Fork 行由 fork_groups 段承载 out.append({ @@ -430,32 +418,33 @@ func tree_camp_rows() -> Array: "vein": String(row.get("vein", "")), "kind": String(row.get("kind", "")), "cap": int(row.get("cap", 1)), - "level": save.attune_refresh_level() if String(row.get("node_id", "")) == "ks_refresh" else save.tree_level(String(row.get("node_id", ""))), # 刷新升级行主语=Y 账本(同账路由 V15) + "level": meta_state.attune_state().refresh_level if String(row.get("node_id", "")) == "ks_refresh" else meta_state.tree_state().nodes.get(String(row.get("node_id", "")), 0), # 刷新升级行主语=Y 账本(同账路由 V15) "price": int(row.get("price", 0)), "currency": String(row.get("currency", "keystone")), "can_buy": tree_can_buy(String(row.get("node_id", ""))), - "gate_text": tree_gate_text(String(row.get("node_id", ""))), + "gate_text": _tree_gate_text(String(row.get("node_id", ""))), }) return out # 行板门槛文案(R1 机制位呈现——requires_level 未达标项拼「需 {前置名} Lv{n}」;达标/未配置=空串。 # 只呈现未达标项:达标后锁标消失,不做全量前置罗列) -func tree_gate_text(node_id: String) -> String: - var row: Dictionary[Variant, Variant] = db.tree_node(node_id) +func _tree_gate_text(node_id: String) -> String: + var row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(node_id) var rl: Dictionary[Variant, Variant] = row.get("requires_level", {}) as Dictionary var parts: Array[String] = [] for req_id in rl.keys(): - if save.tree_level(String(req_id)) < int(rl[req_id]): - var req_row: Dictionary[Variant, Variant] = db.tree_node(String(req_id)) + if meta_state.tree_state().nodes.get(String(req_id), 0) < int(rl[req_id]): + var req_row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(String(req_id)) parts.append("需 %s Lv%d" % [String(req_row.get("display_name", req_id)), int(rl[req_id])]) return "、".join(parts) -func tree_fork_groups() -> Array: +## Fork 行板投影段(原 tree_fork_groups) +func _tree_fork_groups() -> Array: var out: Array[Variant] = [] - var sides: Dictionary[Variant, Variant] = save.fork_sides() - for row in db.tree_rows(): + var sides: Dictionary[Variant, Variant] = meta_state.tree_state().fork_sides + for row in db.crossrun.meta_tree.rows: if String(row.get("kind", "")) != "fork": continue var fg := String(row.get("fork_group", "")) @@ -464,7 +453,7 @@ func tree_fork_groups() -> Array: "node_id": String(row.get("node_id", "")), "display_name": String(row.get("display_name", "")), "active_side": String(sides.get(fg, "")), - "owned": save.tree_level(String(row.get("node_id", ""))) > 0, + "owned": meta_state.tree_state().nodes.get(String(row.get("node_id", "")), 0) > 0, "can_buy": tree_can_buy(String(row.get("node_id", ""))), "price": int(row.get("price", 0)), "sides": [ @@ -478,16 +467,16 @@ func tree_fork_groups() -> Array: # 彩蛋觉醒检查(特化判定——生命/命中两线拉满 且 同调位 1/2/3 全解锁 → easter_ley 免费点亮; # 不依赖 requires/requires_level 前置形态) func _check_easter_egg() -> void: - if save.tree_level("easter_ley") > 0: + if meta_state.tree_state().nodes.get("easter_ley", 0) > 0: return for nid in ["sp_hp", "sp_hit"]: - var row: Dictionary[Variant, Variant] = db.tree_node(nid) - if row.is_empty() or save.tree_level(nid) < int(row.get("cap", 1)): + var row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(nid) + if row.is_empty() or meta_state.tree_state().nodes.get(nid, 0) < int(row.get("cap", 1)): return for i in range(1, 4): - if save.tree_level("att_socket%d" % i) < 1: + if meta_state.tree_state().nodes.get("att_socket%d" % i, 0) < 1: return - save.set_tree_level("easter_ley", 1) + meta_state.set_tree_level("easter_ley", 1) # ---- 同调位账本(装配关系/同调位求值/Y 刷新升级;设计权威 proposal §2)---- @@ -498,30 +487,30 @@ func _check_easter_egg() -> void: # 格 1=普通格/格 2=优秀格/格 3=稀有格递增向下兼容;槽位来源=树同调位节点点亮数,树接口接缝现役)。 # 装配关系写存档持久。 func toggle_attunement(item_id: String) -> bool: - var equipped: Array[Variant] = save.attunement_equipped() + var equipped: Array[Variant] = meta_state.attune_state().equipped if not equipped.has(item_id): - var arow: Dictionary[Variant, Variant] = db.treasure_item_row(item_id) + var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(item_id) if arow.is_empty() or String(arow.get("form", "")) == "": return false # 非同调行(过渡/退役行)禁装配 - if not save.item_unlocked(item_id): + if not meta_state.item_unlocked(item_id): return false # 未解锁禁装配(解锁混合制契约 6——装配以解锁为前置) if equipped.size() >= Attunement.slots(db, save): return false # 满位禁选 - var rank: int = int(db.treasure_items.TIER_RANK.get(String(arow.get("tier", "")), 99)) + var rank: int = int(db.crossrun.treasure_items.TIER_RANK.get(String(arow.get("tier", "")), 99)) if rank > equipped.size() + 1: return false # 件档高于将占格档(第 k 格档=k——普通/优秀/稀有递增) equipped.append(item_id) else: equipped.erase(item_id) - save.set_attunement_equipped(equipped) + meta_state.set_attunement_equipped(equipped) return save.write_to_disk() # 营地装配面投影(账面归本类、拼句归 CampOverlay——_camp_data 同款分工) func attune_camp_status() -> Dictionary: - var equipped: Array[Variant] = save.attunement_equipped() + var equipped: Array[Variant] = meta_state.attune_state().equipped var items: Array[Dictionary] = [] - for row in db.treasure_attunement_rows(): + for row in db.crossrun.treasure_items.attunement_rows(): var iid := String(row.get("item_id", "")) items.append({ "item_id": iid, @@ -530,10 +519,10 @@ func attune_camp_status() -> Dictionary: "tier": String(row.get("tier", "")), "form": String(row.get("form", "")), "equipped": equipped.has(iid), - "unlocked": save.item_unlocked(iid), + "unlocked": meta_state.item_unlocked(iid), }) - var lvl := save.attune_refresh_level() - var next_row: Dictionary[Variant, Variant] = db.meta_attune_refresh_row(lvl + 1) + var lvl := meta_state.attune_state().refresh_level + var next_row: Dictionary[Variant, Variant] = db.crossrun.meta.attune_refresh_row(lvl + 1) return { "slots": Attunement.slots(db, save), "equipped": equipped, @@ -549,15 +538,15 @@ func attune_camp_status() -> Dictionary: # 每冒险开局刷新次数 Y(当前等级行表值;0 级 = 0 次即无刷新能力——P1 甲初始 0 同构) func attune_refresh_charges() -> int: - return int(db.meta_attune_refresh_row(save.attune_refresh_level()).get("refresh_charges", 0)) + return int(db.crossrun.meta.attune_refresh_row(meta_state.attune_state().refresh_level).get("refresh_charges", 0)) # Y 升级购买(直购价 = 下一级行表值 keystone_cost;已满级/余额不足 false 零副作用——buy_forge 同款) func buy_attune_refresh() -> bool: - var next_row: Dictionary[Variant, Variant] = db.meta_attune_refresh_row(save.attune_refresh_level() + 1) + var next_row: Dictionary[Variant, Variant] = db.crossrun.meta.attune_refresh_row(meta_state.attune_state().refresh_level + 1) if next_row.is_empty(): return false - if not save.spend_keystone(int(next_row.get("keystone_cost", 0))): + if not meta_state.spend_keystone(int(next_row.get("keystone_cost", 0))): return false - save.set_attune_refresh_level(save.attune_refresh_level() + 1) + meta_state.set_attune_refresh_level(meta_state.attune_state().refresh_level + 1) return save.write_to_disk() diff --git a/game/core/meta_state_store.gd b/game/core/meta_state_store.gd new file mode 100644 index 00000000..67eb114d --- /dev/null +++ b/game/core/meta_state_store.gd @@ -0,0 +1,176 @@ +## 存档内元进度数据件(D3 拆件——自 save_manager 收编元进度族):存档内元进度数据的读写唯一层。 +## 数据读写原语族(写口 13)+只读投影(读 6——tree/forge/attune/career 四域值拷贝出户); +## 业务规则与写口编排归 MetaProgression,本件只承载键域读写(照单承载,零业务判定)。 +## 零 _data 副本零缓存——全部读写经注入 save 的内存数据模型单点。 +class_name MetaStateStore +extends RefCounted + +var _save # SaveManagerCore 注入引用(_data 唯一取数点) + + +func _init(save_ref) -> void: + _save = save_ref + + +# ---- 写口原语族(13)---- + +# 钥石消费唯一口:余额不足拒绝且零副作用(钥石只增不减——消费除外,元进度验收判据) +func spend_keystone(n: int) -> bool: + if keystone_balance() < n: + return false + _save._data["meta"]["keystone_balance"] = keystone_balance() - n + return true + + +func unlock_item(item_id: String) -> void: + _save._data["meta"]["item_unlocks"][item_id] = true + + +func set_tree_level(node_id: String, level: int) -> void: + var tn: Dictionary[Variant, Variant] = _save._data["meta"].get("tree_nodes", {}) as Dictionary + tn[node_id] = maxi(0, level) + _save._data["meta"]["tree_nodes"] = tn + + +func set_fork_side(group: String, side: String) -> void: + var fs: Dictionary[Variant, Variant] = _save._data["meta"].get("fork_sides", {}) as Dictionary + fs[group] = side + _save._data["meta"]["fork_sides"] = fs + + +func set_forge_route_level(route_id: String, level: int) -> void: + var dom: Dictionary[Variant, Variant] = _forge_domain() + var lv: Dictionary[Variant, Variant] = dom.get("levels", {}) as Dictionary + lv[route_id] = maxi(0, level) + dom["levels"] = lv + _save._data["meta"]["forge_routes"] = dom + + +func add_forge_unlock(route_id: String) -> void: + var dom: Dictionary[Variant, Variant] = _forge_domain() + var un: Array[Variant] = dom.get("unlocked", []) as Array + if not un.has(route_id): + un.append(route_id) + dom["unlocked"] = un + _save._data["meta"]["forge_routes"] = dom + + +func set_forge_weapon(route_id: String) -> void: + var dom: Dictionary[Variant, Variant] = _forge_domain() + dom["weapon"] = route_id + _save._data["meta"]["forge_routes"] = dom + + +func set_forge_armor(route_id: String) -> void: + var dom: Dictionary[Variant, Variant] = _forge_domain() + dom["armor"] = route_id + _save._data["meta"]["forge_routes"] = dom + + +func set_attunement_equipped(item_ids: Array) -> void: + var out: Array[Variant] = [] + for v in item_ids: + out.append(String(v)) + _save._data["meta"]["attunement_equipped"] = out + + +func set_attune_refresh_level(n: int) -> void: + _save._data["meta"]["attune_refresh_level"] = maxi(0, n) + + +# 真视类工具累计使用入账(写入唯一经本口冒险收束聚合,受理计数点=battle 层经冒险层聚合) +func add_vision_used(n: int) -> void: + _save._data["meta"]["vision_used"] = maxi(0, career_state()["vision_used"] + maxi(0, n)) + + +# 域更新原语(正式冒险收束唯一入账口——组合根经本函数入账,禁直改内部结构): +# 钥石入账(summary.keystone_total——逐杀掉落已在冒险内计入,boss_drops 参数为 +# 摘要归档副本不重复入账)+ 逐关卡完成关次 clear_count +1 与 Boss 首杀标记 + Boss 击杀总数累计 +# (同调位里程碑计数源)+ 摘要头部插入(cap 10,最新在前,元素附 +# keystone_drops 副本——复盘可归因)+ 生涯终身计数自增(总局数开局即计: +# 三结局同经本口恰计一次;冒险成功局数按 summary.cleared 判定三关全通才计 1。终身计数写入 +# 单口=本函数,禁第二写入点)。 +func apply_adventure_summary(summary: Dictionary, boss_drops: Array = []) -> void: + _save._data["meta"]["adventure_total"] = career_state()["adventure_total"] + 1 + if bool(summary.get("cleared", false)): + _save._data["meta"]["adventure_cleared"] = career_state()["adventure_cleared"] + 1 + _add_keystone(int(summary.get("keystone_total", 0))) + var cleared := int(summary.get("levels_cleared", 0)) + for li in range(1, cleared + 1): + var key := str(li) + _save._data["meta"]["level_clear_counts"][key] = int(_save._data["meta"]["level_clear_counts"].get(key, 0)) + 1 + _save._data["meta"]["boss_first_kill"][key] = true + _save._data["meta"]["boss_kill_count"] = career_state()["boss_kill_count"] + cleared # Boss 恒唯一终点:levels_cleared 即本次冒险 Boss 击杀数(含重复) + var entry: Dictionary[Variant, Variant] = summary.duplicate(true) + entry["boss_drops"] = boss_drops.duplicate(true) + var arr: Array[Variant] = _save._data["meta"]["recent_summaries"] + arr.push_front(entry) + while arr.size() > _save.SUMMARY_CAP: + arr.pop_back() + + +func mark_first_adventure_done() -> void: + _save._data["meta"]["first_adventure_done"] = true + + +# 钥石入账原语(生产消费零——apply_adventure_summary 专用;测量面经 _ 前缀直调) +func _add_keystone(n: int) -> void: + _save._data["meta"]["keystone_balance"] = keystone_balance() + n + + +# ---- 只读投影族(6——值拷贝出户,禁碰内部结构契约同款)---- + +func keystone_balance() -> int: + return int(_save._data["meta"]["keystone_balance"]) + + +func item_unlocked(item_id: String) -> bool: + return _save._data["meta"]["item_unlocks"].has(item_id) + + +## 天赋树域只读投影:{nodes: node_id→已购等级, fork_sides: fork_group→"a"|"b"} +func tree_state() -> Dictionary: + return { + "nodes": (_save._data["meta"].get("tree_nodes", {}) as Dictionary).duplicate(), + "fork_sides": (_save._data["meta"].get("fork_sides", {}) as Dictionary).duplicate(), + } + + +## 锻造域只读投影:{levels: 六路线等级, unlocked: 直购清单, weapon/armor: 当前选中} +## (解锁态不落档——派生判定单点在 MetaProgression.forge_route_unlocked) +func forge_state() -> Dictionary: + var dom: Dictionary[Variant, Variant] = _forge_domain() + return { + "levels": (dom.get("levels", {}) as Dictionary).duplicate(), + "unlocked": (dom.get("unlocked", []) as Array).duplicate(), + "weapon": String(dom.get("weapon", "sword")), + "armor": String(dom.get("armor", "light")), + } + + +## 同调域只读投影:{equipped: 装配 item_id 有序数组(装配序即呈现序), refresh_level: Y 升级等级} +func attune_state() -> Dictionary: + return { + "equipped": (_save._data["meta"].get("attunement_equipped", []) as Array).duplicate(), + "refresh_level": int(_save._data["meta"].get("attune_refresh_level", 0)), + } + + +## 生涯域只读投影:{clear_counts, boss_first_kill, boss_kill_count, adventure_total, +## adventure_cleared, vision_used, recent_summaries, first_adventure_done} +func career_state() -> Dictionary: + return { + "clear_counts": (_save._data["meta"]["level_clear_counts"] as Dictionary).duplicate(), + "boss_first_kill": (_save._data["meta"]["boss_first_kill"] as Dictionary).duplicate(), + "boss_kill_count": int(_save._data["meta"].get("boss_kill_count", 0)), + "adventure_total": int(_save._data["meta"].get("adventure_total", 0)), + "adventure_cleared": int(_save._data["meta"].get("adventure_cleared", 0)), + "vision_used": int(_save._data["meta"].get("vision_used", 0)), + "recent_summaries": (_save._data["meta"]["recent_summaries"] as Array).duplicate(true), + "first_adventure_done": bool(_save._data["meta"].get("first_adventure_done", true)), + } + + +# 锻造域取数(本件内部单点) +func _forge_domain() -> Dictionary: + return _save._data["meta"].get("forge_routes", {}) as Dictionary diff --git a/game/core/minesweeper_rules.gd b/game/core/minesweeper_rules.gd index 4d31d522..24163cf8 100644 --- a/game/core/minesweeper_rules.gd +++ b/game/core/minesweeper_rules.gd @@ -304,15 +304,22 @@ func _do_dig(c: Vector2i) -> Dictionary: if was_sealed: _seals.erase(c) var reveal_order: Array[Variant] = _chain_reveal_order(c, eff_seals) + _reveal_chain(c, reveal_order, was_sealed) + if action_economy.phase != action_economy.PHASE_ACTIVE: + _emit_round_end_seals_and_payload() + return _outcome(true, "executed", "", "") + + +## 连锁揭示域:首格+连锁新格揭示(BFS 规范序——cell_revealed 发射序即链序,整体随段搬迁)。 +## 格面资源资格:chain/dig 恒 true——安全格与危险格一视同仁(危险格亦携带, +## 负向兑付归 战斗结算 极性路由);initial(封锁格)恒 false。资源本体与极性不在本系统。 +## dug_under_seal = 首格带封印直挖标记(战斗结算 情况④兑付改判消费;连锁格恒 false——连锁不越封印) +func _reveal_chain(c: Vector2i, reveal_order: Array, was_sealed: bool) -> void: for rc in reveal_order: if _revealed.has(rc): continue # 闭包可穿过已开区——已开格零事件(cell_revealed × |O′ ∖ O|) - var is_mine := _board.is_mine(rc) var channel := "dig" if rc == c else "chain" - # 格面资源资格:chain/dig 恒 true——安全格与危险格一视同仁(危险格亦携带, - # 负向兑付归 战斗结算 极性路由);initial(封锁格)恒 false。资源本体与极性不在本系统。 - # dug_under_seal = 首格带封印直挖标记(战斗结算 情况④兑付改判消费;连锁格恒 false——连锁不越封印) - if is_mine: + if _board.is_mine(rc): _revealed[rc] = {"kind": "mine", "digit": -1} cell_revealed.emit({ "cell": rc, "kind": "mine", "digit": -1, @@ -326,9 +333,6 @@ func _do_dig(c: Vector2i) -> Dictionary: "variant_ids": _board.variant_ids(rc), "channel": channel, "resource": true, "dug_under_seal": was_sealed and rc == c, }) - if action_economy.phase != action_economy.PHASE_ACTIVE: - _emit_round_end_seals_and_payload() - return _outcome(true, "executed", "", "") func _board_face_dig(c: Vector2i) -> String: @@ -492,7 +496,7 @@ func _emit_seals_batch() -> void: func _emit_round_end_seals_and_payload() -> void: _emit_seals_batch() - var payload: Dictionary[Variant, Variant] = action_economy.last_round_end # 逐字段取自 行动经济(读取权 = 接口契约) + var payload: Dictionary[Variant, Variant] = action_economy.last_round_end() # 逐字段取自 行动经济(读取权 = 接口契约;属性→方法 K5) round_end_board.emit({"path_tag": payload.get("path_tag", ""), "continuation_hint": payload.get("continuation_hint", "")}) diff --git a/game/core/node_map_generator.gd b/game/core/node_map_generator.gd index d3f43d2d..1de0c1cd 100644 --- a/game/core/node_map_generator.gd +++ b/game/core/node_map_generator.gd @@ -66,6 +66,14 @@ func _setup_and_build(params: Dictionary, seed_val: int) -> Dictionary: ## 参数档可行性判定(执行边界③:参数无解=配置错误)——域校验外加深度预算与战斗保底可放性。 static func check_params(params: Dictionary) -> Array: + var errs: Array[Dictionary] = _cp_domain(params) + if not errs.is_empty(): + return errs + return _cp_structural(params) + + +## 域校验段:五旋钮值域/岔口去路数锁值/配比四类合计 1 +static func _cp_domain(params: Dictionary) -> Array[Dictionary]: var errs: Array[Dictionary] = [] var L := int(params.get("layers", 0)) var e := int(params.get("exit_count", 0)) @@ -89,8 +97,16 @@ static func check_params(params: Dictionary) -> Array: sum += float(params.get(rk, -1.0)) if not is_equal_approx(sum, 1.0): errs.append({"category": "domain", "message": "配比四类合计 %f ≠ 1" % sum}) - if not errs.is_empty(): - return errs + return errs + + +## 结构可行性段:主链段容量与战斗保底可放性(多出口按区域签名累加可放槽) +static func _cp_structural(params: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var L := int(params.get("layers", 0)) + var e := int(params.get("exit_count", 0)) + var en := int(params.get("entry_count", 0)) + var bf := int(params.get("battle_floor", -1)) var t := 0 if e == 1 else _trunk_len(L, e) if e > 1 and t < 1: errs.append({"category": "structural", "message": "层深 %d 与出口数 %d 无解(主链段放不下归属区域)" % [L, e]}) @@ -116,11 +132,33 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array: return errs var by_id := {} var indeg := {} + if not _vo_node_fields(nodes, errs, by_id, indeg): + return errs + if not _vo_edges(nodes, by_id, indeg, errs): + return errs + var flow := _vo_flow_facts(nodes, indeg, errs) + if not flow["ok"]: + return errs + if not _vo_depth_reach(nodes, by_id, flow["entries"], flow["terminals"], errs): + return errs + _vo_shop_counts(nodes, flow["terminals"], errs) + _vo_terminal_shops(nodes, flow["terminals"], errs) + if not _all_paths_one_shop(by_id, flow["entries"], flow["terminals"]): + errs.append({"category": "structural", "message": "存在入口→终点路径穿店数 ≠ 1(必经恰一店破)"}) + _check_fork_rules(nodes, by_id, errs) + _vo_terminal_depths(flow["terminals"], by_id, errs) + _vo_width_rules(nodes, errs) + _vo_params_check(params, nodes, by_id, [flow["entries"], flow["terminals"]], errs) + return errs + + +## 阶段一·节点字段域:字段齐备/句柄唯一/类型词表/深度非负(by_id/indeg 引用填充) +func _vo_node_fields(nodes: Array, errs: Array, by_id: Dictionary, indeg: Dictionary) -> bool: for n in nodes: for k in ["id", "type", "depth", "next"]: if not (n as Dictionary).has(k): errs.append({"category": "structural", "message": "节点字段缺失: %s" % k}) - return errs + return false var id: int = int(n["id"]) if by_id.has(id): errs.append({"category": "dup_key", "message": "节点句柄重复: %d" % id}) @@ -130,8 +168,11 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array: if int(n["depth"]) < 0: errs.append({"category": "domain", "message": "节点深度为负: %d" % n["depth"]}) indeg[id] = 0 - if not errs.is_empty(): - return errs + return errs.is_empty() + + +## 阶段二·边域:出边悬空与环/回边判据(indeg 引用累计) +func _vo_edges(nodes: Array, by_id: Dictionary, indeg: Dictionary, errs: Array) -> bool: for n in nodes: for oid in (n["next"] as Array): if not by_id.has(int(oid)): @@ -141,9 +182,11 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array: errs.append({"category": "structural", "message": "连线未朝前进方向(目标深度 %d 不严格大于源 %d)——环/回边" % [ int(by_id[int(oid)]["depth"]), int(n["depth"])]}) indeg[int(oid)] = int(indeg[int(oid)]) + 1 - if not errs.is_empty(): - return errs + return errs.is_empty() + +## 阶段三·流事实域:入口集(入度 0)/终点集(无出边)与 Boss 终点判据、去路数形态 +func _vo_flow_facts(nodes: Array, indeg: Dictionary, errs: Array) -> Dictionary: var entries := [] var terminals := [] for n in nodes: @@ -159,11 +202,12 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array: errs.append({"category": "structural", "message": "Boss 节点带出边: %d" % id0}) elif outs.size() != 1 and not (outs.size() >= 2 and outs.size() <= 3): errs.append({"category": "domain", "message": "节点 %d 去路数 %d 非〔恰一条或岔口 2–3〕" % [id0, outs.size()]}) - if not errs.is_empty(): - return errs + return {"entries": entries, "terminals": terminals, "ok": errs.is_empty()} - # 深度=自入口最长路径步数(契约 2 赋值语义——出边严格前进的无环判据成立的前提) - var longest := _longest_paths(nodes, by_id, entries, indeg) + +## 阶段四·深度与可达域:深度=自入口最长路径步数(契约 2)+全节点可达某终点 +func _vo_depth_reach(nodes: Array, by_id: Dictionary, entries: Array, terminals: Array, errs: Array) -> bool: + var longest := _longest_paths(nodes, by_id, entries) for n in nodes: var id1: int = int(n["id"]) if int(n["depth"]) != int(longest.get(id1, -1)): @@ -175,16 +219,21 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array: for n in nodes: if not reaches.has(int(n["id"])): errs.append({"category": "structural", "message": "节点 %d 不可达任何终点(断头/孤儿)" % n["id"]}) - if not errs.is_empty(): - return errs + return errs.is_empty() - # 摆放约束①:每终点前一位恰一店、商店总数=终点数、终点前一层去路集合恰含一个终点 + +## 摆放约束①前半·商店总数:商店总数=终点数(商店数=出口数已锁) +func _vo_shop_counts(nodes: Array, terminals: Array, errs: Array) -> void: var shop_count := 0 for n in nodes: if String(n["type"]) == "shop": shop_count += 1 if shop_count != terminals.size(): errs.append({"category": "structural", "message": "商店总数 %d ≠ 终点数 %d(商店数=出口数已锁)" % [shop_count, terminals.size()]}) + + +## 摆放约束①后半·终点前驱:每终点前一位恰一店且为唯一前驱、终点前一层去路集合恰含一个终点 +func _vo_terminal_shops(nodes: Array, terminals: Array, errs: Array) -> void: for tid in terminals: var preds := [] for n in nodes: @@ -204,16 +253,19 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array: if t_hits != 1: errs.append({"category": "structural", "message": "终点 %d 前一层去路集合含终点 %d 个(归属锁定破)" % [tid, t_hits]}) - if not _all_paths_one_shop(by_id, entries, terminals): - errs.append({"category": "structural", "message": "存在入口→终点路径穿店数 ≠ 1(必经恰一店破)"}) - _check_fork_rules(nodes, by_id, errs) +## 计数规则①·多终点深度互异(推论条款 A——定位信息只有两样) +func _vo_terminal_depths(terminals: Array, by_id: Dictionary, errs: Array) -> void: var tdepths := {} for tid in terminals: var dd: int = int(by_id[tid]["depth"]) if tdepths.has(dd): errs.append({"category": "structural", "message": "多终点同深度 %d(推论条款 A 破——定位信息只有两样)" % dd}) tdepths[dd] = true + + +## 计数规则②·宽度上限:同深度节点数 ≤ 分叉生长上限 4(禁平行层摆放) +func _vo_width_rules(nodes: Array, errs: Array) -> void: var per_depth := {} for n in nodes: var d2: int = int(n["depth"]) @@ -222,23 +274,28 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array: if int(per_depth[d3]) > MAX_WIDTH: errs.append({"category": "structural", "message": "深度 %d 节点数 %d 超分叉生长上限 4(平行层摆放)" % [d3, per_depth[d3]]}) + +## 计数规则③·参数档对照:战斗保底/入口数/终点数/层高(params 为空时跳过) +func _vo_params_check(params: Dictionary, nodes: Array, by_id: Dictionary, flow_ends: Array, errs: Array) -> void: + var entries: Array = flow_ends[0] + var terminals: Array = flow_ends[1] var battles := 0 for n in nodes: if String(n["type"]) == "battle": battles += 1 - if not params.is_empty(): - if battles < int(params.get("battle_floor", 0)): - errs.append({"category": "domain", "message": "战斗节点数 %d 低于保底 %d" % [battles, params.get("battle_floor", 0)]}) - if entries.size() != int(params.get("entry_count", entries.size())): - errs.append({"category": "structural", "message": "入口数 %d ≠ 参数档 entry_count %d" % [entries.size(), params.get("entry_count", 0)]}) - if terminals.size() != int(params.get("exit_count", terminals.size())): - errs.append({"category": "structural", "message": "终点数 %d ≠ 参数档 exit_count %d" % [terminals.size(), params.get("exit_count", 0)]}) - var maxd := 0 - for tid in terminals: - maxd = maxi(maxd, int(by_id[tid]["depth"])) - if maxd != int(params.get("layers", maxd)): - errs.append({"category": "domain", "message": "层高(最长路径步数)%d ≠ 参数档 layers %d" % [maxd, params.get("layers", 0)]}) - return errs + if params.is_empty(): + return + if battles < int(params.get("battle_floor", 0)): + errs.append({"category": "domain", "message": "战斗节点数 %d 低于保底 %d" % [battles, params.get("battle_floor", 0)]}) + if entries.size() != int(params.get("entry_count", entries.size())): + errs.append({"category": "structural", "message": "入口数 %d ≠ 参数档 entry_count %d" % [entries.size(), params.get("entry_count", 0)]}) + if terminals.size() != int(params.get("exit_count", terminals.size())): + errs.append({"category": "structural", "message": "终点数 %d ≠ 参数档 exit_count %d" % [terminals.size(), params.get("exit_count", 0)]}) + var maxd := 0 + for tid in terminals: + maxd = maxi(maxd, int(by_id[tid]["depth"])) + if maxd != int(params.get("layers", maxd)): + errs.append({"category": "domain", "message": "层高(最长路径步数)%d ≠ 参数档 layers %d" % [maxd, params.get("layers", 0)]}) # ---- 构型(长图算法 HOW 归实现——D1:28;合法性由 validate_output 强制)---- @@ -252,28 +309,11 @@ func _build(rg: RandomNumberGenerator) -> Dictionary: var entries: Array[int] = [] for i in range(en): entries.append(_mk("battle", 0)) - var prevs: Array[int] = entries.duplicate() - for d in range(1, t + 1): - var nid := _mk("battle", d) - for p in prevs: - (_by_id[p]["next"] as Array).append(nid) - prevs = [nid] + var prevs: Array = _build_trunk(t, entries.duplicate()) var terminals: Array[int] = [] for r in range(e): - var bdepth := L - r - var bid := _mk("boss", bdepth) - var sid := _mk("shop", bdepth - 1) - (_by_id[sid]["next"] as Array).append(bid) - var cur := sid - var sig := String(REGION_SIGNATURES[r]) if e > 1 else "battle" - for d in range(bdepth - 2, t, -1): - var fid := _mk(sig, d) - (_by_id[fid]["next"] as Array).append(cur) - cur = fid - terminals.append(bid) - for p in prevs: - (_by_id[p]["next"] as Array).append(cur) + _build_region(r, e, L, prevs, terminals) # e>1 时 prevs 收束为主链段末节点一处 ⇒ 该节点即归属大岔口(各区域互不连达); # e==1 时 prevs 为入口数组,直接接单区域头(无大岔口——L1 出口恒 1)。 @@ -281,41 +321,61 @@ func _build(rg: RandomNumberGenerator) -> Dictionary: _assign_types(rg, e) var nodes: Array[Dictionary] = [] - for n in _pool: - nodes.append(n) + nodes.assign(_pool) return {"nodes": nodes, "entries": entries, "terminals": terminals} +## 主链段(深度 1..t 逐层单链,主链段零 RNG):返回链末节点集(e>1 时收束为一处即归属大岔口) +func _build_trunk(t: int, prevs: Array) -> Array: + for d in range(1, t + 1): + var nid := _mk("battle", d) + for p in prevs: + (_by_id[p]["next"] as Array).append(nid) + prevs = [nid] + return prevs + + +## 归属区域段(每出口一条支路:Boss 终点+店+签名填充链;区域段零 RNG): +## Boss 深度 = L−r、店在其前一位、签名链自店向主链段方向回铺,支路头接入 prevs(归属大岔口) +func _build_region(r: int, e: int, L: int, prevs: Array, terminals: Array) -> void: + var t := 0 if e == 1 else _trunk_len(L, e) + var bdepth := L - r + var bid := _mk("boss", bdepth) + var sid := _mk("shop", bdepth - 1) + (_by_id[sid]["next"] as Array).append(bid) + var cur := sid + var sig := String(REGION_SIGNATURES[r]) if e > 1 else "battle" + for d in range(bdepth - 2, t, -1): + var fid := _mk(sig, d) + (_by_id[fid]["next"] as Array).append(cur) + cur = fid + terminals.append(bid) + for p in prevs: + (_by_id[p]["next"] as Array).append(cur) + + ## 岔口布置:主链段(深度 ≤ t-2)或单出口图链上(深度 ≤ L-3)的节点扩为 2–3 去路岔口; ## 侧支节点与主去路同深(保持「深度=自入口最长路径步数」不变量),两支各自汇入主去路的后继, ## 于是支路互斥段={主去路节点} 与 {侧支节点}——两支类型互异即同时满足「下一跳互异」与「各有独有类型」。 func _add_forks(rg: RandomNumberGenerator, L: int, t: int, e: int) -> void: var wanted := clampi(int(round(float(_params["branch_density"]) * float(L))), 1, 24) - var anchors := [] - for n in _pool: - if int(n["depth"]) > (t - 2 if e > 1 else L - 3): - continue - if (n["next"] as Array).size() != 1: - continue - anchors.append(n) + var anchors := _collect_fork_anchors(L, t, e) anchors = _shuffle(rg, anchors) var selected := {} # 已扩岔节点 id var succ_selected := {} # 已扩岔节点的主去路 id(禁再作锚点) for a in anchors: if wanted <= 0: break - var node = a - var nid := int(node["id"]) - if (node["next"] as Array).size() != 1: + var nid := int(a["id"]) + if (a["next"] as Array).size() != 1: continue # 同一节点不重复扩岔(去路数上限 3) - var v = _by_id[int((node["next"] as Array)[0])] + var v = _by_id[int((a["next"] as Array)[0])] var vid := int(v["id"]) # 相邻两层不得同时扩岔:若主去路 v 也是岔口,v 的侧支会落进本岔口主支路的互斥段, # 使侧支自己的类型不再独有(契约 5③)——两个方向都要拦。 if selected.has(vid) or succ_selected.has(nid): continue - var v_outs = v["next"] - if v_outs.is_empty(): + if (v["next"] as Array).is_empty(): continue # 主去路无后继(无可汇入点)——该锚点弃用 var extra := 2 if (int(_params["fork_ways_max"]) >= 3 and rg.randf() < 0.35) else 1 var made := 0 @@ -323,8 +383,8 @@ func _add_forks(rg: RandomNumberGenerator, L: int, t: int, e: int) -> void: if _depth_count(int(v["depth"])) >= MAX_WIDTH: break var wid := _mk("battle", int(v["depth"])) - (node["next"] as Array).append(wid) - (_by_id[wid]["next"] as Array).append(int(v_outs[0])) + (a["next"] as Array).append(wid) + (_by_id[wid]["next"] as Array).append(int((v["next"] as Array)[0])) _detour_heads.append(_by_id[wid]) _detour_ids[wid] = true made += 1 @@ -334,6 +394,18 @@ func _add_forks(rg: RandomNumberGenerator, L: int, t: int, e: int) -> void: wanted -= 1 +## 锚收集段(零 RNG):主链段(深度 ≤ t-2)或单出口图链上(深度 ≤ L-3)的去路恰一节点 +func _collect_fork_anchors(L: int, t: int, e: int) -> Array: + var anchors := [] + for n in _pool: + if int(n["depth"]) > (t - 2 if e > 1 else L - 3): + continue + if (n["next"] as Array).size() != 1: + continue + anchors.append(n) + return anchors + + # 确定性洗牌(Fisher-Yates,取数只经注入的纯流 RNG——Array.shuffle() 走引擎全局随机、 # 破「同(种子,参数档)⇒ 同图」的纯函数复现前提,禁用于生成器) func _shuffle(rg: RandomNumberGenerator, arr: Array) -> Array: @@ -358,31 +430,42 @@ func _depth_count(d: int) -> int: ## 单出口图的配比填余(摆放优先、配比填余、战斗保底兜底——§8.1 次序),再统一修岔口下一跳互异。 func _assign_types(rg: RandomNumberGenerator, e: int) -> void: if e == 1: - var free: Array[Dictionary] = [] - var chain_battles := 0 - for n in _pool: - if String(n["type"]) == "shop" or String(n["type"]) == "boss": - continue - if _detour_ids.has(int(n["id"])): - continue # 侧链节点类型由岔口互异修型决定,不占配比填余面 - if int(n["depth"]) == 1: - continue # 区域首节点留战斗——配比填余不破坏岔口下一跳互异的基准形 - if String(n["type"]) == "battle": - chain_battles += 1 - free.append(n) - free = _shuffle(rg, free) - var want := int(round(float(_pool.size()) * (float(_params["reward_ratio"]) + float(_params["rest_ratio"])))) - var floor_n := int(_params["battle_floor"]) - var rest_first := rg.randf() < 0.5 - for i in range(want): - if i >= free.size(): - break - # 战斗保底优先:链上战斗数降到保底下限即停(侧链修型可能再让出战斗位,故保底只看链上) - if chain_battles - 1 < floor_n: - break - var node = free[i] - node["type"] = "rest" if ((i % 2 == 0) == rest_first) else "reward" - chain_battles -= 1 + _fill_ratios(rg) + _fix_detour_heads() + + +## 配比填余段(单出口图;shuffle+randf 取号序整体搬迁——战斗保底优先、侧链不占填余面): +## 摆放优先、配比填余、战斗保底兜底——§8.1 次序 +func _fill_ratios(rg: RandomNumberGenerator) -> void: + var free: Array[Dictionary] = [] + var chain_battles := 0 + for n in _pool: + if String(n["type"]) == "shop" or String(n["type"]) == "boss": + continue + if _detour_ids.has(int(n["id"])): + continue # 侧链节点类型由岔口互异修型决定,不占配比填余面 + if int(n["depth"]) == 1: + continue # 区域首节点留战斗——配比填余不破坏岔口下一跳互异的基准形 + if String(n["type"]) == "battle": + chain_battles += 1 + free.append(n) + free = _shuffle(rg, free) + var want := int(round(float(_pool.size()) * (float(_params["reward_ratio"]) + float(_params["rest_ratio"])))) + var floor_n := int(_params["battle_floor"]) + var rest_first := rg.randf() < 0.5 + for i in range(want): + if i >= free.size(): + break + # 战斗保底优先:链上战斗数降到保底下限即停(侧链修型可能再让出战斗位,故保底只看链上) + if chain_battles - 1 < floor_n: + break + var node = free[i] + node["type"] = "rest" if ((i % 2 == 0) == rest_first) else "reward" + chain_battles -= 1 + + +## 岔口修型段(零 RNG):侧链首节点类型取岔口各去路下一节点类型的补集首件——下一跳互异承面 +func _fix_detour_heads() -> void: for head in _detour_heads: var h = head var fork = null @@ -416,12 +499,15 @@ static func _trunk_len(L: int, e: int) -> int: # ---- 自证辅助 ---- -func _longest_paths(nodes: Array, by_id: Dictionary, entries: Array, indeg: Dictionary) -> Dictionary: +func _longest_paths(nodes: Array, by_id: Dictionary, entries: Array) -> Dictionary: var out := {} var din := {} var q := [] - for id in indeg: - din[int(id)] = int(indeg[int(id)]) + for n in nodes: + din[int(n["id"])] = 0 + for n in nodes: + for oid in (n["next"] as Array): + din[int(oid)] = int(din[int(oid)]) + 1 for eid in entries: out[int(eid)] = 0 q.append(int(eid)) @@ -476,44 +562,53 @@ func _check_fork_rules(nodes: Array, by_id: Dictionary, errs: Array) -> void: if head_types.has(ty): errs.append({"category": "structural", "message": "岔口 %d 各去路下一节点类型重复: %s" % [n["id"], ty]}) head_types[ty] = true - var reach_sets := [] - for oid in outs: - reach_sets.append(_descendants(int(oid), by_id)) - var comps := [] - for i in range(reach_sets.size()): - var excl := {} - for rid in reach_sets[i]: - var shared := false - for j in range(reach_sets.size()): - if j != i and reach_sets[j].has(rid): - shared = true - break - if not shared: - excl[int(rid)] = String(by_id[int(rid)]["type"]) - var arr := [] - for v in excl.values(): - arr.append(v) - arr.sort() - comps.append(arr) + var comps := _fork_signatures(outs, by_id) for i in range(comps.size()): for j in range(i + 1, comps.size()): if comps[i] == comps[j]: errs.append({"category": "structural", "message": "岔口 %d 支路 %d/%d 类型构成相同" % [n["id"], i, j]}) for i in range(comps.size()): - var uniq := false - for ty in comps[i]: - var in_other := false - for j in range(comps.size()): - if j != i and (comps[j] as Array).has(ty): - in_other = true - break - if not in_other: - uniq = true - break - if not uniq: + if not _fork_has_unique(comps, i): errs.append({"category": "structural", "message": "岔口 %d 支路 %d 无独有类型" % [n["id"], i]}) +## 支路签名构造:各去路的独占后代类型集(他人不可达的后代类型,排序数组——支路互斥段签名) +func _fork_signatures(outs: Array, by_id: Dictionary) -> Array: + var reach_sets := [] + for oid in outs: + reach_sets.append(_descendants(int(oid), by_id)) + var comps := [] + for i in range(reach_sets.size()): + var excl := {} + for rid in reach_sets[i]: + var shared := false + for j in range(reach_sets.size()): + if j != i and reach_sets[j].has(rid): + shared = true + break + if not shared: + excl[int(rid)] = String(by_id[int(rid)]["type"]) + var arr := [] + for v in excl.values(): + arr.append(v) + arr.sort() + comps.append(arr) + return comps + + +## 独有类型判定:支路 i 的签名中存在他支路不含的类型 +func _fork_has_unique(comps: Array, i: int) -> bool: + for ty in comps[i]: + var in_other := false + for j in range(comps.size()): + if j != i and (comps[j] as Array).has(ty): + in_other = true + break + if not in_other: + return true + return false + + func _descendants(start: int, by_id: Dictionary) -> Dictionary: var out := {start: true} var q := [start] diff --git a/game/core/refresh_bank.gd b/game/core/refresh_bank.gd index 4502be5b..1259006d 100644 --- a/game/core/refresh_bank.gd +++ b/game/core/refresh_bank.gd @@ -81,8 +81,8 @@ func reset_paid() -> void: # 本次 GP 付费刷新价(与商店货架刷新同源:ceil(基价 × 曲线^本次停点已付费刷)) func gp_cost() -> int: - return int(ceil(float(db.adventure.shop_refresh_cost_base) - * pow(db.adventure.shop_refresh_cost_curve, float(_paid_refreshes)))) + return int(ceil(float(db.run.adventure.shop_refresh_cost_base) + * pow(db.run.adventure.shop_refresh_cost_curve, float(_paid_refreshes)))) # 冒险级复位(宿主 reset_adventure_state 调用——刷新三源与付费计数全零) diff --git a/game/core/reward_economy.gd b/game/core/reward_economy.gd index e02515f2..907a7ec1 100644 --- a/game/core/reward_economy.gd +++ b/game/core/reward_economy.gd @@ -40,18 +40,8 @@ func roll_board(eligible: Array) -> bool: if eligible.is_empty(): _fatal("roll_board 空资格清单(E1 兜底 fail-fast——排雷玩法 不变式④前置)") return false - # 行主序规范化 + 去重(排序责任不外推给 战斗结算;排序键单点 BoardData.row_major_key) - var seen := {} - var list: Array[Dictionary] = [] - for e in eligible: - var c: Vector2i = e.cell - if seen.has(c): - push_warning("[地图战斗资源] 资格清单重复格已去重: %s" % str(c)) - continue - seen[c] = true - list.append({"cell": c}) - list.sort_custom(func(a, b): return BoardData.row_major_key(a.cell) < BoardData.row_major_key(b.cell)) - _eligible = list + _eligible = _normalize_eligible(eligible) + var list: Array[Dictionary] = _eligible _granted.clear() # 新批重置映射受理面(旧图快照消亡) _power_roll.clear() _clear_power = -1 @@ -85,6 +75,21 @@ func roll_board(eligible: Array) -> bool: return true +## 资格清单规范化域(开流前——零 RNG):行主序 + 去重(排序责任不外推给 战斗结算;排序键单点 BoardData.row_major_key) +func _normalize_eligible(eligible: Array) -> Array[Dictionary]: + var seen := {} + var list: Array[Dictionary] = [] + for e in eligible: + var c: Vector2i = e.cell + if seen.has(c): + push_warning("[地图战斗资源] 资格清单重复格已去重: %s" % str(c)) + continue + seen[c] = true + list.append({"cell": c}) + list.sort_custom(func(a, b): return BoardData.row_major_key(a.cell) < BoardData.row_major_key(b.cell)) + return list + + # ---- 查询面(Ready 只读;未开格恒可查——秘宝 桥接结构前提)---- # 相位只读出口(诊断观测面——unrolled/ready/retired;消费方禁直读 _phase 下划线字段) @@ -167,7 +172,7 @@ func retire_mapping() -> bool: # ---- 极性路由字典面(战斗结算 授予段消费):(类型, 极性)→ 格面池字典行(拷贝出口)---- func polarity_row(cell_type: String, polarity: String) -> Dictionary: - for row in db.reward.rows: + for row in db.run.reward.rows: if String(row.get("pool_tag", "")) == "cell" \ and String(row.get("cell_type", "")) == cell_type \ and String(row.get("polarity", "")) == polarity: @@ -217,6 +222,20 @@ func _skewed_cell_rows() -> Array: if board_skew <= 0: return _rollable_rows() var base: Array[Variant] = _rollable_rows() + var families := _skew_families(base) + if families["down"].is_empty() or families["up"].is_empty(): + return base # 结构缺族 = 无偏斜作用面(保守直通——池结构变更须走表加载校验另批) + # 下调总量 = round_half_up(Σ生存×v/100);逐行精确份额 w×v/100 最大余数取整(行序平局) + var drops := _largest_remainder_split(families["down"], board_skew, 100, (2 * _pool_weight(families["down"]) * board_skew + 100) / 200) + # 上浮总量 = 下调总量(守恒),逐行精确份额 drop×w/Σ进攻 最大余数取整 + var gains := _largest_remainder_split(families["up"], drops_total(drops), _pool_weight(families["up"]), drops_total(drops)) + var out := _apply_skew(base, families["down"], families["up"], drops, gains) + assert(_pool_weight(out) == _pool_weight(base), "图级偏斜守恒破(偏斜后合计须等于原池)") + return out + + +## 分族域:生存下调族与进攻上浮族行集拆分(池结构不变式零触碰) +func _skew_families(base: Array) -> Dictionary: var down: Array[Variant] = [] var up: Array[Variant] = [] for row in base: @@ -225,13 +244,11 @@ func _skewed_cell_rows() -> Array: down.append(row) elif SKEW_UP_KINDS.has(k): up.append(row) - if down.is_empty() or up.is_empty(): - return base # 结构缺族 = 无偏斜作用面(保守直通——池结构变更须走表加载校验另批) - var v := board_skew - # 下调总量 = round_half_up(Σ生存×v/100);逐行精确份额 w×v/100 最大余数取整(行序平局) - var drops := _largest_remainder_split(down, v, 100, (2 * _pool_weight(down) * v + 100) / 200) - # 上浮总量 = 下调总量(守恒),逐行精确份额 drop×w/Σ进攻 最大余数取整 - var gains := _largest_remainder_split(up, drops_total(drops), _pool_weight(up), drops_total(drops)) + return {"down": down, "up": up} + + +## 权重装配域:逐行精确份额增减(下调族扣 drops、上浮族加 gains——守恒 assert 随段迁移) +func _apply_skew(base: Array, down: Array, up: Array, drops: Array, gains: Array) -> Array: var di := 0 var ui := 0 var out: Array[Variant] = [] @@ -248,7 +265,6 @@ func _skewed_cell_rows() -> Array: var skewed: Dictionary[Variant, Variant] = row.duplicate() skewed["weight"] = w out.append(skewed) - assert(_pool_weight(out) == _pool_weight(base), "图级偏斜守恒破(偏斜后合计须等于原池)") return out @@ -309,7 +325,7 @@ func set_treasure_unlocked(ids: Dictionary) -> void: func _rollable_rows() -> Array: # 摇号池 = cell 池正向行 weight>0(负向行纯字典不消费权重;封锁格不在本函数职责域——清单由调用方裁剪) var out: Array[Dictionary] = [] - for row in db.reward.rows: + for row in db.run.reward.rows: if String(row.get("cell_type", "")) == RewardTable.TREASURE_ITEM_TYPE: var iid := String(row.get("effect_id", "")) if iid == "" or not _treasure_unlocked.has(iid): @@ -321,7 +337,7 @@ func _rollable_rows() -> Array: func _pool_rows(pool_tag: String) -> Array: var out: Array[Dictionary] = [] - for row in db.reward.rows: + for row in db.run.reward.rows: if row.pool_tag == pool_tag and int(row.weight) > 0: out.append(row) return out @@ -335,7 +351,7 @@ func _pool_weight(rows: Array) -> int: func _row_by_id(rid: String) -> Dictionary: - for row in db.reward.rows: + for row in db.run.reward.rows: if row.reward_id == rid: return row return {} diff --git a/game/core/reward_grant.gd b/game/core/reward_grant.gd index 5b0dfe17..3267c2ac 100644 --- a/game/core/reward_grant.gd +++ b/game/core/reward_grant.gd @@ -120,7 +120,7 @@ func _charge_gate(v: Dictionary, target: Vector2i) -> Dictionary: for h in tu_hits: if String(h.get("kind", "")) == "counter_spend": if not EffectEngine.counter_spend(fx_host.counters_ctx, String(h.get("counter_id", "")), int(h.get("value", 1))): - var vrow: Dictionary[Variant, Variant] = db.treasure_item_row(String(v.item_id)) + var vrow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(v.item_id)) var cn_info: int = int(vrow.get("charge_n", 0)) # 读受理行自身 var seen := int(attune_safe_seen_cb.call()) return {"ok": false, "reason": "%s:安全格积点未达(%d/%d)" % [String(vrow.get("display_name", "")), seen % cn_info if cn_info > 0 else 0, cn_info]} @@ -199,8 +199,8 @@ func grant_routed(snap: Dictionary, polarity: String, att: Dictionary) -> bool: # premium_pct 贯穿 _apply_percent_row 应用与呈现同乘)——标空/挖格/清图不乘,雷安同池冻结不动。 # 取整=四舍五入(与伤害管线 round_direction=floor 分域——registry clause3_inequality 登记; # 1 点量纲行 ac_up/action 取整后不变=整数粒度地板)。 - if source_class == "seal_hit" and int(db.settlement.core.seal_hit_premium) != 100: - var prem_pct: int = int(db.settlement.core.seal_hit_premium) + if source_class == "seal_hit" and int(db.combat.settlement.core.seal_hit_premium) != 100: + var prem_pct: int = int(db.combat.settlement.core.seal_hit_premium) row = row.duplicate() row["power"] = int(round(int(row.power) * prem_pct / 100.0)) row["premium_pct"] = prem_pct @@ -229,7 +229,7 @@ func _apply_percent_row(row: Dictionary, att: Dictionary) -> bool: var eff := String(row.effect_id) if eff == "gp_gain" or eff == "gp_loss": return _apply_gp_row(row, att) - var srow: Dictionary[Variant, Variant] = db.status_effect_row(eff) + var srow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff) # 封印溢价贯穿(仅封印命中正向行携带 premium_pct;其余授予路径缺省 100 零扰动): # 应用幅度与呈现行值同乘,容器经 magnitude_override 拿同一快照(抗表重载口径保持)。 var prem_pct: int = int(row.get("premium_pct", 100)) diff --git a/game/core/save_manager.gd b/game/core/save_manager.gd index ed4a56fa..a4fabc6a 100644 --- a/game/core/save_manager.gd +++ b/game/core/save_manager.gd @@ -4,6 +4,8 @@ ## 禁 preload/import 任何玩法脚本、禁碰 ConfigDB/RngService——须可独立实例化独立验证。 ## 新增存档键先在序列化函数登记并升版本号;键域内容口径归各 GDD 文档,本类照单承载。 ## 存档键清单的事实源=本类序列化/读回代码,不在任何文档维护第二份键清单。 +## 拆件形态(D3 存档域):槽位文件域归 SlotStore(slots)、存档内元进度数据读写归 MetaStateStore +## (meta_state)——本类留磁盘 IO+版本迁移链+断点域+门面装配,消费方经 save.slots / save.meta_state 直连。 class_name SaveManagerCore extends Node @@ -18,18 +20,18 @@ const SLOT_PATH_TEMPLATE := "user://save_slot_%d.json" # 槽文件名唯一派 const LEGACY_SAVE_PATH := "user://save.json" # 旧隐式单档路径(Q1 甲:首次启动一次性字节迁入槽 1,此后无任何隐式读写) const LEGACY_MIGRATED_SUFFIX := ".migrated.bak" # 迁移后旧档改名留痕后缀(字节级留盘不删——处置留痕) -var _path: String # 当前写盘目标=激活槽文件(或 attach_path 注入路径);""=无激活态——一切写盘路径拒绝 -var _active_slot := 0 # 激活槽号 1..SLOT_COUNT;0=无激活态(注入非槽派生名路径时亦 0——写盘判定以 _path 非空为准) var _read_only := false # 高版本拒读置位——write_to_disk 一律拒绝(契约 4 禁写) var _data: Dictionary[String, Variant] = {} # 内存数据模型(_init 即内置默认;load 后为档案内容) var _migrations: Dictionary[int, Callable] = {} # from_version → Callable(data)->data(升一版;v1→v6 生产迁移链现行——_init 自注册) -var _slot_template := SLOT_PATH_TEMPLATE # 槽文件名模板(生产=常量;测试经 attach_slot_template 注入隔离命名空间,禁在呈现层/探针各自拼槽名) +var slots: SlotStore # 槽位文件域件(槽位挂载与模板/激活与建删/摘要窥视——架构 :89) +var meta_state: MetaStateStore # 存档内元进度数据读写唯一层(写口原语+只读投影——架构 :90) +var _migration_fail_reason := "" # 迁移循环失败原因带出(_peek_migrated 返 null 时 load 侧落 corrupt 兜底文案单点) func _init(path: String = "") -> void: - _path = path - _active_slot = _slot_index_of(path) _data = _default_data() + slots = SlotStore.new(self, path) + meta_state = MetaStateStore.new(self) # 生产迁移链自注册(v2 起——测试钩 register_migration 仍可叠挂更早版本步骤) register_migration(1, _migrate_v1_v2) register_migration(2, _migrate_v2_v3) @@ -43,176 +45,103 @@ func _init(path: String = "") -> void: # 类名加 Core 后缀、autoload 占净名)。独立 .new() 实例(单测/工具)不入树零 _ready, # 读盘仍由调用方显式触发。 # 启动态钉死(卡① sv2):autoload 启动不读任何槽——内存=默认合法空态(_init 置好)、 -# 无激活态(_path="",一切写盘路径拒绝并返回 false);玩家侧经选档界面显式进档/新档后才可写。 +# 无激活态(注入路径空,一切写盘路径拒绝并返回 false);玩家侧经选档界面显式进档/新档后才可写。 # 旧隐式单档一次性迁入槽 1(Q1 甲,sv12)在本口执行。 func _ready() -> void: - _migrate_legacy_save(LEGACY_SAVE_PATH, slot_path(1), LEGACY_SAVE_PATH + LEGACY_MIGRATED_SUFFIX) + slots._migrate_legacy_save(LEGACY_SAVE_PATH, slots.slot_path(1), LEGACY_SAVE_PATH + LEGACY_MIGRATED_SUFFIX) -# 原地重置(探针隔离面,仿 RngService.set_master_seed 单例原地重置: -# 换路径+回默认数据+重读盘;对象引用恒定,MetaProgression 持有的 save 引用零失效)。 -# 三槽化后单路径语义(契约 7)=注入路径即激活槽文件(写盘落该路径、无激活态时注入即激活); -# 注入槽派生名时激活号同步记账,注入任意隔离路径时激活号 0(写盘仍合法——隔离面行为零变化)。 -func attach_path(path: String) -> void: - _path = path +# 迁移注册口:from_version → from_version+1 的升级函数(读档时链式调用)。fn 契约: +# Dictionary → Dictionary,须写回 version = from_version + 1。生产迁移链现行(v1→v6,_init 自注册), +# 冒烟经本口验证链式升级。 +func register_migration(from_version: int, fn: Callable) -> void: + _migrations[from_version] = fn + + +# ---- 读盘(进档/注入时调用;启动态不读——_ready 已钉死不隐式激活)---- +# 返回 true = 已就绪可用(含新档/损坏兜底/迁移成功);false = 高版本拒读(默认数据运行、禁写盘)。 +# 损坏兜底:原文件改名 .corrupt.bak(已存在则覆盖)+ push_warning + 默认数据继续——不静默丢档。 +func load_from_disk() -> bool: _read_only = false - _active_slot = _slot_index_of(path) - _data = _default_data() - load_from_disk() - - -# 测试隔离注入口(契约 7 同源——槽文件名模板整体换注,探针/单测经此隔离到自命名空间, -# 禁各自拼槽文件名;换注后回无激活态,槽原语全部落注入命名空间) -func attach_slot_template(template: String) -> void: - _slot_template = template - _path = "" - _active_slot = 0 - _read_only = false - _data = _default_data() - - -# ---- 三存档位承载(卡①:隐式单档 → 3 存档位;位数与文件名口径=SLOT_COUNT/SLOT_PATH_TEMPLATE)---- - -# 槽文件路径唯一派生口(模板单点——禁第二处拼名) -func slot_path(slot: int) -> String: - return _slot_template % slot - - -# 激活槽号查询(0 = 无激活态——启动态钉死后 boot 需经选档显式进档/新档,卡③ Q1 甲) -func active_slot() -> int: - return _active_slot - - -# 写盘目标在位与否(激活槽或注入路径任一;无激活态一切写盘路径拒绝——sv3 判定口) -func has_write_target() -> bool: - return _path != "" - - -# 进档原语(sv5,契约 6/8):激活槽 N+读盘,复用 load_from_disk 迁移/兜底链按槽成立。 -# 高版本拒读槽:激活不成立、恢复切换前三态(路径/激活号/禁写位/数据)——防误写他槽; -# 损坏槽:兜底链接管(.corrupt.bak 改名+空态继续,激活成立)。 -func activate_slot(slot: int) -> bool: - if slot < 1 or slot > SLOT_COUNT: - return false - var prev_path := _path - var prev_slot := _active_slot - var prev_ro := _read_only - var prev_data := _data - _path = slot_path(slot) - _active_slot = slot - _read_only = false - _data = _default_data() - if not load_from_disk(): - _path = prev_path - _active_slot = prev_slot - _read_only = prev_ro - _data = prev_data - return false - return true - - -# 新档原语(sv4):空槽创建=合法白板态(_default_data 全零可玩纪律),创建即激活并落盘。 -# 生产路径只在空槽调用(有档槽开新局=先删档回空再新档,卡③ 契约 4);写盘失败回退切换前三态。 -func create_slot(slot: int) -> bool: - if slot < 1 or slot > SLOT_COUNT: - return false - var prev_path := _path - var prev_slot := _active_slot - var prev_ro := _read_only - var prev_data := _data - _path = slot_path(slot) - _active_slot = slot - _read_only = false - _data = _default_data() - if not write_to_disk(): - _path = prev_path - _active_slot = prev_slot - _read_only = prev_ro - _data = prev_data - return false - return true - - -# 删档原语(sv6,契约 8):该槽文件删除+盘面即时生效(卡内工程规定,挪用现役断点覆盖流语义) -# +无 .tmp 残留;删激活槽时激活指针连清除(内存回默认空态,该槽不再可写——激活态生命周期终点)。 -# .corrupt.bak 损坏备份不追删(用户数据留盘可考;同槽再损坏时兜底流按槽覆盖同名备份)。 -# 删不存在文件的空槽=幂等成功;越界槽号拒绝。 -func delete_slot(slot: int) -> bool: - if slot < 1 or slot > SLOT_COUNT: - return false - var p := slot_path(slot) - if FileAccess.file_exists(p) and not _remove_file(p): - push_error("[存档序列化] 删档失败(文件删除被拒):%s" % p) - return false - var tmp := p + ".tmp" - if FileAccess.file_exists(tmp): - _remove_file(tmp) # 残留清偿尽力而为(主文件已删即达成空槽语义) - if _active_slot == slot: - _path = "" - _active_slot = 0 - _data = _default_data() - return true - - -# 摘要三项按槽窥视读器(sv7/sv8,契约 5/9):钥石余额/冒险成功局数/冒险总局数。 -# 只读零副作用:不激活、不写盘、不触发 _corrupt_fallback 改名(.corrupt.bak 只在正式进档路径产生)。 -# 返回 {"readable", "has_file", "keystone", "cleared", "total"}——readable=true=可读槽(空槽=零值合法态, -# .tmp 残留按空槽——主文件不在即空);readable=false=不可读槽(损坏/高版本/缺版本字段/打开失败, -# 三值无意义——卡③ Q2 裁甲:UI 按空槽呈现,进档时走正式 load 兜底/拒读链); -# has_file=主文件在盘与否(空/有档操作集二分判定来处——卡③ T0 对齐结论;非摘要第四项,零 UI 语义)。 -# 迁移链在此只读推演(生产迁移 Callable 为纯函数,零盘面副作用)。 -func peek_slot_summary(slot: int) -> Dictionary: - if slot < 1 or slot > SLOT_COUNT: - return {"readable": false, "has_file": false, "keystone": 0, "cleared": 0, "total": 0} # 越界=非槽位,非空槽 - return _peek_summary_at(slot_path(slot)) - - -# 窥视注入口(契约 7 隔离面同款——测试/探针经路径注入隔离,禁各自拼槽文件名) -func peek_summary_at(path: String) -> Dictionary: - return _peek_summary_at(path) - - -func _peek_summary_at(path: String) -> Dictionary: - var out := {"readable": false, "has_file": false, "keystone": 0, "cleared": 0, "total": 0} - if not FileAccess.file_exists(path): - out["readable"] = true # 空槽=可读零值合法态 - return out - out["has_file"] = true # 主文件在盘(可读性与占档判定分离——白板档与空槽自此可区分) - var f := FileAccess.open(path, FileAccess.READ) + if not FileAccess.file_exists(slots._path): + _data = _default_data() # 新档合法态 + return true + var f := FileAccess.open(slots._path, FileAccess.READ) if f == null: - return out + return _corrupt_fallback("读打开失败(%s)" % error_string(FileAccess.get_open_error())) + return _load_parsed(f) + + +## 读盘解析域(load_from_disk 主体段提取——解析/版本/迁移/规整;corrupt_fallback 调用点随段迁移,副作用序逐字) +func _load_parsed(f: FileAccess) -> bool: var text := f.get_as_text() f.close() var parsed = JSON.parse_string(text) - if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("version"): - return out + if typeof(parsed) != TYPE_DICTIONARY: + return _corrupt_fallback("JSON 解析失败或顶层结构出域(非对象)") + if not parsed.has("version"): + return _corrupt_fallback("缺版本字段") var v := int(parsed["version"]) if v > SAVE_FORMAT_VERSION: - return out - while v < SAVE_FORMAT_VERSION: - if not _migrations.has(v): - return out - var stepped = _migrations[v].call(parsed) - if typeof(stepped) != TYPE_DICTIONARY: - return out - parsed = stepped - v = int(parsed.get("version", v + 1)) - var norm := _normalize(parsed) - out["readable"] = true - out["keystone"] = int(norm["meta"].get("keystone_balance", 0)) - out["cleared"] = int(norm["meta"].get("adventure_cleared", 0)) - out["total"] = int(norm["meta"].get("adventure_total", 0)) - return out + _read_only = true + _data = _default_data() + push_error("[存档序列化] 档案版本 %d 高于当前 %d——拒读禁写(保护未来档不被覆写,内存默认新档运行)" % [v, SAVE_FORMAT_VERSION]) + return false + var fail := [] + var migrated = _peek_migrated(parsed, fail) + if migrated == null: + return _corrupt_fallback(fail[0]) + _data = _normalize(migrated) + # 老档迁移(关卡节点图生成 v0.4 布尔位——缺字段 = 已有游玩史,不重放教学脚本) + if not _data["meta"].has("first_adventure_done"): + _data["meta"]["first_adventure_done"] = true + return true -# 注入路径的槽号反查(命中槽派生名返回槽号,否则 0——激活号记账用,写盘判定以 _path 非空为准) -func _slot_index_of(path: String) -> int: - for i in range(1, SLOT_COUNT + 1): - if path == slot_path(i): - return i - return 0 +# ---- 写盘(tmp + rename 原子写——写一半断电不留烂档)---- +# 无激活态一律拒绝并返回 false(sv3:启动态不隐式激活,盘面零触碰); +# 高版本拒读态一律拒绝(契约 4 禁写);写失败返回 false。 +func write_to_disk() -> bool: + if slots._path == "": + push_error("[存档序列化] 写盘拒绝——无激活存档位(启动态不隐式激活;经选档进档/新档后落盘)") + return false + if _read_only: + push_error("[存档序列化] 写盘拒绝——档案版本高于当前(拒读禁写态)") + return false + var tmp := slots._path + ".tmp" + var f := FileAccess.open(tmp, FileAccess.WRITE) + if f == null: + push_error("[存档序列化] 临时文件打开失败:%s(%s)" % [tmp, error_string(FileAccess.get_open_error())]) + return false + f.store_string(JSON.stringify(_data, "\t", true)) + f.close() + if not _rename_overwrite(tmp, slots._path): + push_error("[存档序列化] 原子改名失败(tmp 保留待查):%s → %s" % [tmp, slots._path]) + return false + return true +# ---- 冒险断点域(停点粒度续玩快照;内容装配归 AdventureRunner,本类照单承载)---- +# 空字典 = 无断点合法态。值拷贝进出(禁碰内部结构契约同款)——快照写入后调用方再改不渗入存档态。 + +func set_breakpoint(bp: Dictionary) -> void: + _data["breakpoint"] = bp.duplicate(true) + + +func clear_breakpoint() -> void: + _data["breakpoint"] = {} + + +func has_breakpoint() -> bool: + return not (_data.get("breakpoint", {}) as Dictionary).is_empty() + + +func breakpoint_snapshot() -> Dictionary: + return (_data.get("breakpoint", {}) as Dictionary).duplicate(true) + + +# ---- 私有:迁移链 / 损坏兜底 / 规整 / 文件原语 ---- + # v1→v2:断点域出生(旧档无断点 = 合法空态——「继续上局」隐藏,非损坏非债务) func _migrate_v1_v2(data: Dictionary) -> Dictionary: if not data.has("breakpoint"): @@ -266,6 +195,53 @@ func _migrate_v6_v7(data: Dictionary) -> Dictionary: return data +# 迁移循环提取(load/peek 两调用方同构共享):成功=迁移完成 Dictionary;失败=null +# (原因经 fail 数组带出——load 侧落 corrupt 兜底文案、peek 侧保持「返回未填充 out」逐字) +func _peek_migrated(parsed: Variant, fail: Array) -> Variant: + var v := int(parsed["version"]) + while v < SAVE_FORMAT_VERSION: + if not _migrations.has(v): + fail.append("迁移链缺步骤 %d→%d" % [v, v + 1]) + return null + var stepped = _migrations[v].call(parsed) + if typeof(stepped) != TYPE_DICTIONARY: + fail.append("迁移步骤 %d→%d 执行失败" % [v, v + 1]) + return null + parsed = stepped + v = int(parsed.get("version", v + 1)) + return parsed + + +## 摘要窥视内核(只读推演单点——SlotStore 两 peek 方法经槽路径派生委托至此; +## 迁移链在此只读推演——生产迁移 Callable 为纯函数,零盘面副作用) +func _peek_summary_at(path: String) -> Dictionary: + var out := {"readable": false, "has_file": false, "keystone": 0, "cleared": 0, "total": 0} + if not FileAccess.file_exists(path): + out["readable"] = true # 空槽=可读零值合法态 + return out + out["has_file"] = true # 主文件在盘(可读性与占档判定分离——白板档与空槽自此可区分) + var f := FileAccess.open(path, FileAccess.READ) + if f == null: + return out + var text := f.get_as_text() + f.close() + var parsed = JSON.parse_string(text) + if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("version"): + return out + if int(parsed["version"]) > SAVE_FORMAT_VERSION: + return out + var fail := [] + var migrated = _peek_migrated(parsed, fail) + if migrated == null: + return out + var norm := _normalize(migrated) + out["readable"] = true + out["keystone"] = int(norm["meta"].get("keystone_balance", 0)) + out["cleared"] = int(norm["meta"].get("adventure_cleared", 0)) + out["total"] = int(norm["meta"].get("adventure_total", 0)) + return out + + # ---- 默认数据模型(新档合法态——全零;白板存档 = 完整可玩〔元进度 单局循环零依赖〕)---- func _default_data() -> Dictionary[String, Variant]: @@ -299,368 +275,15 @@ func _default_data() -> Dictionary[String, Variant]: } -# ---- 首次冒险布尔位(关卡节点图生成 v1.1 确定性承载)---- -# false = 本档尚无游玩史 → 首局走固定种子 42 + 参数档生成图(教学与存档复现的固定图由此承载,无兜底模板); -# 置位只在冒险收束挂点(MetaProgression.settle_adventure)。 -# 老档迁移:缺字段 = 该档已有游玩史 → true(不重放首局固定图)。 +# 原地重置默认态(SlotStore 槽位原语的协作口——激活/建删/换注回默认数据) +func _reset_to_default() -> void: + _data = _default_data() -func is_first_adventure_done() -> bool: - return bool(_data["meta"].get("first_adventure_done", true)) - - -func mark_first_adventure_done() -> void: - _data["meta"]["first_adventure_done"] = true - - -# ---- 读盘(进档/注入时调用;启动态不读——_ready 已钉死不隐式激活)---- -# 返回 true = 已就绪可用(含新档/损坏兜底/迁移成功);false = 高版本拒读(默认数据运行、禁写盘)。 -# 损坏兜底:原文件改名 .corrupt.bak(已存在则覆盖)+ push_warning + 默认数据继续——不静默丢档。 -func load_from_disk() -> bool: - _read_only = false - if not FileAccess.file_exists(_path): - _data = _default_data() # 新档合法态 - return true - var f := FileAccess.open(_path, FileAccess.READ) - if f == null: - return _corrupt_fallback("读打开失败(%s)" % error_string(FileAccess.get_open_error())) - var text := f.get_as_text() - f.close() - var parsed = JSON.parse_string(text) - if typeof(parsed) != TYPE_DICTIONARY: - return _corrupt_fallback("JSON 解析失败或顶层结构出域(非对象)") - if not parsed.has("version"): - return _corrupt_fallback("缺版本字段") - var v := int(parsed["version"]) - if v > SAVE_FORMAT_VERSION: - _read_only = true - _data = _default_data() - push_error("[存档序列化] 档案版本 %d 高于当前 %d——拒读禁写(保护未来档不被覆写,内存默认新档运行)" % [v, SAVE_FORMAT_VERSION]) - return false - while v < SAVE_FORMAT_VERSION: - if not _migrations.has(v): - return _corrupt_fallback("迁移链缺步骤 %d→%d" % [v, v + 1]) - var stepped = _migrations[v].call(parsed) - if typeof(stepped) != TYPE_DICTIONARY: - return _corrupt_fallback("迁移步骤 %d→%d 执行失败" % [v, v + 1]) - parsed = stepped - v = int(parsed.get("version", v + 1)) - _data = _normalize(parsed) - # 老档迁移(关卡节点图生成 v0.4 布尔位——缺字段 = 已有游玩史,不重放教学脚本) - if not _data["meta"].has("first_adventure_done"): - _data["meta"]["first_adventure_done"] = true - return true - - -# ---- 写盘(tmp + rename 原子写——写一半断电不留烂档)---- -# 无激活态一律拒绝并返回 false(sv3:启动态不隐式激活,盘面零触碰); -# 高版本拒读态一律拒绝(契约 4 禁写);写失败返回 false。 -func write_to_disk() -> bool: - if _path == "": - push_error("[存档序列化] 写盘拒绝——无激活存档位(启动态不隐式激活;经选档进档/新档后落盘)") - return false - if _read_only: - push_error("[存档序列化] 写盘拒绝——档案版本高于当前(拒读禁写态)") - return false - var tmp := _path + ".tmp" - var f := FileAccess.open(tmp, FileAccess.WRITE) - if f == null: - push_error("[存档序列化] 临时文件打开失败:%s(%s)" % [tmp, error_string(FileAccess.get_open_error())]) - return false - f.store_string(JSON.stringify(_data, "\t", true)) - f.close() - if not _rename_overwrite(tmp, _path): - push_error("[存档序列化] 原子改名失败(tmp 保留待查):%s → %s" % [tmp, _path]) - return false - return true - - -# 迁移注册口:from_version → from_version+1 的升级函数(读档时链式调用)。fn 契约: -# Dictionary → Dictionary,须写回 version = from_version + 1。生产迁移链现行(v1→v6,_init 自注册), -# 冒烟经本口验证链式升级。 -func register_migration(from_version: int, fn: Callable) -> void: - _migrations[from_version] = fn - - -# ---- 类型化域访问器(消费面——只经访问器操作,禁碰内部结构)---- - -func keystone_balance() -> int: - return int(_data["meta"]["keystone_balance"]) - - -func add_keystone(n: int) -> void: - _data["meta"]["keystone_balance"] = keystone_balance() + n - - -# 钥石消费唯一口:余额不足拒绝且零副作用(钥石只增不减——消费除外,元进度验收判据) -func spend_keystone(n: int) -> bool: - if keystone_balance() < n: - return false - _data["meta"]["keystone_balance"] = keystone_balance() - n - return true - - -func item_unlocked(item_id: String) -> bool: - return _data["meta"]["item_unlocks"].has(item_id) - - -func unlock_item(item_id: String) -> void: - _data["meta"]["item_unlocks"][item_id] = true - - -func clear_count(level_index: int) -> int: - return int(_data["meta"]["level_clear_counts"].get(str(level_index), 0)) - - -func boss_first_kill_marked(level_index: int) -> bool: - return _data["meta"]["boss_first_kill"].has(str(level_index)) - - -# ---- 同调位账本域(里程碑谓词表计数源 + 装配关系 + Y 升级态)---- - -# Boss 击杀总数(含重复——底稿裁决 4 计数源;收束入账 = apply_adventure_summary 逐关卡完成关次 +1, -# Boss 恒唯一终点故 levels_cleared 即本次冒险 Boss 击杀数) -func boss_kill_count() -> int: - return int(_data["meta"].get("boss_kill_count", 0)) - - -# ---- 生涯终身计数域(跨局汇总页数据源;只读面经访问器, -# 写入唯一经 apply_adventure_summary 收束口,禁第二写入点)---- - -# 冒险总局数(口径=开局即计:三结局 completed/failed/abandoned 同经收束恰计一次,含放弃与失败) -func adventure_total() -> int: - return int(_data["meta"].get("adventure_total", 0)) - - -# 冒险成功局数(口径=三关全通才计 1——判定字段 summary.cleared,adventure_runner「completed」 -# 唯一出口=末关的关卡完成) -func adventure_cleared() -> int: - return int(_data["meta"].get("adventure_cleared", 0)) - - -# 真视类工具累计使用次数(信标网络解锁行为条件;写入唯一经 add_vision_used -# 冒险收束聚合入账,受理计数点=battle 层经冒险层聚合) -func vision_used() -> int: - return int(_data["meta"].get("vision_used", 0)) - - -func add_vision_used(n: int) -> void: - _data["meta"]["vision_used"] = maxi(0, vision_used() + maxi(0, n)) - - -# ---- 天赋树域(MetaProgression 树段读写面)---- - -func tree_nodes() -> Dictionary: - return (_data["meta"].get("tree_nodes", {}) as Dictionary).duplicate() - - -func tree_level(node_id: String) -> int: - return int((_data["meta"].get("tree_nodes", {}) as Dictionary).get(node_id, 0)) - - -func set_tree_level(node_id: String, level: int) -> void: - var tn: Dictionary[Variant, Variant] = _data["meta"].get("tree_nodes", {}) as Dictionary - tn[node_id] = maxi(0, level) - _data["meta"]["tree_nodes"] = tn - - -func fork_sides() -> Dictionary: - return (_data["meta"].get("fork_sides", {}) as Dictionary).duplicate() - - -func fork_side(group: String) -> String: - return String((_data["meta"].get("fork_sides", {}) as Dictionary).get(group, "")) - - -func set_fork_side(group: String, side: String) -> void: - var fs: Dictionary[Variant, Variant] = _data["meta"].get("fork_sides", {}) as Dictionary - fs[group] = side - _data["meta"]["fork_sides"] = fs - - -# ---- 锻造工坊域 forge_routes(MetaProgression 锻造写口读写面;无特效域)---- -# 解锁态不落档(派生):初始持有=表 initial_owned、练满前一档=levels[prereq]≥cap——判定单点在 -# MetaProgression.forge_route_unlocked(读表+读档);本类只承载直购清单 unlocked 与选中标记。 -# 域结构:{levels:{六路线 id→等级}, unlocked:[直购路线 id], weapon:当前武器路线 id, armor:当前甲路线 id} - -func _forge_domain() -> Dictionary: - return _data["meta"].get("forge_routes", {}) as Dictionary - - -func forge_routes_snapshot() -> Dictionary: - return _forge_domain().duplicate(true) # 值拷贝出户(本类契约「禁碰内部结构」——消费方直改不得渗入存档内存态) - - -func forge_route_level(route_id: String) -> int: - var lv: Dictionary[Variant, Variant] = _forge_domain().get("levels", {}) as Dictionary - return int(lv.get(route_id, 0)) - - -func set_forge_route_level(route_id: String, level: int) -> void: - var dom: Dictionary[Variant, Variant] = _forge_domain() - var lv: Dictionary[Variant, Variant] = dom.get("levels", {}) as Dictionary - lv[route_id] = maxi(0, level) - dom["levels"] = lv - _data["meta"]["forge_routes"] = dom - - -func forge_unlocked_purchases() -> Array: - return (_forge_domain().get("unlocked", []) as Array).duplicate() - - -func add_forge_unlock(route_id: String) -> void: - var dom: Dictionary[Variant, Variant] = _forge_domain() - var un: Array[Variant] = dom.get("unlocked", []) as Array - if not un.has(route_id): - un.append(route_id) - dom["unlocked"] = un - _data["meta"]["forge_routes"] = dom - - -func forge_weapon() -> String: - return String(_forge_domain().get("weapon", "sword")) - - -func set_forge_weapon(route_id: String) -> void: - var dom: Dictionary[Variant, Variant] = _forge_domain() - dom["weapon"] = route_id - _data["meta"]["forge_routes"] = dom - - -func forge_armor() -> String: - return String(_forge_domain().get("armor", "light")) - - -func set_forge_armor(route_id: String) -> void: - var dom: Dictionary[Variant, Variant] = _forge_domain() - dom["armor"] = route_id - _data["meta"]["forge_routes"] = dom - - -# 装配关系(同调目录 item_id 有序数组——装配序即呈现序;跨冒险持久,冒险中途不可换装〔底稿 §8.5〕)。 -# 局外进度事实(元进度域同族),非契约 2 反面清单的「局内道具库存」——口径见 存档序列化.md 契约 2。 -func attunement_equipped() -> Array: - return (_data["meta"].get("attunement_equipped", []) as Array).duplicate() - - -func set_attunement_equipped(item_ids: Array) -> void: - var out: Array[Variant] = [] - for v in item_ids: - out.append(String(v)) - _data["meta"]["attunement_equipped"] = out - - -# Y 刷新升级购买等级(0 = 未购 → 每冒险刷新次数 0;等级行值归营地升级行族) -func attune_refresh_level() -> int: - return int(_data["meta"].get("attune_refresh_level", 0)) - - -func set_attune_refresh_level(n: int) -> void: - _data["meta"]["attune_refresh_level"] = maxi(0, n) - - -# 同调位计数源组装(Attunement 服务取数口——clear_count = 逐关完成次数合计〔含重复关,底稿 §2.1 口径〕; -# clear_count(level_index) 逐关访问器零改动复用) -func recent_summaries() -> Array: - return _data["meta"]["recent_summaries"].duplicate(true) # 值拷贝出户(本类契约「禁碰内部结构」——消费方直改不得渗入存档内存态) - - -# 域更新原语(正式冒险收束唯一入账口——组合根经本函数入账,禁直改内部结构): -# 钥石入账(summary.keystone_total——逐杀掉落已在冒险内计入,boss_drops 参数为 -# 摘要归档副本不重复入账)+ 逐关卡完成关次 clear_count +1 与 Boss 首杀标记 + Boss 击杀总数累计 -# (同调位里程碑计数源)+ 摘要头部插入(cap 10,最新在前,元素附 -# keystone_drops 副本——复盘可归因)+ 生涯终身计数自增(总局数开局即计: -# 三结局同经本口恰计一次;冒险成功局数按 summary.cleared 判定三关全通才计 1。终身计数写入 -# 单口=本函数,禁第二写入点)。 -func apply_adventure_summary(summary: Dictionary, boss_drops: Array = []) -> void: - _data["meta"]["adventure_total"] = adventure_total() + 1 - if bool(summary.get("cleared", false)): - _data["meta"]["adventure_cleared"] = adventure_cleared() + 1 - add_keystone(int(summary.get("keystone_total", 0))) - var cleared := int(summary.get("levels_cleared", 0)) - for li in range(1, cleared + 1): - var key := str(li) - _data["meta"]["level_clear_counts"][key] = int(_data["meta"]["level_clear_counts"].get(key, 0)) + 1 - _data["meta"]["boss_first_kill"][key] = true - _data["meta"]["boss_kill_count"] = boss_kill_count() + cleared # Boss 恒唯一终点:levels_cleared 即本次冒险 Boss 击杀数(含重复) - var entry: Dictionary[Variant, Variant] = summary.duplicate(true) - entry["boss_drops"] = boss_drops.duplicate(true) - var arr: Array[Variant] = _data["meta"]["recent_summaries"] - arr.push_front(entry) - while arr.size() > SUMMARY_CAP: - arr.pop_back() - - -# ---- 冒险断点域(停点粒度续玩快照;内容装配归 AdventureRunner,本类照单承载)---- -# 空字典 = 无断点合法态。值拷贝进出(禁碰内部结构契约同款)——快照写入后调用方再改不渗入存档态。 - -func set_breakpoint(bp: Dictionary) -> void: - _data["breakpoint"] = bp.duplicate(true) - - -func clear_breakpoint() -> void: - _data["breakpoint"] = {} - - -func has_breakpoint() -> bool: - return not (_data.get("breakpoint", {}) as Dictionary).is_empty() - - -func breakpoint_snapshot() -> Dictionary: - return (_data.get("breakpoint", {}) as Dictionary).duplicate(true) - - -# ---- 私有:损坏兜底与文件原语 ---- - -# 旧隐式单档一次性迁移(Q1 甲,sv12):src 字节级原样搬运到 dst(零解析零改造——损坏/高版本 -# 旧档字节原样落槽 1,读取时由正式进档路径的兜底/拒读链接管,不静默丢数据); -# dst 已存在不重迁(幂等判据——判别锚④:去判据则重复迁移覆盖槽 1);迁移成功后 src 改名 -# bak 留痕(不删——处置字节留痕,防再次隐式读写)。任一步失败:源保留原地,下次启动重试。 -# tmp+rename 原子形态与 write_to_disk 同纪律。生产调用=src/dst/bak 为 旧单档/槽 1/留痕名。 -func _migrate_legacy_save(src: String, dst: String, bak: String) -> void: - if not FileAccess.file_exists(src): - return # 执行前置:旧档不存在=无操作 - if FileAccess.file_exists(dst): - return # 幂等判据:槽 1 已存在不重迁 - var tmp := dst + ".tmp" - if not _copy_file(src, tmp): - push_warning("[存档序列化] 旧单档迁移失败(源保留,下次启动重试):%s → %s" % [src, tmp]) - return - if not _rename_overwrite(tmp, dst): - push_warning("[存档序列化] 旧单档迁移改名失败(tmp 保留待查,源保留重试):%s → %s" % [tmp, dst]) - return - _rename_overwrite(src, bak) - push_warning("[存档序列化] 旧单档已迁入 %s,原件留痕于 %s" % [dst, bak]) - - -# 字节级文件拷贝(迁移专用——buffer 直拷,零解析零改造) -func _copy_file(from: String, to: String) -> bool: - var src := FileAccess.open(from, FileAccess.READ) - if src == null: - return false - var dst := FileAccess.open(to, FileAccess.WRITE) - if dst == null: - src.close() - return false - while not src.eof_reached(): - var chunk := src.get_buffer(65536) - if chunk.size() > 0: - dst.store_buffer(chunk) - src.close() - dst.close() - return true - - -# 文件删除(删档原语专用——呈现层禁 DirAccess 直删,承载面单点) -func _remove_file(p: String) -> bool: - var dir := DirAccess.open(p.get_base_dir()) - if dir == null: - return false - return dir.remove(p.get_file()) == OK # 损坏兜底:坏档改名 .corrupt.bak(已存在则覆盖)→ 默认数据继续。不静默丢档(警告留痕)、不 crash。 func _corrupt_fallback(reason: String) -> bool: - var bak := _path + ".corrupt.bak" - if not _rename_overwrite(_path, bak): + var bak := slots._path + ".corrupt.bak" + if not _rename_overwrite(slots._path, bak): push_warning("[存档序列化] 坏档备份改名失败(原文件保留原地):%s——%s" % [bak, reason]) else: push_warning("[存档序列化] 检测到损坏档案,已备份为 %s——%s(用默认新档继续)" % [bak, reason]) @@ -689,7 +312,8 @@ func _normalize(data: Dictionary) -> Dictionary[String, Variant]: return merged -# 覆盖式改名(Windows 原生 rename 目标已存在时会失败——先删后改,显式覆盖语义) +# 覆盖式改名(Windows 原生 rename 目标已存在时会失败——先删后改,显式覆盖语义;IO 原语单点—— +# SlotStore 迁移流经本件调用) func _rename_overwrite(from: String, to: String) -> bool: var dir := DirAccess.open(from.get_base_dir()) if dir == null: diff --git a/game/core/settlement/combat_segment.gd b/game/core/settlement/combat_segment.gd index ccb739cd..db8bae28 100644 --- a/game/core/settlement/combat_segment.gd +++ b/game/core/settlement/combat_segment.gd @@ -78,7 +78,7 @@ func _enemy_actions(ri: int) -> void: if int(oc._enemy_system.hero_unit().hp) <= 0: break # hero hp=0 立即短路(死尸挨打行是归因噪音——用户拍板) var skill_id := oc._enemy_system.pending_action(u) - var srow: Dictionary[Variant, Variant] = oc.db.skill_row(skill_id) + var srow: Dictionary[Variant, Variant] = oc.db.combat.skill.row_by_id(skill_id) if srow.is_empty(): oc._fatal("敌实例待发动作悬空: %s" % skill_id) continue @@ -97,7 +97,7 @@ func _enemy_actions(ri: int) -> void: var baseline: int = int(round(float(u.atk) * float(srow.get("damage_mult", 1.0)))) var rider := 0 if srow.has("rider_mult"): - rider = maxi(int(oc.db.settlement.damage_roll.damage_floor), int(floor(float(u.atk) * float(srow.rider_mult)))) + rider = maxi(int(oc.db.combat.settlement.damage_roll.damage_floor), int(floor(float(u.atk) * float(srow.rider_mult)))) var et := oc._damage.open_ticket(DamageController.KIND_ENEMY_SKILL, "enemy_action", "%s %s" % [u.display_name, String(srow.display_name)], {"channel": "combat", "base_value": baseline, "target_rule": DamageController.TARGET_HERO, diff --git a/game/core/settlement/grant_segment.gd b/game/core/settlement/grant_segment.gd index 162a1e3e..2de0123c 100644 --- a/game/core/settlement/grant_segment.gd +++ b/game/core/settlement/grant_segment.gd @@ -147,7 +147,7 @@ func _reveal_mine_apply(mh_hits: Array, snap: Dictionary, cell: Vector2i, ri: in if fx_host._fx_bonus_delta.has(axis): fx_host._fx_bonus_delta[axis] += int(h.get("delta", 0)) "blast_scaled": - var blast_v := maxi(int(oc.db.settlement.damage_roll.damage_floor), int(float(int(snap.get("power", 0))) * float(h.get("mult", 1)))) + var blast_v := maxi(int(oc.db.combat.settlement.damage_roll.damage_floor), int(float(int(snap.get("power", 0))) * float(h.get("mult", 1)))) var bs_t := oc._damage.open_ticket(DamageController.KIND_BOARD_BLAST, "feat_blast_scaled", "陷阱爆发", {"channel": "board", "base_value": blast_v, "target_rule": DamageController.TARGET_ALL_ALIVE, "ri": ri, "origin_cell": cell}) @@ -163,7 +163,7 @@ func _terrain_batch(ri: int) -> void: for evt in oc._facts_reveals: var vids: Array[Variant] = evt.get("variant_ids", []) for vid in vids: - var vrow: Dictionary[Variant, Variant] = oc.db.variation.row_by_id(String(vid)) + var vrow: Dictionary[Variant, Variant] = oc.db.combat.variation.row_by_id(String(vid)) if String(vrow.get("effect", "")) != "flat_damage": continue # 触发词表按 effect 路由:flat_damage=on_open 兑现;hide_number/on_open 外组合 = 数据行新词表未接线(显式忽略面) # 地形变异伤害单据——无类型必中,下限保底在控制器管线内 diff --git a/game/core/shop_session.gd b/game/core/shop_session.gd index 028a98c6..531610d6 100644 --- a/game/core/shop_session.gd +++ b/game/core/shop_session.gd @@ -34,7 +34,7 @@ func bind(refs: Dictionary, hooks: Dictionary) -> void: # 商店覆盖层数据源(只读投影——现价/已购档数在此现算,呈现层禁复算定价公式) func shop_products() -> Array: var out: Array[Dictionary] = [] - for prow in db.shop_products(): + for prow in db.run.shop.product_rows(): out.append({ "product_id": String(prow.get("product_id", "")), "display_name": String(prow.get("display_name", "")), @@ -49,7 +49,7 @@ func shop_products() -> Array: # 现价 = base_price + price_level_bonus(level_index) + price_increment × 该商品已购档数 # (散折价:economy_spend 激活时商品标价 ×80%——见 _spend_discounted) func current_price(prow: Dictionary) -> int: - return _spend_discounted(int(prow.get("base_price", 0)) + db.shop_price_level_bonus(_level_index()) \ + return _spend_discounted(int(prow.get("base_price", 0)) + db.run.shop.price_level_bonus_at(_level_index()) \ + int(prow.get("price_increment", 0)) * purchased_tier(String(prow.get("product_id", "")))) @@ -58,7 +58,7 @@ func current_price(prow: Dictionary) -> int: # + 池货架面 pool_price 同折〕——商店刷新价 shop_refresh_cost 与三选一付费刷新不折) func _spend_discounted(price: int) -> int: if _rules().has("economy_spend"): - return maxi(1, int(floor(float(price) * float(db.shop.spend_discount)))) + return maxi(1, int(floor(float(price) * float(db.run.shop.spend_discount)))) return price @@ -69,7 +69,7 @@ func purchased_tier(product_id: String) -> int: # 购买(未知商品/属性键出域/余额不足拒绝——返回 false 零副作用): # 出账 → 加算区 += flat_value → 购血同步当前生命(封顶新上限)→ 已购档数 +1 func buy_product(product_id: String) -> bool: - var prow: Dictionary[Variant, Variant] = db.shop_product_row(product_id) + var prow: Dictionary[Variant, Variant] = db.run.shop.product_row(product_id) if prow.is_empty(): push_error("[关卡节点地图] 商店房未知商品: %s" % product_id) return false @@ -133,7 +133,7 @@ func leave() -> void: func _draw_shelf() -> void: shelf = [] var sellable: Array[Variant] = [] - for row in db.shop_pool_rows(): + for row in db.run.shop.pool_rows(): var shelf_kind := String(row.get("kind_id", "")) if _pool_cap.is_valid() and not bool(_pool_cap.call(shelf_kind, "")): continue # cap 满剔除(货架候选视图剔除;牌库本体不动) @@ -184,14 +184,14 @@ func pool_stock(kind: String) -> int: # A/b 表 knob provisional;核心现算,呈现层禁复算——契约 5)。relic 卡(保底占位——shop 词表外) # 基价 provisional 15(表 knob relic_base_price——shop.tres 词表扩列归数值批)。 func pool_price(kind: String) -> int: - var row: Dictionary[Variant, Variant] = db.shop_pool_row(kind) + var row: Dictionary[Variant, Variant] = db.run.shop.pool_row(kind) if row.is_empty(): if kind == "relic": - return _spend_discounted(int(ceil(float(db.shop.relic_base_price) * pow(db.shop_price_b(), float(maxi(0, _level_index() - 1))) * pow(db.shop_price_a(), float(visit_buys))))) + return _spend_discounted(int(ceil(float(db.run.shop.relic_base_price) * pow(db.run.shop.price_b, float(maxi(0, _level_index() - 1))) * pow(db.run.shop.price_a, float(visit_buys))))) return 999999 var base := float(maxi(1, int(row.get("base_price", 1)))) - var price_f: float = base * pow(db.shop_price_b(), float(maxi(0, _level_index() - 1))) \ - * pow(db.shop_price_a(), float(visit_buys)) + var price_f: float = base * pow(db.run.shop.price_b, float(maxi(0, _level_index() - 1))) \ + * pow(db.run.shop.price_a, float(visit_buys)) return _spend_discounted(int(ceil(price_f))) @@ -202,11 +202,11 @@ func shop_shelf() -> Array: for tok in shelf: var kind := String((tok as Dictionary).get("kind", "")) var tier := String((tok as Dictionary).get("tier", "")) - var row: Dictionary[Variant, Variant] = db.shop_pool_row(kind) + var row: Dictionary[Variant, Variant] = db.run.shop.pool_row(kind) var display := String(row.get("display_name", kind)) - var value := int(db.adventure.curio_value(kind, tier)) + var value := int(db.run.adventure.curio_value(kind, tier)) if row.is_empty() and kind == "relic": - var rrow: Dictionary[Variant, Variant] = db.feat_row(String(tok.get("feat_id", ""))) + var rrow: Dictionary[Variant, Variant] = db.crossrun.feats.row_by_id(String(tok.get("feat_id", ""))) display = String(rrow.get("display_name", "特性")) value = 0 out.append({ @@ -227,22 +227,22 @@ func shop_shelf() -> Array: func shop_refresh_free_left() -> int: # 刷新执照 shop_refresh_bonus 装配合计叠加(进店入账语义=读数即本店可用, # 与基础 knob 同生命周期;未装配=基值) - return maxi(0, int(db.adventure.shop_refresh_free) + _attune_shop_refresh_bonus()) + return maxi(0, int(db.run.adventure.shop_refresh_free) + _attune_shop_refresh_bonus()) # 刷新执照装配合计(装配集 kind 分发扫描;零装配=0) func _attune_shop_refresh_bonus() -> int: var total := 0 for iid_v in _attunement(): - var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v)) + var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v)) if (arow.get("effect_kinds", []) as Array).has("shop_refresh_bonus"): total += int(arow.get("magnitude", 0)) return total func shop_refresh_cost() -> int: - return int(ceil(float(db.adventure.shop_refresh_cost_base) - * pow(db.adventure.shop_refresh_cost_curve, float(paid_refreshes)))) + return int(ceil(float(db.run.adventure.shop_refresh_cost_base) + * pow(db.run.adventure.shop_refresh_cost_curve, float(paid_refreshes)))) func shop_refresh() -> bool: diff --git a/game/core/slot_store.gd b/game/core/slot_store.gd new file mode 100644 index 00000000..3b7a8746 --- /dev/null +++ b/game/core/slot_store.gd @@ -0,0 +1,189 @@ +## 存档槽位文件域件(D3 拆件——自 save_manager 收编槽位族):槽位挂载与模板、激活与建删、 +## 摘要窥视(peek 族)的唯一所有者。槽位态(路径/激活号/模板)随件承载;数据重置与读写盘 +## 经注入的 save 内部协作家族(_reset_to_default/_load_parsed/_rename_overwrite)完成—— +## 本件零 _data 副本零缓存(load 整体换新不陈旧)。 +class_name SlotStore +extends RefCounted + +var _save # SaveManagerCore 注入引用(IO 与数据域协作单点) +var _path: String = "" # 当前写盘目标=激活槽文件(或 attach_path 注入路径);""=无激活态——一切写盘路径拒绝 +var _active_slot := 0 # 激活槽号 1..SLOT_COUNT;0=无激活态(注入非槽派生名路径时亦 0——写盘判定以 _path 非空为准) +var _slot_template: String # 槽文件名模板(生产=常量;测试经 attach_slot_template 注入隔离命名空间,禁在呈现层/探针各自拼槽名) + + +func _init(save_ref, path: String = "") -> void: + _save = save_ref + _slot_template = _save.SLOT_PATH_TEMPLATE + _path = path + _active_slot = _slot_index_of(path) + + +## 进档注入口:换路径+回默认数据+重读盘(对象引用恒定,MetaProgression 持有的 save 引用零失效)。 +## 三槽化后单路径语义(契约 7)=注入路径即激活槽文件(写盘落该路径、无激活态时注入即激活); +## 注入槽派生名时激活号同步记账,注入任意隔离路径时激活号 0(写盘仍合法——隔离面行为零变化)。 +func attach_path(path: String) -> void: + _path = path + _save._read_only = false + _active_slot = _slot_index_of(path) + _save._reset_to_default() + _save.load_from_disk() + + +## 测试隔离注入口(契约 7 同源——槽文件名模板整体换注,探针/单测经此隔离到自命名空间, +## 禁各自拼槽文件名;换注后回无激活态,槽原语全部落注入命名空间) +func attach_slot_template(template: String) -> void: + _slot_template = template + _path = "" + _active_slot = 0 + _save._read_only = false + _save._reset_to_default() + + +# 槽文件路径唯一派生口(模板单点——禁第二处拼名) +func slot_path(slot: int) -> String: + return _slot_template % slot + + +# 激活槽号查询(0 = 无激活态——启动态钉死后 boot 需经选档显式进档/新档) +func active_slot() -> int: + return _active_slot + + +# 写盘目标在位与否(激活槽或注入路径任一;无激活态一切写盘路径拒绝——sv3 判定口) +func has_write_target() -> bool: + return _path != "" + + +# 进档原语(sv5,契约 6/8):激活槽 N+读盘,复用 load_from_disk 迁移/兜底链按槽成立。 +# 高版本拒读槽:激活不成立、恢复切换前三态(路径/激活号/禁写位/数据)——防误写他槽; +# 损坏槽:兜底链接管(.corrupt.bak 改名+空态继续,激活成立)。 +func activate_slot(slot: int) -> bool: + if slot < 1 or slot > _save.SLOT_COUNT: + return false + var prev_path := _path + var prev_slot := _active_slot + var prev_ro: bool = _save._read_only + var prev_data = _save._data + _path = slot_path(slot) + _active_slot = slot + _save._read_only = false + _save._reset_to_default() + if not _save.load_from_disk(): + _path = prev_path + _active_slot = prev_slot + _save._read_only = prev_ro + _save._data = prev_data + return false + return true + + +# 新档原语(sv4):空槽创建=合法白板态(_default_data 全零可玩纪律),创建即激活并落盘。 +# 生产路径只在空槽调用(有档槽开新局=先删档回空再新档);写盘失败回退切换前三态。 +func create_slot(slot: int) -> bool: + if slot < 1 or slot > _save.SLOT_COUNT: + return false + var prev_path := _path + var prev_slot := _active_slot + var prev_ro: bool = _save._read_only + var prev_data = _save._data + _path = slot_path(slot) + _active_slot = slot + _save._read_only = false + _save._reset_to_default() + if not _save.write_to_disk(): + _path = prev_path + _active_slot = prev_slot + _save._read_only = prev_ro + _save._data = prev_data + return false + return true + + +# 删档原语(sv6,契约 8):该槽文件删除+盘面即时生效+无 .tmp 残留;删激活槽时激活指针连清除 +# (内存回默认空态,该槽不再可写——激活态生命周期终点)。 +# .corrupt.bak 损坏备份不追删(用户数据留盘可考;同槽再损坏时兜底流按槽覆盖同名备份)。 +# 删不存在文件的空槽=幂等成功;越界槽号拒绝。 +func delete_slot(slot: int) -> bool: + if slot < 1 or slot > _save.SLOT_COUNT: + return false + var p := slot_path(slot) + if FileAccess.file_exists(p) and not _remove_file(p): + push_error("[存档序列化] 删档失败(文件删除被拒):%s" % p) + return false + var tmp := p + ".tmp" + if FileAccess.file_exists(tmp): + _remove_file(tmp) # 残留清偿尽力而为(主文件已删即达成空槽语义) + if _active_slot == slot: + _path = "" + _active_slot = 0 + _save._reset_to_default() + return true + + +# 摘要三项按槽窥视读器(sv7/sv8,契约 5/9):钥石余额/冒险成功局数/冒险总局数。 +# 只读零副作用:不激活、不写盘、不触发 _corrupt_fallback 改名(.corrupt.bak 只在正式进档路径产生)。 +# 迁移链在此只读推演(内核 _peek_summary_at 归 save IO 域——本方法经槽路径派生委托)。 +func peek_slot_summary(slot: int) -> Dictionary: + if slot < 1 or slot > _save.SLOT_COUNT: + return {"readable": false, "has_file": false, "keystone": 0, "cleared": 0, "total": 0} # 越界=非槽位,非空槽 + return _save._peek_summary_at(slot_path(slot)) + + +# 窥视注入口(契约 7 隔离面同款——测试/探针经路径注入隔离,禁各自拼槽文件名) +func peek_summary_at(path: String) -> Dictionary: + return _save._peek_summary_at(path) + + +# 注入路径的槽号反查(命中槽派生名返回槽号,否则 0——激活号记账用,写盘判定以 _path 非空为准) +func _slot_index_of(path: String) -> int: + for i in range(1, _save.SLOT_COUNT + 1): + if path == slot_path(i): + return i + return 0 + + +# 旧隐式单档一次性迁移(Q1 甲,sv12):src 字节级原样搬运到 dst(零解析零改造——损坏/高版本 +# 旧档字节原样落槽 1,读取时由正式进档路径的兜底/拒读链接管,不静默丢数据); +# dst 已存在不重迁(幂等判据——判别锚④:去判据则重复迁移覆盖槽 1);迁移成功后 src 改名 +# bak 留痕(不删——处置字节留痕,防再次隐式读写)。任一步失败:源保留原地,下次启动重试。 +# tmp+rename 原子形态与 write_to_disk 同纪律(改名原语 _rename_overwrite 归 save IO 域单点)。生产调用=src/dst/bak 为 旧单档/槽 1/留痕名。 +func _migrate_legacy_save(src: String, dst: String, bak: String) -> void: + if not FileAccess.file_exists(src): + return # 执行前置:旧档不存在=无操作 + if FileAccess.file_exists(dst): + return # 幂等判据:槽 1 已存在不重迁 + var tmp := dst + ".tmp" + if not _copy_file(src, tmp): + push_warning("[存档序列化] 旧单档迁移失败(源保留,下次启动重试):%s → %s" % [src, tmp]) + return + if not _save._rename_overwrite(tmp, dst): + push_warning("[存档序列化] 旧单档迁移改名失败(tmp 保留待查,源保留重试):%s → %s" % [tmp, dst]) + return + _save._rename_overwrite(src, bak) + push_warning("[存档序列化] 旧单档已迁入 %s,原件留痕于 %s" % [dst, bak]) + + +# 字节级文件拷贝(迁移专用——buffer 直拷,零解析零改造) +func _copy_file(from: String, to: String) -> bool: + var src := FileAccess.open(from, FileAccess.READ) + if src == null: + return false + var dst := FileAccess.open(to, FileAccess.WRITE) + if dst == null: + src.close() + return false + while not src.eof_reached(): + var chunk := src.get_buffer(65536) + if chunk.size() > 0: + dst.store_buffer(chunk) + src.close() + dst.close() + return true + + +# 文件删除(删档原语专用——呈现层禁 DirAccess 直删,承载面单点) +func _remove_file(p: String) -> bool: + var dir := DirAccess.open(p.get_base_dir()) + if dir == null: + return false + return dir.remove(p.get_file()) == OK diff --git a/game/core/status_container.gd b/game/core/status_container.gd index e1433e10..52def7f8 100644 --- a/game/core/status_container.gd +++ b/game/core/status_container.gd @@ -29,44 +29,58 @@ func apply_effect(effect_id: String, source: Dictionary, duration_override: int if not _open: push_warning("[增益状态] 域闭授予(EC1)") return false - var row: Dictionary[Variant, Variant] = db.status_effect_row(effect_id) + var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(effect_id) if row.is_empty(): push_warning("[增益状态] 未知 effect_id(EC2): %s" % effect_id) return false var policy: String = row.stack_policy var dur: int = int(row.duration_settlements) if duration_override < 1 else duration_override var max_stack: int = int(row.max_stack) - # 授予时快照(抗表重载——EC9) - var snap_m: int = int(row.magnitude) - if source.has("magnitude_override"): - snap_m = int(source["magnitude_override"]) # 封印溢价贯穿(迭代14② T4——封印命中 percent 形态同乘快照;缺省路径零扰动) - var snap_unit: String = String(row.unit) - var snap_display: String = String(row.display_name) + var snap := _grant_snapshot(row, source) match policy: "independent": - _instances.append(_new_instance(effect_id, 1, dur, source, snap_m, snap_unit, snap_display)) + _instances.append(_new_instance(effect_id, 1, dur, source, snap)) return true "refresh": - for inst in _instances: - if inst.effect_id == effect_id: - inst.remaining = dur # R ← D(取实例快照 D——同表值);L 不变;空刷新恒 true - return true - _instances.append(_new_instance(effect_id, 1, dur, source, snap_m, snap_unit, snap_display)) - return true + return _apply_refresh(effect_id, dur, snap, source) "stack": - for inst in _instances: - if inst.effect_id == effect_id: - if int(inst.layers) >= max_stack: - return false # 满层拒绝原子——容器零写入(拒收由战斗结算记"奖励未生效"行) - inst.layers = int(inst.layers) + 1 # stack 授予不触碰 R(spec 决议) - return true - _instances.append(_new_instance(effect_id, 1, dur, source, snap_m, snap_unit, snap_display)) - return true + return _apply_stack(effect_id, dur, snap, source, max_stack) push_warning("[增益状态] 未知 stack_policy") return false -func _new_instance(effect_id: String, layers: int, dur: int, source: Dictionary, m: int, unit: String, display: String) -> Dictionary: +## 授予时快照域(抗表重载——EC9;封印溢价贯穿——迭代14② T4 percent 形态同乘快照、缺省路径零扰动) +func _grant_snapshot(row: Dictionary, source: Dictionary) -> Dictionary: + var snap_m: int = int(row.magnitude) + if source.has("magnitude_override"): + snap_m = int(source["magnitude_override"]) + return {"m": snap_m, "unit": String(row.unit), "display": String(row.display_name)} + + +## refresh 臂:命中改 R 未命中追加(R ← D 取实例快照 D——同表值;L 不变;空刷新恒 true) +func _apply_refresh(effect_id: String, dur: int, snap: Dictionary, source: Dictionary) -> bool: + for inst in _instances: + if inst.effect_id == effect_id: + inst.remaining = dur + return true + _instances.append(_new_instance(effect_id, 1, dur, source, snap)) + return true + + +## stack 臂:满层拒零写入·未满加层·未命中追加(stack 授予不触碰 R——spec 决议; +## 满层拒绝原子——容器零写入,拒收由战斗结算记"奖励未生效"行) +func _apply_stack(effect_id: String, dur: int, snap: Dictionary, source: Dictionary, max_stack: int) -> bool: + for inst in _instances: + if inst.effect_id == effect_id: + if int(inst.layers) >= max_stack: + return false + inst.layers = int(inst.layers) + 1 + return true + _instances.append(_new_instance(effect_id, 1, dur, source, snap)) + return true + + +func _new_instance(effect_id: String, layers: int, dur: int, source: Dictionary, snap: Dictionary) -> Dictionary: return { "effect_id": effect_id, "layers": layers, @@ -74,9 +88,9 @@ func _new_instance(effect_id: String, layers: int, dur: int, source: Dictionary, "source_class": String(source.get("source_class", "")), "origin_cell": source.get("origin_cell", Vector2i(-1, -1)), "round_index": int(source.get("round_index", 0)), - "snap_m": m, - "snap_unit": unit, - "snap_display": display, + "snap_m": snap["m"], + "snap_unit": snap["unit"], + "snap_display": snap["display"], } @@ -84,7 +98,7 @@ func _new_instance(effect_id: String, layers: int, dur: int, source: Dictionary, func query_active(side: String) -> Array: var out: Array[Dictionary] = [] for inst in _instances: - var row: Dictionary[Variant, Variant] = db.status_effect_row(inst.effect_id) + var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(inst.effect_id) if row.is_empty(): continue if String(row.target_side) != side: diff --git a/game/core/treasure_items.gd b/game/core/treasure_items.gd index fb0ab1b9..e3015e22 100644 --- a/game/core/treasure_items.gd +++ b/game/core/treasure_items.gd @@ -68,9 +68,10 @@ func _usable_now(item_id: String, board_has_preview_info: Callable) -> bool: func _item_row(item_id: String) -> Dictionary: if _db == null: return {} - if _db.has_method("treasure_item_row"): - return _db.treasure_item_row(item_id) # 表访问收口(正式面走 ConfigDB 访问器) - return _db.treasure_items.row_by_id(item_id) # 桩表面(单测 StubDB 无访问器——直读保留,随测试面演进退役) + var db_crossrun = _db.get("crossrun") + if db_crossrun != null: + return db_crossrun.treasure_items.row_by_id(item_id) # 表访问收口(正式面走 ConfigDB 容器直连) + return _db.treasure_items.row_by_id(item_id) # 桩表面(单测 StubDB 无容器——直读保留,随测试面演进退役) # 不标具体类型原因:单元测试注入 StubDB 桩表(treasure_items_test.gd)——typed 赋值会运行期拒收,保守保持 null 注 var _db = null # ConfigDB(行定义读取——_init 注入;测试可注入桩表) @@ -97,31 +98,16 @@ func validate_use(handle: int, target: Vector2i, board_size: Vector2i, cell_unre var kinds: Array[Variant] = row.get("effect_kinds", []) var resolved: Array[Variant] = [] if kinds.has("mine_reveal") or kinds.has("reward_preview") or kinds.has("reveal_tiles") or kinds.has("safe_dig"): - # 空间效果(真视 reveal_tiles/天眼复合 safe_dig 入空间解析; - # 真视窄域护栏 REVEAL_KINDS 同守): - if target.x < 0 or target.y < 0 or target.x >= int(board_size.x) or target.y >= int(board_size.y): - return _reject("目标越板") - if String(row.get("target", "")) == "cell": - # 单格档(线香真视单格;≤3×3 窄域契约内的点目标) - resolved.append(target) - else: - # 3×3 窗口 ∩ 板内格(边缘受理——护栏只缩不扩) - for dy in range(-1, 2): - for dx in range(-1, 2): - var c := target + Vector2i(dx, dy) - if c.x >= 0 and c.y >= 0 and c.x < int(board_size.x) and c.y < int(board_size.y): - resolved.append(c) + # 空间效果(真视 reveal_tiles/天眼复合 safe_dig 入空间解析;真视窄域护栏 REVEAL_KINDS 同守) + var spatial := _spatial_reject_reason(row, target, board_size) + if spatial != "": + return _reject(spatial) + resolved = _window_cells(row, target, board_size) # 点击级零信息:纯预读行窗口全已开 = 没东西可看(拒绝零消耗); # 真视恒可用(零雷窗口=「这 9 格没雷」信息非空——GDD 裁定) - if kinds.has("reward_preview") and not kinds.has("mine_reveal"): - var any_unrevealed := false - if cell_unrevealed_cb.is_valid(): - for c in resolved: - if bool(cell_unrevealed_cb.call(c)): - any_unrevealed = true - break - if not any_unrevealed: - return _reject("窗口内无可预读的未开格") + if kinds.has("reward_preview") and not kinds.has("mine_reveal") \ + and _preview_window_dead(resolved, cell_unrevealed_cb): + return _reject("窗口内无可预读的未开格") return { "ok": true, "reason": "", @@ -132,6 +118,38 @@ func validate_use(handle: int, target: Vector2i, board_size: Vector2i, cell_unre } +## 空间解析域:目标越板拒(cell/3×3 两档共用板界)——返回拒绝原因,空串=通过 +func _spatial_reject_reason(row: Dictionary, target: Vector2i, board_size: Vector2i) -> String: + if target.x < 0 or target.y < 0 or target.x >= int(board_size.x) or target.y >= int(board_size.y): + return "目标越板" + return "" + + +## 预读窗口域:单格档=点目标(线香真视单格;≤3×3 窄域契约内);其余=3×3 窗口 ∩ 板内格(边缘受理——护栏只缩不扩) +func _window_cells(row: Dictionary, target: Vector2i, board_size: Vector2i) -> Array: + var resolved: Array[Variant] = [] + if String(row.get("target", "")) == "cell": + resolved.append(target) + else: + for dy in range(-1, 2): + for dx in range(-1, 2): + var c := target + Vector2i(dx, dy) + if c.x >= 0 and c.y >= 0 and c.x < int(board_size.x) and c.y < int(board_size.y): + resolved.append(c) + return resolved + + +## 预读死窗判定:纯预读行窗口全已开 = 没东西可看(cell_unrevealed_cb = 排雷玩法 只读面) +func _preview_window_dead(resolved: Array, cell_unrevealed_cb: Callable) -> bool: + var any_unrevealed := false + if cell_unrevealed_cb.is_valid(): + for c in resolved: + if bool(cell_unrevealed_cb.call(c)): + any_unrevealed = true + break + return not any_unrevealed + + # ---- 叠加层(战斗结算逐 kind 应用——空间先、ap 后的序在编排侧钉死)---- func apply_overlay(kind: String, cells: Array) -> void: diff --git a/game/core/unit_container.gd b/game/core/unit_container.gd index 066f5ca0..2eff1088 100644 --- a/game/core/unit_container.gd +++ b/game/core/unit_container.gd @@ -33,42 +33,58 @@ func _init(db_ref: ConfigDb) -> void: # (下标=行集行序,长度短于行集时缺省 -1 = 该行行值路径;空数组 = 全行定值)。 # 逐行实例化、生成序 = 文件行序(行序即契约,敌人系统 v0.15);行集实例总数(各行传入数量求和) # ≤ ENEMY_ROWSET_INSTANCE_MAX 守卫 fail-fast。 -func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_overrides: Array = [], - atk_override: int = -1, max_hp_override: int = -1, enemy_atk_overrides: Array = [], - hero_attack_type: String = "", hero_armor_type: String = "", row_kind: String = "battle") -> bool: +# spawn 引用束(K6′ 签名束化——10 形参收单 Dictionary 束):束键封闭词表—— +# 必载 level_index/node_depth/hero_hp;可选 unit_count_overrides=[]/enemy_atk_overrides=[]/ +# hero_atk_override=-1/hero_max_hp_override=-1/hero_attack_type=""/hero_armor_type=""/ +# row_kind="battle"(缺省语义与现行形参缺省逐字同值)。 +# enemy_atk_override:遭遇骰同流的 spawn 期整战持久 roll 结果 +# (-1 = 表值定值路径;≥0 = 行值替换后再乘关卡 atk_mult)。敌人系统 零随机纪律保持。 +# 行集受理:unit_count_overrides / enemy_atk_overrides 为**逐行数组** +# (下标=行集行序,长度短于行集时缺省 -1 = 该行行值路径;空数组 = 全行定值)。 +# 逐行实例化、生成序 = 文件行序(行序即契约,敌人系统 v0.15);行集实例总数(各行传入数量求和) +# ≤ ENEMY_ROWSET_INSTANCE_MAX 守卫 fail-fast。 +func spawn(req: Dictionary) -> bool: if _spawned: push_warning("[敌人系统] Alive 相位重复 spawn(相位守卫)") return false # 行集数组口径(resolver 按 (L,band) 带寻址返回全部行、文件序) - var rowset: Array[Variant] = db.resolve_enemy_rowset(level_index, node_depth, row_kind) + var rowset: Array[Variant] = db.combat.enemy.resolve_rowset(int(req.level_index), int(req.node_depth), String(req.get("row_kind", "battle"))) if rowset.is_empty(): - push_error("[敌人系统] 遭遇行解析失败/越域 (%d,%d,%s)" % [level_index, node_depth, row_kind]) + push_error("[敌人系统] 遭遇行解析失败/越域 (%s,%s,%s)" % [str(req.get("level_index")), str(req.get("node_depth")), String(req.get("row_kind", "battle"))]) assert(false, "遭遇行悬空(R1 前置守卫)") return false - var eff_atk: int = atk_override if atk_override >= 1 else int(db.hero.atk) - var eff_max_hp: int = max_hp_override if max_hp_override >= 1 else int(db.hero.max_hp) + if not _spawn_hero_unit(int(req.hero_hp), int(req.get("hero_atk_override", -1)), int(req.get("hero_max_hp_override", -1)), String(req.get("hero_attack_type", "")), String(req.get("hero_armor_type", ""))): + return false + var mod: Dictionary[Variant, Variant] = db.run.difficulty.resolve_modifiers(int(req.level_index), int(req.node_depth)) + if not _spawn_rowset(rowset, req.get("unit_count_overrides", []), req.get("enemy_atk_overrides", []), float(mod.get("hp_mult", 1.0)), float(mod.get("atk_mult", 1.0))): + return false + _spawned = true + _despawned = false + return true + + +## 英雄单位构建域:生效上限与类型轴快照(读注入值,空串回退锻造工坊配置默认值 normal/light)。 +## max_hp = 生效上限(非入参 hero_hp)——跨战斗携带 HP 时上限不得压扁 +## (恢复点/治疗包的满血守卫与治疗上限随之失效) +func _spawn_hero_unit(hero_hp: int, atk_override: int, max_hp_override: int, hero_attack_type: String, hero_armor_type: String) -> bool: + var eff_atk: int = atk_override if atk_override >= 1 else int(db.combat.hero.atk) + var eff_max_hp: int = max_hp_override if max_hp_override >= 1 else int(db.combat.hero.max_hp) if hero_hp > eff_max_hp or hero_hp <= 0: push_warning("[敌人系统] hero_hp 入参越界(EC16): %d" % hero_hp) return false - # max_hp = 生效上限(非入参 hero_hp)——跨战斗携带 HP 时上限不得压扁 - # (恢复点/治疗包的满血守卫与治疗上限随之失效) - _hero = {"hp": hero_hp, "max_hp": eff_max_hp, "atk": eff_atk, "is_hero": true, "alive": true, "display_name": String(db.hero.display_name), "enemy_id": "hero", "armor_type": hero_armor_type if hero_armor_type != "" else db.forge_default_armor_type(), "attack_type": hero_attack_type if hero_attack_type != "" else db.forge_default_attack_type()} # 护甲/攻击类型轴快照(读注入值,空串回退锻造工坊配置默认值 normal/light;甲效矩阵消费面) - _enemies.clear() # 难度曲线与Boss DifficultyTable 修饰:施加于行值、行值快照之前——MVP 空表 = 恒 1.0 无行为(AC-10);混编行集内全部敌种同乘((L,D) 精确匹配一次取值——F9 同乘不改变异,敌人系统 v0.15) - var mod: Dictionary[Variant, Variant] = db.resolve_difficulty_modifiers(level_index, node_depth) - var hp_mult: float = float(mod.get("hp_mult", 1.0)) - var atk_mult: float = float(mod.get("atk_mult", 1.0)) - # 逐行数量定值(敌人系统 零随机纪律——骰定在 战斗结算 遭遇骰层按行完成,本层收确定值; - # 行 i 数量 = overrides[i](≥1 时)否则该行 unit_count 行值) - var per_row_counts: Array[int] = [] + _hero = {"hp": hero_hp, "max_hp": eff_max_hp, "atk": eff_atk, "is_hero": true, "alive": true, "display_name": String(db.combat.hero.display_name), "enemy_id": "hero", "armor_type": hero_armor_type if hero_armor_type != "" else db.crossrun.forge.default_armor_type, "attack_type": hero_attack_type if hero_attack_type != "" else db.crossrun.forge.default_attack_type} # 护甲/攻击类型轴快照(甲效矩阵消费面) + return true + + +## 行集实例化域(难度曲线修饰施加于行值、行值快照之前——MVP 空表 = 恒 1.0 无行为〔AC-10〕; +## 混编行集内全部敌种同乘——(L,D) 精确匹配一次取值,F9 同乘不改变异,敌人系统 v0.15) +func _spawn_rowset(rowset: Array, unit_count_overrides: Array, enemy_atk_overrides: Array, hp_mult: float, atk_mult: float) -> bool: + _enemies.clear() + var per_row_counts := _row_instance_counts(rowset, unit_count_overrides) + if per_row_counts.is_empty(): + return false var total_instances := 0 - for row_i in range(rowset.size()): - var row_i_dict: Dictionary[Variant, Variant] = rowset[row_i] - var ovr: int = int(unit_count_overrides[row_i]) if row_i < unit_count_overrides.size() else -1 - var count: int = ovr if ovr >= 1 else int(row_i_dict.unit_count) - if count < EnemyTable.UNIT_COUNT_MIN or count > EnemyTable.UNIT_COUNT_MAX: - push_warning("[敌人系统] 行 %d 实例化数量越域(单种数域 [%d,%d]): %d" % [row_i, EnemyTable.UNIT_COUNT_MIN, EnemyTable.UNIT_COUNT_MAX, count]) - return false - per_row_counts.append(count) + for count in per_row_counts: total_instances += count # 行集实例总数守卫(行集构成纪律③——各行骰后数量求和 ≤4;骰后值加载期不可判,此处 fail-fast) if total_instances > EnemyTable.ROWSET_INSTANCE_MAX: @@ -99,11 +115,24 @@ func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_overrides "is_hero": false, }) handle += 1 - _spawned = true - _despawned = false return true +## 逐行数量定值(敌人系统 零随机纪律——骰定在 战斗结算 遭遇骰层按行完成,本层收确定值; +## 行 i 数量 = overrides[i](≥1 时)否则该行 unit_count 行值)。越域行 = 空数组(受理拒绝信号) +func _row_instance_counts(rowset: Array, unit_count_overrides: Array) -> Array[int]: + var per_row_counts: Array[int] = [] + for row_i in range(rowset.size()): + var row_i_dict: Dictionary[Variant, Variant] = rowset[row_i] + var ovr: int = int(unit_count_overrides[row_i]) if row_i < unit_count_overrides.size() else -1 + var count: int = ovr if ovr >= 1 else int(row_i_dict.unit_count) + if count < EnemyTable.UNIT_COUNT_MIN or count > EnemyTable.UNIT_COUNT_MAX: + push_warning("[敌人系统] 行 %d 实例化数量越域(单种数域 [%d,%d]): %d" % [row_i, EnemyTable.UNIT_COUNT_MIN, EnemyTable.UNIT_COUNT_MAX, count]) + return [] + per_row_counts.append(count) + return per_row_counts + + # 实例池快照(proposal §2.2:开战把模板快照一份,冷却全零——与"生成拿到当时的表值快照"同一条 # 快照纪律。触发条件:技能行 trigger 是字段权威——快照随读;带 latch 行写单向闩标记 # trigger_latched 初始锁定;缺省无 trigger=无条件技) @@ -115,7 +144,7 @@ func _pool_snapshot(row: Dictionary) -> Array[Dictionary]: "weight": int(entry.get("weight", 0)), "cooldown_left": 0, } - var srow_snap: Dictionary[Variant, Variant] = db.skill_row(String(entry.get("skill_id", ""))) + var srow_snap: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(String(entry.get("skill_id", ""))) if srow_snap.has("trigger"): snap_entry["trigger"] = (srow_snap.get("trigger", {}) as Dictionary).duplicate(true) if bool((snap_entry["trigger"] as Dictionary).get("latch", false)): @@ -218,7 +247,7 @@ func special_skills(u: Dictionary, facts: Dictionary = {}) -> Array: for e in u.pool: if _entry_blocked(e, u, facts): continue - var srow: Dictionary[Variant, Variant] = db.skill_row(String(e.skill_id)) + var srow: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(String(e.skill_id)) if not srow.is_empty() and String(srow.type) != "basic": out.append({"skill_id": String(e.skill_id), "weight": int(e.weight)}) return out @@ -276,7 +305,7 @@ func commit_action(u: Dictionary) -> void: if sid == "": _diag("commit_action 无待发动作(兑现序违例)") return - var srow: Dictionary[Variant, Variant] = db.skill_row(sid) + var srow: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(sid) var cd: int = int(srow.get("cooldown_beats", 0)) if not srow.is_empty() else 0 for e in u.pool: if String(e.skill_id) == sid: diff --git a/game/data/schemas/adventure_cap_segment.gd b/game/data/schemas/adventure_cap_segment.gd new file mode 100644 index 00000000..4d09d676 --- /dev/null +++ b/game/data/schemas/adventure_cap_segment.gd @@ -0,0 +1,15 @@ +## 冒险表·已拿上限段(K4′ 拆段——curio_cap_* 9 槽随段件嵌套;段内槽名逐字不动; +## 概率与上限解耦〔奇物.md 池结构案D〕——概率位=档+令牌数、上限=本字段〔拿了才计、刷出不计〕; +## cap 满后该 kind 从候选视图剔除〔提前退役〕。99=实质无上限占位,实数归调参;专长 cap 在 feats.tres 行字段)。 +class_name AdventureCapSegment +extends Resource + +@export var curio_cap_attack: int = 99 # [1,99] +@export var curio_cap_max_hp: int = 99 # [1,99] +@export var curio_cap_armor: int = 99 # [1,99] +@export var curio_cap_gp: int = 99 # [1,99] +@export var curio_cap_ap: int = 99 # [1,99] +@export var curio_cap_heal: int = 99 # [1,99] +@export var curio_cap_refresh: int = 99 # [1,99] +@export var curio_cap_rap: int = 99 # [1,99] +@export var curio_cap_time_sand: int = 99 # [1,99] diff --git a/game/data/schemas/adventure_quota_segment.gd b/game/data/schemas/adventure_quota_segment.gd new file mode 100644 index 00000000..7ddc3870 --- /dev/null +++ b/game/data/schemas/adventure_quota_segment.gd @@ -0,0 +1,18 @@ +## 冒险表·令牌配额段(K4′ 拆段——curio_quota_* 13 槽随段件嵌套;段内槽名逐字不动; +## 嵌套段单 .tres 保持——形态同 settlement_table 拆段既裁)。kind/tier 词表与配额结构锚留宿主。 +class_name AdventureQuotaSegment +extends Resource + +@export var curio_quota_attack_common: int = 18 # [0,99] +@export var curio_quota_attack_excellent: int = 5 # [0,99] +@export var curio_quota_max_hp_common: int = 17 # [0,99] +@export var curio_quota_max_hp_excellent: int = 5 # [0,99] +@export var curio_quota_armor_common: int = 17 # [0,99] +@export var curio_quota_armor_excellent: int = 5 # [0,99] +@export var curio_quota_gp_common: int = 18 # [0,99] +@export var curio_quota_gp_excellent: int = 5 # [0,99] +@export var curio_quota_heal_excellent: int = 3 # [0,99]——恢复件(refresh 挤占 2 席位,总量 102 锚不变) +@export var curio_quota_refresh_excellent: int = 2 # [0,99]——刷新次数件(补给类成员;provisional 归曲线调参) +@export var curio_quota_ap_rare: int = 3 # [0,99]——AP 件(稀有档) +@export var curio_quota_rap_rare: int = 2 # [0,99]——储备金袋(投放 provisional 归调参) +@export var curio_quota_time_sand_rare: int = 2 # [0,99]——时沙(投放 provisional 归调参) diff --git a/game/data/schemas/adventure_table.gd b/game/data/schemas/adventure_table.gd index ef87d79b..bd3512bd 100644 --- a/game/data/schemas/adventure_table.gd +++ b/game/data/schemas/adventure_table.gd @@ -45,49 +45,11 @@ const TIER_QUOTAS := {"common": 70, "excellent": 25, "rare": 7, "legendary": 0} @export var shop_refresh_cost_base: int = 20 # [1,999] knob(MC-1 provisional——GP 刷新基价) @export var shop_refresh_cost_curve: float = 1.8 # [1.0,4.0] knob(MC-1 provisional——按已付费刷新数幂曲线,A 链同族) -# —— 令牌配额(kind × tier 计数;未列组合 = 0。四级档名 common/excellent/rare/legendary -# 承袭同一物理位序〔70/25/7/0〕;各 kind 现值见行注默认)—— -@export var curio_quota_attack_common: int = 18 # [0,99] -@export var curio_quota_attack_excellent: int = 5 # [0,99] -@export var curio_quota_max_hp_common: int = 17 # [0,99] -@export var curio_quota_max_hp_excellent: int = 5 # [0,99] -@export var curio_quota_armor_common: int = 17 # [0,99] -@export var curio_quota_armor_excellent: int = 5 # [0,99] -@export var curio_quota_gp_common: int = 18 # [0,99] -@export var curio_quota_gp_excellent: int = 5 # [0,99] -@export var curio_quota_heal_excellent: int = 3 # [0,99]——恢复件(refresh 挤占 2 席位,总量 102 锚不变) -@export var curio_quota_refresh_excellent: int = 2 # [0,99]——刷新次数件(补给类成员;provisional 归曲线调参) -@export var curio_quota_ap_rare: int = 3 # [0,99]——AP 件(稀有档) -@export var curio_quota_rap_rare: int = 2 # [0,99]——储备金袋(投放 provisional 归调参) -@export var curio_quota_time_sand_rare: int = 2 # [0,99]——时沙(投放 provisional 归调参) - -# —— 令牌件值(小/大数值对——v2.2 令牌池口径定稿:20/50、+1/+2、+1/+2、+5/+10、恢复+6、AP+1)—— -@export var curio_value_attack_common: int = 1 # [0,999] knob——小增伤 -@export var curio_value_attack_excellent: int = 2 # [0,999] knob——大增伤 -@export var curio_value_max_hp_common: int = 5 # [0,999] knob——小生命 -@export var curio_value_max_hp_excellent: int = 10 # [0,999] knob——大生命 -@export var curio_value_armor_common: int = 1 # [0,999] knob——小护甲 -@export var curio_value_armor_excellent: int = 2 # [0,999] knob——大护甲 -@export var curio_value_gp_common: int = 20 # [0,999] knob——小GP -@export var curio_value_gp_excellent: int = 50 # [0,999] knob——大GP -@export var curio_value_heal_excellent: int = 6 # [0,999] knob——恢复(当即回血,封顶生效上限) -@export var curio_value_refresh_excellent: int = 1 # [0,9] knob(provisional)——刷新次数件兑付值(三选一重抽次数 +N——_attune_refresh_x 增量,不进加算区) -@export var curio_value_ap_rare: int = 1 # [0,10] knob——AP 件每场战斗首轮行动点基线增量 -@export var curio_value_rap_rare: int = 3 # [0,99] knob(provisional)——储备金袋 RAP +N -@export var curio_value_time_sand_rare: int = 2 # [0,99] knob(provisional)——时沙 下轮 AP +N - -# —— 已拿上限 cap(概率与上限解耦〔奇物.md 池结构案D〕——案②令牌池+提前退役: -# 概率位=档+令牌数〔牌库物理张数〕、上限=本字段〔拿了才计、刷出不计〕;cap 满后该 kind 从候选 -# 视图剔除〔提前退役〕。99=实质无上限占位,实数归调参;专长 cap 在 feats.tres 行字段〕)—— -@export var curio_cap_attack: int = 99 # [1,99] -@export var curio_cap_max_hp: int = 99 # [1,99] -@export var curio_cap_armor: int = 99 # [1,99] -@export var curio_cap_gp: int = 99 # [1,99] -@export var curio_cap_ap: int = 99 # [1,99] -@export var curio_cap_heal: int = 99 # [1,99] -@export var curio_cap_refresh: int = 99 # [1,99] -@export var curio_cap_rap: int = 99 # [1,99] -@export var curio_cap_time_sand: int = 99 # [1,99] +# —— 列族三段件(K4′ 拆段——36 个列族标量槽随段件嵌套,段内槽名逐字不动; +# 嵌套段单 .tres 保持——形态同 settlement_table 拆段既裁)—— +@export var quota_segment: AdventureQuotaSegment = AdventureQuotaSegment.new() # 令牌配额段(curio_quota_* 13) +@export var value_segment: AdventureValueSegment = AdventureValueSegment.new() # 令牌件值段(curio_value_* 13) +@export var cap_segment: AdventureCapSegment = AdventureCapSegment.new() # 已拿上限段(curio_cap_* 9) # —— 专长获取专长凭证(非 Boss 渠道的专长入口形态,奇物.md v2.7 渠道轴定位; # 四口径契约④:拿到即开不可囤积/罕见只在概率不在价格/一局限购一次/计步保底连续 5 展示未出 @@ -120,53 +82,53 @@ func curio_apply_route(kind: String) -> String: ## heal/refresh excellent、ap rare、凭证 kind rare 档) func curio_quota(kind: String, tier: String) -> int: match [kind, tier]: - ["attack", TIER_COMMON]: return curio_quota_attack_common - ["attack", TIER_EXCELLENT]: return curio_quota_attack_excellent - ["max_hp", TIER_COMMON]: return curio_quota_max_hp_common - ["max_hp", TIER_EXCELLENT]: return curio_quota_max_hp_excellent - ["armor", TIER_COMMON]: return curio_quota_armor_common - ["armor", TIER_EXCELLENT]: return curio_quota_armor_excellent - ["gp", TIER_COMMON]: return curio_quota_gp_common - ["gp", TIER_EXCELLENT]: return curio_quota_gp_excellent - ["heal", TIER_EXCELLENT]: return curio_quota_heal_excellent - ["refresh", TIER_EXCELLENT]: return curio_quota_refresh_excellent - ["ap", TIER_RARE]: return curio_quota_ap_rare - ["rap", TIER_RARE]: return curio_quota_rap_rare - ["time_sand", TIER_RARE]: return curio_quota_time_sand_rare + ["attack", TIER_COMMON]: return quota_segment.curio_quota_attack_common + ["attack", TIER_EXCELLENT]: return quota_segment.curio_quota_attack_excellent + ["max_hp", TIER_COMMON]: return quota_segment.curio_quota_max_hp_common + ["max_hp", TIER_EXCELLENT]: return quota_segment.curio_quota_max_hp_excellent + ["armor", TIER_COMMON]: return quota_segment.curio_quota_armor_common + ["armor", TIER_EXCELLENT]: return quota_segment.curio_quota_armor_excellent + ["gp", TIER_COMMON]: return quota_segment.curio_quota_gp_common + ["gp", TIER_EXCELLENT]: return quota_segment.curio_quota_gp_excellent + ["heal", TIER_EXCELLENT]: return quota_segment.curio_quota_heal_excellent + ["refresh", TIER_EXCELLENT]: return quota_segment.curio_quota_refresh_excellent + ["ap", TIER_RARE]: return quota_segment.curio_quota_ap_rare + ["rap", TIER_RARE]: return quota_segment.curio_quota_rap_rare + ["time_sand", TIER_RARE]: return quota_segment.curio_quota_time_sand_rare return 0 ## 令牌件值(kind × tier;四级构成随配额表) func curio_value(kind: String, tier: String = TIER_COMMON) -> int: match [kind, tier]: - ["attack", TIER_COMMON]: return curio_value_attack_common - ["attack", TIER_EXCELLENT]: return curio_value_attack_excellent - ["max_hp", TIER_COMMON]: return curio_value_max_hp_common - ["max_hp", TIER_EXCELLENT]: return curio_value_max_hp_excellent - ["armor", TIER_COMMON]: return curio_value_armor_common - ["armor", TIER_EXCELLENT]: return curio_value_armor_excellent - ["gp", TIER_COMMON]: return curio_value_gp_common - ["gp", TIER_EXCELLENT]: return curio_value_gp_excellent - ["heal", TIER_EXCELLENT]: return curio_value_heal_excellent - ["refresh", TIER_EXCELLENT]: return curio_value_refresh_excellent - ["ap", TIER_RARE]: return curio_value_ap_rare - ["rap", TIER_RARE]: return curio_value_rap_rare - ["time_sand", TIER_RARE]: return curio_value_time_sand_rare + ["attack", TIER_COMMON]: return value_segment.curio_value_attack_common + ["attack", TIER_EXCELLENT]: return value_segment.curio_value_attack_excellent + ["max_hp", TIER_COMMON]: return value_segment.curio_value_max_hp_common + ["max_hp", TIER_EXCELLENT]: return value_segment.curio_value_max_hp_excellent + ["armor", TIER_COMMON]: return value_segment.curio_value_armor_common + ["armor", TIER_EXCELLENT]: return value_segment.curio_value_armor_excellent + ["gp", TIER_COMMON]: return value_segment.curio_value_gp_common + ["gp", TIER_EXCELLENT]: return value_segment.curio_value_gp_excellent + ["heal", TIER_EXCELLENT]: return value_segment.curio_value_heal_excellent + ["refresh", TIER_EXCELLENT]: return value_segment.curio_value_refresh_excellent + ["ap", TIER_RARE]: return value_segment.curio_value_ap_rare + ["rap", TIER_RARE]: return value_segment.curio_value_rap_rare + ["time_sand", TIER_RARE]: return value_segment.curio_value_time_sand_rare return 0 ## 已拿上限读数(案②——kind 维度;专长 cap 走 feats.tres 行字段不走本面) func curio_cap(kind: String) -> int: match kind: - "attack": return curio_cap_attack - "max_hp": return curio_cap_max_hp - "armor": return curio_cap_armor - "gp": return curio_cap_gp - "ap": return curio_cap_ap - "heal": return curio_cap_heal - "refresh": return curio_cap_refresh - "rap": return curio_cap_rap - "time_sand": return curio_cap_time_sand + "attack": return cap_segment.curio_cap_attack + "max_hp": return cap_segment.curio_cap_max_hp + "armor": return cap_segment.curio_cap_armor + "gp": return cap_segment.curio_cap_gp + "ap": return cap_segment.curio_cap_ap + "heal": return cap_segment.curio_cap_heal + "refresh": return cap_segment.curio_cap_refresh + "rap": return cap_segment.curio_cap_rap + "time_sand": return cap_segment.curio_cap_time_sand return 99 diff --git a/game/data/schemas/adventure_value_segment.gd b/game/data/schemas/adventure_value_segment.gd new file mode 100644 index 00000000..e64fb15c --- /dev/null +++ b/game/data/schemas/adventure_value_segment.gd @@ -0,0 +1,18 @@ +## 冒险表·令牌件值段(K4′ 拆段——curio_value_* 13 槽随段件嵌套;段内槽名逐字不动; +## 小/大数值对——v2.2 令牌池口径定稿:20/50、+1/+2、+1/+2、+5/+10、恢复+6、AP+1)。 +class_name AdventureValueSegment +extends Resource + +@export var curio_value_attack_common: int = 1 # [0,999] knob——小增伤 +@export var curio_value_attack_excellent: int = 2 # [0,999] knob——大增伤 +@export var curio_value_max_hp_common: int = 5 # [0,999] knob——小生命 +@export var curio_value_max_hp_excellent: int = 10 # [0,999] knob——大生命 +@export var curio_value_armor_common: int = 1 # [0,999] knob——小护甲 +@export var curio_value_armor_excellent: int = 2 # [0,999] knob——大护甲 +@export var curio_value_gp_common: int = 20 # [0,999] knob——小GP +@export var curio_value_gp_excellent: int = 50 # [0,999] knob——大GP +@export var curio_value_heal_excellent: int = 6 # [0,999] knob——恢复(当即回血,封顶生效上限) +@export var curio_value_refresh_excellent: int = 1 # [0,9] knob(provisional)——刷新次数件兑付值(三选一重抽次数 +N——_attune_refresh_x 增量,不进加算区) +@export var curio_value_ap_rare: int = 1 # [0,10] knob——AP 件每场战斗首轮行动点基线增量 +@export var curio_value_rap_rare: int = 3 # [0,99] knob(provisional)——储备金袋 RAP +N +@export var curio_value_time_sand_rare: int = 2 # [0,99] knob(provisional)——时沙 下轮 AP +N diff --git a/game/data/schemas/board_gen_table.gd b/game/data/schemas/board_gen_table.gd index 664c4ab2..78420031 100644 --- a/game/data/schemas/board_gen_table.gd +++ b/game/data/schemas/board_gen_table.gd @@ -19,63 +19,100 @@ func validate() -> Array: var levels := {} var anchor_ok := {} for row in rows: - var L: int = int(row.get("level_index", 0)) - var D: int = int(row.get("node_depth", -1)) - var key := "%d_%d" % [L, D] - if levels.has(key): - errs.append({"category": "dup_key", "message": "BoardGen 键重复: %s" % key}) - levels[key] = true - if L < 1: - errs.append({"category": "domain", "message": "level_index 须 ≥1"}) - if D < 0: - errs.append({"category": "domain", "message": "node_depth 须 ≥0"}) - var w: int = int(row.get("board_width", 0)) - var h: int = int(row.get("board_height", 0)) - if w < 8 or w > 64 or h < 8 or h > 64: - errs.append({"category": "domain", "message": "(%d,%d) 尺寸越域 [8,64]" % [L, D]}) - var d: float = float(row.get("mine_density", 0.0)) - if d <= 0.0 or d > 0.5: - errs.append({"category": "domain", "message": "(%d,%d) mine_density 越域 (0,0.5]" % [L, D]}) - var t: float = float(row.get("inferability_target", 0.0)) - if t < TARGET_ABS_FLOOR or t > 1.0: - errs.append({"category": "domain", "message": "(%d,%d) inferability_target 越域 [0.85,1](P1 绝对锚)" % [L, D]}) - if not SOLVER_RULES.has(row.get("solver_rules", "")): - errs.append({"category": "enum", "message": "(%d,%d) solver_rules 出域" % [L, D]}) - if row.get("solver_rules", "") != "single_subset": - errs.append({"category": "frozen", "message": "(%d,%d) solver_rules 冻结 single_subset" % [L, D]}) - var ms: int = int(row.get("min_safe_count", 0)) - if ms < 1 or ms > 999: - errs.append({"category": "domain", "message": "(%d,%d) min_safe_count 越域 [1,999]" % [L, D]}) - var att: int = int(row.get("max_gen_attempts", 0)) - if att < 1 or att > 32: - errs.append({"category": "domain", "message": "(%d,%d) max_gen_attempts 越域 [1,32]" % [L, D]}) - # 布雷数下限(R3)——零雷板拒绝(−9 = 出生区格数,常量单点 BoardData.SPAWN_AREA_CELLS) - var n_mines := int(round(d * float(w * h - BoardData.SPAWN_AREA_CELLS))) - if n_mines < 1: - errs.append({"category": "cross_field", "message": "(%d,%d) 布雷数下限违例 round(d×(W×H−9)) ≥ 1" % [L, D]}) - # F2b min_safe 可行性 - if w * h - n_mines < ms: - errs.append({"category": "cross_field", "message": "(%d,%d) min_safe_feasible 违例" % [L, D]}) - # F2c 成本可行性 - if att * w * h > COST_BUDGET: - errs.append({"category": "cross_field", "message": "(%d,%d) cost_feasible 违例 attempts×W×H ≤ %d" % [L, D, COST_BUDGET]}) - # 图类型词表 v1.0(dungeon=地牢基线 / underdark=幽暗地域;扩词走本表修订) - var bt: String = str(row.get("board_type", "")) - if not BOARD_TYPE_VOCABULARY.has(bt): - errs.append({"category": "cross_field", "message": "(%d,%d) board_type 越词表: %s" % [L, D, bt]}) - # 初始展开收窄两参数(加载期校验违例致命;缺省 shave=1 现行投值 / cap=0 闸关防御口径) - var sr: int = int(row.get("shave_ring_radius", 1)) - if sr < 0 or sr > 2: - errs.append({"category": "domain", "message": "(%d,%d) shave_ring_radius 越域 [0,2]: %d" % [L, D, sr]}) - var r0_cap: int = int(row.get("r0_max_cap", 0)) - if r0_cap > 0: - # 护栏(需求卡片 §二):cap ≥ 出生格数 9(BoardData.SPAWN_AREA_CELLS)、cap ≤ 板格数一半 - if r0_cap < BoardData.SPAWN_AREA_CELLS: - errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 低于出生保障面 %d: %d" % [L, D, BoardData.SPAWN_AREA_CELLS, r0_cap]}) - if r0_cap > (w * h) / 2: - errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 超板格数一半 %d: %d" % [L, D, (w * h) / 2, r0_cap]}) - anchor_ok[L] = anchor_ok.get(L, false) or D == 0 + errs.append_array(_validate_row(row, levels, anchor_ok)) + errs.append_array(_validate_row_constraints(row)) + errs.append_array(_validate_shave_params(row)) for L in anchor_ok.keys(): if not anchor_ok[L]: errs.append({"category": "anchor", "message": "level %d 缺 depth-0 锚行(EC15)" % L}) return errs + + +## 行域:键唯一与进度/尺寸/密度/推理目标/solver 规则冻结(levels/anchor_ok 引用累计) +func _validate_row(row: Dictionary, levels: Dictionary, anchor_ok: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var L: int = int(row.get("level_index", 0)) + var D: int = int(row.get("node_depth", -1)) + var key := "%d_%d" % [L, D] + if levels.has(key): + errs.append({"category": "dup_key", "message": "BoardGen 键重复: %s" % key}) + levels[key] = true + if L < 1: + errs.append({"category": "domain", "message": "level_index 须 ≥1"}) + if D < 0: + errs.append({"category": "domain", "message": "node_depth 须 ≥0"}) + var w: int = int(row.get("board_width", 0)) + var h: int = int(row.get("board_height", 0)) + if w < 8 or w > 64 or h < 8 or h > 64: + errs.append({"category": "domain", "message": "(%d,%d) 尺寸越域 [8,64]" % [L, D]}) + var d: float = float(row.get("mine_density", 0.0)) + if d <= 0.0 or d > 0.5: + errs.append({"category": "domain", "message": "(%d,%d) mine_density 越域 (0,0.5]" % [L, D]}) + var t: float = float(row.get("inferability_target", 0.0)) + if t < TARGET_ABS_FLOOR or t > 1.0: + errs.append({"category": "domain", "message": "(%d,%d) inferability_target 越域 [0.85,1](P1 绝对锚)" % [L, D]}) + if not SOLVER_RULES.has(row.get("solver_rules", "")): + errs.append({"category": "enum", "message": "(%d,%d) solver_rules 出域" % [L, D]}) + if row.get("solver_rules", "") != "single_subset": + errs.append({"category": "frozen", "message": "(%d,%d) solver_rules 冻结 single_subset" % [L, D]}) + anchor_ok[L] = anchor_ok.get(L, false) or D == 0 + return errs + + +## 约束域:min_safe/生成尝试预算/布雷数下限与可行性/成本护栏/图类型词表 +func _validate_row_constraints(row: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var L: int = int(row.get("level_index", 0)) + var D: int = int(row.get("node_depth", -1)) + var w: int = int(row.get("board_width", 0)) + var h: int = int(row.get("board_height", 0)) + var d: float = float(row.get("mine_density", 0.0)) + var ms: int = int(row.get("min_safe_count", 0)) + if ms < 1 or ms > 999: + errs.append({"category": "domain", "message": "(%d,%d) min_safe_count 越域 [1,999]" % [L, D]}) + var att: int = int(row.get("max_gen_attempts", 0)) + if att < 1 or att > 32: + errs.append({"category": "domain", "message": "(%d,%d) max_gen_attempts 越域 [1,32]" % [L, D]}) + # 布雷数下限(R3)——零雷板拒绝(−9 = 出生区格数,常量单点 BoardData.SPAWN_AREA_CELLS) + var n_mines := int(round(d * float(w * h - BoardData.SPAWN_AREA_CELLS))) + if n_mines < 1: + errs.append({"category": "cross_field", "message": "(%d,%d) 布雷数下限违例 round(d×(W×H−9)) ≥ 1" % [L, D]}) + # F2b min_safe 可行性 + if w * h - n_mines < ms: + errs.append({"category": "cross_field", "message": "(%d,%d) min_safe_feasible 违例" % [L, D]}) + # F2c 成本可行性 + if att * w * h > COST_BUDGET: + errs.append({"category": "cross_field", "message": "(%d,%d) cost_feasible 违例 attempts×W×H ≤ %d" % [L, D, COST_BUDGET]}) + # 图类型词表 v1.0(dungeon=地牢基线 / underdark=幽暗地域;扩词走本表修订) + var bt: String = str(row.get("board_type", "")) + if not BOARD_TYPE_VOCABULARY.has(bt): + errs.append({"category": "cross_field", "message": "(%d,%d) board_type 越词表: %s" % [L, D, bt]}) + return errs + + +## 初始展开收窄域(加载期校验违例致命;缺省 shave=1 现行投值 / cap=0 闸关防御口径) +func _validate_shave_params(row: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var L: int = int(row.get("level_index", 0)) + var D: int = int(row.get("node_depth", -1)) + var w: int = int(row.get("board_width", 0)) + var h: int = int(row.get("board_height", 0)) + var sr: int = int(row.get("shave_ring_radius", 1)) + if sr < 0 or sr > 2: + errs.append({"category": "domain", "message": "(%d,%d) shave_ring_radius 越域 [0,2]: %d" % [L, D, sr]}) + var r0_cap: int = int(row.get("r0_max_cap", 0)) + if r0_cap > 0: + # 护栏(需求卡片 §二):cap ≥ 出生格数 9(BoardData.SPAWN_AREA_CELLS)、cap ≤ 板格数一半 + if r0_cap < BoardData.SPAWN_AREA_CELLS: + errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 低于出生保障面 %d: %d" % [L, D, BoardData.SPAWN_AREA_CELLS, r0_cap]}) + if r0_cap > (w * h) / 2: + errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 超板格数一半 %d: %d" % [L, D, (w * h) / 2, r0_cap]}) + return errs + + +var _resolver # R1 解析协作件(ConfigDB load_tables 装配段注入——resolver 文件与键零搬移) + + +## 进度行解析委托(消费面 db.combat.board_gen.resolve_row——R1 ceil/clamp 语义归 ConfigDbResolver) +func resolve_row(L: int, D: int) -> Dictionary: + return _resolver.combat.board_gen.resolve_row(L, D) diff --git a/game/data/schemas/difficulty_table.gd b/game/data/schemas/difficulty_table.gd index 964eabdd..db21e2bd 100644 --- a/game/data/schemas/difficulty_table.gd +++ b/game/data/schemas/difficulty_table.gd @@ -28,3 +28,11 @@ func validate() -> Array: if v < MULT_MIN or v > MULT_MAX: errs.append({"category": "mult_oor_warning", "message": "%s (%s) 越域 [0.1,10] 使用时 clamp: %f" % [key, k, v]}) return errs + + +var _resolver # R1 解析协作件(ConfigDB load_tables 装配段注入——resolver 文件与键零搬移) + + +## 难度修饰解析委托(消费面 db.run.difficulty.resolve_modifiers——clamp 语义归 ConfigDbResolver) +func resolve_modifiers(L: int, D: int) -> Dictionary: + return _resolver.run.difficulty.resolve_modifiers(L, D) diff --git a/game/data/schemas/enemy_table.gd b/game/data/schemas/enemy_table.gd index 633fa734..851f2a6c 100644 --- a/game/data/schemas/enemy_table.gd +++ b/game/data/schemas/enemy_table.gd @@ -44,98 +44,147 @@ func validate() -> Array: var anchor_ok := {} var boss_ok := {} for row in rows: - var L: int = int(row.get("level_index", 0)) - var rk: String = String(row.get("row_kind", "")) - var band: int = int(row.get("depth_band", -1)) - var eid: String = row.get("enemy_id", "") - var key := "%d_%s_%d_%s" % [L, rk, band, eid] - if seen.has(key): - errs.append({"category": "dup_key", "message": "EnemyTable 键重复: %s" % key}) - seen[key] = true - if L < 1 or band < 0: - errs.append({"category": "domain", "message": "进度键越域 (%d,band %d)" % [L, band]}) - if not ROW_KINDS.has(rk): - errs.append({"category": "domain", "message": "%s row_kind 未知枚举: \"%s\"(域 {battle, boss})" % [eid, rk]}) - if eid == "": - errs.append({"category": "domain", "message": "enemy_id 空"}) - if String(row.get("display_name", "")) == "": - errs.append({"category": "domain", "message": "%s display_name 空" % eid}) - var uc: int = int(row.get("unit_count", 0)) - if uc < UNIT_COUNT_MIN or uc > UNIT_COUNT_MAX: - errs.append({"category": "domain", "message": "%s unit_count 越域 [%d,%d]" % [eid, UNIT_COUNT_MIN, UNIT_COUNT_MAX]}) - # unit_count_max(可省略——遭遇随机化批,难度曲线与Boss GDD Core Rule 8):区间 [unit_count, max],缺省 = 定值 - if row.has("unit_count_max"): - var ucm: int = int(row.get("unit_count_max", uc)) - if ucm < uc or ucm > UNIT_COUNT_MAX: - errs.append({"category": "cross_field", "message": "%s unit_count_max(%d) 越域 [unit_count(%d),%d]" % [eid, ucm, uc, UNIT_COUNT_MAX]}) - var hp: int = int(row.get("hp", 0)) - if hp < 1 or hp > 999: - errs.append({"category": "domain", "message": "%s hp 越域 [1,999]" % eid}) - var atk: int = int(row.get("atk", -1)) - if atk < 0 or atk > 999: - errs.append({"category": "domain", "message": "%s atk 越域 [0,999]" % eid}) - # atk_min/atk_max(可省略配对):遭遇骰同流 spawn 期整战持久 roll; - # 中点锚 = 行动经济 条款①同款期望当量纪律(调区间必同步调 atk) - var has_amin: bool = row.has("atk_min") - var has_amax: bool = row.has("atk_max") - if has_amin != has_amax: - errs.append({"category": "cross_field", "message": "%s atk_min/atk_max 必须成对出现" % eid}) - elif has_amin: - var amin: int = int(row.get("atk_min", 0)) - var amax: int = int(row.get("atk_max", 0)) - if amin < 0 or amax > 999 or amin > amax: - errs.append({"category": "domain", "message": "%s atk 区间越域 [0,999] 且 min≤max: %d..%d" % [eid, amin, amax]}) - if atk != int((amin + amax) / 2.0): - errs.append({"category": "cross_field", "message": "%s atk(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [eid, atk, (amin + amax) / 2]}) - # 池模板(单表结构校验;跨表构成纪律在 ConfigDB): - # 非空池 + 条目 {skill_id 非空, weight > 0} + 池内 skill_id 不重复。 - # 普攻冷却恒 0(SkillTable schema 锁死)→ 可摇集合恒非空,"空池不可能"由此结构保证。 - var pool: Array[Variant] = row.get("pool", []) - if pool.is_empty(): - errs.append({"category": "domain", "message": "%s pool 空池(禁——技能池行必带池模板)" % eid}) - var pool_ids := {} - for entry_v in pool: - if not (entry_v is Dictionary): - errs.append({"category": "domain", "message": "%s pool 条目非字典: %s" % [eid, str(entry_v)]}) - continue - var entry: Dictionary[Variant, Variant] = entry_v - var sid: String = String(entry.get("skill_id", "")) - if sid == "": - errs.append({"category": "domain", "message": "%s pool 条目 skill_id 空" % eid}) - elif pool_ids.has(sid): - errs.append({"category": "dup_key", "message": "%s pool 内 skill_id 重复: %s" % [eid, sid]}) - pool_ids[sid] = true - var w: int = int(entry.get("weight", 0)) - if w <= 0 or w > 9999: - errs.append({"category": "domain", "message": "%s pool 权重越域 (0,9999]: %d" % [eid, w]}) - # armor_type(可省略——缺省 light 向后兼容) - var at: String = String(row.get("armor_type", "light")) - if not ARMOR_TYPES.has(at): - errs.append({"category": "enum", "message": "%s armor_type 未知枚举: %s(域 {light, ward, heavy})" % [eid, at]}) - # attack_type(可省略,缺省 normal 向后兼容;写法沿用 armor_type 同款) - var att: String = String(row.get("attack_type", "normal")) - if not ATTACK_TYPES.has(att): - errs.append({"category": "enum", "message": "%s attack_type 未知枚举: %s(域 {normal, pierce, magic})" % [eid, att]}) - # armor_class(可省略——缺省 0 向后兼容;防守方掷点判定阈值基数) - var ac: int = int(row.get("armor_class", 0)) - if ac < 0 or ac > 999: - errs.append({"category": "domain", "message": "%s armor_class 越域 [0,999]: %d" % [eid, ac]}) + errs.append_array(_validate_row_base(row, seen)) + var eid2 := String(row.get("enemy_id", "")) + errs.append_array(_validate_row_numbers(row, eid2)) + errs.append_array(_validate_row_atk_range(row, eid2)) + errs.append_array(_validate_row_pool(row, eid2)) + errs.append_array(_validate_row_types(row, eid2)) # 行集构成纪律②(行序即契约,敌人系统 v0.15):同带键多行必须 # 文件内相邻。迭代中记录上一行行集键;当前行集键曾出现过但 ≠ 上一行行集键 = 同带键行被隔断,报错。 # 同带键多敌种混编合法;同带键同敌种第二行仍由 # dup_key 拦截(键含 enemy_id)。错误类别沿用 rowset_rule(定位面不变)。 - var rowset_key := "%d_%s_%d" % [L, rk, band] + var rowset_key := "%d_%s_%d" % [int(row.get("level_index", 0)), String(row.get("row_kind", "")), int(row.get("depth_band", -1))] if rowset_key != last_rowset_key: - if closed_rowset.has(rowset_key): - errs.append({"category": "rowset_rule", "message": "(%d,%s,%d) 行集行序违例:同带键多行必须文件内相邻(行序即契约——resolver 返回序/生成序/叙述取首行全依赖文件行序)" % [L, rk, band]}) + errs.append_array(_validate_rowset_closed(rowset_key, closed_rowset, row)) closed_rowset[rowset_key] = true last_rowset_key = rowset_key - if rk == "battle" and band == 0: - anchor_ok[L] = true - elif rk == "boss": - boss_ok[L] = true - if not anchor_ok.has(L): - anchor_ok[L] = false + var L2: int = int(row.get("level_index", 0)) + var rk2: String = String(row.get("row_kind", "")) + if rk2 == "battle" and int(row.get("depth_band", -1)) == 0: + anchor_ok[L2] = true + elif rk2 == "boss": + boss_ok[L2] = true + if not anchor_ok.has(L2): + anchor_ok[L2] = false + errs.append_array(_validate_anchor_rows(anchor_ok, boss_ok)) + return errs + + +## 行头基面域:键提取/查重与登记/进度键越域/row_kind 枚举/enemy_id 非空/display_name +func _validate_row_base(row: Dictionary, seen: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var L: int = int(row.get("level_index", 0)) + var rk: String = String(row.get("row_kind", "")) + var band: int = int(row.get("depth_band", -1)) + var eid: String = row.get("enemy_id", "") + var key := "%d_%s_%d_%s" % [L, rk, band, eid] + if seen.has(key): + errs.append({"category": "dup_key", "message": "EnemyTable 键重复: %s" % key}) + seen[key] = true + if L < 1 or band < 0: + errs.append({"category": "domain", "message": "进度键越域 (%d,band %d)" % [L, band]}) + if not ROW_KINDS.has(rk): + errs.append({"category": "domain", "message": "%s row_kind 未知枚举: \"%s\"(域 {battle, boss})" % [eid, rk]}) + if eid == "": + errs.append({"category": "domain", "message": "enemy_id 空"}) + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % eid}) + return errs + + +## 数值域:unit_count(含可省略上限)/hp/atk 基值 +func _validate_row_numbers(row: Dictionary, eid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var uc: int = int(row.get("unit_count", 0)) + if uc < UNIT_COUNT_MIN or uc > UNIT_COUNT_MAX: + errs.append({"category": "domain", "message": "%s unit_count 越域 [%d,%d]" % [eid, UNIT_COUNT_MIN, UNIT_COUNT_MAX]}) + # unit_count_max(可省略——遭遇随机化批,难度曲线与Boss GDD Core Rule 8):区间 [unit_count, max],缺省 = 定值 + if row.has("unit_count_max"): + var ucm: int = int(row.get("unit_count_max", uc)) + if ucm < uc or ucm > UNIT_COUNT_MAX: + errs.append({"category": "cross_field", "message": "%s unit_count_max(%d) 越域 [unit_count(%d),%d]" % [eid, ucm, uc, UNIT_COUNT_MAX]}) + var hp: int = int(row.get("hp", 0)) + if hp < 1 or hp > 999: + errs.append({"category": "domain", "message": "%s hp 越域 [1,999]" % eid}) + var atk: int = int(row.get("atk", -1)) + if atk < 0 or atk > 999: + errs.append({"category": "domain", "message": "%s atk 越域 [0,999]" % eid}) + return errs + + +## atk 区间域(可省略配对):遭遇骰同流 spawn 期整战持久 roll; +## 中点锚 = 行动经济 条款①同款期望当量纪律(调区间必同步调 atk) +func _validate_row_atk_range(row: Dictionary, eid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var atk: int = int(row.get("atk", -1)) + var has_amin: bool = row.has("atk_min") + var has_amax: bool = row.has("atk_max") + if has_amin != has_amax: + errs.append({"category": "cross_field", "message": "%s atk_min/atk_max 必须成对出现" % eid}) + elif has_amin: + var amin: int = int(row.get("atk_min", 0)) + var amax: int = int(row.get("atk_max", 0)) + if amin < 0 or amax > 999 or amin > amax: + errs.append({"category": "domain", "message": "%s atk 区间越域 [0,999] 且 min≤max: %d..%d" % [eid, amin, amax]}) + if atk != int((amin + amax) / 2.0): + errs.append({"category": "cross_field", "message": "%s atk(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [eid, atk, (amin + amax) / 2]}) + return errs + + +## 池模板域(单表结构校验;跨表构成纪律在 ConfigDB): +## 非空池 + 条目 {skill_id 非空, weight > 0} + 池内 skill_id 不重复。 +## 普攻冷却恒 0(SkillTable schema 锁死)→ 可摇集合恒非空,"空池不可能"由此结构保证。 +func _validate_row_pool(row: Dictionary, eid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var pool: Array[Variant] = row.get("pool", []) + if pool.is_empty(): + errs.append({"category": "domain", "message": "%s pool 空池(禁——技能池行必带池模板)" % eid}) + var pool_ids := {} + for entry_v in pool: + if not (entry_v is Dictionary): + errs.append({"category": "domain", "message": "%s pool 条目非字典: %s" % [eid, str(entry_v)]}) + continue + var entry: Dictionary[Variant, Variant] = entry_v + var sid: String = String(entry.get("skill_id", "")) + if sid == "": + errs.append({"category": "domain", "message": "%s pool 条目 skill_id 空" % eid}) + elif pool_ids.has(sid): + errs.append({"category": "dup_key", "message": "%s pool 内 skill_id 重复: %s" % [eid, sid]}) + pool_ids[sid] = true + var w: int = int(entry.get("weight", 0)) + if w <= 0 or w > 9999: + errs.append({"category": "domain", "message": "%s pool 权重越域 (0,9999]: %d" % [eid, w]}) + return errs + + +## 攻防类型域(均可省略——armor_type 缺省 light/attack_type 缺省 normal/armor_class 缺省 0 向后兼容) +func _validate_row_types(row: Dictionary, eid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var at: String = String(row.get("armor_type", "light")) + if not ARMOR_TYPES.has(at): + errs.append({"category": "enum", "message": "%s armor_type 未知枚举: %s(域 {light, ward, heavy})" % [eid, at]}) + var att: String = String(row.get("attack_type", "normal")) + if not ATTACK_TYPES.has(att): + errs.append({"category": "enum", "message": "%s attack_type 未知枚举: %s(域 {normal, pierce, magic})" % [eid, att]}) + # armor_class(防守方掷点判定阈值基数) + var ac: int = int(row.get("armor_class", 0)) + if ac < 0 or ac > 999: + errs.append({"category": "domain", "message": "%s armor_class 越域 [0,999]: %d" % [eid, ac]}) + return errs + + +## 行序违例域:当前行集键曾出现且 ≠ 上一行行集键 = 同带键行被隔断(行序即契约——resolver 返回序/生成序/叙述取首行全依赖文件行序) +func _validate_rowset_closed(rowset_key: String, closed_rowset: Dictionary, row: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + if closed_rowset.has(rowset_key): + errs.append({"category": "rowset_rule", "message": "(%d,%s,%d) 行集行序违例:同带键多行必须文件内相邻(行序即契约——resolver 返回序/生成序/叙述取首行全依赖文件行序)" % [int(row.get("level_index", 0)), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]}) + return errs + + +## 锚行尾段:每 level 必有 (battle, band 0) 锚行与 Boss 行集 +func _validate_anchor_rows(anchor_ok: Dictionary, boss_ok: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] for L in anchor_ok.keys(): if not anchor_ok[L]: errs.append({"category": "anchor", "message": "EnemyTable level %d 缺 (battle, band 0) 锚行" % L}) @@ -158,3 +207,11 @@ static func band_of(band_keys: Array, D: int) -> int: # 进度键解析单点收口(全仓统走 # ConfigDbResolver.resolve_enemy_rowset 带寻址行集数组:D 维经本表 band_of 落带(偏难一侧), # 禁双算法并存〔状态记录 312〕;L 维 ceil/clamp 与越顶 clamp 语义归 resolver)。 + + +var _resolver # R1 解析协作件(ConfigDB load_tables 装配段注入——resolver 文件与键零搬移) + + +## 带寻址行集解析委托(消费面 db.combat.enemy.resolve_rowset——D 维 band_of 落带语义归 ConfigDbResolver) +func resolve_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary]: + return _resolver.combat.enemy.resolve_rowset(L, D, row_kind) diff --git a/game/data/schemas/forge_table.gd b/game/data/schemas/forge_table.gd index a9ffb6ec..57447c6a 100644 --- a/game/data/schemas/forge_table.gd +++ b/game/data/schemas/forge_table.gd @@ -45,66 +45,101 @@ func validate() -> Array: for row in routes: # 预扫:route_id→kind 全集(前置反查需前向引用——轻甲行在长枪行之后) kind_by_id[String(row.get("route_id", ""))] = String(row.get("kind", "")) for row in routes: - var rid := String(row.get("route_id", "")) - if not ROUTE_IDS.has(rid): - errs.append({"category": "enum", "message": "锻造路线 route_id 出域 %s: %s" % [ROUTE_IDS, rid]}) - if seen.has(rid): - errs.append({"category": "dup_key", "message": "锻造路线 route_id 重复: %s" % rid}) - seen[rid] = true - var kind := String(row.get("kind", "")) - if not ["weapon", "armor"].has(kind): - errs.append({"category": "enum", "message": "%s kind 出域 {weapon, armor}: %s" % [rid, kind]}) + var head_out := _validate_route_head(row, seen, type_seen) + errs.append_array(head_out["errs"]) + if head_out["skip"]: continue - # 类型轴:武器=attack_type 域、甲=armor_type 域;每型恰一路线(三武器三甲各一) - var axis := String(row.get("type_axis", "")) - var domain: Array[Variant] = ATTACK_TYPES if kind == "weapon" else ARMOR_TYPES - if not domain.has(axis): - errs.append({"category": "enum", "message": "%s type_axis 出域 %s: %s" % [rid, domain, axis]}) - var type_key := "%s|%s" % [kind, axis] - if type_seen.has(type_key): - errs.append({"category": "dup_key", "message": "%s 类型轴重复(每型恰一路线): %s %s" % [kind, axis, rid]}) - type_seen[type_key] = true - # 等级/增量/价格/基础 AC 值域 - var cap_v: int = int(row.get("cap", -1)) - if cap_v < 1 or cap_v > 9: - errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [rid, cap_v]}) - var gain: int = int(row.get("per_level_gain", -1)) - if gain < 0 or gain > 9: - errs.append({"category": "domain", "message": "%s per_level_gain 越域 [0,9]: %d" % [rid, gain]}) - var base_ac: int = int(row.get("base_ac", -1)) - if kind == "armor": - if base_ac < 0 or base_ac > 999: - errs.append({"category": "domain", "message": "%s base_ac 越域 [0,999]: %d" % [rid, base_ac]}) - elif base_ac != 0: - errs.append({"category": "cross_field", "message": "%s 武器行 base_ac 须为 0(甲型基础 AC 只属护甲路线): %d" % [rid, base_ac]}) - var base_price: int = int(row.get("base_price", -1)) - if base_price < 1 or base_price > 999: - errs.append({"category": "domain", "message": "%s base_price 越域 [1,999]: %d" % [rid, base_price]}) - var ratio: int = int(row.get("price_ratio_pct", -1)) - if ratio < 100 or ratio > 500: - errs.append({"category": "domain", "message": "%s price_ratio_pct 越域 [100,500]: %d" % [rid, ratio]}) - var unlock_price: int = int(row.get("unlock_price", -1)) - if unlock_price < 0 or unlock_price > 999: - errs.append({"category": "domain", "message": "%s unlock_price 越域 [0,999]: %d" % [rid, unlock_price]}) - # 解锁通道(并联双通道):初始持有行无前置无解锁价;待解锁行须有一次性解锁价+合法前置 - var initial: bool = bool(row.get("initial_owned", false)) - var prereq := String(row.get("prereq_route", "")) - if initial: - if prereq != "": - errs.append({"category": "cross_field", "message": "%s 初始持有行不得带前置: %s" % [rid, prereq]}) - if unlock_price != 0: - errs.append({"category": "cross_field", "message": "%s 初始持有行解锁价须为 0: %d" % [rid, unlock_price]}) - initial_ids[rid] = kind - else: - if unlock_price < 1: - errs.append({"category": "cross_field", "message": "%s 待解锁行须有一次性解锁价(并联双通道②): %d" % [rid, unlock_price]}) - if not ROUTE_IDS.has(prereq): - errs.append({"category": "enum", "message": "%s prereq_route 出域 %s: %s" % [rid, ROUTE_IDS, prereq]}) - elif prereq == rid: - errs.append({"category": "cross_field", "message": "%s prereq_route 不得自指" % rid}) - elif kind_by_id.has(prereq) and String(kind_by_id[prereq]) != kind: - errs.append({"category": "cross_field", "message": "%s 前置 %s 类型不符(武器←武器/甲←甲): %s vs %s" % [rid, prereq, kind, String(kind_by_id[prereq])]}) - # 初始持有恰两行且一武器一甲(长剑/轻甲) + var rid := String(row.get("route_id", "")) + errs.append_array(_validate_route_values(row, rid)) + errs.append_array(_validate_route_unlock(row, rid, kind_by_id, initial_ids)) + errs.append_array(_validate_initial_coverage(initial_ids)) + return errs + + +## 行头域:route_id 词表与查重/kind 出域(skip=跳过行剩余判据)/类型轴域与每型恰一路线 +func _validate_route_head(row: Dictionary, seen: Dictionary, type_seen: Dictionary) -> Dictionary: + var errs: Array[Dictionary] = [] + var skip := false + var rid := String(row.get("route_id", "")) + if not ROUTE_IDS.has(rid): + errs.append({"category": "enum", "message": "锻造路线 route_id 出域 %s: %s" % [ROUTE_IDS, rid]}) + if seen.has(rid): + errs.append({"category": "dup_key", "message": "锻造路线 route_id 重复: %s" % rid}) + seen[rid] = true + var kind := String(row.get("kind", "")) + if not ["weapon", "armor"].has(kind): + errs.append({"category": "enum", "message": "%s kind 出域 {weapon, armor}: %s" % [rid, kind]}) + skip = true + return {"errs": errs, "skip": skip} + # 类型轴:武器=attack_type 域、甲=armor_type 域;每型恰一路线(三武器三甲各一) + var axis := String(row.get("type_axis", "")) + var domain: Array[Variant] = ATTACK_TYPES if kind == "weapon" else ARMOR_TYPES + if not domain.has(axis): + errs.append({"category": "enum", "message": "%s type_axis 出域 %s: %s" % [rid, domain, axis]}) + var type_key := "%s|%s" % [kind, axis] + if type_seen.has(type_key): + errs.append({"category": "dup_key", "message": "%s 类型轴重复(每型恰一路线): %s %s" % [kind, axis, rid]}) + type_seen[type_key] = true + return {"errs": errs, "skip": skip} + + +## 数值域:等级/增量/价格/基础 AC 值域(甲型基础 AC 只属护甲路线) +func _validate_route_values(row: Dictionary, rid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var kind := String(row.get("kind", "")) + var cap_v: int = int(row.get("cap", -1)) + if cap_v < 1 or cap_v > 9: + errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [rid, cap_v]}) + var gain: int = int(row.get("per_level_gain", -1)) + if gain < 0 or gain > 9: + errs.append({"category": "domain", "message": "%s per_level_gain 越域 [0,9]: %d" % [rid, gain]}) + var base_ac: int = int(row.get("base_ac", -1)) + if kind == "armor": + if base_ac < 0 or base_ac > 999: + errs.append({"category": "domain", "message": "%s base_ac 越域 [0,999]: %d" % [rid, base_ac]}) + elif base_ac != 0: + errs.append({"category": "cross_field", "message": "%s 武器行 base_ac 须为 0(甲型基础 AC 只属护甲路线): %d" % [rid, base_ac]}) + var base_price: int = int(row.get("base_price", -1)) + if base_price < 1 or base_price > 999: + errs.append({"category": "domain", "message": "%s base_price 越域 [1,999]: %d" % [rid, base_price]}) + var ratio: int = int(row.get("price_ratio_pct", -1)) + if ratio < 100 or ratio > 500: + errs.append({"category": "domain", "message": "%s price_ratio_pct 越域 [100,500]: %d" % [rid, ratio]}) + var unlock_price: int = int(row.get("unlock_price", -1)) + if unlock_price < 0 or unlock_price > 999: + errs.append({"category": "domain", "message": "%s unlock_price 越域 [0,999]: %d" % [rid, unlock_price]}) + return errs + + +## 解锁通道域(并联双通道):初始持有行无前置无解锁价;待解锁行须有一次性解锁价+合法前置(武器←武器/甲←甲) +func _validate_route_unlock(row: Dictionary, rid: String, kind_by_id: Dictionary, initial_ids: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var kind := String(row.get("kind", "")) + var unlock_price: int = int(row.get("unlock_price", -1)) + var initial: bool = bool(row.get("initial_owned", false)) + var prereq := String(row.get("prereq_route", "")) + if initial: + if prereq != "": + errs.append({"category": "cross_field", "message": "%s 初始持有行不得带前置: %s" % [rid, prereq]}) + if unlock_price != 0: + errs.append({"category": "cross_field", "message": "%s 初始持有行解锁价须为 0: %d" % [rid, unlock_price]}) + initial_ids[rid] = kind + else: + if unlock_price < 1: + errs.append({"category": "cross_field", "message": "%s 待解锁行须有一次性解锁价(并联双通道②): %d" % [rid, unlock_price]}) + if not ROUTE_IDS.has(prereq): + errs.append({"category": "enum", "message": "%s prereq_route 出域 %s: %s" % [rid, ROUTE_IDS, prereq]}) + elif prereq == rid: + errs.append({"category": "cross_field", "message": "%s prereq_route 不得自指" % rid}) + elif kind_by_id.has(prereq) and String(kind_by_id[prereq]) != kind: + errs.append({"category": "cross_field", "message": "%s 前置 %s 类型不符(武器←武器/甲←甲): %s vs %s" % [rid, prereq, kind, String(kind_by_id[prereq])]}) + return errs + + +## 初始持有覆盖域(两尾段):初始持有恰两行且一武器一甲(长剑/轻甲)+ +## 默认轴=初始持有路线类型轴(Q1 裸体态语义:未锻造=0 加值且类型=普通/轻甲) +func _validate_initial_coverage(initial_ids: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] var init_weapons := 0 var init_armors := 0 for k in initial_ids.keys(): @@ -114,7 +149,6 @@ func validate() -> Array: init_armors += 1 if init_weapons != 1 or init_armors != 1: errs.append({"category": "coverage", "message": "初始持有须恰一武器一甲(现 sword/light): 武器 %d / 甲 %d" % [init_weapons, init_armors]}) - # 默认轴=初始持有路线类型轴(Q1 裸体态语义:未锻造=0 加值且类型=普通/轻甲) if init_weapons == 1 and init_armors == 1: for row in routes: if not bool(row.get("initial_owned", false)): @@ -124,3 +158,27 @@ func validate() -> Array: if String(row.get("kind", "")) == "armor" and String(row.get("type_axis", "")) != default_armor_type: errs.append({"category": "cross_field", "message": "default_armor_type 须=初始甲路线类型轴(裸体态轻甲): %s vs %s" % [default_armor_type, String(row.get("type_axis", ""))]}) return errs + + +# ---- 行族访问器(自 ConfigDB 归位;消费面 db.crossrun.forge. 直连)—— +# routes 属性在册故行族读取命名 route_rows(避开成员名冲突)---- + +## 六路线行族(副本——调用方禁改) +func route_rows() -> Array: + return routes.duplicate() + + +## 路线行单点(键 route_id;缺行=空字典) +func route(route_id: String) -> Dictionary: + for row in routes: + if String(row.get("route_id", "")) == route_id: + return row + return {} + + +## 按类型轴反查护甲路线(armor_type → 路线行——入战 AC 三层之「甲型基础 AC」取数口) +func armor_route_by_type(armor_type: String) -> Dictionary: + for row in routes: + if String(row.get("kind", "")) == "armor" and String(row.get("type_axis", "")) == armor_type: + return row + return {} diff --git a/game/data/schemas/level_gen_params.gd b/game/data/schemas/level_gen_params.gd index 20df9628..53284bcd 100644 --- a/game/data/schemas/level_gen_params.gd +++ b/game/data/schemas/level_gen_params.gd @@ -1,5 +1,5 @@ ## 关卡节点图参数档 schema(节点图生成器核心批 卡① T1):五旋钮+战斗保底+岔口去路数的行式数据表。 -## 键 = level_index(关卡行集单点),消费面经 ConfigDB.level_gen_params_row() 读取;生成器只吃参数档、不认关卡号(契约 6)。 +## 键 = level_index(关卡行集单点),消费面经 ConfigDB.run.level_gen_params.row_by_level_index() 读取;生成器只吃参数档、不认关卡号(契约 6)。 ## 数值全 provisional(定值批落终值);域校验沿关卡节点图生成.md v1.1 调参表已锁条款——层深 L1≥6 递增增幅变大、 ## 配比四类合计 1、出口 L1 恒 1/L2-L3 1-3、岔口去路数恒 2/3、战斗保底≥0。 class_name LevelGenParams @@ -17,50 +17,13 @@ func validate() -> Array: var seen := {} var layers_by_level := {} for row in rows: - var L: int = int(row.get("level_index", 0)) - var key := "level %d" % L - if seen.has(L): - errs.append({"category": "dup_key", "message": "LevelGenParams 键重复: %d" % L}) - seen[L] = true - if L < 1: - errs.append({"category": "domain", "message": "%s 关卡序号越域(≥1)" % key}) - var ks := (row as Dictionary).keys() - ks.sort() - var expect := ROW_KEYS.duplicate() - expect.sort() - if ks != expect: - errs.append({"category": "structural", "message": "%s 字段键集非 ROW_KEYS 闭集" % key}) + var head := _validate_row_head(row, seen) + errs.append_array(head["errs"]) + if not head["ok"]: continue - var layers: int = int(row.get("layers", 0)) - if layers < 6: - errs.append({"category": "domain", "message": "%s 层深 %d 低于下限 6(层深 L1≥6 已锁)" % [key, layers]}) - layers_by_level[L] = layers - var bd: float = float(row.get("branch_density", -1.0)) - if bd < 0.0 or bd > 1.0: - errs.append({"category": "domain", "message": "%s 分支密度 %f 越域 [0,1]" % [key, bd]}) - var ratio_sum := 0.0 - for rk in RATIO_KEYS: - var v: float = float(row.get(rk, -1.0)) - if v < 0.0 or v > 1.0: - errs.append({"category": "domain", "message": "%s %s=%f 越域 [0,1]" % [key, rk, v]}) - ratio_sum += v - if not is_equal_approx(ratio_sum, 1.0): - errs.append({"category": "domain", "message": "%s 配比四类合计 %f ≠ 1(已锁)" % [key, ratio_sum]}) - var entry_count: int = int(row.get("entry_count", 0)) - if entry_count < 1: - errs.append({"category": "domain", "message": "%s 入口个数 %d 越域(≥1)" % [key, entry_count]}) - var exit_count: int = int(row.get("exit_count", 0)) - if exit_count < 1 or exit_count > 3: - errs.append({"category": "domain", "message": "%s 出口个数 %d 越域 [1,3](已锁范围)" % [key, exit_count]}) - if L == 1 and exit_count != 1: - errs.append({"category": "domain", "message": "%s 出口个数 %d ≠ 1(L1 出口恒 1 已锁)" % [key, exit_count]}) - var bf: int = int(row.get("battle_floor", -1)) - if bf < 0: - errs.append({"category": "domain", "message": "%s 战斗保底 %d 越域(≥0)" % [key, bf]}) - var fmin: int = int(row.get("fork_ways_min", 0)) - var fmax: int = int(row.get("fork_ways_max", 0)) - if fmin != 2 or fmax != 3: - errs.append({"category": "domain", "message": "%s 岔口去路数 (%d,%d) ≠ (2,3)(已锁)" % [key, fmin, fmax]}) + errs.append_array(_validate_row_shape(row, head["L"], head["key"], layers_by_level)) + errs.append_array(_validate_row_counts(row, head["L"], head["key"])) + # 层深递进尾段(L1 Array: return errs +## 行头域:level_index 查重/关卡序号越域/字段键集闭集(ok=跳过行剩余判据) +func _validate_row_head(row: Dictionary, seen: Dictionary) -> Dictionary: + var errs: Array[Dictionary] = [] + var L: int = int(row.get("level_index", 0)) + var key := "level %d" % L + if seen.has(L): + errs.append({"category": "dup_key", "message": "LevelGenParams 键重复: %d" % L}) + seen[L] = true + if L < 1: + errs.append({"category": "domain", "message": "%s 关卡序号越域(≥1)" % key}) + var ks := (row as Dictionary).keys() + ks.sort() + var expect := ROW_KEYS.duplicate() + expect.sort() + if ks != expect: + errs.append({"category": "structural", "message": "%s 字段键集非 ROW_KEYS 闭集" % key}) + return {"errs": errs, "L": L, "key": key, "ok": false} + return {"errs": errs, "L": L, "key": key, "ok": true} + + +## 行形态域:层深下限(layers_by_level 引用登记——层深递进尾段取数)/分支密度/配比四类合计 1 +func _validate_row_shape(row: Dictionary, L: int, key: String, layers_by_level: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var layers: int = int(row.get("layers", 0)) + if layers < 6: + errs.append({"category": "domain", "message": "%s 层深 %d 低于下限 6(层深 L1≥6 已锁)" % [key, layers]}) + layers_by_level[L] = layers + var bd: float = float(row.get("branch_density", -1.0)) + if bd < 0.0 or bd > 1.0: + errs.append({"category": "domain", "message": "%s 分支密度 %f 越域 [0,1]" % [key, bd]}) + var ratio_sum := 0.0 + for rk in RATIO_KEYS: + var v: float = float(row.get(rk, -1.0)) + if v < 0.0 or v > 1.0: + errs.append({"category": "domain", "message": "%s %s=%f 越域 [0,1]" % [key, rk, v]}) + ratio_sum += v + if not is_equal_approx(ratio_sum, 1.0): + errs.append({"category": "domain", "message": "%s 配比四类合计 %f ≠ 1(已锁)" % [key, ratio_sum]}) + return errs + + +## 行计数域:入口/出口(L1 恒 1)/战斗保底/岔口去路数锁值 +func _validate_row_counts(row: Dictionary, L: int, key: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var entry_count: int = int(row.get("entry_count", 0)) + if entry_count < 1: + errs.append({"category": "domain", "message": "%s 入口个数 %d 越域(≥1)" % [key, entry_count]}) + var exit_count: int = int(row.get("exit_count", 0)) + if exit_count < 1 or exit_count > 3: + errs.append({"category": "domain", "message": "%s 出口个数 %d 越域 [1,3](已锁范围)" % [key, exit_count]}) + if L == 1 and exit_count != 1: + errs.append({"category": "domain", "message": "%s 出口个数 %d ≠ 1(L1 出口恒 1 已锁)" % [key, exit_count]}) + var bf: int = int(row.get("battle_floor", -1)) + if bf < 0: + errs.append({"category": "domain", "message": "%s 战斗保底 %d 越域(≥0)" % [key, bf]}) + var fmin: int = int(row.get("fork_ways_min", 0)) + var fmax: int = int(row.get("fork_ways_max", 0)) + if fmin != 2 or fmax != 3: + errs.append({"category": "domain", "message": "%s 岔口去路数 (%d,%d) ≠ (2,3)(已锁)" % [key, fmin, fmax]}) + return errs + + # 参数档单点读取(键 = level_index,缺行返回空字典——沿 level_row() 体例) func row_by_level_index(level_index: int) -> Dictionary: for row in rows: diff --git a/game/data/schemas/level_table.gd b/game/data/schemas/level_table.gd index 1d7fabea..b52a3dec 100644 --- a/game/data/schemas/level_table.gd +++ b/game/data/schemas/level_table.gd @@ -170,3 +170,8 @@ static func _mark_backward(target: int, outs: Dictionary, seen: Dictionary) -> v if (outs[nid] as Array).has(cur) and not seen.has(int(nid)): seen[int(nid)] = true queue.append(int(nid)) + + +## 序列总关数(AdventureRunner levels_total / MetaProgression 关卡完成累加域的取数口径) +func level_count() -> int: + return rows.size() diff --git a/game/data/schemas/meta_table.gd b/game/data/schemas/meta_table.gd index 7f68bd4c..3150b389 100644 --- a/game/data/schemas/meta_table.gd +++ b/game/data/schemas/meta_table.gd @@ -18,7 +18,15 @@ func validate() -> Array: for family in ["boss_keystone", "conversion", "attune_refresh_upgrade"]: if get(family).is_empty(): errs.append({"category": "empty_table", "message": "MetaTable 行族为空: %s" % family}) - # boss_keystone:level_index 唯一 + 连续从 1 + 两档域 + first ≥ repeat(首杀全量、重复小额恒定) + errs.append_array(_validate_boss_keystone()) + errs.append_array(_validate_conversion()) + errs.append_array(_validate_attune_refresh()) + return errs + + +## boss_keystone 行族域:level_index 唯一 + 连续从 1 + 两档域 + first ≥ repeat(首杀全量、重复小额恒定) +func _validate_boss_keystone() -> Array[Dictionary]: + var errs: Array[Dictionary] = [] var seen_li := {} for row in boss_keystone: var li: int = int(row.get("level_index", 0)) @@ -36,9 +44,13 @@ func validate() -> Array: if li_at != i + 1: errs.append({"category": "structural", "message": "boss_keystone level_index 不连续(须从 1 连续递增): 位 %d = %d" % [i + 1, li_at]}) break - # 无 workshop_items 行族(解锁混合制——条件谓词挂 treasure_items 行内; - # 本表不校验秘宝解锁,单点=TreasureItemTable.validate) - # conversion:attr 词表与 forge 一致 + 唯一 + 覆盖恰三 + 平移量域 [0,100] + return errs + + +## conversion 行族域(属性换算):attr 词表与 forge 一致 + 唯一 + 覆盖恰三 + 平移量域 [0,100] +## (无 workshop_items 行族——解锁混合制:条件谓词挂 treasure_items 行内;本表不校验秘宝解锁,单点=TreasureItemTable.validate) +func _validate_conversion() -> Array[Dictionary]: + var errs: Array[Dictionary] = [] var seen_conv := {} for row in conversion: var cattr: String = row.get("attr", "") @@ -54,7 +66,12 @@ func validate() -> Array: for conv_key in FORGE_ATTRS: if not seen_conv.has(conv_key): errs.append({"category": "coverage", "message": "conversion 缺属性行: %s(三行与 forge 词表一致)" % conv_key}) - # attune_refresh_upgrade:level 唯一 + 连续从 1 + charges/cost 域 + return errs + + +## attune_refresh_upgrade 行族域(Y 刷新升级):level 唯一 + 连续从 1 + charges/cost 域 +func _validate_attune_refresh() -> Array[Dictionary]: + var errs: Array[Dictionary] = [] var seen_al := {} for row in attune_refresh_upgrade: var al: int = int(row.get("level", -1)) @@ -73,3 +90,21 @@ func validate() -> Array: errs.append({"category": "structural", "message": "attune_refresh_upgrade level 不连续(须从 1 连续递增): 位 %d = %d" % [i + 1, al_at]}) break return errs + + +# ---- 行族访问器(自 ConfigDB 归位——行集解析单点;消费面 db.crossrun.meta. 直连)---- + +## Boss 钥石两档行单点(键 level_index;缺行=空字典) +func boss_keystone_row(level_index: int) -> Dictionary: + for row in boss_keystone: + if int(row.get("level_index", -1)) == level_index: + return row + return {} + + +## Y 刷新升级行单点(键 = 等级,缺行返回空字典=未购/已满级合法态) +func attune_refresh_row(level: int) -> Dictionary: + for row in attune_refresh_upgrade: + if int(row.get("level", -1)) == level: + return row + return {} diff --git a/game/data/schemas/meta_tree_table.gd b/game/data/schemas/meta_tree_table.gd index 8e75a3a5..e88ceae2 100644 --- a/game/data/schemas/meta_tree_table.gd +++ b/game/data/schemas/meta_tree_table.gd @@ -39,57 +39,105 @@ func validate() -> Array: errs.append({"category": "dup_key", "message": "node_id 重复: %s" % nid}) continue seen[nid] = true - if String(row.get("display_name", "")) == "": - errs.append({"category": "domain", "message": "%s display_name 空" % nid}) - if not VEINS.has(String(row.get("vein", ""))): - errs.append({"category": "enum", "message": "%s vein 出域" % nid}) - var kind := String(row.get("kind", "")) - if not KINDS.has(kind): - errs.append({"category": "enum", "message": "%s kind 出域" % nid}) - if not CURRENCIES.has(String(row.get("currency", ""))): - errs.append({"category": "enum", "message": "%s currency 出域" % nid}) - for k in row.keys(): - if not KEY_WHITELIST.has(String(k)): - errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s" % [nid, String(k)]}) - var cap := int(row.get("cap", 1)) - if cap < 1 or cap > 9: - errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [nid, cap]}) - if kind in ["unlock", "socket", "fork", "easter"] and cap != 1: - errs.append({"category": "cross_field", "message": "%s %s 行 cap 恒 1" % [nid, kind]}) - var price := int(row.get("price", 0)) - if price < 0 or price > 9999: - errs.append({"category": "domain", "message": "%s price 越域 [0,9999]: %d" % [nid, price]}) + errs.append_array(_validate_row_head(row, nid)) + errs.append_array(_validate_row_numbers(row, nid)) + errs.append_array(_validate_row_effects(row, nid)) + errs.append_array(_validate_row_fork(row, nid, fork_seen)) + errs.append_array(_validate_requires_dangling(rows, seen)) + errs.append_array(_validate_requires_level(rows, seen)) + # 彩蛋 ≤3 + var easter := 0 + for row in rows: + if String(row.get("kind", "")) == "easter": + easter += 1 + if easter > 3: + errs.append({"category": "load_bearing", "message": "彩蛋节点 %d > 3(上限 3)" % easter}) + return errs + + +## 行头域:命名与枚举判据(display_name/vein/kind/currency/行键白名单) +func _validate_row_head(row: Dictionary, nid: String) -> Array[Variant]: + var errs: Array[Variant] = [] + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % nid}) + if not VEINS.has(String(row.get("vein", ""))): + errs.append({"category": "enum", "message": "%s vein 出域" % nid}) + var kind := String(row.get("kind", "")) + if not KINDS.has(kind): + errs.append({"category": "enum", "message": "%s kind 出域" % nid}) + if not CURRENCIES.has(String(row.get("currency", ""))): + errs.append({"category": "enum", "message": "%s currency 出域" % nid}) + for k in row.keys(): + if not KEY_WHITELIST.has(String(k)): + errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s" % [nid, String(k)]}) + return errs + + +## 数值域:cap 越域/kind×cap 交叉/price 越域 +func _validate_row_numbers(row: Dictionary, nid: String) -> Array[Variant]: + var errs: Array[Variant] = [] + var kind := String(row.get("kind", "")) + var cap := int(row.get("cap", 1)) + if cap < 1 or cap > 9: + errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [nid, cap]}) + if kind in ["unlock", "socket", "fork", "easter"] and cap != 1: + errs.append({"category": "cross_field", "message": "%s %s 行 cap 恒 1" % [nid, kind]}) + var price := int(row.get("price", 0)) + if price < 0 or price > 9999: + errs.append({"category": "domain", "message": "%s price 越域 [0,9999]: %d" % [nid, price]}) + return errs + + +## 效果域:effects 键词表封闭与局外树层级纪律(archetype 禁入) +func _validate_row_effects(row: Dictionary, nid: String) -> Array[Variant]: + var errs: Array[Variant] = [] + var fx: Dictionary[Variant, Variant] = row.get("effects", {}) as Dictionary + for ek in fx.keys(): + if not (STAT_KEYS.has(String(ek)) or RULE_KEYS.has(String(ek))): + errs.append({"category": "zero_flat", "message": "%s effects 键出域(stat 线/rule 词表封闭): %s" % [nid, String(ek)]}) + # 🔴 层级纪律:局外树禁规则改写型特性(archetype 键出域=加载致命) + if fx.has("archetype"): + errs.append({"category": "load_bearing", "message": "%s effects 含 archetype(🔴 层级纪律——特性禁入局外树)" % nid}) + return errs + + +## Fork 结构域:一节点双态结构判据(fork_group 词表/组唯一/主 effects 须空/a/b 双键规则面) +func _validate_row_fork(row: Dictionary, nid: String, fork_seen: Dictionary) -> Array[Variant]: + var errs: Array[Variant] = [] + var kind := String(row.get("kind", "")) + if kind == "fork": + var fg := String(row.get("fork_group", "")) + if not FORK_GROUPS.has(fg): + errs.append({"category": "enum", "message": "%s fork_group 出域" % nid}) + if fork_seen.has(fg): + errs.append({"category": "dup_key", "message": "%s fork 组重复(一组恰一节点): %s" % [nid, fg]}) + fork_seen[fg] = true var fx: Dictionary[Variant, Variant] = row.get("effects", {}) as Dictionary - for ek in fx.keys(): - if not (STAT_KEYS.has(String(ek)) or RULE_KEYS.has(String(ek))): - errs.append({"category": "zero_flat", "message": "%s effects 键出域(stat 线/rule 词表封闭): %s" % [nid, String(ek)]}) - # 🔴 层级纪律:局外树禁规则改写型特性(archetype 键出域=加载致命) - if fx.has("archetype"): - errs.append({"category": "load_bearing", "message": "%s effects 含 archetype(🔴 层级纪律——特性禁入局外树)" % nid}) - # Fork 一节点双态结构:fork 行必带 fork_group(每组恰一节点);主 effects 须空,效果只放 - # a/b 双键且键 ⊆ RULE_KEYS(A/B 只放规则面——层级纪律) - if kind == "fork": - var fg := String(row.get("fork_group", "")) - if not FORK_GROUPS.has(fg): - errs.append({"category": "enum", "message": "%s fork_group 出域" % nid}) - if fork_seen.has(fg): - errs.append({"category": "dup_key", "message": "%s fork 组重复(一组恰一节点): %s" % [nid, fg]}) - fork_seen[fg] = true - if not fx.is_empty(): - errs.append({"category": "cross_field", "message": "%s fork 行主 effects 须空(效果只放 a/b 双键)" % nid}) - for skey in ["effects_a", "effects_b"]: - var sd: Dictionary[Variant, Variant] = row.get(skey, {}) as Dictionary - if sd.is_empty(): - errs.append({"category": "domain", "message": "%s %s 缺失或空(双态节点须双侧效果)" % [nid, skey]}) - for k in sd.keys(): - if not RULE_KEYS.has(String(k)): - errs.append({"category": "zero_flat", "message": "%s %s 键出域(A/B 只放规则面): %s" % [nid, skey, String(k)]}) - # requires 悬空引用 + if not fx.is_empty(): + errs.append({"category": "cross_field", "message": "%s fork 行主 effects 须空(效果只放 a/b 双键)" % nid}) + for skey in ["effects_a", "effects_b"]: + var sd: Dictionary[Variant, Variant] = row.get(skey, {}) as Dictionary + if sd.is_empty(): + errs.append({"category": "domain", "message": "%s %s 缺失或空(双态节点须双侧效果)" % [nid, skey]}) + for k in sd.keys(): + if not RULE_KEYS.has(String(k)): + errs.append({"category": "zero_flat", "message": "%s %s 键出域(A/B 只放规则面): %s" % [nid, skey, String(k)]}) + return errs + + +## requires 悬空引用域 +func _validate_requires_dangling(rows: Array, seen: Dictionary) -> Array[Variant]: + var errs: Array[Variant] = [] for row in rows: for req in (row.get("requires", []) as Array): if not seen.has(String(req)): errs.append({"category": "dangling", "message": "%s requires 悬空: %s" % [String(row.get("node_id", "")), String(req)]}) - # requires_level 等级阈值前置(R1 机制位——键须为在册节点、阈值整数 ≥1;类型错即拒) + return errs + + +## requires_level 等级阈值前置域(R1 机制位——键须为在册节点、阈值整数 ≥1;类型错即拒) +func _validate_requires_level(rows: Array, seen: Dictionary) -> Array[Variant]: + var errs: Array[Variant] = [] for row in rows: var nid2: String = String(row.get("node_id", "")) var rl_v: Variant = row.get("requires_level", {}) @@ -101,13 +149,6 @@ func validate() -> Array: errs.append({"category": "domain", "message": "%s requires_level 阈值须 ≥1: %s=%d" % [nid2, String(req_id), int(rl_v[req_id])]}) elif row.has("requires_level"): errs.append({"category": "domain", "message": "%s requires_level 须为 Dictionary {node_id: n}" % nid2}) - # 彩蛋 ≤3 - var easter := 0 - for row in rows: - if String(row.get("kind", "")) == "easter": - easter += 1 - if easter > 3: - errs.append({"category": "load_bearing", "message": "彩蛋节点 %d > 3(上限 3)" % easter}) return errs diff --git a/game/data/schemas/reward_table.gd b/game/data/schemas/reward_table.gd index 65ef00f1..610e1845 100644 --- a/game/data/schemas/reward_table.gd +++ b/game/data/schemas/reward_table.gd @@ -30,84 +30,129 @@ func validate() -> Array: if seen.has(rid): errs.append({"category": "dup_key", "message": "reward_id 重复: %s" % rid}) seen[rid] = true - var ctype: String = row.get("cell_type", "") - if not CELL_TYPES.has(ctype): - errs.append({"category": "enum", "message": "%s cell_type 出域(五型封闭): %s" % [rid, ctype]}) - var pol: String = row.get("polarity", "") - if not POLARITIES.has(pol): - errs.append({"category": "enum", "message": "%s polarity 出域: %s" % [rid, pol]}) - var atk_type: String = String(row.get("attack_type", "")) - if atk_type != "" and not ["normal", "pierce", "magic"].has(atk_type): - errs.append({"category": "enum", "message": "%s attack_type 出域 {normal, pierce, magic}: %s" % [rid, atk_type]}) - # aux 投放行专属校验:极性恒正/加权入池/effect_id=item_id 非空(悬空在 config_db 跨表层 - # 对秘宝词表校验——reward_dangling_treasure)/无 power 无区间(数值语义不复用,道具效果读 TreasureItemTable) - if ctype == TREASURE_ITEM_TYPE: - # 负向行 = 危险格极性字典占位(雷安同池摇号下危险格可摇中 aux 身份——负路由零兑付兜底, - # effect_id 空/power 0/weight 0 不入池) - if pol == "negative": - if String(row.get("effect_id", "")) != "" or int(row.get("power", 0)) != 0 or int(row.get("weight", 0)) != 0: - errs.append({"category": "cross_field", "message": "%s aux 负占位行仅允许 空effect_id/0power/0weight" % rid}) - continue - if String(row.get("effect_id", "")) == "": - errs.append({"category": "domain", "message": "%s aux 正行 effect_id 空(应=item_id)" % rid}) - if int(row.get("power", 0)) != 0 or row.has("power_min"): - errs.append({"category": "cross_field", "message": "%s aux 正行禁 power/区间数值(道具数值读 TreasureItemTable)" % rid}) - var pool: String = row.get("pool_tag", "") - var pool_reserved: bool = pool.begins_with("clear_") and pool != "clear_default" - if not POOL_TAGS.has(pool) and not pool_reserved: - errs.append({"category": "enum", "message": "%s pool_tag 未知: %s" % [rid, pool]}) - elif pool_reserved: - errs.append({"category": "enum", "message": "%s pool_tag clear_ 命名空间预留拒绝: %s" % [rid, pool]}) - if String(row.get("display_name", "")) == "": - errs.append({"category": "domain", "message": "%s display_name 空" % rid}) - var power: int = int(row.get("power", 0)) - var weight: int = int(row.get("weight", -1)) - var tier: int = int(row.get("tier", 0)) - if weight < 0 or weight > 999: - errs.append({"category": "domain", "message": "%s weight 越域 [0,999]" % rid}) - if tier < 1 or tier > 8: - errs.append({"category": "domain", "message": "%s tier 越域 [1,8]" % rid}) - var stacks: int = int(row.get("stacks", 1)) - if stacks < 1 or stacks > 16: - errs.append({"category": "domain", "message": "%s stacks 越域 [1,16]: %d" % [rid, stacks]}) - # 跨字段:effect_id 非空 = percent 行(引用悬空校验归 ConfigDB 跨表通道);空 = flat 行。 - # flat 行 power ≥ 1(直付/直兑行:治疗/群伤/直伤/行动点)——action 型 = 排队点数行(power = ±点数量级)。 - var eff: String = row.get("effect_id", "") - if power < 0 or power > 999: - errs.append({"category": "domain", "message": "%s power 越域 [0,999]: %d" % [rid, power]}) - if eff == "" and power < 1: - errs.append({"category": "cross_field", "message": "%s flat 行 power ≥ 1" % rid}) - # 区间配对(缺省 = 定值行向后兼容):必成对 · percent 行禁止 · power = 中点锚 - var has_min: bool = row.has("power_min") - var has_max: bool = row.has("power_max") - if has_min != has_max: - errs.append({"category": "cross_field", "message": "%s power_min/power_max 必须成对出现" % rid}) - elif has_min: - var pmin: int = int(row.get("power_min", 0)) - var pmax: int = int(row.get("power_max", 0)) - if pmin < 1 or pmax > 999 or pmin > pmax: - errs.append({"category": "domain", "message": "%s power 域违例 [1,999] 且 min≤max: %d..%d" % [rid, pmin, pmax]}) - if eff != "": - errs.append({"category": "cross_field", "message": "%s percent 行禁区间字段" % rid}) - if power != int((pmin + pmax) / 2.0): - # 中点锚:power = 区间中点(行动经济 条款①期望当量锚——调区间必同步调 power) - errs.append({"category": "cross_field", "message": "%s power(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [rid, power, (pmin + pmax) / 2]}) + errs.append_array(_validate_row_axes(row, rid)) + var aux_out := _validate_row_aux(row, rid) + errs.append_array(aux_out["errs"]) + if aux_out["skip"]: + continue + errs.append_array(_validate_row_pool_tag(row, rid)) + errs.append_array(_validate_row_values(row, rid)) + errs.append_array(_validate_row_power_range(row, rid)) # 字典完整性 / 池护栏累计(cell 池行) - if pool == "cell" and CELL_TYPES.has(ctype) and POLARITIES.has(pol): - cell_dict["%s|%s" % [ctype, pol]] = true - if pol == "positive" and weight > 0: + if String(row.get("pool_tag", "")) == "cell" and CELL_TYPES.has(String(row.get("cell_type", ""))) and POLARITIES.has(String(row.get("polarity", ""))): + cell_dict["%s|%s" % [String(row.get("cell_type", "")), String(row.get("polarity", ""))]] = true + if String(row.get("polarity", "")) == "positive" and int(row.get("weight", -1)) > 0: cell_positive_live += 1 - if pool == "clear_default" and eff == "" and power >= 1: + if String(row.get("pool_tag", "")) == "clear_default" and String(row.get("effect_id", "")) == "" and int(row.get("power", 0)) >= 1: clear_value_ok = true - # ① 字典完整性:五型 × 正负两行齐备(缺任一即致命) + errs.append_array(_validate_table_closure(cell_dict, cell_positive_live, clear_value_ok)) + return errs + + +## 轴域:cell_type 五型封闭/polarity 两极/attack_type 可省略枚举 +func _validate_row_axes(row: Dictionary, rid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var ctype: String = row.get("cell_type", "") + if not CELL_TYPES.has(ctype): + errs.append({"category": "enum", "message": "%s cell_type 出域(五型封闭): %s" % [rid, ctype]}) + var pol: String = row.get("polarity", "") + if not POLARITIES.has(pol): + errs.append({"category": "enum", "message": "%s polarity 出域: %s" % [rid, pol]}) + var atk_type: String = String(row.get("attack_type", "")) + if atk_type != "" and not ["normal", "pierce", "magic"].has(atk_type): + errs.append({"category": "enum", "message": "%s attack_type 出域 {normal, pierce, magic}: %s" % [rid, atk_type]}) + return errs + + +## aux 投放行域(掉落投放行):极性恒正/加权入池/effect_id=item_id 非空(悬空在 config_db 跨表层 +## 对秘宝词表校验——reward_dangling_treasure)/无 power 无区间(数值语义不复用,道具效果读 TreasureItemTable); +## 负占位行返回 skip=true(跳过行剩余判据——原 continue 语义) +func _validate_row_aux(row: Dictionary, rid: String) -> Dictionary: + var errs: Array[Dictionary] = [] + var skip := false + if String(row.get("cell_type", "")) == TREASURE_ITEM_TYPE: + # 负向行 = 危险格极性字典占位(雷安同池摇号下危险格可摇中 aux 身份——负路由零兑付兜底, + # effect_id 空/power 0/weight 0 不入池) + if String(row.get("polarity", "")) == "negative": + if String(row.get("effect_id", "")) != "" or int(row.get("power", 0)) != 0 or int(row.get("weight", 0)) != 0: + errs.append({"category": "cross_field", "message": "%s aux 负占位行仅允许 空effect_id/0power/0weight" % rid}) + skip = true + return {"errs": errs, "skip": skip} + if String(row.get("effect_id", "")) == "": + errs.append({"category": "domain", "message": "%s aux 正行 effect_id 空(应=item_id)" % rid}) + if int(row.get("power", 0)) != 0 or row.has("power_min"): + errs.append({"category": "cross_field", "message": "%s aux 正行禁 power/区间数值(道具数值读 TreasureItemTable)" % rid}) + return {"errs": errs, "skip": skip} + + +## 池标签域:pool_tag 词表与 clear_ 命名空间预留拒绝/display_name 非空 +func _validate_row_pool_tag(row: Dictionary, rid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var pool: String = row.get("pool_tag", "") + var pool_reserved: bool = pool.begins_with("clear_") and pool != "clear_default" + if not POOL_TAGS.has(pool) and not pool_reserved: + errs.append({"category": "enum", "message": "%s pool_tag 未知: %s" % [rid, pool]}) + elif pool_reserved: + errs.append({"category": "enum", "message": "%s pool_tag clear_ 命名空间预留拒绝: %s" % [rid, pool]}) + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % rid}) + return errs + + +## 数值域:weight/tier/stacks/power 越域与 flat 行 power ≥ 1 +## (effect_id 非空 = percent 行(引用悬空校验归 ConfigDB 跨表通道);空 = flat 行。 +## flat 行 power ≥ 1(直付/直兑行:治疗/群伤/直伤/行动点)——action 型 = 排队点数行(power = ±点数量级)) +func _validate_row_values(row: Dictionary, rid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var power: int = int(row.get("power", 0)) + var weight: int = int(row.get("weight", -1)) + var tier: int = int(row.get("tier", 0)) + if weight < 0 or weight > 999: + errs.append({"category": "domain", "message": "%s weight 越域 [0,999]" % rid}) + if tier < 1 or tier > 8: + errs.append({"category": "domain", "message": "%s tier 越域 [1,8]" % rid}) + var stacks: int = int(row.get("stacks", 1)) + if stacks < 1 or stacks > 16: + errs.append({"category": "domain", "message": "%s stacks 越域 [1,16]: %d" % [rid, stacks]}) + var eff: String = row.get("effect_id", "") + if power < 0 or power > 999: + errs.append({"category": "domain", "message": "%s power 越域 [0,999]: %d" % [rid, power]}) + if eff == "" and power < 1: + errs.append({"category": "cross_field", "message": "%s flat 行 power ≥ 1" % rid}) + return errs + + +## 区间配对域(缺省 = 定值行向后兼容):必成对 · percent 行禁止 · power = 中点锚 +func _validate_row_power_range(row: Dictionary, rid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var power: int = int(row.get("power", 0)) + var eff: String = row.get("effect_id", "") + var has_min: bool = row.has("power_min") + var has_max: bool = row.has("power_max") + if has_min != has_max: + errs.append({"category": "cross_field", "message": "%s power_min/power_max 必须成对出现" % rid}) + elif has_min: + var pmin: int = int(row.get("power_min", 0)) + var pmax: int = int(row.get("power_max", 0)) + if pmin < 1 or pmax > 999 or pmin > pmax: + errs.append({"category": "domain", "message": "%s power 域违例 [1,999] 且 min≤max: %d..%d" % [rid, pmin, pmax]}) + if eff != "": + errs.append({"category": "cross_field", "message": "%s percent 行禁区间字段" % rid}) + if power != int((pmin + pmax) / 2.0): + # 中点锚:power = 区间中点(行动经济 条款①期望当量锚——调区间必同步调 power) + errs.append({"category": "cross_field", "message": "%s power(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [rid, power, (pmin + pmax) / 2]}) + return errs + + +## 表闭合域(三尾段):①九型字典完整性(类型×极性齐备)②池空护栏 ③clear_default 恒正当量 +func _validate_table_closure(cell_dict: Dictionary, cell_positive_live: int, clear_value_ok: bool) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] for ctype in CELL_TYPE_KEYS: for pol in POLARITIES: if not cell_dict.has("%s|%s" % [ctype, pol]): errs.append({"category": "cross_field", "message": "九型字典缺行: %s %s——(类型, 极性) 齐备为加载前提" % [ctype, pol]}) - # ② 池空护栏:cell 池 ≥1 正向行 weight>0(摇号池非空) if cell_positive_live < 1: errs.append({"category": "cross_field", "message": "cell 池零活正向行(weight>0)——摇号池空"}) - # ③ clear_default 恒正当量(行动经济 条款④——清图奖励 > 0 的行级兑现口径) if not clear_value_ok: errs.append({"category": "cross_field", "message": "clear_default 池无正当量活行(清图奖励 > 0 违例)"}) return errs diff --git a/game/data/schemas/shop_table.gd b/game/data/schemas/shop_table.gd index 8028e36e..768493e2 100644 --- a/game/data/schemas/shop_table.gd +++ b/game/data/schemas/shop_table.gd @@ -133,3 +133,55 @@ func _validate_price_factors(errs: Array[Dictionary]) -> void: errs.append({"category": "domain", "message": "relic_base_price 越域 [1.0,99.0]: %f" % relic_base_price}) if spend_discount <= 0.0 or spend_discount > 1.0: errs.append({"category": "domain", "message": "spend_discount 越域 (0.0,1.0]: %f" % spend_discount}) + + +# ---- 行族访问器(自 ConfigDB 归位——行集解析单点;消费面 db.run.shop. 直连)---- + +## GP 掉落行单点(键 tier;缺行=空字典——掉落按 0 兑付的兜底在调用方) +func gp_drop_row(tier: String) -> Dictionary: + for row in gp_drops: + if String(row.get("tier", "")) == tier: + return row + return {} + + +## 关卡 GP 加成单点(键 level_index;缺行=0) +func level_gp_bonus_at(level_index: int) -> int: + for row in level_gp_bonus: + if int(row.get("level_index", -1)) == level_index: + return int(row.get("bonus", 0)) + return 0 + + +## 价格关卡加成单点(键 level_index;缺行=0) +func price_level_bonus_at(level_index: int) -> int: + for row in price_level_bonus: + if int(row.get("level_index", -1)) == level_index: + return int(row.get("bonus", 0)) + return 0 + + +## 商品行族({product_id, display_name, attr, flat_value, base_price, price_increment}) +func product_rows() -> Array: + return products + + +## 商品行单点(键 product_id;缺行=空字典) +func product_row(product_id: String) -> Dictionary: + for row in products: + if String(row.get("product_id", "")) == product_id: + return row + return {} + + +## 货架行族({kind_id, display_name, base_price}——行序即抽签/呈现稳定序) +func pool_rows() -> Array: + return pool_shelf + + +## 货架行单点(键 kind_id;缺行=空字典) +func pool_row(kind_id: String) -> Dictionary: + for row in pool_shelf: + if String(row.get("kind_id", "")) == kind_id: + return row + return {} diff --git a/game/data/schemas/skill_table.gd b/game/data/schemas/skill_table.gd index 335d9664..aa12eda8 100644 --- a/game/data/schemas/skill_table.gd +++ b/game/data/schemas/skill_table.gd @@ -31,73 +31,108 @@ func validate() -> Array: var seen := {} var has_basic := false for row in rows: - var sid: String = row.get("skill_id", "") - if sid == "": - errs.append({"category": "domain", "message": "skill_id 缺失/空"}) + var head := _validate_row_head(row, seen) + errs.append_array(head["errs"]) + if not head["ok"]: continue - if seen.has(sid): - errs.append({"category": "dup_key", "message": "skill_id 重复: %s" % sid}) - seen[sid] = true - if String(row.get("display_name", "")) == "": - errs.append({"category": "domain", "message": "%s display_name 空" % sid}) - var type: String = row.get("type", "") - if not SKILL_TYPES.has(type): - errs.append({"category": "enum", "message": "%s type 未知枚举: %s(域 %s)" % [sid, type, str(SKILL_TYPES)]}) - continue - _validate_trigger(sid, row, errs) # trigger(触发条件,可省略——四键白名单/词表出域/类型分派/普攻禁配;加载期报错止动) - if type == "basic" and row.has("trigger"): - errs.append({"category": "cross_field", "message": "%s 普攻行禁配 trigger(普攻恒可摇的结构前提)" % sid}) - # 冷却域(全类型):施放后连续 N 拍不可摇中 - var cd: int = int(row.get("cooldown_beats", -1)) - if cd < 0 or cd > 99: - errs.append({"category": "domain", "message": "%s cooldown_beats 越域 [0,99]: %d" % [sid, cd]}) - # 普通攻击两条域校验锁死特殊性:倍率恒 1、冷却恒 0——想配的就是伤害技(proposal §2.2) + var sid: String = head["sid"] + var type: String = head["type"] + _validate_common_fields(sid, row, type, errs) if type == "basic": has_basic = true - _validate_basic_row(sid, row, cd, errs) - # 伤害技:存倍率不存固定值——伤害 = 施放者攻击基线 × 技能倍率(三乘法链第一乘); - # 可选 rider_mult = 必中附加分量(后置加法、不吃骰不出暴击——proposal-status-vocabulary §2) + _validate_basic_row(sid, row, int(row.get("cooldown_beats", -1)), errs) if type == "damage": - if not row.has("damage_mult"): - errs.append({"category": "cross_field", "message": "%s 伤害技缺 damage_mult(存倍率不存固定值)" % sid}) - else: - var m: float = float(row.damage_mult) - if m < MULT_DOMAIN_MIN or m > MULT_DOMAIN_MAX: - errs.append({"category": "domain", "message": "%s damage_mult 越域 [%.1f, %.1f]: %s" % [sid, MULT_DOMAIN_MIN, MULT_DOMAIN_MAX, str(m)]}) - if row.has("buff_ref") or row.has("buff_duration"): - errs.append({"category": "cross_field", "message": "%s 伤害技不带增益字段" % sid}) - if row.has("rider_mult"): - var rm: float = float(row.rider_mult) - if rm <= 0.0 or rm > RIDER_MULT_MAX: - errs.append({"category": "domain", "message": "%s rider_mult 越域 (0, %.1f]: %s" % [sid, RIDER_MULT_MAX, str(rm)]}) - # 增益技:0 伤害动作(无倍率),效果引用 + 持续回合;悬空引用由 ConfigDB 跨表校验兜底 + _validate_damage_row(sid, row, errs) if type == "buff": - if row.has("damage_mult"): - errs.append({"category": "cross_field", "message": "%s 增益技为 0 伤害动作,不带 damage_mult" % sid}) - if row.has("rider_mult"): - errs.append({"category": "cross_field", "message": "%s 增益技不带必中附加分量(rider_mult 仅伤害技)" % sid}) - if String(row.get("buff_ref", "")) == "": - errs.append({"category": "cross_field", "message": "%s 增益技缺 buff_ref(效果引用)" % sid}) - var bd: int = int(row.get("buff_duration", 0)) - if bd < 1 or bd > 99: - errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, bd]}) - # 妨害技:0 伤害施放动作,对主角施加负面状态—— - # 字段复用 buff_ref/buff_duration(落点由状态行 target_side 路由,ConfigDB side 守卫兜底);池纪律妨害 ≤1 在跨表通道 + _validate_buff_row(sid, row, errs) if type == "debuff": - if row.has("damage_mult"): - errs.append({"category": "cross_field", "message": "%s 妨害技为 0 伤害动作,不带 damage_mult" % sid}) - if row.has("rider_mult"): - errs.append({"category": "cross_field", "message": "%s 妨害技不带必中附加分量(rider_mult 仅伤害技)" % sid}) - if String(row.get("buff_ref", "")) == "": - errs.append({"category": "cross_field", "message": "%s 妨害技缺 buff_ref(效果引用)" % sid}) - var dbd: int = int(row.get("buff_duration", 0)) - if dbd < 1 or dbd > 99: - errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, dbd]}) + _validate_debuff_row(sid, row, errs) + _validate_table_invariants(has_basic, errs) + return errs + + +## 行头域:skill_id 非空与查重/display_name/type 枚举(ok=跳过行剩余判据) +func _validate_row_head(row: Dictionary, seen: Dictionary) -> Dictionary: + var errs: Array[Dictionary] = [] + var sid: String = row.get("skill_id", "") + if sid == "": + errs.append({"category": "domain", "message": "skill_id 缺失/空"}) + return {"errs": errs, "sid": sid, "type": "", "ok": false} + if seen.has(sid): + errs.append({"category": "dup_key", "message": "skill_id 重复: %s" % sid}) + seen[sid] = true + return {"errs": errs, "sid": sid, "type": "", "ok": false} + seen[sid] = true + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % sid}) + var type: String = row.get("type", "") + if not SKILL_TYPES.has(type): + errs.append({"category": "enum", "message": "%s type 未知枚举: %s(域 %s)" % [sid, type, str(SKILL_TYPES)]}) + return {"errs": errs, "sid": sid, "type": type, "ok": false} + return {"errs": errs, "sid": sid, "type": type, "ok": true} + + +## 公共域(全类型):trigger 触发条件校验/普攻行禁配 trigger/冷却域 +func _validate_common_fields(sid: String, row: Dictionary, type: String, errs: Array[Dictionary]) -> void: + _validate_trigger(sid, row, errs) # trigger(触发条件,可省略——四键白名单/词表出域/类型分派/普攻禁配;加载期报错止动) + if type == "basic" and row.has("trigger"): + errs.append({"category": "cross_field", "message": "%s 普攻行禁配 trigger(普攻恒可摇的结构前提)" % sid}) + # 冷却域(全类型):施放后连续 N 拍不可摇中 + var cd: int = int(row.get("cooldown_beats", -1)) + if cd < 0 or cd > 99: + errs.append({"category": "domain", "message": "%s cooldown_beats 越域 [0,99]: %d" % [sid, cd]}) + + +## 伤害技域:存倍率不存固定值——伤害 = 施放者攻击基线 × 技能倍率(三乘法链第一乘); +## 可选 rider_mult = 必中附加分量(后置加法、不吃骰不出暴击——proposal-status-vocabulary §2) +func _validate_damage_row(sid: String, row: Dictionary, errs: Array[Dictionary]) -> void: + if not row.has("damage_mult"): + errs.append({"category": "cross_field", "message": "%s 伤害技缺 damage_mult(存倍率不存固定值)" % sid}) + else: + var m: float = float(row.damage_mult) + if m < MULT_DOMAIN_MIN or m > MULT_DOMAIN_MAX: + errs.append({"category": "domain", "message": "%s damage_mult 越域 [%.1f, %.1f]: %s" % [sid, MULT_DOMAIN_MIN, MULT_DOMAIN_MAX, str(m)]}) + if row.has("buff_ref") or row.has("buff_duration"): + errs.append({"category": "cross_field", "message": "%s 伤害技不带增益字段" % sid}) + if row.has("rider_mult"): + var rm: float = float(row.rider_mult) + if rm <= 0.0 or rm > RIDER_MULT_MAX: + errs.append({"category": "domain", "message": "%s rider_mult 越域 (0, %.1f]: %s" % [sid, RIDER_MULT_MAX, str(rm)]}) + + +## 增益技域:0 伤害动作(无倍率),效果引用 + 持续回合;悬空引用由 ConfigDB 跨表校验兜底 +func _validate_buff_row(sid: String, row: Dictionary, errs: Array[Dictionary]) -> void: + if row.has("damage_mult"): + errs.append({"category": "cross_field", "message": "%s 增益技为 0 伤害动作,不带 damage_mult" % sid}) + if row.has("rider_mult"): + errs.append({"category": "cross_field", "message": "%s 增益技不带必中附加分量(rider_mult 仅伤害技)" % sid}) + if String(row.get("buff_ref", "")) == "": + errs.append({"category": "cross_field", "message": "%s 增益技缺 buff_ref(效果引用)" % sid}) + var bd: int = int(row.get("buff_duration", 0)) + if bd < 1 or bd > 99: + errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, bd]}) + + +## 妨害技域:0 伤害施放动作,对主角施加负面状态—— +## 字段复用 buff_ref/buff_duration(落点由状态行 target_side 路由,ConfigDB side 守卫兜底);池纪律妨害 ≤1 在跨表通道 +func _validate_debuff_row(sid: String, row: Dictionary, errs: Array[Dictionary]) -> void: + if row.has("damage_mult"): + errs.append({"category": "cross_field", "message": "%s 妨害技为 0 伤害动作,不带 damage_mult" % sid}) + if row.has("rider_mult"): + errs.append({"category": "cross_field", "message": "%s 妨害技不带必中附加分量(rider_mult 仅伤害技)" % sid}) + if String(row.get("buff_ref", "")) == "": + errs.append({"category": "cross_field", "message": "%s 妨害技缺 buff_ref(效果引用)" % sid}) + var dbd: int = int(row.get("buff_duration", 0)) + if dbd < 1 or dbd > 99: + errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, dbd]}) + + +## 表不变量域:普攻行存在性(池构成纪律的结构前提)与保底旋钮值域 +func _validate_table_invariants(has_basic: bool, errs: Array[Dictionary]) -> void: if not has_basic: errs.append({"category": "structural", "message": "SkillTable 无普攻行(basic)——池构成纪律的结构前提"}) if pity_threshold < 1 or pity_threshold > 99: errs.append({"category": "domain", "message": "pity_threshold 越域 [1,99]: %d" % pity_threshold}) - return errs # trigger 域校验(敌人系统 v1.2 触发条件:四键白名单/词表出域/类型分派——枚举事实算子限域 eq/neq diff --git a/game/data/schemas/status_effect_table.gd b/game/data/schemas/status_effect_table.gd index 6811a235..4e1568cb 100644 --- a/game/data/schemas/status_effect_table.gd +++ b/game/data/schemas/status_effect_table.gd @@ -18,30 +18,12 @@ func validate() -> Array: return errs var seen := {} for row in rows: - var eid: String = row.get("effect_id", "") - if eid == "": - errs.append({"category": "domain", "message": "effect_id 缺失/空"}) + var head := _validate_row_head(row, seen) + errs.append_array(head["errs"]) + if not head["ok"]: continue - if seen.has(eid): - errs.append({"category": "dup_key", "message": "effect_id 重复: %s" % eid}) - seen[eid] = true - if String(row.get("display_name", "")) == "": - errs.append({"category": "domain", "message": "%s display_name 空" % eid}) - if not TARGET_SIDES.has(row.get("target_side", "")): - errs.append({"category": "enum", "message": "%s target_side 出域" % eid}) - var unit: String = row.get("unit", "") - if not UNITS.has(unit): - errs.append({"category": "enum", "message": "%s unit 出域" % eid}) - if not STACK_POLICIES.has(row.get("stack_policy", "")): - errs.append({"category": "enum", "message": "%s stack_policy 出域" % eid}) - var m: int = int(row.get("magnitude", 0)) - # 跨字段:percent ∈ [-100,100] 且 ≠0 / flat ≥ 1 / ac ∈ [-20,20] 且 ≠0(AC 阈值轴——幅度域独立,防呆上限 20) - if unit == "percent" and (m == 0 or m < -100 or m > 100): - errs.append({"category": "cross_field", "message": "%s percent magnitude 域错: %d" % [eid, m]}) - if unit == "flat" and m < 1: - errs.append({"category": "cross_field", "message": "%s flat magnitude 须 ≥1: %d" % [eid, m]}) - if unit == "ac" and (m == 0 or m < -20 or m > 20): - errs.append({"category": "cross_field", "message": "%s ac magnitude 域错([-20,20] 且 ≠0): %d" % [eid, m]}) + var eid: String = head["eid"] + errs.append_array(_validate_magnitude_domain(row, eid, head["unit"])) var dur: int = int(row.get("duration_settlements", 0)) if dur < 1 or dur > 99: errs.append({"category": "domain", "message": "%s duration_settlements 越域 [1,99]: %d" % [eid, dur]}) @@ -51,6 +33,44 @@ func validate() -> Array: return errs +## 行头域:effect_id 非空与查重/display_name/target_side/unit/stack_policy 词表 +func _validate_row_head(row: Dictionary, seen: Dictionary) -> Dictionary: + var errs: Array[Dictionary] = [] + var eid: String = row.get("effect_id", "") + if eid == "": + errs.append({"category": "domain", "message": "effect_id 缺失/空"}) + return {"errs": errs, "eid": eid, "unit": "", "ok": false} + if seen.has(eid): + errs.append({"category": "dup_key", "message": "effect_id 重复: %s" % eid}) + seen[eid] = true + return {"errs": errs, "eid": eid, "unit": "", "ok": false} + seen[eid] = true + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % eid}) + if not TARGET_SIDES.has(row.get("target_side", "")): + errs.append({"category": "enum", "message": "%s target_side 出域" % eid}) + var unit: String = row.get("unit", "") + if not UNITS.has(unit): + errs.append({"category": "enum", "message": "%s unit 出域" % eid}) + if not STACK_POLICIES.has(row.get("stack_policy", "")): + errs.append({"category": "enum", "message": "%s stack_policy 出域" % eid}) + return {"errs": errs, "eid": eid, "unit": unit, "ok": true} + + +## 幅度量纲域(三段量纲判据——percent/flat/ac 各自值域; +## ac = AC 阈值轴效果,非池百分比非定点值——幅度域独立,防呆上限 20) +func _validate_magnitude_domain(row: Dictionary, eid: String, unit: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var m: int = int(row.get("magnitude", 0)) + if unit == "percent" and (m == 0 or m < -100 or m > 100): + errs.append({"category": "cross_field", "message": "%s percent magnitude 域错: %d" % [eid, m]}) + if unit == "flat" and m < 1: + errs.append({"category": "cross_field", "message": "%s flat magnitude 须 ≥1: %d" % [eid, m]}) + if unit == "ac" and (m == 0 or m < -20 or m > 20): + errs.append({"category": "cross_field", "message": "%s ac magnitude 域错([-20,20] 且 ≠0): %d" % [eid, m]}) + return errs + + func row_by_id(effect_id: String) -> Dictionary: for row in rows: if row.get("effect_id", "") == effect_id: diff --git a/game/data/schemas/treasure_item_table.gd b/game/data/schemas/treasure_item_table.gd index 20acd403..ceaeccf0 100644 --- a/game/data/schemas/treasure_item_table.gd +++ b/game/data/schemas/treasure_item_table.gd @@ -73,136 +73,195 @@ func validate() -> Array: errs.append({"category": "dup_key", "message": "item_id 重复: %s" % iid}) continue seen[iid] = true - if String(row.get("display_name", "")) == "": - errs.append({"category": "domain", "message": "%s display_name 空" % iid}) - var retired: bool = bool(row.get("retired", false)) - var deferred: bool = bool(row.get("deferred", false)) - # dimension_tags:五维词表 + 非空(退役/失义行仍须登记维度——语义面保留) - var tags: Array[Variant] = row.get("dimension_tags", []) - if tags.is_empty(): - errs.append({"category": "domain", "message": "%s dimension_tags 空" % iid}) - for tag in tags: - if not DIMENSIONS.has(String(tag)): - errs.append({"category": "enum", "message": "%s dimension_tag 出域 %s: %s" % [iid, str(DIMENSIONS), str(tag)]}) - var kinds: Array[Variant] = row.get("effect_kinds", []) - var target: String = String(row.get("target", "none")) - if not TARGETS.has(target): - errs.append({"category": "enum", "message": "%s target 出域 %s: %s" % [iid, str(TARGETS), target]}) - var phase: String = String(row.get("use_phase", "active_dig")) - if not USE_PHASES.has(phase): - errs.append({"category": "enum", "message": "%s use_phase 出域 %s: %s" % [iid, str(USE_PHASES), phase]}) - # 🔴 零裸 flat 结构护栏:键白名单封闭(同调批 T1——机制轴禁无条件属性加项) - for k in row.keys(): - if not KEY_WHITELIST.has(String(k)): - errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s(KEY_WHITELIST 外键禁入)" % [iid, String(k)]}) - # triggers 事件订阅行(被动件形态;与 effect_kinds 互斥校验 - # 面=passive 行(单形态——效果行为只走订阅);active_charged 行两形合法并存 - # (effect_kinds=主动分发通道 + triggers=受理闸订阅,掘进蓄能先例)) - if row.has("triggers"): - if (row.get("effect_kinds", []) as Array).size() > 0 and String(row.get("form", "")) != "active_charged" and not bool(row.get("retired", false)): - errs.append({"category": "domain", "message": "%s triggers 与 effect_kinds 并存(换代单形态——被动件迁移期禁双挂)" % iid}) - EffectEngine.validate_trigger_row(iid, row, row.get("triggers", []) as Array, errs) - var form: String = String(row.get("form", "")) - if not FORMS.has(form): - errs.append({"category": "enum", "message": "%s form 出域 %s: %s" % [iid, str(FORMS), form]}) - if retired or deferred: + errs.append_array(_validate_row_head(row, iid)) + if bool(row.get("retired", false)) or bool(row.get("deferred", false)): # 退役/失义行:免 effect_kinds 非空与承重护栏校验(登记面保留,不入投放与 F2) continue - # ---- 活行校验 ---- - if kinds.is_empty() and not row.has("triggers"): - errs.append({"category": "domain", "message": "%s effect_kinds 空(活行;失义件应标 deferred,被动件应挂 triggers 订阅)" % iid}) var kind_seen := {} - for k in kinds: - var kind: String = String(k) - if not EFFECT_KINDS.has(kind): - errs.append({"category": "enum", "message": "%s effect_kind 出域 %s: %s" % [iid, str(EFFECT_KINDS), kind]}) - if kind_seen.has(kind): - errs.append({"category": "dup_key", "message": "%s effect_kind 重复: %s" % [iid, kind]}) - kind_seen[kind] = true - # 承重护栏①真视窄域:真视类活行 target 必须 area_3x3 或 cell(一次最多 3×3,禁全图看雷; - # reveal_tiles 与 cell 档纳入同护栏,线香单格=cell ⊂ 3×3 契约内) - for rv in REVEAL_KINDS: - if kind_seen.has(String(rv)) and not (target == "area_3x3" or target == "cell"): - errs.append({"category": "load_bearing", "message": "%s 真视窄域护栏:%s 行 target 必须 area_3x3 或 cell,实得 %s(秘宝 GDD 结构冻结)" % [iid, String(rv), target]}) - # 承重护栏②board_wide 仅恢复推理类:全图域行禁替推理效果 - if target == "board_wide" and kind_seen.has("mine_reveal"): - errs.append({"category": "load_bearing", "message": "%s board_wide 行 effect_kinds 不得含 mine_reveal(全图域仅恢复推理原料类——Alpha 重定后落位)" % iid}) - # magnitude:MAGNITUDE_KINDS 行消费(≥1);其余活行恒 0(同调批 T1——消费面自 ap_grant 扩至 N 值 kind) - var mag: int = int(row.get("magnitude", 0)) - if mag < 0 or mag > 999: - errs.append({"category": "domain", "message": "%s magnitude 越域 [0,999]: %d" % [iid, mag]}) - var mag_consuming := false - for k in kind_seen.keys(): - if MAGNITUDE_KINDS.has(String(k)): - mag_consuming = true - if mag_consuming and mag < 1: - errs.append({"category": "cross_field", "message": "%s N 值 kind 行 magnitude 应 ≥1,实得 %d" % [iid, mag]}) - if not mag_consuming and mag != 0 and not row.has("triggers"): - errs.append({"category": "cross_field", "message": "%s 非 N 值 kind 活行 magnitude 应 0(trigger 行 magnitude=value_from 取数源豁免),实得 %d" % [iid, mag]}) - # ---- 同调行约束(form 非空——装配面;过渡行 form="" 不校验)---- + errs.append_array(_validate_row_active(row, iid, kind_seen)) + var form: String = String(row.get("form", "")) if form != "": - if int(row.get("slot_cost", 1)) != 1: - errs.append({"category": "load_bearing", "message": "%s 同调占位统一 1 槽(slot_cost 恒 1——底稿裁决 5,schema 强制)" % iid}) - # 档位必填(槽位档位约束数据面:件档≤格档判定的取数源) - var tier: String = String(row.get("tier", "")) - if not TIERS.has(tier): - errs.append({"category": "domain", "message": "%s 同调行 tier 必填且 ∈ %s,实得 %s" % [iid, str(TIERS), tier]}) - var charges: int = int(row.get("charges_per_adventure", 0)) - if form == "passive" and charges != 0: - errs.append({"category": "cross_field", "message": "%s passive 行 charges_per_adventure 应 0,实得 %d" % [iid, charges]}) - if form == "active_charged" and charges < 0: - errs.append({"category": "domain", "message": "%s active_charged 行 charges_per_adventure 越域 ≥0,实得 %d" % [iid, charges]}) - if form == "active_charged" and charges == 0: - # 充能 0=积点制/滑耗制合法态(试炼镐转化获取,无固定充能上限): - # 必须带使用闸——ap_charge_safe 滑耗(安全格积点)或 treasure_used+counter_spend 受理闸,二者缺一即孤儿行 - var has_gate := kind_seen.has("ap_charge_safe") - if not has_gate and row.has("triggers"): - for tr_v in (row.get("triggers", []) as Array): - if String((tr_v as Dictionary).get("do", {}).get("kind", "")) == "counter_spend": - has_gate = true - break - if not has_gate: - errs.append({"category": "cross_field", "message": "%s active_charged 行 charges=0(积点制)须带 ap_charge_safe 或 counter_spend 受理闸,实得二者皆无" % iid}) - # 充能重置周期(adventure 缺省/battle 每战/level 每关;仅 active_charged 行可设) - var creset: String = String(row.get("charge_reset", "adventure")) - if not CHARGE_RESETS.has(creset): - errs.append({"category": "enum", "message": "%s charge_reset 出域 %s: %s" % [iid, str(CHARGE_RESETS), creset]}) - if form != "active_charged" and row.has("charge_reset") and creset != "adventure": - errs.append({"category": "cross_field", "message": "%s 非 active_charged 行 charge_reset 仅可缺省(adventure),实得 %s" % [iid, creset]}) - # 解锁混合制字段校验(契约 6:钥石购买价与条件谓词互斥——每件单解锁方式,混合不做) - var price: int = int(row.get("unlock_keystone_cost", 0)) - var ureq_count: int = (row.get("unlock_requires", []) as Array).size() - if price != 0 and ureq_count > 0: - errs.append({"category": "cross_field", "message": "%s 解锁方式互斥:unlock_keystone_cost 与 unlock_requires 不得并存(每件单解锁方式,契约 6)" % iid}) - if price < 0 or price > 999: - errs.append({"category": "domain", "message": "%s unlock_keystone_cost 越域 [0,999](0=非购买件缺省),实得 %d" % [iid, price]}) - var eff_rounds: int = int(row.get("effective_rounds", 0)) - if eff_rounds < 0 or eff_rounds > 999: - errs.append({"category": "domain", "message": "%s effective_rounds 越域 [0,999]: %d" % [iid, eff_rounds]}) - if kind_seen.has("ap_rounds_front") and eff_rounds < 1: - errs.append({"category": "cross_field", "message": "%s ap_rounds_front 行 effective_rounds 应 ≥1(P4 甲表值化),实得 %d" % [iid, eff_rounds]}) - if not kind_seen.has("ap_rounds_front") and eff_rounds != 0 and not row.has("triggers"): - errs.append({"category": "cross_field", "message": "%s 非 ap_rounds_front 行 effective_rounds 应 0(trigger 行=when value_from 取数源豁免),实得 %d" % [iid, eff_rounds]}) - var charge_n: int = int(row.get("charge_n", 0)) - if charge_n < 0 or charge_n > 999: - errs.append({"category": "domain", "message": "%s charge_n 越域 [0,999]: %d" % [iid, charge_n]}) - if kind_seen.has("ap_charge_safe") and charge_n < 1: - errs.append({"category": "cross_field", "message": "%s ap_charge_safe 行 charge_n 应 ≥1(每次充能需 N 安全格),实得 %d" % [iid, charge_n]}) - if not kind_seen.has("ap_charge_safe") and charge_n != 0: - errs.append({"category": "cross_field", "message": "%s 非 ap_charge_safe 行 charge_n 应 0,实得 %d" % [iid, charge_n]}) - # unlock_requires 谓词行:空 = 已解锁(首批全解锁合法态,底稿 §2.2);非空键 ∈ 计数键封闭枚举 - var ureqs: Array[Variant] = row.get("unlock_requires", []) - var ukeys := {} - for req in ureqs: - var ukey: String = String(req.get("key", "")) - if not UNLOCK_COUNTER_KEYS.has(ukey): - errs.append({"category": "enum", "message": "%s unlock_requires 计数键出域 %s: %s" % [iid, str(UNLOCK_COUNTER_KEYS), ukey]}) - if ukeys.has(ukey): - errs.append({"category": "dup_key", "message": "%s unlock_requires 计数键重复: %s" % [iid, ukey]}) - ukeys[ukey] = true - var uth: int = int(req.get("threshold", 0)) - if uth < 1 or uth > 999: - errs.append({"category": "domain", "message": "%s unlock_requires 阈值越域 [1,999]: %d" % [iid, uth]}) + errs.append_array(_validate_row_attunement(row, iid, kind_seen)) + errs.append_array(_validate_row_unlock_costs(row, iid)) + errs.append_array(_validate_row_effect_numbers(row, iid, kind_seen)) + errs.append_array(_validate_row_unlock_requires(row, iid)) + return errs + + +## 行头域:display_name/维度标签/target/use_phase/行键白名单/triggers 订阅形态/form 词表 +func _validate_row_head(row: Dictionary, iid: String) -> Array[Variant]: + var errs: Array[Variant] = [] + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % iid}) + # dimension_tags:五维词表 + 非空(退役/失义行仍须登记维度——语义面保留) + var tags: Array[Variant] = row.get("dimension_tags", []) + if tags.is_empty(): + errs.append({"category": "domain", "message": "%s dimension_tags 空" % iid}) + for tag in tags: + if not DIMENSIONS.has(String(tag)): + errs.append({"category": "enum", "message": "%s dimension_tag 出域 %s: %s" % [iid, str(DIMENSIONS), str(tag)]}) + var target: String = String(row.get("target", "none")) + if not TARGETS.has(target): + errs.append({"category": "enum", "message": "%s target 出域 %s: %s" % [iid, str(TARGETS), target]}) + var phase: String = String(row.get("use_phase", "active_dig")) + if not USE_PHASES.has(phase): + errs.append({"category": "enum", "message": "%s use_phase 出域 %s: %s" % [iid, str(USE_PHASES), phase]}) + # 🔴 零裸 flat 结构护栏:键白名单封闭(同调批 T1——机制轴禁无条件属性加项) + for k in row.keys(): + if not KEY_WHITELIST.has(String(k)): + errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s(KEY_WHITELIST 外键禁入)" % [iid, String(k)]}) + errs.append_array(_validate_row_triggers(row, iid)) + var form: String = String(row.get("form", "")) + if not FORMS.has(form): + errs.append({"category": "enum", "message": "%s form 出域 %s: %s" % [iid, str(FORMS), form]}) + return errs + + +## 订阅形态域:triggers 事件订阅行判据与行级触发词表校验(被动件形态;与 effect_kinds 互斥校验 +## 面=passive 行(单形态——效果行为只走订阅);active_charged 行两形合法并存 +## (effect_kinds=主动分发通道 + triggers=受理闸订阅,掘进蓄能先例)) +func _validate_row_triggers(row: Dictionary, iid: String) -> Array[Variant]: + var errs: Array[Variant] = [] + if row.has("triggers"): + if (row.get("effect_kinds", []) as Array).size() > 0 and String(row.get("form", "")) != "active_charged" and not bool(row.get("retired", false)): + errs.append({"category": "domain", "message": "%s triggers 与 effect_kinds 并存(换代单形态——被动件迁移期禁双挂)" % iid}) + EffectEngine.validate_trigger_row(iid, row, row.get("triggers", []) as Array, errs) + return errs + + +## 活行域:effect_kinds 非空与词表/真视窄域与 board_wide 承重护栏/magnitude 语义(kind_seen 引用填充——同调域消费) +func _validate_row_active(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]: + var errs: Array[Variant] = [] + var kinds: Array[Variant] = row.get("effect_kinds", []) + var target: String = String(row.get("target", "none")) + if kinds.is_empty() and not row.has("triggers"): + errs.append({"category": "domain", "message": "%s effect_kinds 空(活行;失义件应标 deferred,被动件应挂 triggers 订阅)" % iid}) + for k in kinds: + var kind: String = String(k) + if not EFFECT_KINDS.has(kind): + errs.append({"category": "enum", "message": "%s effect_kind 出域 %s: %s" % [iid, str(EFFECT_KINDS), kind]}) + if kind_seen.has(kind): + errs.append({"category": "dup_key", "message": "%s effect_kind 重复: %s" % [iid, kind]}) + kind_seen[kind] = true + # 承重护栏①真视窄域:真视类活行 target 必须 area_3x3 或 cell(一次最多 3×3,禁全图看雷; + # reveal_tiles 与 cell 档纳入同护栏,线香单格=cell ⊂ 3×3 契约内) + for rv in REVEAL_KINDS: + if kind_seen.has(String(rv)) and not (target == "area_3x3" or target == "cell"): + errs.append({"category": "load_bearing", "message": "%s 真视窄域护栏:%s 行 target 必须 area_3x3 或 cell,实得 %s(秘宝 GDD 结构冻结)" % [iid, String(rv), target]}) + # 承重护栏②board_wide 仅恢复推理类:全图域行禁替推理效果 + if target == "board_wide" and kind_seen.has("mine_reveal"): + errs.append({"category": "load_bearing", "message": "%s board_wide 行 effect_kinds 不得含 mine_reveal(全图域仅恢复推理原料类——Alpha 重定后落位)" % iid}) + errs.append_array(_validate_row_magnitude(row, iid, kind_seen)) + return errs + + +## N 值语义域:magnitude 越域与 MAGNITUDE_KINDS 行消费(≥1);其余活行恒 0(同调批 T1——消费面自 ap_grant 扩至 N 值 kind) +func _validate_row_magnitude(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]: + var errs: Array[Variant] = [] + var mag: int = int(row.get("magnitude", 0)) + if mag < 0 or mag > 999: + errs.append({"category": "domain", "message": "%s magnitude 越域 [0,999]: %d" % [iid, mag]}) + var mag_consuming := false + for k in kind_seen.keys(): + if MAGNITUDE_KINDS.has(String(k)): + mag_consuming = true + if mag_consuming and mag < 1: + errs.append({"category": "cross_field", "message": "%s N 值 kind 行 magnitude 应 ≥1,实得 %d" % [iid, mag]}) + if not mag_consuming and mag != 0 and not row.has("triggers"): + errs.append({"category": "cross_field", "message": "%s 非 N 值 kind 活行 magnitude 应 0(trigger 行 magnitude=value_from 取数源豁免),实得 %d" % [iid, mag]}) + return errs + + +## 同调充能与档位域(form 非空——装配面;过渡行 form="" 不校验):槽位统一/档位必填/充能值域与积点制受理闸/充能重置周期 +func _validate_row_attunement(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]: + var errs: Array[Variant] = [] + if int(row.get("slot_cost", 1)) != 1: + errs.append({"category": "load_bearing", "message": "%s 同调占位统一 1 槽(slot_cost 恒 1——底稿裁决 5,schema 强制)" % iid}) + # 档位必填(槽位档位约束数据面:件档≤格档判定的取数源) + var tier: String = String(row.get("tier", "")) + if not TIERS.has(tier): + errs.append({"category": "domain", "message": "%s 同调行 tier 必填且 ∈ %s,实得 %s" % [iid, str(TIERS), tier]}) + var charges: int = int(row.get("charges_per_adventure", 0)) + var form: String = String(row.get("form", "")) + if form == "passive" and charges != 0: + errs.append({"category": "cross_field", "message": "%s passive 行 charges_per_adventure 应 0,实得 %d" % [iid, charges]}) + if form == "active_charged" and charges < 0: + errs.append({"category": "domain", "message": "%s active_charged 行 charges_per_adventure 越域 ≥0,实得 %d" % [iid, charges]}) + if form == "active_charged" and charges == 0: + # 充能 0=积点制/滑耗制合法态(试炼镐转化获取,无固定充能上限): + # 必须带使用闸——ap_charge_safe 滑耗(安全格积点)或 treasure_used+counter_spend 受理闸,二者缺一即孤儿行 + errs.append_array(_validate_row_charge_gate(row, iid, kind_seen)) + # 充能重置周期(adventure 缺省/battle 每战/level 每关;仅 active_charged 行可设) + var creset: String = String(row.get("charge_reset", "adventure")) + if not CHARGE_RESETS.has(creset): + errs.append({"category": "enum", "message": "%s charge_reset 出域 %s: %s" % [iid, str(CHARGE_RESETS), creset]}) + if form != "active_charged" and row.has("charge_reset") and creset != "adventure": + errs.append({"category": "cross_field", "message": "%s 非 active_charged 行 charge_reset 仅可缺省(adventure),实得 %s" % [iid, creset]}) + return errs + + +## 积点制受理闸域:charges=0 的 active_charged 行必须带使用闸(ap_charge_safe 滑耗或 counter_spend 受理闸) +func _validate_row_charge_gate(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]: + var errs: Array[Variant] = [] + var has_gate := kind_seen.has("ap_charge_safe") + if not has_gate and row.has("triggers"): + for tr_v in (row.get("triggers", []) as Array): + if String((tr_v as Dictionary).get("do", {}).get("kind", "")) == "counter_spend": + has_gate = true + break + if not has_gate: + errs.append({"category": "cross_field", "message": "%s active_charged 行 charges=0(积点制)须带 ap_charge_safe 或 counter_spend 受理闸,实得二者皆无" % iid}) + return errs + + +## 解锁互斥域(form 非空行,契约 6):钥石购买价与条件谓词互斥——每件单解锁方式,混合不做 +func _validate_row_unlock_costs(row: Dictionary, iid: String) -> Array[Variant]: + var errs: Array[Variant] = [] + # 解锁混合制字段校验(契约 6:钥石购买价与条件谓词互斥——每件单解锁方式,混合不做) + var price: int = int(row.get("unlock_keystone_cost", 0)) + var ureq_count: int = (row.get("unlock_requires", []) as Array).size() + if price != 0 and ureq_count > 0: + errs.append({"category": "cross_field", "message": "%s 解锁方式互斥:unlock_keystone_cost 与 unlock_requires 不得并存(每件单解锁方式,契约 6)" % iid}) + if price < 0 or price > 999: + errs.append({"category": "domain", "message": "%s unlock_keystone_cost 越域 [0,999](0=非购买件缺省),实得 %d" % [iid, price]}) + return errs + + +## 效果数值域(form 非空行):effective_rounds 与 charge_n 的值域及 kind 交叉 +func _validate_row_effect_numbers(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]: + var errs: Array[Variant] = [] + var eff_rounds: int = int(row.get("effective_rounds", 0)) + if eff_rounds < 0 or eff_rounds > 999: + errs.append({"category": "domain", "message": "%s effective_rounds 越域 [0,999]: %d" % [iid, eff_rounds]}) + if kind_seen.has("ap_rounds_front") and eff_rounds < 1: + errs.append({"category": "cross_field", "message": "%s ap_rounds_front 行 effective_rounds 应 ≥1(P4 甲表值化),实得 %d" % [iid, eff_rounds]}) + if not kind_seen.has("ap_rounds_front") and eff_rounds != 0 and not row.has("triggers"): + errs.append({"category": "cross_field", "message": "%s 非 ap_rounds_front 行 effective_rounds 应 0(trigger 行=when value_from 取数源豁免),实得 %d" % [iid, eff_rounds]}) + var charge_n: int = int(row.get("charge_n", 0)) + if charge_n < 0 or charge_n > 999: + errs.append({"category": "domain", "message": "%s charge_n 越域 [0,999]: %d" % [iid, charge_n]}) + if kind_seen.has("ap_charge_safe") and charge_n < 1: + errs.append({"category": "cross_field", "message": "%s ap_charge_safe 行 charge_n 应 ≥1(每次充能需 N 安全格),实得 %d" % [iid, charge_n]}) + if not kind_seen.has("ap_charge_safe") and charge_n != 0: + errs.append({"category": "cross_field", "message": "%s 非 ap_charge_safe 行 charge_n 应 0,实得 %d" % [iid, charge_n]}) + return errs + + +## 谓词行域:unlock_requires 计数键封闭枚举与阈值(空 = 已解锁——首批全解锁合法态,底稿 §2.2) +func _validate_row_unlock_requires(row: Dictionary, iid: String) -> Array[Variant]: + var errs: Array[Variant] = [] + var ureqs: Array[Variant] = row.get("unlock_requires", []) + var ukeys := {} + for req in ureqs: + var ukey: String = String(req.get("key", "")) + if not UNLOCK_COUNTER_KEYS.has(ukey): + errs.append({"category": "enum", "message": "%s unlock_requires 计数键出域 %s: %s" % [iid, str(UNLOCK_COUNTER_KEYS), ukey]}) + if ukeys.has(ukey): + errs.append({"category": "dup_key", "message": "%s unlock_requires 计数键重复: %s" % [iid, ukey]}) + ukeys[ukey] = true + var uth: int = int(req.get("threshold", 0)) + if uth < 1 or uth > 999: + errs.append({"category": "domain", "message": "%s unlock_requires 阈值越域 [1,999]: %d" % [iid, uth]}) return errs diff --git a/game/data/schemas/variation_table.gd b/game/data/schemas/variation_table.gd index 65c4c7b6..1b4af099 100644 --- a/game/data/schemas/variation_table.gd +++ b/game/data/schemas/variation_table.gd @@ -26,57 +26,82 @@ func validate() -> Array: var errs: Array[Dictionary] = [] var seen := {} for row in rows: - var vid: String = row.get("variant_id", "") - if vid == "": - errs.append({"category": "domain", "message": "variant_id 缺失/空"}) + var head := _validate_row(row, seen) + errs.append_array(head["errs"]) + if not head["ok"]: continue - if seen.has(vid): - errs.append({"category": "dup_key", "message": "variant_id 重复: %s" % vid}) + var vid: String = head["vid"] + errs.append_array(_validate_effect_interlocks(row, vid)) + errs.append_array(_validate_global_interlocks(row, vid)) + return errs + + +## 行域:variant_id 非空与查重/display_name/unlock_level/四词表(scope/effect/trigger/placement) +func _validate_row(row: Dictionary, seen: Dictionary) -> Dictionary: + var errs: Array[Dictionary] = [] + var vid: String = row.get("variant_id", "") + if vid == "": + errs.append({"category": "domain", "message": "variant_id 缺失/空"}) + return {"errs": errs, "vid": vid, "ok": false} + if seen.has(vid): + errs.append({"category": "dup_key", "message": "variant_id 重复: %s" % vid}) seen[vid] = true - if String(row.get("display_name", "")) == "": - errs.append({"category": "domain", "message": "%s display_name 空" % vid}) - var ul: int = int(row.get("unlock_level", 0)) - if ul < 1 or ul > 999: - errs.append({"category": "domain", "message": "%s unlock_level 越域 [1,999]: %d" % [vid, ul]}) - if not SCOPES.has(row.get("scope", "")): - errs.append({"category": "enum", "message": "%s scope 出域: %s" % [vid, row.get("scope")]}) - if not EFFECTS.has(row.get("effect", "")): - errs.append({"category": "enum", "message": "%s effect 出域: %s" % [vid, row.get("effect")]}) - if not TRIGGERS.has(row.get("trigger", "")): - errs.append({"category": "enum", "message": "%s trigger 出域: %s" % [vid, row.get("trigger")]}) - if not PLACEMENTS.has(row.get("placement", "")): - errs.append({"category": "enum", "message": "%s placement 出域: %s" % [vid, row.get("placement")]}) - # 跨字段互锁(加载期致命——枚举语义一致性立闸): - # 伤害型 = 挖开即伤 ⇒ 触发 on_open + 只落未开格(closed_only)+ 伤害量为加法核小整数 - if row.get("effect", "") == "flat_damage": - if row.get("trigger", "") != "on_open": - errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ trigger 必为 on_open" % vid}) - if row.get("placement", "") != "closed_only": - errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ placement 必为 closed_only(只出现在未打开的格子)" % vid}) - var ev: int = int(row.get("effect_value", 0)) - if ev < 1 or ev > 9: - errs.append({"category": "domain", "message": "%s effect_value 越域 [1,9](加法核小整数): %d" % [vid, ev]}) - # 信息型 = 被打开且显示数字时起作用 ⇒ 触发 on_number_shown - if row.get("effect", "") == "hide_number" and row.get("trigger", "") != "on_number_shown": - errs.append({"category": "cross_field", "message": "%s hide_number ⇒ trigger 必为 on_number_shown" % vid}) - # 信息遮蔽型只允许落安全格(遮蔽对象是数字;危险格无数字可遮)——placement=any 时由读取方 - # 呈现面按 kind=safe 自然短路,此处不设布置约束 - # 全局作用域型互锁(resource_face ⟺ scope=global 三条 + 新值域; - # 判定键=effect/scope 词值,既有六 region 行(flat_damage/hide_number)不入本组——零误伤) - if row.get("effect", "") == "resource_face": - if row.get("scope", "") != "global": - errs.append({"category": "cross_field", "message": "%s resource_face ⇒ scope 必为 global(整图作用域)" % vid}) - # resource_face 行 effect_value = 生存族权重下调百分比(0=中性合法态——off 锚用) - var gev: int = int(row.get("effect_value", 0)) - if gev < 0 or gev > 50: - errs.append({"category": "domain", "message": "%s effect_value 越域 [0,50](资源面偏斜百分比): %d" % [vid, gev]}) - if row.get("scope", "") == "global": - if row.get("effect", "") != "resource_face": - errs.append({"category": "cross_field", "message": "%s scope=global ⇒ effect 必为 resource_face" % vid}) - if row.get("placement", "") != "any": - errs.append({"category": "cross_field", "message": "%s scope=global ⇒ placement 必为 any(整图无格子布置域)" % vid}) - if row.get("trigger", "") != "always": - errs.append({"category": "cross_field", "message": "%s scope=global ⇒ trigger 必为 always(整图恒在,无触发时机)" % vid}) + return {"errs": errs, "vid": vid, "ok": false} + seen[vid] = true + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % vid}) + var ul: int = int(row.get("unlock_level", 0)) + if ul < 1 or ul > 999: + errs.append({"category": "domain", "message": "%s unlock_level 越域 [1,999]: %d" % [vid, ul]}) + if not SCOPES.has(row.get("scope", "")): + errs.append({"category": "enum", "message": "%s scope 出域: %s" % [vid, row.get("scope")]}) + if not EFFECTS.has(row.get("effect", "")): + errs.append({"category": "enum", "message": "%s effect 出域: %s" % [vid, row.get("effect")]}) + if not TRIGGERS.has(row.get("trigger", "")): + errs.append({"category": "enum", "message": "%s trigger 出域: %s" % [vid, row.get("trigger")]}) + if not PLACEMENTS.has(row.get("placement", "")): + errs.append({"category": "enum", "message": "%s placement 出域: %s" % [vid, row.get("placement")]}) + return {"errs": errs, "vid": vid, "ok": true} + + +## 效果互锁域(加载期致命——枚举语义一致性立闸): +## 伤害型 = 挖开即伤 ⇒ 触发 on_open + 只落未开格(closed_only)+ 伤害量为加法核小整数; +## 信息型 = 被打开且显示数字时起作用 ⇒ 触发 on_number_shown +## (信息遮蔽型只允许落安全格——遮蔽对象是数字,危险格无数字可遮;placement=any 时由读取方 +## 呈现面按 kind=safe 自然短路,此处不设布置约束) +func _validate_effect_interlocks(row: Dictionary, vid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + if row.get("effect", "") == "flat_damage": + if row.get("trigger", "") != "on_open": + errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ trigger 必为 on_open" % vid}) + if row.get("placement", "") != "closed_only": + errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ placement 必为 closed_only(只出现在未打开的格子)" % vid}) + var ev: int = int(row.get("effect_value", 0)) + if ev < 1 or ev > 9: + errs.append({"category": "domain", "message": "%s effect_value 越域 [1,9](加法核小整数): %d" % [vid, ev]}) + if row.get("effect", "") == "hide_number" and row.get("trigger", "") != "on_number_shown": + errs.append({"category": "cross_field", "message": "%s hide_number ⇒ trigger 必为 on_number_shown" % vid}) + return errs + + +## 全局作用域互锁域(resource_face ⟺ scope=global;判定键=effect/scope 词值, +## 既有六 region 行(flat_damage/hide_number)不入本组——零误伤); +## resource_face 行 effect_value = 生存族权重下调百分比(0=中性合法态——off 锚用) +func _validate_global_interlocks(row: Dictionary, vid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + if row.get("effect", "") == "resource_face": + if row.get("scope", "") != "global": + errs.append({"category": "cross_field", "message": "%s resource_face ⇒ scope 必为 global(整图作用域)" % vid}) + var gev: int = int(row.get("effect_value", 0)) + if gev < 0 or gev > 50: + errs.append({"category": "domain", "message": "%s effect_value 越域 [0,50](资源面偏斜百分比): %d" % [vid, gev]}) + if row.get("scope", "") == "global": + if row.get("effect", "") != "resource_face": + errs.append({"category": "cross_field", "message": "%s scope=global ⇒ effect 必为 resource_face" % vid}) + if row.get("placement", "") != "any": + errs.append({"category": "cross_field", "message": "%s scope=global ⇒ placement 必为 any(整图无格子布置域)" % vid}) + if row.get("trigger", "") != "always": + errs.append({"category": "cross_field", "message": "%s scope=global ⇒ trigger 必为 always(整图恒在,无触发时机)" % vid}) return errs @@ -85,3 +110,38 @@ func row_by_id(variant_id: String) -> Dictionary: if row.get("variant_id", "") == variant_id: return row return {} + + +# ---- 候选池访问器(自 ConfigDB 归位;消费面 db.combat.variation. 直连)---- +# 门控第三维 = 节点身份(全局作用域变异):scope=global 行仅 boss/elite 节点入池, +# 其余节点(battle 等)静默跳过——门控兜底 P0 口径(零报错零警告);elite 系前向词位(首发零表行)。 +# node_type 缺省 "" = 旧双参调用口径(global 行跳过)——既有测试/冒烟调用零扰动。 + +## 候选池门控(∅ board_types = 通配;字段名对齐 board_type 词表,无 biome_tags) +func variant_pool(level_index: int, board_type: String, node_type: String = "") -> Array: + var pool: Array[Dictionary] = [] + for row in rows: + if String(row.get("scope", "region")) == "global" \ + and node_type != "boss" and node_type != "elite": + continue + if int(row.get("unlock_level", 999)) > level_index: + continue + var tags: Array[Variant] = row.get("board_types", []) + if tags.is_empty(): + pool.append(row) + continue + if tags.has(board_type): + pool.append(row) + return pool + + +## 全局型单行决议(战斗编排装板期消费——访问器纪律:消费面禁直扫表): +## boss/elite 节点取候选池内首个 scope=global 行(首发批全局池恒恰一行;多行为配置越界态, +## 按表序取首行确定性兜底);空 = 无全局型(调用方回落 region 布置——兜底回落合法态) +func global_variant_row(level_index: int, board_type: String, node_type: String) -> Dictionary: + if node_type != "boss" and node_type != "elite": + return {} + for row in variant_pool(level_index, board_type, node_type): + if String(row.get("scope", "region")) == "global": + return row + return {} diff --git a/game/data/tables/adventure.tres b/game/data/tables/adventure.tres index 17a5c641..c60c6d66 100644 --- a/game/data/tables/adventure.tres +++ b/game/data/tables/adventure.tres @@ -1,11 +1,12 @@ -[gd_resource type="Resource" script_class="AdventureTable" load_steps=2 format=3] +[gd_resource type="Resource" script_class="AdventureTable" load_steps=5 format=3] [ext_resource type="Script" path="res://data/schemas/adventure_table.gd" id="1"] +[ext_resource type="Script" path="res://data/schemas/adventure_quota_segment.gd" id="2"] +[ext_resource type="Script" path="res://data/schemas/adventure_value_segment.gd" id="3"] +[ext_resource type="Script" path="res://data/schemas/adventure_cap_segment.gd" id="4"] -[resource] -script = ExtResource("1") -clear_heal_pct = 20 -curio_candidate_count = 3 +[sub_resource type="Resource" id="AdventureTable_quota"] +script = ExtResource("2") curio_quota_attack_common = 18 curio_quota_attack_excellent = 5 curio_quota_max_hp_common = 17 @@ -17,6 +18,9 @@ curio_quota_gp_excellent = 5 curio_quota_heal_excellent = 3 curio_quota_refresh_excellent = 2 curio_quota_ap_rare = 3 + +[sub_resource type="Resource" id="AdventureTable_value"] +script = ExtResource("3") curio_value_attack_common = 1 curio_value_attack_excellent = 2 curio_value_max_hp_common = 5 @@ -28,26 +32,18 @@ curio_value_gp_excellent = 50 curio_value_heal_excellent = 6 curio_value_refresh_excellent = 1 curio_value_ap_rare = 1 + +[sub_resource type="Resource" id="AdventureTable_cap"] +script = ExtResource("4") + +[resource] +script = ExtResource("1") +clear_heal_pct = 20 +curio_candidate_count = 3 +quota_segment = SubResource("AdventureTable_quota") +value_segment = SubResource("AdventureTable_value") +cap_segment = SubResource("AdventureTable_cap") feat_ticket_chance = 20 feat_ticket_quota = 2 feat_ticket_limit = 1 feat_ticket_pity_step = 5 - -; 迭代 20 T2 池结构案D 换代(2026-10-11,奇物.md 池结构与抽取 v1):四级档位 70/25/7/0 = 102 枚—— -; 稀有档投放三 kind=AP 件 3/储备金袋 2/时沙 2(ap_rare 显式载于本表,与 schema 缺省同值); -; 概率与上限解耦(案②令牌池+提前退役):概率位=档+令牌数、已拿上限=curio_cap_*(拿了才计); -; 曝光槽=三选一 2 格全池+1 格优秀档以上子池(子池空回退全池);凭证不进子池。 -; 前史 TP0 令牌池换代(2026-10-03,node-reward v2.2 追认 b 落地):三选一池 = 固定配额令牌池—— -; 洗牌不放回抽 3、选中踢池未选回池、池耗即枯竭; -; 种类次数上限退役(池消耗即上限;货架库存同账本=牌库剩余令牌数)。 -; 档位构成(现行):普通〔小GP 18/小增伤 18/小护甲 17/小生命 17〕+ 优秀〔大GP/大增伤/大护甲/大生命 各 5+恢复 3+刷新件 2〕 -; + 稀有〔AP 件 3/储备金袋 2/时沙 2〕。 -; 数值对:小GP 20/大GP 50、小增伤 +1/大 +2、小护甲 +1/大 +2、小生命 +5/大 +10、恢复 +6(当即回血)、AP +1。 -; 恢复件 = 新入池种类(优秀档)。 -; 退役字段:bonus_weight_*(E-S 权重族)/ bonus_cap_*(种类次数上限族)/ BONUS_CAP_UNLIMITED 哨兵—— -; 洗牌改令牌 Fisher-Yates(不放回抽 3,消费 reward_pick 封闭流)。 -; 迭代 11 第②批 商店共享池注记:货架 = 令牌级抽签(同 kind 去重每 kind 至多一卡、价格公式 tier-blind -; ceil(基价×b^(关序−1)×A^visit) 维持批②裁定、购买消费在架令牌本体)。 -; 末关的关卡完成不发三选一与回血(奖励只朝下一战生效)。 -; 全部 provisional-pending-曲线调参批(值锚与上调约束归 难度曲线与Boss;档位配额结构锚 70/25/7/0—— -; schema TIER_QUOTAS,validate 硬断言,改总量须重裁)。 diff --git a/game/presentation/battle_narrator.gd b/game/presentation/battle_narrator.gd index 066aa8d9..d8874ef6 100644 --- a/game/presentation/battle_narrator.gd +++ b/game/presentation/battle_narrator.gd @@ -73,7 +73,7 @@ static func boss_name(runner: Variant, db: ConfigDb) -> String: if runner != null: var node: Dictionary[Variant, Variant] = runner.current_node() if String(node.get("type", "")) == "boss": - var rowset: Array[Variant] = db.resolve_enemy_rowset(int(runner.level_row.level_index), int(node.get("node_depth", -1)), "boss") + var rowset: Array[Variant] = db.combat.enemy.resolve_rowset(int(runner.level_row.level_index), int(node.get("node_depth", -1)), "boss") if not rowset.is_empty(): return String(rowset[0].get("display_name", "")) return "遗迹守卫" diff --git a/game/presentation/board_resolvers.gd b/game/presentation/board_resolvers.gd index 554d8c5e..0856ee2f 100644 --- a/game/presentation/board_resolvers.gd +++ b/game/presentation/board_resolvers.gd @@ -19,8 +19,8 @@ func _init(db_ref: ConfigDb, debug_overlay_ref: DebugOverlay, settlement_getter: # 禁表写路径(双写叠加隐患源头); # 合成式单点在 UiKit.scaled——与调试浮窗生效值预览共用,本类不持有同式私有实现) func debug_hero_override() -> Dictionary: - return {"atk": UiKit.scaled(int(_db.hero.atk), _debug_overlay.atk_pct()), - "max_hp": UiKit.scaled(int(_db.hero.max_hp), _debug_overlay.hp_pct())} + return {"atk": UiKit.scaled(int(_db.combat.hero.atk), _debug_overlay.atk_pct()), + "max_hp": UiKit.scaled(int(_db.combat.hero.max_hp), _debug_overlay.hp_pct())} # 奖励徽章身份解析(组合根守卫:仅挖掘期 Active 相位内查询——地图战斗资源 Ready 域外零读、零诊断噪音; @@ -45,7 +45,7 @@ func variant_mask_lookup(c: Vector2i) -> bool: if b == null: return false for vid in b.variant_ids(c): - var vrow: Dictionary[Variant, Variant] = _db.variation.row_by_id(String(vid)) + var vrow: Dictionary[Variant, Variant] = _db.combat.variation.row_by_id(String(vid)) if String(vrow.get("effect", "")) == "hide_number": return true return false @@ -61,7 +61,7 @@ func variant_mark_lookup(c: Vector2i) -> String: if b == null: return "" for vid in b.variant_ids(c): - var vrow: Dictionary[Variant, Variant] = _db.variation.row_by_id(String(vid)) + var vrow: Dictionary[Variant, Variant] = _db.combat.variation.row_by_id(String(vid)) if String(vrow.get("effect", "")) == "flat_damage": return "flat_damage" return "" diff --git a/game/presentation/camp_shop_presenter.gd b/game/presentation/camp_shop_presenter.gd index 86fcc5ec..fea7cb36 100644 --- a/game/presentation/camp_shop_presenter.gd +++ b/game/presentation/camp_shop_presenter.gd @@ -59,12 +59,12 @@ func on_camp_closed() -> void: func camp_data() -> Dictionary: var meta = _meta_get.call() return { - "balance": meta.keystone_balance() if meta != null else 0, + "balance": meta.meta_state.keystone_balance() if meta != null else 0, "workshop": meta.treasure_shop_status() if meta != null else [], # 键名沿 workshop(视图消费键不变——行内容=秘宝匣 12 件解锁投影) "forge": meta.forge_shop_status() if meta != null else [], # 锻造工坊两区六路线投影 "attune": meta.attune_camp_status() if meta != null else {}, # 同调装配面投影 - "tree": meta.tree_camp_rows() if meta != null else [], # 树面投影(含 forge_workshop/ks_workshop 钥匙门判据行) - "fork_groups": meta.tree_fork_groups() if meta != null else [], + "tree": meta.tree_camp_status()["rows"] if meta != null else [], # 树面投影(含 forge_workshop/ks_workshop 钥匙门判据行) + "fork_groups": meta.tree_camp_status()["fork_groups"] if meta != null else [], "recent": _save_get.call().recent_summaries() if _save_get.call() != null else [], } @@ -107,7 +107,7 @@ func on_camp_tree_buy(node_id: String) -> void: var meta = _meta_get.call() if meta == null: return - var ok: bool = meta.tree_buy_fork(node_id) if String(db.tree_node(node_id).get("kind", "")) == "fork" else meta.tree_buy(node_id) + var ok: bool = meta.tree_buy_fork(node_id) if String(db.crossrun.meta_tree.row_by_id(node_id).get("kind", "")) == "fork" else meta.tree_buy(node_id) if not ok: toast.show_toast("购买被拒——前置/行门槛/余额不足", "warn") return @@ -157,8 +157,8 @@ func on_camp_forge_select(route_id: String) -> void: var meta = _meta_get.call() if meta == null: return - var ok: bool = meta.forge_select_weapon(route_id) if String(db.forge_route(route_id).get("kind", "")) == "weapon" \ - else meta.forge_select_armor(route_id) + var ok: bool = meta.forge_select("weapon", route_id) if String(db.crossrun.forge.route(route_id).get("kind", "")) == "weapon" \ + else meta.forge_select("armor", route_id) if not ok: toast.show_toast("切换被拒——路线未解锁", "warn") return @@ -177,7 +177,7 @@ func on_shop_buy(product_id: String) -> void: toast.show_toast("GP不足——买不起这件商品", "warn") return shop_overlay.open_shelf(runner.shop_shelf(), int(runner.gp)) - shop_overlay.set_visit_info(int(runner.shop_bp_visit_buys()), float(db.shop_price_a())) # T6 爬价规则行 + shop_overlay.set_visit_info(int(runner.shop_bp_visit_buys()), float(db.run.shop.price_a)) # T6 爬价规则行 shop_overlay.set_refresh_display(runner.shop_refresh_free_left(), runner.shop_refresh_cost(), int(runner.gp) >= runner.shop_refresh_cost()) _refresh_presentations.call() return @@ -199,7 +199,7 @@ func on_shop_refresh() -> void: toast.show_toast("GP不足——刷新失败", "warn") return shop_overlay.open_shelf(runner.shop_shelf(), int(runner.gp)) - shop_overlay.set_visit_info(int(runner.shop_bp_visit_buys()), float(db.shop_price_a())) # T6 爬价规则行 + shop_overlay.set_visit_info(int(runner.shop_bp_visit_buys()), float(db.run.shop.price_a)) # T6 爬价规则行 shop_overlay.set_refresh_display(runner.shop_refresh_free_left(), runner.shop_refresh_cost(), int(runner.gp) >= runner.shop_refresh_cost()) _refresh_presentations.call() diff --git a/game/presentation/curio_interact_controller.gd b/game/presentation/curio_interact_controller.gd index afa4bece..f5cbf94f 100644 --- a/game/presentation/curio_interact_controller.gd +++ b/game/presentation/curio_interact_controller.gd @@ -165,9 +165,9 @@ func _curio_card_texts(candidates: Array) -> Array: text = "专长获取 选中或买下后立刻开一次专长三选一" elif kind == "relic": - text = Narrator.feat_card_text(db.feat_row(rid)) + text = Narrator.feat_card_text(db.crossrun.feats.row_by_id(rid)) else: - text = Narrator.bonus_card_text(kind, tier, int(db.adventure.curio_value(kind, tier))) + text = Narrator.bonus_card_text(kind, tier, int(db.run.adventure.curio_value(kind, tier))) out.append({"id": cid, "text": text}) return out @@ -251,6 +251,6 @@ func item_name_lookup() -> Dictionary: var lookup := {} var meta = _meta_get.call() if meta != null: - for row in db.treasure_attunement_rows(): + for row in db.crossrun.treasure_items.attunement_rows(): lookup[String(row.get("item_id", ""))] = String(row.get("display_name", "")) return lookup diff --git a/game/presentation/hud_presenter.gd b/game/presentation/hud_presenter.gd index 408bc94f..09e8219c 100644 --- a/game/presentation/hud_presenter.gd +++ b/game/presentation/hud_presenter.gd @@ -79,7 +79,7 @@ static func _refresh_hero(stage: BattleStageView, info: HeroInfoPanelView, s: Di # ---- 行动经济(AP 点阵 / RAP / 弃图刷新按钮态——RAP 读数在本条) ---- static func _refresh_economy(ap_bar: ApBarView, s: Dictionary, db: ConfigDb, refresh_remaining: int) -> void: - ap_bar.set_ap(int(s.ap), int(db.action_economy.ap_per_round)) + ap_bar.set_ap(int(s.ap), int(db.run.action_economy.ap_per_round)) ap_bar.set_rap(int(s.rap)) var digging := String(s.phase) == ActionEconomy.PHASE_ACTIVE ap_bar.set_refresh_state(refresh_remaining, @@ -92,11 +92,11 @@ static func _refresh_economy(ap_bar: ApBarView, s: Dictionary, db: ConfigDb, ref # 无 archetype 字段,聚合形态会空键兜底实显「流派 ×N · 档一」僵尸文案;流派档位非现行机制) static func feat_holdings_lines(db: ConfigDb, held: Array) -> Array[String]: var rlines: Array[String] = [] - if db == null or db.feats == null or (db.feats.rows as Array).is_empty() or held.is_empty(): + if db == null or db.feats == null or (db.crossrun.feats.rows as Array).is_empty() or held.is_empty(): return rlines var counts := {} for rid in held: - var rrow: Dictionary[Variant, Variant] = db.feat_row(String(rid)) + var rrow: Dictionary[Variant, Variant] = db.crossrun.feats.row_by_id(String(rid)) var fname := String(rrow.get("display_name", String(rid))) counts[fname] = int(counts.get(fname, 0)) + 1 for fname in counts.keys(): @@ -112,7 +112,7 @@ static func board_variant_lines(db: ConfigDb, ids: Array) -> Array[String]: if db == null or db.variation == null: return lines for vid in ids: - var row: Dictionary[Variant, Variant] = db.variation.row_by_id(String(vid)) + var row: Dictionary[Variant, Variant] = db.combat.variation.row_by_id(String(vid)) if not row.is_empty(): lines.append(String(row.get("display_name", vid))) lines.append("回复与护甲资源减少") @@ -229,7 +229,7 @@ static func _p90(a: Array) -> float: # (两入口开层时 runner 恒在役——状态面板卡 F3 证据链) static func status_data(db: ConfigDb, meta: MetaProgressionCore, runner: Variant, treasure_ui: Variant) -> Dictionary: return { - "balance": meta.keystone_balance(), + "balance": meta.meta_state.keystone_balance(), "hero_hp": int(runner.hero_hp), # LevelRunner 属性面(_ledger 直取下标——键缺失=致命) "max_hp": runner.effective_max_hp(), "atk": runner.effective_atk(), @@ -257,20 +257,20 @@ static func status_curios(treasure_ui: Variant) -> Array: static func career_data(db: ConfigDb, save_manager: SaveManagerCore, meta: MetaProgressionCore) -> Dictionary: var clears: Array[Dictionary] = [] var boss_first := 0 - for i in db.level_count(): + for i in db.run.level.level_count(): var li: int = i + 1 - clears.append({"display_name": String(db.level_row(li).get("display_name", "")), - "count": save_manager.clear_count(li)}) - if save_manager.boss_first_kill_marked(li): + clears.append({"display_name": String(db.run.level.row_by_level_index(li).get("display_name", "")), + "count": save_manager.meta_state.career_state().clear_counts.get(str(li), 0)}) + if save_manager.meta_state.career_state().boss_first_kill.has(str(li)): boss_first += 1 return { - "total": save_manager.adventure_total(), - "cleared": save_manager.adventure_cleared(), + "total": save_manager.meta_state.career_state().adventure_total, + "cleared": save_manager.meta_state.career_state().adventure_cleared, "clears": clears, "boss_first_kills": boss_first, - "boss_first_total": db.level_count(), - "boss_kills": save_manager.boss_kill_count(), - "keystone": meta.keystone_balance(), + "boss_first_total": db.run.level.level_count(), + "boss_kills": save_manager.meta_state.career_state().boss_kill_count, + "keystone": meta.meta_state.keystone_balance(), } @@ -280,7 +280,7 @@ static func career_data(db: ConfigDb, save_manager: SaveManagerCore, meta: MetaP static func save_select_data(save_manager: SaveManagerCore) -> Dictionary: var slots: Array[Dictionary] = [] for i in range(1, save_manager.SLOT_COUNT + 1): - var p := save_manager.peek_slot_summary(i) # 推断 Dictionary(窥视返回合同——裸注解触 R1、参数化赋值运行期严格拒) + var p := save_manager.slots.peek_slot_summary(i) # 推断 Dictionary(窥视返回合同——裸注解触 R1、参数化赋值运行期严格拒) slots.append({"slot": i, "occupied": bool(p.get("has_file", false)) and bool(p.get("readable", false)), "keystone": int(p.get("keystone", 0)), diff --git a/game/presentation/main.gd b/game/presentation/main.gd index e4adf790..fb8b14f5 100644 --- a/game/presentation/main.gd +++ b/game/presentation/main.gd @@ -312,7 +312,7 @@ func _on_confirm_accepted() -> void: _close_modal(confirm_dialog) # 模态栈单口 match _confirm_consumer: "delete_slot": - save_manager.delete_slot(_confirm_consumer_slot) + save_manager.slots.delete_slot(_confirm_consumer_slot) save_select_overlay.open_save_select(HudPresenter.save_select_data(save_manager)) # 就地刷新(该槽回空态) _confirm_consumer = "" @@ -366,9 +366,9 @@ func _wire_adventure() -> void: static func build_adventure_params(save_manager_ref: SaveManagerCore, meta_ref: MetaProgressionCore, extra: Dictionary = {}) -> Dictionary: var params: Dictionary[Variant, Variant] = { "hero_override": meta_ref.hero_override() if meta_ref != null else {}, - "attack_type": meta_ref.hero_attack_type() if meta_ref != null else "", - "armor_type": meta_ref.hero_armor_type() if meta_ref != null else "", - "attunement": save_manager_ref.attunement_equipped() if save_manager_ref != null else [], + "attack_type": meta_ref.hero_type_axes()["attack"] if meta_ref != null else "", + "armor_type": meta_ref.hero_type_axes()["armor"] if meta_ref != null else "", + "attunement": save_manager_ref.meta_state.attune_state().equipped if save_manager_ref != null else [], "attune_refresh_y": meta_ref.attune_refresh_charges() if meta_ref != null else 0, "tree_rules": meta_ref.tree_rule_keys() if meta_ref != null else [], } @@ -447,7 +447,7 @@ func _on_battle_finished(payload: Dictionary) -> void: match String(decision.outcome): "next_layer": _inter_label.text = "第 %d 层通过!\nHP %d/%d 带入下一层" % [ - int(decision.layer), int(decision.carry_hp), int(db.hero.max_hp)] + int(decision.layer), int(decision.carry_hp), int(db.combat.hero.max_hp)] _inter_overlay.visible = true _refresh_presentations() return @@ -772,11 +772,11 @@ func _on_refresh_confirmed() -> void: logger.log_action("refresh", Vector2i(-1, -1), ok) if ok: logger.log_ux("ux_refresh_attempt", {"result": "accepted", "round_refresh_index": _refresh_used_snapshot()}) - board_zone.toast.show_toast("RAP−%d · 旧板封印作废——新图已就位,AP 保留继续挖" % int(db.action_economy.rap_refresh_cost), "gold") + board_zone.toast.show_toast("RAP−%d · 旧板封印作废——新图已就位,AP 保留继续挖" % int(db.run.action_economy.rap_refresh_cost), "gold") else: # 资源面/上限拒绝(EC13 合法拒绝可见;行动经济 无载荷——客户端按上下文推断) var rap: int = int(run_scope.settlement.presentation_snapshot().get("rap", 0)) if run_scope.settlement != null else 0 - var cost: int = int(db.action_economy.rap_refresh_cost) + var cost: int = int(db.run.action_economy.rap_refresh_cost) var result := "rejected_rap" if rap < cost else "rejected_cap" logger.log_ux("ux_refresh_attempt", {"result": result, "round_refresh_index": _refresh_used_snapshot() + 1}) if result == "rejected_rap": @@ -811,7 +811,7 @@ func _refresh_used_snapshot() -> int: func _refresh_presentations() -> void: # 每个核心信号/写调用返回后的全量重查装配(更新触发注记——无推送信号,命令式重查) - var refresh_remaining: int = maxi(0, int(db.action_economy.refresh_cap_per_round) - _refresh_used_snapshot()) + var refresh_remaining: int = maxi(0, int(db.run.action_economy.refresh_cap_per_round) - _refresh_used_snapshot()) HudPresenter.refresh(stage, info_panel, ap_bar, run_scope.settlement, run_scope.runner, run_scope.level_active, db, refresh_remaining) _treasure_ui.refresh_slots() # C 区秘宝槽重查(试炼镐积点进度随挖格刷新悬停浮窗) _sync_settlement_visuals() @@ -906,6 +906,6 @@ func _seed_value() -> int: # 封闭流、禁 randomize()/自建 RNG——组合根建种子属引擎熵豁免面,不进玩法 RNG 消费) if _seed_edit.text.is_valid_int(): return int(_seed_edit.text) - if save_manager.is_first_adventure_done(): + if save_manager.meta_state.career_state().first_adventure_done: return randi() # rng-seed-exempt(非首局空框引擎熵——组合根建种子不进玩法 RNG 消费,static_scan S1/S2 标记豁免) return 42 diff --git a/game/presentation/main_adventure_flow.gd b/game/presentation/main_adventure_flow.gd index f0c2a4ea..f05b8c18 100644 --- a/game/presentation/main_adventure_flow.gd +++ b/game/presentation/main_adventure_flow.gd @@ -80,7 +80,7 @@ static func _on_runner_node(root: Variant, node: Dictionary, info: Dictionary) - root._open_modal(root.shop_overlay) if root.run_scope.runner.is_shop_pool(): root.shop_overlay.open_shelf(root.run_scope.runner.shop_shelf(), int(root.run_scope.runner.gp)) - root.shop_overlay.set_visit_info(int(root.run_scope.runner.shop_bp_visit_buys()), float(root.db.shop_price_a())) # 爬价规则行 + root.shop_overlay.set_visit_info(int(root.run_scope.runner.shop_bp_visit_buys()), float(root.db.run.shop.price_a)) # 爬价规则行 root.shop_overlay.set_refresh_display(root.run_scope.runner.shop_refresh_free_left(), root.run_scope.runner.shop_refresh_cost(), int(root.run_scope.runner.gp) >= root.run_scope.runner.shop_refresh_cost()) # MC-1 else: root.shop_overlay.open_shop(root.run_scope.runner.shop_products(), int(root.run_scope.runner.gp)) diff --git a/game/presentation/main_overlay_flow.gd b/game/presentation/main_overlay_flow.gd index 3b0337d6..ff74ed03 100644 --- a/game/presentation/main_overlay_flow.gd +++ b/game/presentation/main_overlay_flow.gd @@ -68,14 +68,14 @@ static func _on_save_select_closed(root: Variant) -> void: # 空槽新档链(Q3 裁乙暂行落点=现役 CampOverlay;卡④ 重立卡后改挂场景图):卡① 创建=激活=白板落盘, # 落点开层。动线序(禁先关后开):先落点开层(开始层保持隐藏),选档层被模态栈遮挡隐藏、不关闭。 static func _on_save_select_new(root: Variant, slot: int) -> void: - root.save_manager.create_slot(slot) + root.save_manager.slots.create_slot(slot) root._camp_pres.open_camp() # 有档槽进档链(契约 8 进档即激活):卡① 激活+读盘(兜底/拒读链按槽成立)→暂行落点 CampOverlay。 static func _on_save_select_load(root: Variant, slot: int) -> void: - root.save_manager.activate_slot(slot) + root.save_manager.slots.activate_slot(slot) root._camp_pres.open_camp() diff --git a/game/presentation/main_ui_build.gd b/game/presentation/main_ui_build.gd index 862c51f7..badcc2bd 100644 --- a/game/presentation/main_ui_build.gd +++ b/game/presentation/main_ui_build.gd @@ -81,7 +81,7 @@ static func _build_battle_wiring(root: Variant) -> void: root.board_zone.chord_attempted.connect(root._on_chord_attempted) root.logger = PlaytestLogger.new() - root.logger.ap_per_round = int(root.db.action_economy.ap_per_round) + root.logger.ap_per_round = int(root.db.run.action_economy.ap_per_round) static func _build_overlays(root: Variant) -> void: # 模态覆盖层(登记序 = 树序 = z 遮挡序:图例 < 明细回看 < 节点 < 关卡画面 < 三选一 < 终局 @@ -193,7 +193,7 @@ static func _build_debug_and_resolvers(root: Variant) -> void: # 诊断转储入口在本浮窗,玩家画面零常驻调试件)。 # 树序在开始层与确认弹窗之后 = 浮于菜单之上的 Dialog(不隐藏开始层——与确认弹窗同款形态); # 开窗唯一入口 = _show_debug_overlay(C6 单口——角标/探针/启动路径全收口) - root.debug_overlay = DebugOverlay.new(root.db, int(root.db.hero.atk), int(root.db.hero.max_hp)) + root.debug_overlay = DebugOverlay.new(root.db, int(root.db.combat.hero.atk), int(root.db.combat.hero.max_hp)) root.add_child(root.debug_overlay) root._seed_edit = root.debug_overlay.seed_edit # 种子读取面在浮窗(_seed_value 唯一消费点不变) root.debug_overlay.closed.connect(root._on_modal_closed.bind(root.debug_overlay)) @@ -235,7 +235,7 @@ static func _build_modal_flow(root: Variant) -> void: root._modals = root._modal_flow.stack # 探针 _list 登记序断言缝(ui_struct 登记序=z 序纪律断言) # 视觉层合同装配(唯一事实源=LAYER_CONTRACT:树序+登记序同源派生——改序只改合同表) root._apply_layer_contract() - root._tune_sim = TuneSimController.new(root.db, root.debug_overlay, int(root.db.hero.max_hp)) + root._tune_sim = TuneSimController.new(root.db, root.debug_overlay, int(root.db.combat.hero.max_hp)) static func fatal_screen(root: Variant, msg: String) -> void: var l := UiKit.label(msg,UiKit.FS_SUB, true, UiKit.RED_ALERT) diff --git a/game/presentation/treasure_interact_controller.gd b/game/presentation/treasure_interact_controller.gd index 7deab97d..de978289 100644 --- a/game/presentation/treasure_interact_controller.gd +++ b/game/presentation/treasure_interact_controller.gd @@ -44,9 +44,9 @@ func unlocked_set() -> Dictionary: var out := {} if meta == null: return out - for row in db.treasure_transitional_rows(): + for row in db.crossrun.treasure_items.transitional_rows(): var iid := String(row.get("item_id", "")) - if meta.is_item_unlocked(iid): + if meta.meta_state.item_unlocked(iid): out[iid] = true return out @@ -79,7 +79,7 @@ func on_slot_clicked(slot_handle: int) -> void: break if rec.is_empty() or bool(rec.get("used", false)): return - var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(rec.get("item_id", ""))) + var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(rec.get("item_id", ""))) if String(arow.get("target", "none")) == "none": attempt_use(slot_handle) # AP 类:显式无目标即用(不再伪造哨兵坐标) return @@ -106,7 +106,7 @@ func attempt_use(handle: int, cell: Variant = null) -> void: refresh_slots() refresh_overlays() board_view.refresh() # 移除格即时落图(无移除态的件 diff 零变化幂等) - var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(v.get("item_id", ""))) + var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(v.get("item_id", ""))) toast.show_toast("已使用 %s" % String(arow.get("display_name", "")), "info") @@ -144,7 +144,7 @@ func inventory_projection() -> Array: # 库存投影行(字形=名首字;usable=整图零信息预判回调接排雷玩法只读面)+ # 积点进度浮窗行(自动兑付件 C 区悬停 tooltip 补 N/M 进度;件 id 锁定=装配行唯一) func _projection_row(rec: Dictionary, cs: Dictionary) -> Dictionary: - var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(rec.get("item_id", ""))) + var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(rec.get("item_id", ""))) var dn := String(arow.get("display_name", "")) var note := "" if bool(cs.get("assembled", false)) and String(cs.get("item_id", "")) == String(rec.get("item_id", "")): diff --git a/game/presentation/tune_sim_controller.gd b/game/presentation/tune_sim_controller.gd index 4f681dab..bf089c07 100644 --- a/game/presentation/tune_sim_controller.gd +++ b/game/presentation/tune_sim_controller.gd @@ -38,7 +38,7 @@ func run(n: int) -> void: # 共享日志会话(模拟运行与真人试玩同日志合规——操作/轮次/战斗/关卡完成全量 JSONL; # 一文件多局,run 行携带种子切分局次) var logger = PlaytestLogger.new() - logger.ap_per_round = int(db.action_economy.ap_per_round) + logger.ap_per_round = int(db.run.action_economy.ap_per_round) var log_path: String = logger.start_session(SEED_BASE) print("[tune_sim] 模拟日志: %s" % log_path) panel.set_sim_running(true) diff --git a/game/presentation/ui_assembler.gd b/game/presentation/ui_assembler.gd index d1b2706e..4c815653 100644 --- a/game/presentation/ui_assembler.gd +++ b/game/presentation/ui_assembler.gd @@ -32,7 +32,7 @@ static func build_battle_ui(root: Control, db: ConfigDb) -> Dictionary: var board_zone := BoardZoneView.new() bottom.add_child(board_zone) - board_zone.ap_bar.set_refresh_cost(int(db.action_economy.rap_refresh_cost)) # RAP 读数表值驱动 + board_zone.ap_bar.set_refresh_cost(int(db.run.action_economy.rap_refresh_cost)) # RAP 读数表值驱动 # 结算演出覆盖层(A–D 之上——z 序 = addChild 序,模态覆盖层组在 build_overlays 中更晚挂入) var settlement_fx := SettlementFxView.new() @@ -65,7 +65,7 @@ static func build_battle_ui(root: Control, db: ConfigDb) -> Dictionary: # 本组不含明细回看 overlay——冒险中回看归「生涯」页; # 本组不含营地覆盖层——在 camp_screen 屏壳构建) static func build_overlays(host: Control, db: ConfigDb) -> Dictionary: - var legend_overlay := LegendOverlay.new(int(db.action_economy.rap_refresh_cost)) # RAP 行表值注入 + var legend_overlay := LegendOverlay.new(int(db.run.action_economy.rap_refresh_cost)) # RAP 行表值注入 host.add_child(legend_overlay) var node_overlay := NodeOverlay.new() diff --git a/game/presentation/views/battle_log_view.gd b/game/presentation/views/battle_log_view.gd index 3fe1ca55..fb036e42 100644 --- a/game/presentation/views/battle_log_view.gd +++ b/game/presentation/views/battle_log_view.gd @@ -133,7 +133,7 @@ func _flush_pending_agg() -> void: func _source_label(src_key: String) -> String: if src_key == "": return "" - return String(_db.settlement.core.source_label_map.get(src_key, src_key)) + return String(_db.combat.settlement.core.source_label_map.get(src_key, src_key)) func _gold_frame(text: String) -> String: @@ -171,7 +171,7 @@ func _format_row(row: Dictionary, agg_count := -1) -> String: if row.get("roll", null) != null: roll_txt = "〔掷 %d vs 护甲 %d" % [int(row.roll), int(row.ac)] if bool(row.get("crit", false)): - roll_txt += " · 暴击×%d" % int(_db.settlement.damage_roll.crit_multiplier) + roll_txt += " · 暴击×%d" % int(_db.combat.settlement.damage_roll.crit_multiplier) elif int(row.tier) < 100: roll_txt += " · ×%d%%" % int(row.tier) roll_txt += "〕" diff --git a/game/probe/probe_ui_util.gd b/game/probe/probe_ui_util.gd index f7ccfb68..1ea2f8db 100644 --- a/game/probe/probe_ui_util.gd +++ b/game/probe/probe_ui_util.gd @@ -104,13 +104,13 @@ static func isolate_save(main, tag: String, mark_first_done: bool = false, with_ var iso = SaveManagerScript.new(save_path) iso.load_from_disk() if mark_first_done: - iso.mark_first_adventure_done() + iso.meta_state.mark_first_adventure_done() # --script 模式无裸 autoload 标识符(run_all.gd 头注)——经 main 节点路径解析 var sm = main.get_node("/root/SaveManager") if swap_save: - sm.attach_path(save_path) + sm.slots.attach_path(save_path) if mark_first_done: - sm.mark_first_adventure_done() + sm.meta_state.mark_first_adventure_done() iso = sm else: main.get_node("/root/MetaProgression").save = iso # 只换 meta 侧(营地段语义)——SaveManager 全局不动 @@ -123,10 +123,10 @@ static func isolate_save(main, tag: String, mark_first_done: bool = false, with_ # 驱动后写盘目标=隔离槽文件(持久化断言读 user://_slot_1.json)。返回 false=新档钮缺失(驱动中断)。 static func open_camp_via_savesel(main, tag: String) -> bool: var sm = main.get_node("/root/SaveManager") - sm.attach_slot_template("user://%s_slot_%%d.json" % tag) + sm.slots.attach_slot_template("user://%s_slot_%%d.json" % tag) for i in range(1, 4): # 夹具清场——空槽起(陈旧槽文件会翻有档态、新档钮缺席) for suf in ["", ".tmp", ".corrupt.bak"]: - var sp: String = sm.slot_path(i) + suf + var sp: String = sm.slots.slot_path(i) + suf if FileAccess.file_exists(sp): DirAccess.remove_absolute(sp) main._open_save_select() diff --git a/game/probe/screen_flow_probe.gd b/game/probe/screen_flow_probe.gd index 1ee71d1a..ac32ec58 100644 --- a/game/probe/screen_flow_probe.gd +++ b/game/probe/screen_flow_probe.gd @@ -132,7 +132,7 @@ func _step_camp_back_to_menu() -> void: # 返回钮真实信号路径关闭→ESC 语境不响应(卡⑤ 契约 2 范围外——零实现即断言)。---- func _step_save_select_open_back() -> void: ProbeUiUtil.isolate_save(main, "smoke_screen_flow_savesel", false, false, true) - main.save_manager.attach_slot_template("user://smoke_screen_flow_savesel_slot_%d.json") + main.save_manager.slots.attach_slot_template("user://smoke_screen_flow_savesel_slot_%d.json") var esb: Button = ProbeUiUtil.find_button(main._start_overlay, "进入存档", "prefix") if esb == null: _problems.append("[savesel] mn3 进入存档钮缺失(卡② 交付物)") diff --git a/game/probe/ui_click_probe.gd b/game/probe/ui_click_probe.gd index c67df5cd..bc44cc7d 100644 --- a/game/probe/ui_click_probe.gd +++ b/game/probe/ui_click_probe.gd @@ -71,7 +71,7 @@ func _ready() -> void: _problems.append("树淬炼柜台购买钮缺失(点击路径——四柜台重排后树=主界面)") else: # 树钮句柄用后即取(refresh_camp 随刷重建树行板——旧钮 queue_free,跨刷新句柄必失效) - iso_c.add_keystone(9999) + iso_c.meta_state._add_keystone(9999) main.camp_overlay.refresh_camp(main._camp_data()) await get_tree().process_frame var buy_btn: Button = null @@ -81,15 +81,15 @@ func _ready() -> void: if buy_btn == null: _problems.append("脊柱生命行缺失(点击路径)") else: - var bal_before: int = iso_c.keystone_balance() - var price: int = int(main.db.tree_node("sp_hp").get("price", 0)) + var bal_before: int = iso_c.meta_state.keystone_balance() + var price: int = int(main.db.crossrun.meta_tree.row_by_id("sp_hp").get("price", 0)) buy_btn.pressed.emit() # 合成购买点击 → _on_camp_tree_buy → meta.tree_buy(写调用唯一归组合根) await get_tree().process_frame - if int(iso_c.keystone_balance()) != bal_before - price: - _problems.append("营地树购买未出账(%d→%d 应 −%d)" % [bal_before, iso_c.keystone_balance(), price]) + if int(iso_c.meta_state.keystone_balance()) != bal_before - price: + _problems.append("营地树购买未出账(%d→%d 应 −%d)" % [bal_before, iso_c.meta_state.keystone_balance(), price]) var iso_chk = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp_slot_1.json") iso_chk.load_from_disk() - if int(iso_chk.tree_level("sp_hp")) != 1: + if int(iso_chk.meta_state.tree_state().nodes.get("sp_hp", 0)) != 1: _problems.append("营地树购买未持久(reload 后级 != 1)") # Fork 双态切换点击路径(V20——购=解锁默认 A,钮切 B;前置=同调位 1,先点亮) var socket_btn: Button = null @@ -123,7 +123,7 @@ func _ready() -> void: await get_tree().process_frame var iso_fk = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp_slot_1.json") iso_fk.load_from_disk() - if String(iso_fk.fork_side("fg_posture")) != "b": + if String(iso_fk.meta_state.tree_state().fork_sides.get("fg_posture", "")) != "b": _problems.append("Fork 切态未持久(reload 后激活态 != b)") # ---- 营地六处灰字取色断言(迭代36 wc4——真实入口开营地后按文本锚取 label, # font_color 逐处 == STONE_DEEP;六源位 :167 图鉴锁文/:417+:476 行状态字/:509 同调形态/:513 档位标签/:525 刷新行)---- @@ -189,7 +189,7 @@ func _ready() -> void: if FileAccess.file_exists(cp): DirAccess.remove_absolute(cp) ProbeUiUtil.isolate_save(main, "smoke_click_probe_savesel", true, false, true) - main.save_manager.attach_slot_template(csel_tpl) + main.save_manager.slots.attach_slot_template(csel_tpl) var f_c2 := FileAccess.open(csel_tpl % 2, FileAccess.WRITE) f_c2.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 100, "adventure_total": 3, "adventure_cleared": 1, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) @@ -215,8 +215,8 @@ func _ready() -> void: _problems.append("[savesel] sl12 动线序违约:选档隐藏帧营地层未可见(先关后开=回闪形态)") elif samples[0][2]: _problems.append("[savesel] sl12 动线序违约:开始层在落点帧恢复(回闪一帧)") - if main.save_manager.active_slot() != 1 or not FileAccess.file_exists(csel_tpl % 1): - _problems.append("[savesel] sl5 新档链失守(active=%d 应=1 且槽1 白板应落盘)" % main.save_manager.active_slot()) + if main.save_manager.slots.active_slot() != 1 or not FileAccess.file_exists(csel_tpl % 1): + _problems.append("[savesel] sl5 新档链失守(active=%d 应=1 且槽1 白板应落盘)" % main.save_manager.slots.active_slot()) var occluded: Array = main._modals._occluded.get(main.camp_overlay, []) if not occluded.has(main.save_select_overlay): _problems.append("[savesel] sl12 选档层被关闭而非栈记账挂起(先关后开形态——遮挡挂起中间态钉死)") @@ -233,8 +233,8 @@ func _ready() -> void: else: load_btn.pressed.emit() await get_tree().process_frame - if main.save_manager.active_slot() != 2: - _problems.append("[savesel] sl6 进档链失守(active=%d 应=2——激活该槽)" % main.save_manager.active_slot()) + if main.save_manager.slots.active_slot() != 2: + _problems.append("[savesel] sl6 进档链失守(active=%d 应=2——激活该槽)" % main.save_manager.slots.active_slot()) if not main.camp_overlay.visible or main.save_select_overlay.visible: _problems.append("[savesel] sl6 落点未开营地层或选档未隐藏") main._on_camp_closed() @@ -834,7 +834,7 @@ func _run_modal_probes_once() -> void: var oracle: Array = [] for rec in rnr.treasure_inventory.query_inventory(Callable()): var rr: Dictionary = rec - oracle.append({"name": String(main.db.treasure_item_row(String(rr.get("item_id", ""))).get("display_name", "")), + oracle.append({"name": String(main.db.crossrun.treasure_items.row_by_id(String(rr.get("item_id", ""))).get("display_name", "")), "used": bool(rr.get("used", false))}) var sd_curios: Array = (sd as Dictionary).get("curios", []) var curios_ok: bool = (sd_curios as Array).size() == (oracle as Array).size() @@ -896,7 +896,7 @@ func _run_modal_probes_once() -> void: var used_now: int = int(main.run_scope.settlement.presentation_snapshot().get("refresh_used", 0)) if used_now != 1: _problems.append("刷新轮内计数错误(%d)" % used_now) - var remain_expect: int = maxi(0, int(main.db.action_economy.refresh_cap_per_round) - used_now) + var remain_expect: int = maxi(0, int(main.db.run.action_economy.refresh_cap_per_round) - used_now) if not main.ap_bar.refresh_remaining_text().contains(str(remain_expect)): _problems.append("HUD 刷新余量与快照推导失符(HUD='%s' 应含 %d)" % [main.ap_bar.refresh_remaining_text(), remain_expect]) diff --git a/game/probe/ui_input_probe.gd b/game/probe/ui_input_probe.gd index f804ce56..ba6c4ac7 100644 --- a/game/probe/ui_input_probe.gd +++ b/game/probe/ui_input_probe.gd @@ -475,7 +475,7 @@ func _ready() -> void: # attune_param_sum 只读出口→set_fog_glow_bonus〕→视野 +4 生效与卸装复原。 # settlement.attunement 手动注入=模拟 begin_battle enc 注入后的运行态〔注入面本体归 # 冒烟+单测覆盖〕;不重启冒险——case⑯ 起既有板面继续用) - main.save_manager.set_attunement_equipped(["wide_scout_band"]) + main.save_manager.meta_state.set_attunement_equipped(["wide_scout_band"]) var p_lantern: Dictionary = main.build_adventure_params(main.save_manager, main.meta) if not (p_lantern.get("attunement", []) as Array).has("wide_scout_band"): _problems.append("case⑮ 装配未进开局参数面(attunement=%s 应含 wide_scout_band)" % str(p_lantern.get("attunement", []))) @@ -491,7 +491,7 @@ func _ready() -> void: _problems.append("case⑮ 卸装后视野未复原(lantern 应=0——契约复原语义显式重算)") if int(bv.cells_probe()[ring_cell].fog_state) == 0: _problems.append("case⑮ 卸装后 dist==R+1 格未复原遮蔽") - main.save_manager.set_attunement_equipped([]) + main.save_manager.meta_state.set_attunement_equipped([]) # ---- case⑯ 状态面板输入锁语义(R3——开层锁棋盘、关层恢复无残留;真实 push_input # 路径:入口角标本体 press+release 真点击 → 状态面板开;锁期挖掘点击零穿透;「继续」关层后 # 同格挖掘重新到达——锁语义与既有模态同款,无状态残留。置于全 case 之尾:取格若需前沿移位 diff --git a/game/probe/ui_probe.gd b/game/probe/ui_probe.gd index 26896b6d..b82bc2c1 100644 --- a/game/probe/ui_probe.gd +++ b/game/probe/ui_probe.gd @@ -99,7 +99,7 @@ func _ready() -> void: problems.append("B 区轮次·阶段指示缺失(%s)" % main.stage.round_label.text) if main.stage.enemy_box.get_child_count() < 1: problems.append("B 区敌方站桩未构建(%d 个)" % main.stage.enemy_box.get_child_count()) - if main.ap_bar._dots.get_child_count() != int(main.db.action_economy.ap_per_round): + if main.ap_bar._dots.get_child_count() != int(main.db.run.action_economy.ap_per_round): problems.append("D 区 AP 点阵数量错误(%d)" % main.ap_bar._dots.get_child_count()) if main.ap_bar.refresh_remaining_text() == "": problems.append("D 区弃图刷新按钮文案缺失") diff --git a/game/probe/ui_struct_probe.gd b/game/probe/ui_struct_probe.gd index 9c687982..8ec33796 100644 --- a/game/probe/ui_struct_probe.gd +++ b/game/probe/ui_struct_probe.gd @@ -81,7 +81,7 @@ func _ready() -> void: if FileAccess.file_exists(spp): DirAccess.remove_absolute(spp) ProbeUiUtil.isolate_save(main, "smoke_ui_struct_probe_savesel", true, false, true) - main.save_manager.attach_slot_template(ssel_tpl) # 注入口(激活态回零——sl9 重启链同构态) + main.save_manager.slots.attach_slot_template(ssel_tpl) # 注入口(激活态回零——sl9 重启链同构态) var f_s1 := FileAccess.open(ssel_tpl % 1, FileAccess.WRITE) f_s1.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 555, "adventure_total": 7, "adventure_cleared": 2, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) @@ -89,7 +89,7 @@ func _ready() -> void: var f_s3 := FileAccess.open(ssel_tpl % 3, FileAccess.WRITE) f_s3.store_string("broken json not a save{{{") # 槽3 坏档(窥视 readable=false→按空呈现) f_s3.close() - if main.save_manager.active_slot() != 0: + if main.save_manager.slots.active_slot() != 0: problems.append("[savesel] 开层前激活态非零(sl9 重启链——boot/注入面后应无激活)") main._open_save_select() _settle() @@ -201,8 +201,8 @@ func _ready() -> void: ], 20) _settle() var ticket_row: Dictionary = main.shop_overlay._shelf_rows[0] - if String(main.db.shop_pool_row("feat_ticket").get("display_name", "")) != "专长获取": - problems.append("[shop_term] shop.tres 专长凭证件名未换代(现=%s)" % String(main.db.shop_pool_row("feat_ticket").get("display_name", ""))) + if String(main.db.run.shop.pool_row("feat_ticket").get("display_name", "")) != "专长获取": + problems.append("[shop_term] shop.tres 专长凭证件名未换代(现=%s)" % String(main.db.run.shop.pool_row("feat_ticket").get("display_name", ""))) if ticket_row.card.name_l.text != "专长获取": problems.append("[shop_term] 专长凭证件名未用物品名「专长获取」(现=%s)" % ticket_row.card.name_l.text) if ticket_row.btn.get_parent() != ticket_row.card.body or ticket_row.btn.get_index() >= ticket_row.card.foot_l.get_index(): diff --git a/game/probe/ui_tune_probe.gd b/game/probe/ui_tune_probe.gd index 51c39b7e..4cd3b9a7 100644 --- a/game/probe/ui_tune_probe.gd +++ b/game/probe/ui_tune_probe.gd @@ -36,8 +36,8 @@ func _ready() -> void: await get_tree().process_frame var problems: Array = [] # 基线读表(表值驱动——不写字面量) - var base_atk := int(main.db.hero.atk) - var base_hp := int(main.db.hero.max_hp) + var base_atk := int(main.db.combat.hero.atk) + var base_hp := int(main.db.combat.hero.max_hp) var boosted_atk := base_atk * 2 # 攻击滑条 100 → ×2 var boosted_hp := int(round(base_hp * 1.5)) # 生命滑条 50 → +50% # ① 全局浮层角标开窗(调试入口=UI 最顶层浮层,任何 UI 不遮挡—— @@ -86,8 +86,8 @@ func _ready() -> void: if main.debug_overlay.visible or not main._start_overlay.visible: problems.append("关闭后层级状态错误") # ⑦ hero 表运行期不可变(E4 收口回归断言:任何代码 reintroduce 表写在此被抓获——原"表值残留"断言语义升级) - if int(main.db.hero.atk) != base_atk or int(main.db.hero.max_hp) != base_hp: - problems.append("hero 表被运行时改写(E4 违例:atk=%s hp=%s,期望恒基线 %d/%d)" % [str(main.db.hero.atk), str(main.db.hero.max_hp), base_atk, base_hp]) + if int(main.db.combat.hero.atk) != base_atk or int(main.db.combat.hero.max_hp) != base_hp: + problems.append("hero 表被运行时改写(E4 违例:atk=%s hp=%s,期望恒基线 %d/%d)" % [str(main.db.combat.hero.atk), str(main.db.combat.hero.max_hp), base_atk, base_hp]) # ⑧ 单次加成生效回归断言:应用加成 + 启动单场战斗 → 呈现快照 hero 必须是"基线 × 单次加成" # (攻击 ×2 / 生命 +50%)——旧缺陷(残留值被当基线再乘)在 encounter 参数链上同样被抓获 # 存档隔离(停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律; @@ -165,11 +165,11 @@ func _ready() -> void: # SaveManager 是 autoload 单例——换注走原地 attach(首局态挂载+恢复隔离档); # --script 模式无裸 autoload 标识符——经 main 节点路径解析 var sm = main.get_node("/root/SaveManager") - sm.attach_path("user://smoke_tune_probe_seed.json") # 全新空档——first_adventure_done=false(首局态;新档缺省面) + sm.slots.attach_path("user://smoke_tune_probe_seed.json") # 全新空档——first_adventure_done=false(首局态;新档缺省面) main.debug_overlay.seed_edit.text = "" if main._seed_value() != 42: problems.append("首局空框种子 != 42(P5 甲失守——教学态可复现破约)") - sm.attach_path("user://smoke_tune_probe_save.json") # 恢复隔离档(非首局态——isolate_save 已建档置位) + sm.slots.attach_path("user://smoke_tune_probe_save.json") # 恢复隔离档(非首局态——isolate_save 已建档置位) var draws: Array[int] = [] for i in range(4): draws.append(main._seed_value()) diff --git a/game/sim/adventure_monte_carlo.gd b/game/sim/adventure_monte_carlo.gd index 718a2e9c..64277218 100644 --- a/game/sim/adventure_monte_carlo.gd +++ b/game/sim/adventure_monte_carlo.gd @@ -78,7 +78,7 @@ func _initialize() -> void: var slots: int = 3 var passives: Array = [] var actives: Array = [] - for r in db.treasure_attunement_rows(): + for r in db.crossrun.treasure_items.attunement_rows(): if String(r.get("form", "")) == "passive": passives.append(String(r.get("item_id", ""))) elif String(r.get("form", "")) == "active_charged": @@ -102,7 +102,7 @@ func _initialize() -> void: for policy in policies: # 每策略一个共享日志会话(全量 JSONL 体量随局数增长,分文件便于定位) var logger = load("res://presentation/playtest_logger.gd").new() - logger.ap_per_round = int(db.action_economy.ap_per_round) + logger.ap_per_round = int(db.run.action_economy.ap_per_round) var log_path: String = logger.start_session(seed0) var tally := {} for i in range(n): diff --git a/game/sim/board_stat_probe.gd b/game/sim/board_stat_probe.gd index 70ef61d8..1c036e44 100644 --- a/game/sim/board_stat_probe.gd +++ b/game/sim/board_stat_probe.gd @@ -24,7 +24,7 @@ func _initialize() -> void: var gen = load("res://core/board_generator.gd").new() print("=== 板形统计:N=%d/行 · 种子基 %d ===" % [n, seed0]) print("%-4s %-4s %-8s %-8s %-10s %-10s %-10s %-10s %-10s %-10s" % ["L", "D", "宽x高", "类型", "|M|均", "|M|min", "|M|max", "|S|均", "雷密度实测", "达标率"]) - for row in db.board_gen.rows: + for row in db.combat.board_gen.rows: var L: int = int(row.get("level_index", 0)) var D: int = int(row.get("node_depth", -1)) var w: int = int(row.get("board_width", 0)) diff --git a/game/sim/calibrate_boards.gd b/game/sim/calibrate_boards.gd index afdbe38a..71daea40 100644 --- a/game/sim/calibrate_boards.gd +++ b/game/sim/calibrate_boards.gd @@ -9,9 +9,13 @@ extends SceneTree class FakeDB: - var row: Dictionary - func resolve_board_gen(_L: int, _D: int) -> Dictionary: - return row + class _FakeBoardGen: + var row: Dictionary + func resolve_row(_L: int, _D: int) -> Dictionary: + return row + class _FakeCombat: + var board_gen := _FakeBoardGen.new() + var combat := _FakeCombat.new() func status_effect_row(_id: String) -> Dictionary: return {} @@ -28,7 +32,7 @@ func _initialize() -> void: return var gen = load("res://core/board_generator.gd").new() print("level,depth,w,h,n,mines_mean,mines_med,safe_mean,safe_med,r0_mean,r0_sd,r0_p90,r0_max,inf_mean,cap_rej_mean") - for row in db.board_gen.rows: + for row in db.combat.board_gen.rows: var rd := { "level_index": int(row.level_index), "node_depth": int(row.node_depth), "board_width": int(row.board_width), "board_height": int(row.board_height), diff --git a/game/sim/damage_probe.gd b/game/sim/damage_probe.gd index ed146547..da833a38 100644 --- a/game/sim/damage_probe.gd +++ b/game/sim/damage_probe.gd @@ -53,17 +53,17 @@ func _initialize() -> void: func _probe_boss(db, seed_val: int) -> void: var math = load("res://core/damage_math.gd") - var clamp_lo: int = int(db.settlement.core.pool_clamp_lo) - var clamp_hi: int = int(db.settlement.core.pool_clamp_hi) + var clamp_lo: int = int(db.combat.settlement.core.pool_clamp_lo) + var clamp_hi: int = int(db.combat.settlement.core.pool_clamp_hi) var hero_def_negative := 0 - for eff_id in db.settlement.core.effect_pool_map.keys(): - if String(db.settlement.core.effect_pool_map[eff_id]) != "hero_defense": + for eff_id in db.combat.settlement.core.effect_pool_map.keys(): + if String(db.combat.settlement.core.effect_pool_map[eff_id]) != "hero_defense": continue - var row: Dictionary = db.status_effect_row(eff_id) + var row: Dictionary = db.combat.status_effects.row_by_id(eff_id) if not row.is_empty() and String(row.get("unit", "")) == "percent": hero_def_negative = mini(hero_def_negative, int(row.get("magnitude", 0))) print("表值面:hero_defense 池最负 percent 行 %d · pool_clamp [%d, %d] · damage_floor %d · AC 基准 %d(+ac_up×%d 层)" % [ - hero_def_negative, clamp_lo, clamp_hi, int(db.settlement.damage_roll.damage_floor), int(db.hero.armor_class), int(db.status_effect_row("ac_up").get("max_stack", 0))]) + hero_def_negative, clamp_lo, clamp_hi, int(db.combat.settlement.damage_roll.damage_floor), int(db.combat.hero.armor_class), int(db.combat.status_effects.row_by_id("ac_up").get("max_stack", 0))]) print("--- Run A:fresh 池(零挖掘停手轮——无授予,攻击链裸验)---") var run_a := _run_boss(db, seed_val, false) @@ -92,7 +92,7 @@ func _run_boss(db, seed_val: int, dig: bool) -> Dictionary: settlement.step_row.connect(func(row): cap["rows"].append(row)) settlement.settlement_snapshot.connect(func(s): cap["snaps"].append(s)) settlement.battle_finished.connect(func(p): cap["result"] = String(p.get("result", ""))) - if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(db.hero.max_hp), "node_type": "boss"}): + if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(db.combat.hero.max_hp), "node_type": "boss"}): cap["result"] = "begin_failed" return cap var rounds := 0 @@ -175,11 +175,11 @@ func _analyze_round(db, math, rows: Array, beats: Dictionary) -> Dictionary: # 池归属表驱动(settlement.effect_pool_map),percent 池加算后 clamp(战斗结算 F2 同口径) var pools := {"hero_defense": 0, "enemy_output": 0, "hero_offense": 0, "flat_attach": 0} for eid in last_pool: - var pool_name: String = String(db.settlement.core.effect_pool_map.get(eid, "")) + var pool_name: String = String(db.combat.settlement.core.effect_pool_map.get(eid, "")) if pools.has(pool_name): pools[pool_name] += int(last_pool[eid]) - var clamp_lo: int = int(db.settlement.core.pool_clamp_lo) - var clamp_hi: int = int(db.settlement.core.pool_clamp_hi) + var clamp_lo: int = int(db.combat.settlement.core.pool_clamp_lo) + var clamp_hi: int = int(db.combat.settlement.core.pool_clamp_hi) var pd: int = clampi(int(pools["hero_defense"]), clamp_lo, clamp_hi) var pe: int = clampi(int(pools["enemy_output"]), clamp_lo, clamp_hi) var lines: Array = [] @@ -194,11 +194,11 @@ func _analyze_round(db, math, rows: Array, beats: Dictionary) -> Dictionary: # 掷点口径重建:基线按行留档档位打折(暴击 ×倍率),附加必中 var tier: int = int(row.get("tier", 100)) var crit: bool = bool(row.get("crit", false)) - var base_factor: float = float(tier) * (float(db.settlement.damage_roll.crit_multiplier) / 100.0 if crit else 1.0) + var base_factor: float = float(tier) * (float(db.combat.settlement.damage_roll.crit_multiplier) / 100.0 if crit else 1.0) var rolled_base := int(floor(float(baseline) * base_factor / 100.0)) var raw: int = rolled_base + attach var tick: int = int(row.get("value", 0)) - var expected: int = maxi(int(db.settlement.damage_roll.damage_floor), math.pool_damage(raw, pd, pe)) + var expected: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), math.pool_damage(raw, pd, pe)) var roll_txt := "掷%s" % str(int(row.get("roll", -1))) var zeroed := "✓" if tick == expected else "✗ 算术不符!" lines.append("拍=%s %s/档%d%s 基线%d→掷后%d 附加%d hero_def=%d enemy_out=%d d_tick=%d 期望=%d %s" % [ @@ -266,7 +266,7 @@ func _encounter_prediction(db, seed_val: int) -> Dictionary: if t != "battle" and t != "boss": continue var depth := int(node.get("node_depth", -1)) - var row: Dictionary = db.resolve_enemy_rowset(1, depth, t)[0] # 行集首行(row_kind 随节点类型) + var row: Dictionary = db.combat.enemy.resolve_rowset(1, depth, t)[0] # 行集首行(row_kind 随节点类型) var lo := int(row.get("unit_count", 1)) var hi := int(row.get("unit_count_max", lo)) var rolled := lo @@ -280,7 +280,7 @@ func _encounter_prediction(db, seed_val: int) -> Dictionary: func _level_row(db, level_index: int) -> Dictionary: - for row in db.level.rows: + for row in db.run.level.rows: if int(row.level_index) == level_index: return row return {} @@ -415,11 +415,11 @@ func _scan_spawns(db, seed0: int, n_seeds: int) -> Dictionary: var kind: String = String(dep.get("kind", "battle")) total += 1 var settlement = load("res://core/battle_orchestrator.gd").new(db, rng) - if not settlement.begin_battle({"level_index": 1, "node_depth": depth, "node_type": kind, "hero_hp": int(db.hero.max_hp)}): + if not settlement.begin_battle({"level_index": 1, "node_depth": depth, "node_type": kind, "hero_hp": int(db.combat.hero.max_hp)}): bad += 1 continue var n: int = settlement.presentation_snapshot().enemies.size() - var row: Dictionary = db.resolve_enemy_rowset(1, depth, kind)[0] # 行集首行(同上,row_kind 随节点类型) + var row: Dictionary = db.combat.enemy.resolve_rowset(1, depth, kind)[0] # 行集首行(同上,row_kind 随节点类型) var lo := int(row.get("unit_count", 1)) var hi := int(row.get("unit_count_max", lo)) if n < 1 or n < lo or n > hi: diff --git a/game/sim/dump_battle.gd b/game/sim/dump_battle.gd index 2d44544e..e13a342a 100644 --- a/game/sim/dump_battle.gd +++ b/game/sim/dump_battle.gd @@ -19,7 +19,7 @@ func _initialize() -> void: level = int(OS.get_cmdline_user_args()[0]) if OS.get_cmdline_user_args().size() > 1: rng.set_master_seed(int(OS.get_cmdline_user_args()[1])) - settlement.begin_battle({"level_index": level, "node_depth": 0, "hero_hp": db.hero.max_hp}) + settlement.begin_battle({"level_index": level, "node_depth": 0, "hero_hp": db.combat.hero.max_hp}) print("=== 棋盘摘要 L%d ===" % level) var bd = settlement.minesweeper.current_board() print("尺寸 %dx%d · 雷 %d · 安全格 %d · R₀ %d" % [bd.width, bd.height, bd.mines.size(), bd.safe_cells().size(), bd.metrics.r0_count]) diff --git a/game/sim/experiment_density.gd b/game/sim/experiment_density.gd index 03c46dc6..26879000 100644 --- a/game/sim/experiment_density.gd +++ b/game/sim/experiment_density.gd @@ -56,13 +56,17 @@ func _params_from(row: Dictionary) -> Dictionary: } class FakeDB: - var row: Dictionary - func resolve_board_gen(_L: int, _D: int) -> Dictionary: - return row + class _FakeBoardGen: + var row: Dictionary + func resolve_row(_L: int, _D: int) -> Dictionary: + return row + class _FakeCombat: + var board_gen := _FakeBoardGen.new() + var combat := _FakeCombat.new() func status_effect_row(_id: String) -> Dictionary: return {} func _db(_real, row: Dictionary): var f := FakeDB.new() - f.row = row + f.combat.board_gen.row = row return f diff --git a/game/sim/level_autoplay.gd b/game/sim/level_autoplay.gd index bae08513..61d2597c 100644 --- a/game/sim/level_autoplay.gd +++ b/game/sim/level_autoplay.gd @@ -39,7 +39,7 @@ static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: if own_session: logger = load("res://presentation/playtest_logger.gd").new() logger.start_session(seed_val) - logger.ap_per_round = int(db.action_economy.ap_per_round) + logger.ap_per_round = int(db.run.action_economy.ap_per_round) logger.log_run({"seed": seed_val, "level_index": level_index, "atk_pct": atk_pct, "hp_pct": hp_pct, "policy": policy, "route": route}) var rng = load("res://core/rng_service.gd").new() rng.set_master_seed(seed_val) @@ -117,7 +117,7 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_ if own_session: logger = load("res://presentation/playtest_logger.gd").new() logger.start_session(seed_val) - logger.ap_per_round = int(db.action_economy.ap_per_round) + logger.ap_per_round = int(db.run.action_economy.ap_per_round) logger.log_run({"seed": seed_val, "adventure": true, "atk_pct": atk_pct, "hp_pct": hp_pct, "policy": policy, "route": route, "hero_override": hero_override}) var rng = load("res://core/rng_service.gd").new() diff --git a/game/sim/monte_carlo.gd b/game/sim/monte_carlo.gd index f9600231..93d1705c 100644 --- a/game/sim/monte_carlo.gd +++ b/game/sim/monte_carlo.gd @@ -44,7 +44,7 @@ func _run_encounter(db, L: int) -> void: print("=== L%d · %d 种子(败 %d / 超时 %d)===" % [L, N, losses, timeouts]) print(" 胜利轮数 均值 %.2f 中位 %d p10 %.1f p90 %.1f 分布 %s" % [_mean(rounds_v), _median(rounds_v), _pct_f(rounds_v, 0.1), _pct_f(rounds_v, 0.9), _hist(rounds_v)]) print(" 每轮承伤 均值 %.2f 中位 %.1f p90 %.1f" % [_mean(dmg_per_round), _median_f(dmg_per_round), _pct_f(dmg_per_round, 0.9)]) - print(" 整场承伤 均值 %.1f 中位 %.0f(主角 max_hp=%d)" % [_mean(dmg_total), _median_f(dmg_total), int(db.hero.max_hp)]) + print(" 整场承伤 均值 %.1f 中位 %.0f(主角 max_hp=%d)" % [_mean(dmg_total), _median_f(dmg_total), int(db.combat.hero.max_hp)]) print(" 开安全格 均值 %.1f 中位 %d" % [_mean(safe_opened), _median(safe_opened)]) print(" 清图(换板) 均值 %.2f 中位 %d" % [_mean(clears), _median(clears)]) print(" 攻防buff授予 均值 %.2f" % [_mean(buff_grants)]) @@ -65,7 +65,7 @@ func _run_battle(db, seed_val: int, L: int) -> Dictionary: settlement.minesweeper.cell_revealed.connect(func(evt): if evt.kind == "safe" and evt.channel != "initial": out.safe_opened += 1) - if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": int(db.hero.max_hp)}): + if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp)}): return out var guard_rounds := 40 while out.result == "" and guard_rounds > 0: diff --git a/game/sim/rhythm_batch.gd b/game/sim/rhythm_batch.gd index ab4a7f4a..057f2b78 100644 --- a/game/sim/rhythm_batch.gd +++ b/game/sim/rhythm_batch.gd @@ -19,7 +19,7 @@ func _initialize() -> void: quit(1) return var logger = load("res://presentation/playtest_logger.gd").new() - logger.ap_per_round = int(db.action_economy.ap_per_round) + logger.ap_per_round = int(db.run.action_economy.ap_per_round) var log_path: String = logger.start_session(seed0) print("节奏批日志: %s" % log_path) var autoplay = load("res://sim/level_autoplay.gd") diff --git a/game/sim/skill_rhythm_probe.gd b/game/sim/skill_rhythm_probe.gd index 456fe6c8..f2355fe5 100644 --- a/game/sim/skill_rhythm_probe.gd +++ b/game/sim/skill_rhythm_probe.gd @@ -14,7 +14,7 @@ func _initialize() -> void: db.load_tables("res://data/tables", false) # ① 表侧(行集数组取首行,战斗节点查 battle 键域) - var row = db.resolve_enemy_rowset(1, 1, "battle")[0] + var row = db.combat.enemy.resolve_rowset(1, 1, "battle")[0] print("[1] row(1,1) display=", row.get("display_name"), " unit_count=", row.get("unit_count"), " pool=", row.get("pool")) @@ -50,7 +50,7 @@ func _run_battle(db, seed_val: int, L: int, depth: int, casts: Dictionary) -> Di if row.type == "damage" and row.source_class == "enemy_action": var sid: String = str(row.get("skill_id", "?")) casts[sid] = casts.get(sid, 0) + 1) - if not settlement.begin_battle({"level_index": L, "node_depth": depth, "hero_hp": int(db.hero.max_hp)}): + if not settlement.begin_battle({"level_index": L, "node_depth": depth, "hero_hp": int(db.combat.hero.max_hp)}): return out var guard_rounds := 40 while out.result == "" and guard_rounds > 0: diff --git a/game/sim/verifier_batch.gd b/game/sim/verifier_batch.gd index 39165bd3..f3038855 100644 --- a/game/sim/verifier_batch.gd +++ b/game/sim/verifier_batch.gd @@ -39,7 +39,7 @@ func _initialize() -> void: quit(1) return var logger = load("res://presentation/playtest_logger.gd").new() - logger.ap_per_round = int(db.action_economy.ap_per_round) + logger.ap_per_round = int(db.run.action_economy.ap_per_round) var log_path: String = logger.start_session(opt.seed0) print("模拟日志: %s" % log_path) var autoplay = load("res://sim/level_autoplay.gd") @@ -154,7 +154,7 @@ func _seg_campaign(db, autoplay, logger, opt: Dictionary, counts: Dictionary) -> for r in range(int(opt.k)): var seed_val := _next_seed(int(opt.seed0)) var ov: Dictionary = meta.hero_override() - var keystone_before: int = meta.keystone_balance() + var keystone_before: int = meta.meta_state.keystone_balance() var st: Dictionary = autoplay.run_adventure(db, seed_val, 0, 0, false, logger, policy, "min", ov) var row := _adventure_row(st, policy, seed_val) row["seg"] = "campaign" @@ -171,15 +171,15 @@ func _seg_campaign(db, autoplay, logger, opt: Dictionary, counts: Dictionary) -> var settle: Dictionary = meta.settle_adventure(st.summary) row["keystone_income"] = int(settle.total) row["purchases"] = _spend(meta, String(opt.spend)) - row["keystone_after"] = meta.keystone_balance() - row["tree"] = {"sp_hp": meta.save.tree_level("sp_hp"), "sp_hit": meta.save.tree_level("sp_hit")} + row["keystone_after"] = meta.meta_state.keystone_balance() + row["tree"] = {"sp_hp": meta.save.meta_state.tree_state().nodes.get("sp_hp", 0), "sp_hit": meta.save.meta_state.tree_state().nodes.get("sp_hit", 0)} row["new_unlocks"] = settle.new_unlocks _emit(row) counts.campaign += 1 if (r + 1) % 10 == 0: print("进度 campaign %s %d/%d(树 血%d/命中%d · 钥石 %d)" % [policy, r + 1, int(opt.k), - meta.save.tree_level("sp_hp"), meta.save.tree_level("sp_hit"), - meta.keystone_balance()]) + meta.save.meta_state.tree_state().nodes.get("sp_hp", 0), meta.save.meta_state.tree_state().nodes.get("sp_hit", 0), + meta.meta_state.keystone_balance()]) _remove_save(save_path) # 段尾清理(与 smoke 存档纪律同源——不遗留隔离档) @@ -193,9 +193,9 @@ func _spend(meta, policy_name: String) -> Array: guard -= 1 var bought := false for nid in order: - var price: int = int(meta.db.tree_node(nid).get("price", 0)) - if price > 0 and meta.keystone_balance() >= price and meta.tree_buy(nid): - out.append({"node_id": nid, "level": meta.save.tree_level(nid), "price": price}) + var price: int = int(meta.db.crossrun.meta_tree.row_by_id(nid).get("price", 0)) + if price > 0 and meta.meta_state.keystone_balance() >= price and meta.tree_buy(nid): + out.append({"node_id": nid, "level": meta.save.meta_state.tree_state().nodes.get(nid, 0), "price": price}) bought = true break if not bought: diff --git a/game/tests/README.md b/game/tests/README.md index 462d894c..0b535715 100644 --- a/game/tests/README.md +++ b/game/tests/README.md @@ -43,72 +43,17 @@ tests/ > **分层判据**:能不能在 `--script` 模式下不加载 autoload 就验完?能 = 单元测试; > 需要两个以上真实系统协同并走落盘/落库 = 集成测试;需要完整项目引导与场景树 = 冒烟/系统/UI 探针。 -**能力清单 v1**(2026-10-28 立——验证与清单体系整改阶段 1 的覆盖索引:一行=一个玩家或系统可观测的能力,代表用例只给指针、不复制断言细节;原「集成旅程清单」五条跨系统旅程已并入本表。首期覆盖核心四域(29 行)+跨域条件闸 1 行,共 30 行;其余域按同一形态扩面): +**集成旅程清单**(2026-10-28 立——跨域旅程用例的登记处:一个行为穿过多个领域的对外方法=旅程,验收落本层;缺口旅程由属主批/工程债批按批落用例后回填本表): -> **与验证集清单的关系**:`tests/验证集清单.md` 已于阶段 2「存量断言精简」完成后删除——冻结对账基准(基点 `b9260138`/1587 条)与精简对账结果(33 站点并为 7、1587→1561,合回 `87d241c1`)留 git 历史。本表是该清单退役后的唯一覆盖索引(维护锚=test-conventions §8.3)。本表不是闸,不替代改动—验收对照表、体验验证清单与静态纪律;每行的唯一事实源仍是 `game/tests/` 下的测试代码。 -> -> **代表用例口径**:写 `文件:函数`,每行 2–3 个;函数内可含 `Kit.check_quiet` 静默通道(逐断言提取器口径外,test-conventions §5.2 已备案)——本表以函数为锚,不受该工具局限影响。 -> -> **维护规则(每批收口核对本批受影响行)**:本批改到哪个能力,收口时核对该行代表用例可 grep、在本批真实跑批中执行、真实入口未绕过改动点,并把「最后验证批次」刷新为本批回执;新增能力加行,能力退役删行。全表核对频率与抽样规则归阶段 3。首期未覆盖:排雷循环(棋盘生成/排雷玩法/行动经济)、表现与支撑(HUD/主题/字体/地形瓦片/模态)、数据配置与跨系统契约——按同一形态后续扩面。 -> -> **旅程补录方法**:新旅程=加一行,「系统」列写涉及边界;源=architecture「API 边界」表+「数据流」存档/读档路径+各 GDD 登记的 deferral 集成用例。 -> -> 「最后验证批次」列记回执 commit;多数行初值 `20c13c3a`=存量断言精简 A+B 首批的全量域闸回执,RNG 行随 C 档批刷新为 `fd6cf968`。 +| 旅程 | 涉及边界 | 现行覆盖 | 缺口 | 登记批 | +|---|---|---|---|---| +| 存档⇄RNG 快照往返保真 | SaveManager×AdventureRunner×RngService | `save_rng_roundtrip_test.gd` 用例一(闸 02) | — | 迭代 29 T3 | +| 旧档迁移后断点续玩零跳号 | SaveManager 迁移链×AdventureRunner×BoardGenerator | `save_rng_roundtrip_test.gd` 用例二(闸 02) | — | 迭代 29 T3 | +| 断点附随件与事件日志会话 | AdventureRunner×SaveManager×日志面 | `cross_system_flow_test.gd`(闸 02) | 盘点候选 | 断言迁移批(2026-10-23) | +| 关卡图断点重生成同图/生成图确定性 | LevelRunner×节点图生成器×AdventureRunner | `node_map_determinism_test.gd`(闸 02) | — | 断言迁移批(2026-10-23,迭代 35 卡②随动换代) | +| 回放确定性(同种子逐位) | 全玩法链 | `parity_anchor.py`(闸 11 条件闸) | — | 迭代 29 T3 收编为条件闸件 | -**旅程段一:开机与存档** - -| 能力 | 系统 | 真实入口 | 代表用例(文件:函数) | 最后验证批次 | 缺口 | -|---|---|---|---|---|---| -| 存档写盘⇄读回往返保真 | 存档序列化 | `SaveManagerCore.write_to_disk()`/`load_from_disk()` | `save_manager_domain_test.gd:_smoke_save` | `20c13c3a` | — | -| 三槽位隔离与激活失败回滚 | 存档序列化 | `SaveManagerCore.activate_slot()`/`create_slot()`/`delete_slot()` | `save_slot_isolation_test.gd:_test_three_slot_roundtrip_and_isolation`;`save_slot_isolation_test.gd:_test_activate_failure_restores_previous_slot` | `20c13c3a` | — | -| 槽位摘要窥视与坏槽三态 | 存档序列化 | `SaveManagerCore.peek_slot_summary()` | `save_slot_summary_test.gd:_test_peek_three_slots_distinct_values`;`save_slot_summary_test.gd:_test_peek_empty_and_unreadable_states`;`save_slot_summary_test.gd:_test_peek_zero_side_effect` | `20c13c3a` | — | -| 启动态无激活不写盘 | 存档序列化 | `SaveManagerCore.has_write_target()`/`write_to_disk()` | `save_boot_no_activate_test.gd:_test_write_rejected_without_target`;`save_boot_no_activate_test.gd:_test_out_of_range_slots_rejected` | `20c13c3a` | — | -| 旧档迁移链与断点续玩零跳号(集成) | 存档序列化×冒险结构 | `SaveManagerCore.load_from_disk()`(内置迁移链)× `AdventureRunner.resume_adventure()` | `save_rng_roundtrip_test.gd:_test_old_version_save_migrates_and_resumes` | `20c13c3a` | — | -| 存档⇄RNG 快照往返保真(集成) | 存档序列化×RNG 服务×冒险结构 | `AdventureRunner.start_adventure()` → `SaveManagerCore.write_to_disk()`/`load_from_disk()` | `save_rng_roundtrip_test.gd:_test_disk_roundtrip_preserves_breakpoint` | `20c13c3a` | — | -| RNG 统一流派生与取号账本 | RNG 服务 | `RngServiceCore.derive_at()`/`open_stream()`/`counter()` | `rng_service_test.gd:_test_derive_determinism`;`rng_service_test.gd:_test_stream_draw_determinism`;`rng_stream_boot_test.gd:_smoke_initialize` | `fd6cf968` | — | -| 断点片段往返与续玩幂等 | 冒险结构×关卡节点地图 | `LevelRunner.breakpoint_fragment()`/`restore_fragment()`;`AdventureRunner.resume_adventure()` | `breakpoint_resume_test.gd:_init` | `20c13c3a` | — | -| 跨系统附随面:事件日志落盘与断点附随件(集成) | RNG 服务×存档序列化×冒险结构 | `RngServiceCore.new_adventure()`(开事件日志会话);`SaveManagerCore.breakpoint_snapshot()`(断点附随键) | `cross_system_flow_test.gd:_rng_event_log_session`;`cross_system_flow_test.gd:_feats_breakpoint_roundtrip` | `20c13c3a` | 日志面仅此一条会话覆盖 | - -**旅程段二:冒险开局与选路** - -| 能力 | 系统 | 真实入口 | 代表用例(文件:函数) | 最后验证批次 | 缺口 | -|---|---|---|---|---|---| -| 冒险生命周期:开局受理/三关序列/败北线 | 冒险结构 | `AdventureRunner.start_adventure()`/`continue_to_next_level()` | `adventure_feats_test.gd:_smoke_adventure_api`;`adventure_feats_test.gd:_smoke_adventure` | `20c13c3a` | — | -| 专长(奇物)授予与凭证保底 | 奇物 | `AdventureRunner.present_node_curio()`/`feats_held()` × 效果引擎兑付 | `adventure_feats_test.gd:_smoke_feats`;`feat_vouchers_test.gd:_init` | `20c13c3a` | — | -| 节点图生成合法性与同种子复现 | 关卡节点图生成 | `NodeMapGenerator.generate()`/`validate_output()` | `node_map_generator_test.gd:_test_output_ok`;`node_map_generator_test.gd:_test_reproducible`;`node_map_generator_test.gd:_test_exit_structure` | `20c13c3a` | — | -| 节点图生长递增与无解 fail-fast | 关卡节点图生成 | `NodeMapGenerator.generate()`/`is_fatal()` | `node_map_growth_test.gd:_test_growth`;`node_map_growth_test.gd:_test_fatal_subprocess` | `20c13c3a` | — | -| 起关/选路/断点重生成同图(集成) | 关卡节点地图×关卡节点图生成×冒险结构 | `LevelRunner.start_level()`/`start_level_external()`/`pick_next_node()`;`AdventureRunner.resume_adventure()` | `node_map_determinism_test.gd:_smoke_generated_map_determinism` | `20c13c3a` | — | -| 信息迷雾类型可见性 | 关卡节点地图 | `LevelRunner.type_visible_ids()` | `level_map_type_vis_test.gd:_test_layer_distance`;`level_map_type_vis_test.gd:_test_boss_exempt`;`level_map_type_vis_test.gd:_test_resume_rebuild` | `20c13c3a` | — | -| 关卡表消费侧校验与关卡推进 | 关卡节点地图×数据配置 | `LevelTable.validate_graph()`;`LevelRunner.start_level()` | `level_table_validate_test.gd:_test_positive`;`level_table_validate_test.gd:_test_negative`;`level_shop_economy_test.gd:_smoke_level` | `20c13c3a` | — | - -**旅程段三:战斗结算** - -| 能力 | 系统 | 真实入口 | 代表用例(文件:函数) | 最后验证批次 | 缺口 | -|---|---|---|---|---|---| -| 伤害公式与掷点乘区 | 战斗结算 | `DamageMath.pool_damage()`/`roll_discount_percent()`/`rolled_component()` | `damage_math_test.gd:_test_pool_damage`;`damage_math_test.gd:_test_roll_discount_percent`;`roll_multiplier_test.gd:_smoke_armor_roll` | `20c13c3a` | — | -| 伤害单据编排与回放 | 战斗结算 | `DamageController.submit()`/`open_ticket()`/`flush()` | `damage_controller_test.gd:_run_recipe_sequence`;`damage_controller_test.gd:_test_five_recipes_and_replay` | `20c13c3a` | — | -| 轮内流与结算五段全序 | 战斗结算 | `BattleOrchestrator.begin_battle()` | `round_flow_skill_pool_test.gd:_smoke_full_battle`;`round_flow_skill_pool_test.gd:_smoke_settlement_segments` | `20c13c3a` | — | -| 技能池数据面与回放独立 | 敌人系统 | `BattleOrchestrator.begin_battle()`(技能池抽号/校验) | `round_flow_skill_pool_test.gd:_smoke_skill_pool_data`;`round_flow_skill_pool_test.gd:_smoke_skill_pool_replay_independence` | `20c13c3a` | — | -| Boss 半血解锁技门控 | 敌人系统 | `BattleOrchestrator.begin_battle()`(Boss 行) | `skill_unlock_test.gd:_test_gate_predicate`;`skill_unlock_test.gd:_test_latch_irreversible` | `20c13c3a` | — | -| 封印与极性路由/不可逆 | 地图战斗资源×战斗结算 | 效果引擎 × `BattleOrchestrator.begin_battle()` | `seal_polarity_test.gd:_smoke_seal_ap_exhausted`;`seal_polarity_test.gd:_smoke_seal_irreversible_quadrants`;`seal_polarity_test.gd:_smoke_polarity_routing` | `20c13c3a` | — | -| 变异布置门控 | 地形变异 | `BattleOrchestrator.begin_battle()`(变异布置段) | `variant_placement_test.gd:_smoke_variant_placement`;`variant_placement_test.gd:_smoke_global_variant` | `20c13c3a` | — | - -**旅程段四:奖励、经济与局外成长** - -| 能力 | 系统 | 真实入口 | 代表用例(文件:函数) | 最后验证批次 | 缺口 | -|---|---|---|---|---|---| -| 清图奖励双份与零路径 | 地图战斗资源 | `BattleOrchestrator.begin_battle()` → 步骤流行(`source_class=clear_reward`) | `seal_polarity_test.gd:_smoke_clear_reward_chain`;`seal_polarity_test.gd:_smoke_clear_reward_zero_paths` | `20c13c3a` | — | -| 奖励三选一候选/刷新付费/呈现 | 奇物×节点奖励屏 | `AdventureRunner.present_node_curio()`(数据面);奖励节点覆盖层场景(呈现面) | `curio_refresh_paid_test.gd:_init`;`reward_overlay_test.gd:_run` | `20c13c3a` | — | -| GP 钱包/escrow/商店收口/共享池 | 地图战斗资源×商店 | `LevelRunner.buy_product()`/`player_request_refresh()`/`close_wallet()`/`set_shop_pool()` | `level_shop_economy_test.gd:_smoke_gp_wallet`;`level_shop_economy_test.gd:_smoke_shop_room`;`level_shop_economy_test.gd:_smoke_shop_pool` | `20c13c3a` | — | -| 元进度入账与锻造两轴 | 元进度×锻造工坊 | `SaveManagerCore.add_keystone()`/`spend_keystone()`/`set_forge_route_level()` | `meta_progression_forge_test.gd:_meta_pricing_and_buy`;`meta_progression_forge_test.gd:_smoke_forge_axes` | `20c13c3a` | — | -| 天赋树购买与六键合成 | 天赋树 | `SaveManagerCore.tree_nodes()`/`set_tree_level()` | `meta_progression_forge_test.gd:_smoke_tree`;`meta_progression_forge_test.gd:_smoke_tree_rules`;`tree_rules_v11_test.gd:_init` | `20c13c3a` | — | -| 秘宝结构冻结与 12 件效果 | 秘宝 | `TreasureItems.add_item()`/`query_inventory()`/`validate_use()` | `treasure_items_test.gd:_init`;`treasure_effects_test.gd:_init` | `20c13c3a` | — | - -**跨域条件闸** - -| 能力 | 系统 | 真实入口 | 代表用例 | 最后验证批次 | 缺口 | -|---|---|---|---|---|---| -| 回放确定性(同种子三变体逐位) | 全玩法链 | `tools/parity_anchor.py`(闸 11 条件闸,按批发起) | `tools/parity_anchor.py`(整件为条件闸) | —(条件闸) | 条件闸未随本批执行 | +候选旅程盘点方法:源=architecture「API 边界」表逐行+「数据流」存档/读档路径+各 GDD 登记的 deferral 集成用例;新旅程先登记「缺口」列,用例按批落地后回填「现行覆盖」。 ## 断言计数双口径(2026-10-08 迭代 19 卡① T4 订正) @@ -125,7 +70,7 @@ tests/ |---|---|---|---| | `tools/run_smoke.py`(冒烟测试) | 这版能否启动、能否加载表、能否进主屏——零逻辑判定 | 退出码+脚本错误扫描 | 开机路径/表加载改动后的快速自检;收口由闸编排 | | `tools/run_system_test.py`(系统测试) | 真实玩法路径的行为断言面(推理→战斗→结算→进度推进) | 退出码+脚本错误扫描 | 玩法链路改动的调试期确认 | -| `tests/run_all.gd`(单元与集成) | 公式/状态机/契约/跨系统闭环(64 套件 manifest) | 退出码+三硬闸(见下);每套件子进程带截止时间守护——超时先 taskkill /T 整树回收再判该套件 FAIL、批继续(默认 120s,环境变量 `RUN_ALL_SUITE_TIMEOUT_SEC` 可覆盖) | 新套件单跑调试;逻辑改动的最小单元复跑;开发期选跑——追加 `-- --only=<词[,词…]>` 只跑路径命中词的套件(零命中=用法错 exit 2;选跑态仅供开发期最窄确认,不产闸回执、不作验收凭据) | +| `tests/run_all.gd`(单元与集成) | 公式/状态机/契约/跨系统闭环(64 套件 manifest) | 退出码+三硬闸(见下) | 新套件单跑调试;逻辑改动的最小单元复跑;开发期选跑——追加 `-- --only=<词[,词…]>` 只跑路径命中词的套件(零命中=用法错 exit 2;选跑态仅供开发期最窄确认,不产闸回执、不作验收凭据) | | `tools/probe_gate.sh`(场景探针,`--click`/`--full` 两模式) | UI 可达性、可见性、状态——真实实例化场景+真实输入路径 | 探针自检+退出码 | 新增 UI 区域的配套探针直跑与断言复核 | | `tools/render_gate.sh`(渲染 golden) | 画面基线——截图与基线图逐像素容差比对 | golden 比对脚本(依赖 PIL) | 主题/素材/排版类改动;窗口化运行,需开显示器 | | `game/tests/static_scan.sh`(S 域) | 项目纪律+第三方质量规则:RNG 单点/core 纯度/类型标注/文件头/注释禁词/旧符号残留;S13=gdstyle 质量规则(重复 load、循环内 await、循环内分配、不可达代码等真缺陷类;基线键=文件|规则|函数名(类/文件级=文件|规则,无数字条目带消息),数字漂移不假红,同键指标恶化超基线登记值即红) | 退出码(逐规则 FAIL 行) | 改产品码前后的快速自检;新增纪律先补扫描规则再修码 | @@ -151,9 +96,8 @@ python tools/run_system_test.py # 场景探针(UI 探针层)——以探针场景为运行目标,走完整项目引导(autoload 在位) godot --headless --path . res://probe/.tscn -# 挂死防护内建于 run_all.gd(引擎无死锁探测,超时靠外部判):每套件子进程带截止时间—— -# 默认 120s,环境变量 RUN_ALL_SUITE_TIMEOUT_SEC 可覆盖;超时先 taskkill /T 整树回收 -#(cmd wrapper 连引擎孙进程一起杀,不留孤儿),该套件判 FAIL、批继续——单套件挂死不拖死整闸 +# 本地闸必须配外部 wall-clock 超时(引擎无死锁探测;run_all_gates.sh 已内置) +timeout 120s godot --headless --script tests/run_all.gd ``` > 套件 manifest 现况:`_suites()` 登记 **64 条**(61 单元+3 集成,2026-10-28 实数;口径=manifest 条目数, diff --git a/game/tests/integration/cross_system_flow_test.gd b/game/tests/integration/cross_system_flow_test.gd index 89b7bc25..431864b7 100644 --- a/game/tests/integration/cross_system_flow_test.gd +++ b/game/tests/integration/cross_system_flow_test.gd @@ -112,24 +112,24 @@ func _save_summary_integration() -> void: # ⑨ 摘要集成(冒险结构批产物驱动):run_adventure 真局摘要 → apply 原语 → 域读数 → 写盘读回保持 var st9: Dictionary = LevelAutoplay.run_adventure(_db, 42, 900, 400) var f9 = sm_script.new(path9) - f9.apply_adventure_summary(st9.get("summary", {})) + f9.meta_state.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)) + for li in range(1, int(_db.run.level.rows.size()) + 1): + ks_run += int(_db.crossrun.meta.boss_keystone_row(li).get("first_kill", 0)) Kit.check("摘要集成:冒险驱动满配冒险成功(seed 42)", String(st9.get("result", "")) == "completed") - Kit.check("集成钥石余额 = ΣBoss 首杀行值(%d——逐杀掉落全源)" % f9.keystone_balance(), f9.keystone_balance() == ks_run) - Kit.check("集成关卡完成次数表全 1", f9.clear_count(1) == 1 and f9.clear_count(2) == 1 and f9.clear_count(3) == 1) - Kit.check("集成三关首杀标记齐", f9.boss_first_kill_marked(1) and f9.boss_first_kill_marked(2) and f9.boss_first_kill_marked(3)) - Kit.check("集成 recent 1 条且 seed 42", f9.recent_summaries().size() == 1 and int(f9.recent_summaries()[0].get("master_seed", -1)) == 42) + Kit.check("集成钥石余额 = ΣBoss 首杀行值(%d——逐杀掉落全源)" % f9.meta_state.keystone_balance(), f9.meta_state.keystone_balance() == ks_run) + Kit.check("集成关卡完成次数表全 1", f9.meta_state.career_state().clear_counts.get(str(1), 0) == 1 and f9.meta_state.career_state().clear_counts.get(str(2), 0) == 1 and f9.meta_state.career_state().clear_counts.get(str(3), 0) == 1) + Kit.check("集成三关首杀标记齐", f9.meta_state.career_state().boss_first_kill.has(str(1)) and f9.meta_state.career_state().boss_first_kill.has(str(2)) and f9.meta_state.career_state().boss_first_kill.has(str(3))) + Kit.check("集成 recent 1 条且 seed 42", f9.meta_state.career_state().recent_summaries.size() == 1 and int(f9.meta_state.career_state().recent_summaries[0].get("master_seed", -1)) == 42) var st9b: Dictionary = LevelAutoplay.run_adventure(_db, 42, 900, 400) - f9.apply_adventure_summary(st9b.get("summary", {})) - Kit.check("第二局后关卡完成次数全 2", f9.clear_count(1) == 2 and f9.clear_count(2) == 2 and f9.clear_count(3) == 2) - Kit.check("第二局后余额翻倍(2 × Σ首杀——模拟驱动恒首杀口径)", f9.keystone_balance() == 2 * ks_run) - Kit.check("集成 recent 长度 2", f9.recent_summaries().size() == 2) + f9.meta_state.apply_adventure_summary(st9b.get("summary", {})) + Kit.check("第二局后关卡完成次数全 2", f9.meta_state.career_state().clear_counts.get(str(1), 0) == 2 and f9.meta_state.career_state().clear_counts.get(str(2), 0) == 2 and f9.meta_state.career_state().clear_counts.get(str(3), 0) == 2) + Kit.check("第二局后余额翻倍(2 × Σ首杀——模拟驱动恒首杀口径)", f9.meta_state.keystone_balance() == 2 * ks_run) + Kit.check("集成 recent 长度 2", f9.meta_state.career_state().recent_summaries.size() == 2) Kit.check("集成写盘成功", f9.write_to_disk()) var r9 = sm_script.new(path9) - Kit.check("集成写盘读回全量保持", r9.load_from_disk() and r9.keystone_balance() == 2 * ks_run and r9.clear_count(3) == 2 - and r9.recent_summaries().size() == 2 and r9.boss_first_kill_marked(3)) + Kit.check("集成写盘读回全量保持", r9.load_from_disk() and r9.meta_state.keystone_balance() == 2 * ks_run and r9.meta_state.career_state().clear_counts.get(str(3), 0) == 2 + and r9.meta_state.career_state().recent_summaries.size() == 2 and r9.meta_state.career_state().boss_first_kill.has(str(3))) if FileAccess.file_exists(path9): # 段尾清理(与段首清场对称——源 _smoke_save 同款) DirAccess.remove_absolute(path9) @@ -147,7 +147,7 @@ func _smoke_playtest_logger() -> void: var snaps: Array = [] settlement.settlement_snapshot.connect(func(snap): snaps.append(snap)) Kit.check("会话文件开启(%s)" % path, path.contains("playtest_20260912")) - Kit.check("快照战斗启动", settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})) + Kit.check("快照战斗启动", settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)})) var minesweeper = settlement.minesweeper for i in range(2): minesweeper.settle_batch([{"action": "stop"}]) @@ -159,7 +159,7 @@ func _smoke_playtest_logger() -> void: if not s1.has(k): missing.append(k) Kit.check("快照字段齐备(缺 %s)" % str(missing), missing.is_empty()) - Kit.check("快照含英雄/经济/板序号(板 seq=%d)" % int(s1.board.seq), int(s1.hero.max_hp) == int(_db.hero.max_hp) and s1.economy.has("phase") and s1.board.has("seq")) + Kit.check("快照含英雄/经济/板序号(板 seq=%d)" % int(s1.board.seq), int(s1.hero.max_hp) == int(_db.combat.hero.max_hp) and s1.economy.has("phase") and s1.board.has("seq")) # spawn_count 回归(playtest-2026-09-12 报告 Bug#1):停手两轮零击杀(主角攻击 6×2 < 24 HP 确定)→ # unit_count 必须 == spawn_count(全灭才允许 <);快照级区分「首轮全灭」vs「空场」的数据面在位 Kit.check("快照 spawn_count 在位且 == unit_count(无击杀轮:%d/%d——全灭/空场可判别)" % [int(s1.get("spawn_count", -1)), int(s1.get("unit_count", -1))], diff --git a/game/tests/integration/node_map_determinism_test.gd b/game/tests/integration/node_map_determinism_test.gd index 1f85e314..773fce03 100644 --- a/game/tests/integration/node_map_determinism_test.gd +++ b/game/tests/integration/node_map_determinism_test.gd @@ -35,7 +35,7 @@ func _init() -> void: func _smoke_generated_map_determinism() -> void: # ① 纯函数复现:图 = f(参数档, 种子)——同档同种子两次产图逐位同图,换种子必变图 var gen = NodeMapGenerator.new(_fresh_rng(910001)) - var params2: Dictionary = _db.level_gen_params_row(2) + var params2: Dictionary = _db.run.level_gen_params.row_by_level_index(2) var g_a: Dictionary = gen.generate(params2, 910002) var g_b: Dictionary = gen.generate(params2, 910002) var g_c: Dictionary = gen.generate(params2, 910003) @@ -60,7 +60,7 @@ func _smoke_generated_map_determinism() -> void: Kit.check("冒险模式起关受理(入口停点)", runner2.start_level_external(3, Callable(self, "_smoke_rest_stub"), true)) Kit.check("起关产图零计数器消费(同图不依赖取数号位)", rng_c.counter() == counter_before) - var params3: Dictionary = _db.level_gen_params_row(3) + var params3: Dictionary = _db.run.level_gen_params.row_by_level_index(3) var entry_count: int = int(params3.get("entry_count", -1)) Kit.check("入口停点候选数=参数档入口个数(L3 档 %d——多入口首跳即选路,禁写死单入口)〔v2〕" % entry_count, entry_count >= 1 and (runner2.next_candidates() as Array).size() == entry_count) @@ -81,7 +81,7 @@ func _smoke_generated_map_determinism() -> void: Kit.check("种子 42 两次开局图逐位同图(教学局恒同图——首局固定种子的确定性承载)〔v7〕", adr_x.runner().nodes() == adr_y.runner().nodes()) Kit.check("开局图 = L1 参数档在种子 42 下的生成图(开局无隐式取数分叉)", - adr_x.runner().nodes() == (gen.generate(_db.level_gen_params_row(1), 42) as Dictionary).get("nodes", [])) + adr_x.runner().nodes() == (gen.generate(_db.run.level_gen_params.row_by_level_index(1), 42) as Dictionary).get("nodes", [])) # ⑤ 断点写→恢复→再产图同图(续玩按主种子+参数档重生成,断点不存图快照) Kit.check("停点断点落盘在档", sv_w.has_breakpoint()) var bp: Dictionary = sv_w.breakpoint_snapshot() @@ -96,21 +96,21 @@ func _smoke_generated_map_determinism() -> void: adr_old.resume_adventure(bp_old, {}) == false) # ⑥ 关卡表不再携带图数据(图源换代后 level 行只剩行级字段) var tpl_free := true - for li in range(1, _db.level_count() + 1): - if (_db.level_row(li) as Dictionary).has("templates"): + for li in range(1, _db.run.level.level_count() + 1): + if (_db.run.level.row_by_level_index(li) as Dictionary).has("templates"): tpl_free = false Kit.check("level 表三行均无 templates 键(模板数据已清出)〔v9〕", - tpl_free and _db.level_count() == 3) + tpl_free and _db.run.level.level_count() == 3) # ⑦ 收束置位:新档 false → settle 置 true 恰一次(幂等) var sv = SaveManagerScript.new(_bp_path + ".flag.json") sv.load_from_disk() Kit.check("新档首次冒险布尔位 = false(默认域——首局恒走固定种子 42)", - sv.is_first_adventure_done() == false) + sv.meta_state.career_state().first_adventure_done == false) var mp = load("res://core/meta_progression.gd").new(_db, sv) mp.settle_adventure({"levels_cleared": 1, "keystone_total": 10, "cleared": false, "result": "failed"}) - Kit.check("冒险收束点置位(三结局同写——写入只在收束挂点)", sv.is_first_adventure_done() == true) + Kit.check("冒险收束点置位(三结局同写——写入只在收束挂点)", sv.meta_state.career_state().first_adventure_done == true) mp.settle_adventure({"levels_cleared": 0, "keystone_total": 0, "cleared": false, "result": "failed"}) - Kit.check("置位幂等(重复收束不翻转)", sv.is_first_adventure_done() == true) + Kit.check("置位幂等(重复收束不翻转)", sv.meta_state.career_state().first_adventure_done == true) DirAccess.remove_absolute(_bp_path) DirAccess.remove_absolute(_bp_path + ".flag.json") diff --git a/game/tests/integration/save_rng_roundtrip_test.gd b/game/tests/integration/save_rng_roundtrip_test.gd index edfba694..263b7842 100644 --- a/game/tests/integration/save_rng_roundtrip_test.gd +++ b/game/tests/integration/save_rng_roundtrip_test.gd @@ -103,7 +103,7 @@ func _test_old_version_save_migrates_and_resumes() -> void: var sm_old := SaveManagerScript.new(SAVE_PATH) Kit.check_quiet(sm_old.load_from_disk(), "旧档用例:旧档经迁移链可载") Kit.check_quiet(sm_old.has_breakpoint(), "旧档用例:迁移后断点未被迁移链吃掉") - var forge: Dictionary = sm_old.forge_routes_snapshot() + var forge: Dictionary = sm_old.meta_state.forge_state() Kit.check_quiet(str(forge.get("weapon", "")) == "sword", "旧档用例:迁移补齐当前版本新增域(缺 forge_routes 的旧档按初始持有态可玩)") diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 52c01ca9..7259ef5b 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -1,16 +1,12 @@ # tests/run_all.gd — 单元测试统一入口(双载体的逻辑测试调用点,套件 manifest 协议 D2 AC-2b) -# 用法:godot --headless --path game --script res://tests/run_all.gd +# 用法:godot --headless --path game --script res://tests/run_all.gd (本地闸 02 闸包装外部超时:timeout 120s …) # 选跑态(开发期直跑专用):追加 `-- --only=<词[,词…]>` ——对套件 res:// 路径做子串匹配,只跑命中套件; # 零命中/空值=用法错 quit(2)。选跑态仅供开发期最窄确认,不产闸回执、不作验收凭据; -# 批末全量闸不带参数不经选跑分支。 +# 批末全量闸不带参数不经选跑分支——默认(无参数)行为逐字节不变。 # 协议:套件清单逐条"套件 → 期望退出码";致命类独立进程(expect=1)。 # 执行形态:逐套件起独立 Godot 子进程实跑 #(OS.get_executable_path() 解析本引擎二进制——--script 模式无 autoload/全局标识符, # 每个测试文件自带显式实例化夹具,见 tests/README 协议要点)。 -# 挂死守护:每套件子进程带截止时间(默认 120s,环境变量 RUN_ALL_SUITE_TIMEOUT_SEC 可覆盖)。 -# 套件挂死(_init 在 quit() 前中断,子进程永不退出)=超时先 taskkill /T 整树回收 -#(连 cmd wrapper 带引擎孙进程一起杀,只杀 wrapper 会留孤儿殃及后续全部 Godot 启动) -# 再判该套件 FAIL 继续下一套件——单套件挂死不拖死整批;正常退出路径判定形态不变。 # 场景探针(.tscn 载体)由本地闸(probe_gate.sh)单独步骤调用,不经本入口。 extends SceneTree @@ -18,11 +14,6 @@ extends SceneTree # 错误不改进程退出码、不计失败数,只能由调用方扫输出——三硬闸之二)。 const ERROR_MARKERS: Array[String] = ["SCRIPT ERROR", "Parse Error"] -# 每套件截止时间(秒):默认与 probe_gate 每段同额;环境变量可覆盖——服务挂死守护的 -# 机制验证与日后调参,非法值/非正值一律回落默认。 -const SUITE_TIMEOUT_ENV := "RUN_ALL_SUITE_TIMEOUT_SEC" -const SUITE_TIMEOUT_DEFAULT_SEC := 120 - func _init() -> void: # 选跑态解析:--only=<词[,词…]>(经 -- 分隔的 user args)——对套件 res:// 路径做子串匹配。 @@ -163,69 +154,21 @@ func _run_suite(suite: Dictionary, idx: int, filtered: bool = false) -> int: + "非 console 版是 GUI 子系统,子进程 stdout/stderr 句柄无效,脚本错误扫描无从落地") return 1 # 子进程输出只能落盘再读:OS.execute 的 output 管道在本机 Godot/Windows 组合下恒为空 - #(实测 0 行),故整条命令交给 cmd 并重定向到日志。退出码同样走文件通道—— - # 阻塞式 execute 无超时能力(套件挂死时整批陪葬),换 create_process 非阻塞起进程 - # 后又没有退出码读取 API,故由 cmd /V:ON 在套件退出后把 !ERRORLEVEL! 回显落盘, - # 值与阻塞式 execute 经 cmd 回传的同一。 + #(实测 0 行),故整条命令交给 cmd /c 并重定向到日志,退出码由 cmd 原样回传。 var proj := ProjectSettings.globalize_path("res://") var log := proj + "/tests/.gates/run_all%s_%02d.log" % ["_filter" if filtered else "", idx] - var codefile := log + ".code" - if FileAccess.file_exists(codefile): - DirAccess.remove_absolute(codefile) # 陈值防护:上轮临界残件不许冒充本轮退出码 DirAccess.make_dir_recursive_absolute(proj + "/tests/.gates") - # /V:ON=延迟展开:!ERRORLEVEL! 到 echo 执行时(套件已退出)才取值; - # 延迟展开会吞字面 !,命令串各段(引擎路径/工程路径/套件路径/日志路径)不得含 !。 - var cmd := '"%s" --headless --path "%s" --script "%s" > "%s" 2>&1 & echo !ERRORLEVEL! > "%s"' % [bin, proj, path, log, codefile] + var cmd := '"%s" --headless --path "%s" --script "%s" > "%s" 2>&1' % [bin, proj, path, log] var out := PackedStringArray() - var pid: int = OS.create_process("cmd.exe", PackedStringArray(["/V:ON", "/C", cmd])) - if pid < 0: - push_error("[run_all] FAIL %s — cmd 子进程启动失败(pid=%d)" % [path, pid]) - return 1 - # 轮询先查存活再查截止:进程在轮询间隙自然退出时按正常路径判,截止临界不误杀。 - var timed_out := false - var deadline_ms := Time.get_ticks_msec() + _suite_timeout_sec() * 1000 - while true: - if not OS.is_process_running(pid): - break - if Time.get_ticks_msec() >= deadline_ms: - timed_out = true - break - OS.delay_msec(200) + var code: int = OS.execute("cmd.exe", PackedStringArray(["/c", cmd]), out, true, false) var text := FileAccess.get_file_as_string(log) if FileAccess.file_exists(log) else "" var negative := bool(suite.get("negative", false)) - if timed_out: - var kill_out := PackedStringArray() - OS.execute("taskkill", PackedStringArray(["/F", "/T", "/PID", str(pid)]), kill_out, true) - # taskkill 落刀到进程对象消失有间隔:等死确认再放行下一套件(10s 宽限), - # 确认不了也如实报红留 PID 供手工清杀,不静默放行。 - var reap_deadline := Time.get_ticks_msec() + 10000 - while OS.is_process_running(pid) and Time.get_ticks_msec() < reap_deadline: - OS.delay_msec(200) - var reaped := not OS.is_process_running(pid) - # 临界窗口:截止到落刀之间套件可能恰好跑完 echo——同删退出码文件,.gates 不积残件。 - if FileAccess.file_exists(codefile): - DirAccess.remove_absolute(codefile) - if negative: - print("[run_all] %s:负面套件——全文输出已折叠(输出中的错误为其测试目标,非缺陷)" % path) - else: - print(text) - if reaped: - push_error("[run_all] FAIL %s — 超时挂死(%ds),进程树已整树回收(pid=%d)" % [path, _suite_timeout_sec(), pid]) - else: - push_error("[run_all] FAIL %s — 超时挂死(%ds),进程树回收未确认(pid=%d,需手工清杀残留)" % [path, _suite_timeout_sec(), pid]) - return 1 # 负面套件(坏表/违规输入是它的测试目标)全文折叠,只回显结论行—— # 它输出里的 Parse Error 是预期产物,回显出去会让编排层的日志扫描误判成真实脚本错误。 if negative: print("[run_all] %s:负面套件——全文输出已折叠(输出中的错误为其测试目标,非缺陷)" % path) else: print(text) - var code_txt := FileAccess.get_file_as_string(codefile).strip_edges() if FileAccess.file_exists(codefile) else "" - if code_txt == "" or not code_txt.is_valid_int(): - push_error("[run_all] FAIL %s — 退出码文件缺失或非数字(%s)——子进程未走到退出码落盘即死,判红" % [path, codefile]) - return 1 - var code: int = int(code_txt) - DirAccess.remove_absolute(codefile) # 用毕即删:退出码经文件通道但不落 residuum(.gates 只留 *.log) if not negative and _has_script_error(text): push_error("[run_all] FAIL %s — 输出含脚本错误标记:%s" % [path, _script_error_line(text)]) return 1 @@ -262,13 +205,6 @@ func _engine_bin() -> String: return "" -func _suite_timeout_sec() -> int: - var env := OS.get_environment(SUITE_TIMEOUT_ENV) - if env != "" and env.is_valid_int() and int(env) > 0: - return int(env) - return SUITE_TIMEOUT_DEFAULT_SEC - - func _has_script_error(text: String) -> bool: return _script_error_line(text) != "" diff --git a/game/tests/smoke_boot.gd b/game/tests/smoke_boot.gd index 76a5c9c0..d3d5172c 100644 --- a/game/tests/smoke_boot.gd +++ b/game/tests/smoke_boot.gd @@ -66,8 +66,10 @@ func _check_main_scene(main: Node) -> void: if main == null: return var run_scope: Node = main.get("run_scope") + check(run_scope != null, "闲时会话 run_scope 非空(永非 null 契约)") check(run_scope != null and run_scope.is_inside_tree(), "闲时会话 run_scope 在树") var start_overlay: Control = main.get("_start_overlay") + check(start_overlay != null, "开始菜单层已装配(_start_overlay)") check(start_overlay != null and start_overlay.visible, "启动态菜单可见(boot 后玩家见菜单)") check(main.get("settings_store") != null, "系统设置存储已装配(settings_store)") check(main.get("debug_badge") != null, "调试入口角标已装配(debug_badge)") diff --git a/game/tests/system_test.gd b/game/tests/system_test.gd index d136015e..24f09f01 100644 --- a/game/tests/system_test.gd +++ b/game/tests/system_test.gd @@ -173,10 +173,12 @@ func _smoke_boot_chain() -> void: var sm = main.get_node("/root/SaveManager") # ④-1 boot 路径:启动态钉死(卡① sv2)——boot 无激活态、断点在档恒否,续玩钮恒隐 #(原「按钮态==真实档断点态」关系判据随启动态换代改写:test-conventions §9.5 申报在案,卡② mn8 再承接) + check(not sm.has_breakpoint(), + "V2d boot 无断点在档(启动态不隐式激活——卡① sv2 钉死)〔装配生命批 V2·卡② mn8 改写〕") # ④-1b 启动态三断言(卡① sv2——真实 autoload boot 链) - check(sm.active_slot() == 0 and not sm.has_write_target(), + check(sm.slots.active_slot() == 0 and not sm.slots.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, + check(sm.meta_state.keystone_balance() == 0 and not sm.has_breakpoint() and sm.meta_state.career_state().adventure_total == 0, "V2-s2 boot 内存=默认合法空态(钥石/断点/总局数全零值)〔卡① sv2〕") check(not sm.write_to_disk(), "V2-s3 boot 无激活写盘拒绝(write_to_disk=false 生产入口)〔卡① sv3〕") @@ -185,7 +187,7 @@ func _smoke_boot_chain() -> void: 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") + sm.slots.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〕") @@ -220,8 +222,8 @@ func _smoke_run_lifecycle() -> void: 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() + sm.slots.attach_path("user://smoke_run_lifecycle.json") + sm.meta_state.mark_first_adventure_done() # ---- 第一局:开局→注脏→放弃→终局出口→会话收束 ---- main.debug_overlay.seed_edit.text = "42" main._start_adventure_from_menu() @@ -276,8 +278,8 @@ func _smoke_sim_guards() -> void: for p in ["user://smoke_sim_guards.json", "user://smoke_sim_guards.json.tmp"]: if FileAccess.file_exists(p): DirAccess.remove_absolute(p) - sm.attach_path("user://smoke_sim_guards.json") - sm.mark_first_adventure_done() + sm.slots.attach_path("user://smoke_sim_guards.json") + sm.meta_state.mark_first_adventure_done() # wd6:闲时态 + 模拟运行态 → 调试两层入口守卫早退(开始层不收起、无结算对象) check(main.run_scope.settlement == null and not main.run_scope.is_adventure, "前置:闲时会话(settlement 空 + is_adventure=false)") @@ -418,7 +420,7 @@ func _smoke_lifecycle_matrix() -> void: 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") + sm.slots.attach_path("user://smoke_lifecycle.json") main.debug_overlay.seed_edit.text = "42" # 阶段①闲时 await _stage_faces(main, "V3a 闲时", "null", "null", "null", @@ -511,7 +513,7 @@ func _smoke_lifecycle_matrix() -> void: # 营地经进档开层(卡② mn9 新路径——campbtn 已撤);槽模板注入隔离命名空间 #(防碰真实槽 user://save_slot_N.json——真实档红线),营地闭合后恢复生命周期 attach 写盘目标 #(卡① sv3 无激活拒写——后续阶段断点写盘依赖) - sm.attach_slot_template("user://smoke_lifecycle_slot_%d.json") + sm.slots.attach_slot_template("user://smoke_lifecycle_slot_%d.json") main._open_save_select() for i in range(2): await process_frame @@ -544,7 +546,7 @@ func _smoke_lifecycle_matrix() -> void: main._on_save_select_closed() # 关闭被栈还原的选档层(动线序中间态——卡④ 换代点) for i in range(2): await process_frame - sm.attach_path("user://smoke_lifecycle.json") # 恢复生命周期写盘目标 + sm.slots.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): @@ -576,7 +578,7 @@ func _smoke_lifecycle_matrix() -> void: 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.slots.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": [], diff --git a/game/tests/unit/battle_settlement/damage_controller_test.gd b/game/tests/unit/battle_settlement/damage_controller_test.gd index d2a58b9d..f3b343a5 100644 --- a/game/tests/unit/battle_settlement/damage_controller_test.gd +++ b/game/tests/unit/battle_settlement/damage_controller_test.gd @@ -53,7 +53,7 @@ func _harness(db): var ctrl = DamageControllerScript.new(db, rng, units, effects) ctrl.emit_row_cb = Callable(self, "_on_row") effects.notify_battle_start() - var spawn_ok: bool = units.spawn(1, 0, int(db.hero.max_hp)) + var spawn_ok: bool = units.spawn({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp)}) return {"db": db, "rng": rng, "units": units, "effects": effects, "ctrl": ctrl, "spawn_ok": spawn_ok} @@ -79,15 +79,15 @@ func _run_recipe_sequence(db) -> Array: for e in units.all_enemies(): enemy_hp_before.append(int(e.hp)) var front = units.default_target() - var threshold_expected: int = ctrl.defender_ac(front) + int(h.db.settlement.damage_roll.attack_bonus) + var threshold_expected: int = ctrl.defender_ac(front) + int(h.db.combat.settlement.damage_roll.attack_bonus) var atk_type := "" - var eff_keys: Array = h.db.settlement.damage_roll.armor_effectiveness.keys() + var eff_keys: Array = h.db.combat.settlement.damage_roll.armor_effectiveness.keys() if eff_keys.size() > 0: atk_type = String(eff_keys[0]) - var floor_v: int = int(h.db.settlement.damage_roll.damage_floor) + var floor_v: int = int(h.db.combat.settlement.damage_roll.damage_floor) var t1: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack", "T1主角攻击", - {"channel": "战斗段", "base_value": int(h.db.hero.atk), + {"channel": "战斗段", "base_value": int(h.db.combat.hero.atk), "target_rule": DamageControllerScript.TARGET_FRONT_ALIVE, "ri": 1})) # 敌方技来源取后位敌(前位是攻击/直伤/群伤的靶位——来源与靶位分离,免死置干扰配方 redeemed 断言) var alive_list: Array = units.query_alive("enemy") @@ -124,9 +124,9 @@ func _run_recipe_sequence(db) -> Array: var r1: Dictionary = _row_by_display_prefix("T1主角攻击") var ex1: Dictionary = r1.get("extra", {}) Kit.check("主角攻击行 AC = 防守方 AC + 攻击加成(开单前快照一致)", int(ex1.get("ac", -1)) == threshold_expected) - Kit.check("掷点 ∈ [1, 骰面]", int(ex1.get("roll", -1)) >= 1 and int(ex1.get("roll", -1)) <= int(h.db.settlement.damage_roll.roll_faces)) + Kit.check("掷点 ∈ [1, 骰面]", int(ex1.get("roll", -1)) >= 1 and int(ex1.get("roll", -1)) <= int(h.db.combat.settlement.damage_roll.roll_faces)) var tier_ok := false - for dv in h.db.settlement.damage_roll.roll_tier_discounts: + for dv in h.db.combat.settlement.damage_roll.roll_tier_discounts: if int(dv) == int(ex1.get("tier", -1)): tier_ok = true if int(ex1.get("tier", -1)) == 100: @@ -159,7 +159,7 @@ func _run_recipe_sequence(db) -> Array: continue if String(r.source_class).is_empty() or String(r.display).is_empty(): protocol_ok = false - if int(r.value) < int(h.db.settlement.damage_roll.damage_floor): + if int(r.value) < int(h.db.combat.settlement.damage_roll.damage_floor): protocol_ok = false if not (r.extra is Dictionary) or not r.extra.has("target"): protocol_ok = false @@ -187,11 +187,11 @@ func _test_policy_immediate_mode(db) -> void: if not h.spawn_ok: return h.ctrl.policy = DamageControllerScript.POLICY_IMMEDIATE - var floor_v: int = int(h.db.settlement.damage_roll.damage_floor) + var floor_v: int = int(h.db.combat.settlement.damage_roll.damage_floor) var t: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "A模式反噬", {"channel": "棋盘兑付", "base_value": floor_v, "target_rule": DamageControllerScript.TARGET_HERO, "ri": 1})) Kit.check("A 模式开单即结(无 flush 即 redeemed)", h.ctrl.outcome(t) == "redeemed") - Kit.check("A 模式主角即时扣血", int(h.units.hero_unit().hp) == int(h.db.hero.max_hp) - floor_v) + Kit.check("A 模式主角即时扣血", int(h.units.hero_unit().hp) == int(h.db.combat.hero.max_hp) - floor_v) # ---- 来源敌死亡:队列中未结单据作废(死亡即不动手) ---- @@ -267,7 +267,7 @@ func _test_target_id_payload(db: ConfigDb) -> void: var expect_id := String(target.get("enemy_id", "")) Kit.check("前置:目标敌携非空 enemy_id", expect_id != "") var ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack", "T6目标id", - {"channel": "战斗段", "base_value": int(db.hero.atk), + {"channel": "战斗段", "base_value": int(db.combat.hero.atk), "target_rule": DamageControllerScript.TARGET_FRONT_ALIVE, "ri": 1}) var fx_payload: Dictionary = ticket.context.get("fx_attack_payload", {}) Kit.check("attack_commit fx 载荷携 target_id〔wd12〕", @@ -284,7 +284,7 @@ func _test_requires_gate_and_zero(db) -> void: var h = _harness(db) if not h.spawn_ok: return - var floor_v: int = int(h.db.settlement.damage_roll.damage_floor) + var floor_v: int = int(h.db.combat.settlement.damage_roll.damage_floor) # 先清场(flush_submit 同步开单——清图批专用通道的契约面同时被覆盖) var front_hp: int = int(h.units.query_alive("enemy")[0].max_hp) var big: int = front_hp * 10 @@ -294,7 +294,7 @@ func _test_requires_gate_and_zero(db) -> void: var t_zero: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward", "空场群伤", {"channel": "棋盘兑付", "base_value": floor_v, "target_rule": DamageControllerScript.TARGET_ALL_ALIVE, "ri": 1})) var gated_ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack", "前置门攻击", - {"channel": "战斗段", "base_value": int(h.db.hero.atk), + {"channel": "战斗段", "base_value": int(h.db.combat.hero.atk), "target_rule": DamageControllerScript.TARGET_FRONT_ALIVE, "ri": 1}) gated_ticket["requires"] = t_zero var t_gated: int = h.ctrl.submit(gated_ticket) @@ -310,7 +310,7 @@ func _test_hero_death_halt(db) -> void: var h = _harness(db) if not h.spawn_ok: return - var max_hp: int = int(h.db.hero.max_hp) + var max_hp: int = int(h.db.combat.hero.max_hp) var t_kill: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "致死反噬", {"channel": "棋盘兑付", "base_value": max_hp, "target_rule": DamageControllerScript.TARGET_HERO, "ri": 1})) var t_after: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "余单", @@ -327,14 +327,14 @@ func _test_ac_merge(db) -> void: var h = _harness(db) if not h.spawn_ok: return - var base_ac: int = int(h.db.hero.armor_class) + var base_ac: int = int(h.db.combat.hero.armor_class) Kit.check("缺省兜底:defender_ac(hero) = hero 表值", h.ctrl.defender_ac(h.units.hero_unit()) == base_ac) - var override_v: int = base_ac + int(h.db.settlement.damage_roll.damage_floor) + var override_v: int = base_ac + int(h.db.combat.settlement.damage_roll.damage_floor) h.ctrl.set_hero_ac_override(override_v) Kit.check("override 合流入参生效", h.ctrl.defender_ac(h.units.hero_unit()) == override_v) # ac 型状态效果叠其上(找 hero 侧 unit=ac 行) var ac_eff := {} - for row in h.db.status_effects.rows: + for row in h.db.combat.status_effects.rows: if String(row.get("unit", "")) == "ac" and String(row.get("target_side", "")) == "hero": ac_eff = row break @@ -354,15 +354,15 @@ func _test_pool_clamp(db) -> void: var h = _harness(db) if not h.spawn_ok: return - var lo: int = int(h.db.settlement.core.pool_clamp_lo) - var hi: int = int(h.db.settlement.core.pool_clamp_hi) + var lo: int = int(h.db.combat.settlement.core.pool_clamp_lo) + var hi: int = int(h.db.combat.settlement.core.pool_clamp_hi) Kit.check("空效果池读 0(表 clamp 域含 0 前提)", h.ctrl.clamped_pool("hero_offense") == 0 and lo <= 0 and hi >= 0) # 找 hero_offense 池的 percent 型 hero 侧效果行 var pool_eff := {} - for row in h.db.status_effects.rows: + for row in h.db.combat.status_effects.rows: var eid := String(row.get("effect_id", "")) if String(row.get("unit", "")) == "percent" and String(row.get("target_side", "")) == "hero" \ - and String(h.db.settlement.core.effect_pool_map.get(eid, "")) == "hero_offense": + and String(h.db.combat.settlement.core.effect_pool_map.get(eid, "")) == "hero_offense": pool_eff = row break if pool_eff.is_empty(): @@ -377,7 +377,7 @@ func _test_pool_clamp(db) -> void: second_ok and h.ctrl.clamped_pool("hero_offense") == expected1) # ac 型效果不入 percent 池 var ac_eff2 := {} - for row2 in h.db.status_effects.rows: + for row2 in h.db.combat.status_effects.rows: if String(row2.get("unit", "")) == "ac" and String(row2.get("target_side", "")) == "hero": ac_eff2 = row2 break @@ -387,7 +387,7 @@ func _test_pool_clamp(db) -> void: Kit.check("ac 型效果不入池(percent 池隔离)", h.ctrl.clamped_pool("hero_offense") == before) # stack 政策效果:全新容器授予至满层 → 再授拒绝(满层拒绝原子——容器零写入) var stack_eff := {} - for row3 in h.db.status_effects.rows: + for row3 in h.db.combat.status_effects.rows: if String(row3.get("stack_policy", "")) == "stack": stack_eff = row3 break diff --git a/game/tests/unit/battle_settlement/damage_math_test.gd b/game/tests/unit/battle_settlement/damage_math_test.gd index 24bac4be..db07970d 100644 --- a/game/tests/unit/battle_settlement/damage_math_test.gd +++ b/game/tests/unit/battle_settlement/damage_math_test.gd @@ -24,7 +24,7 @@ func _init() -> void: func _floor_val() -> int: - return int(db.settlement.damage_roll.damage_floor) + return int(db.combat.settlement.damage_roll.damage_floor) # ---- F2 乘区:池内加算 → 池间乘算 → floor(单一取整规则)---- @@ -32,11 +32,11 @@ func _floor_val() -> int: func _test_pool_damage() -> void: var d := _floor_val() + 9 # 表值推导的任意正探针量(非玩法行值) Kit.check("中性双池 (0,0) 原样透传", DamageMathScript.pool_damage(d, 0, 0) == d) - Kit.check("负敌方池压低不增高", DamageMathScript.pool_damage(d, 0, int(db.settlement.core.pool_clamp_lo)) <= d) - Kit.check("正·主角池抬升不低于原值", DamageMathScript.pool_damage(d, int(db.settlement.core.pool_clamp_hi), 0) >= d) + Kit.check("负敌方池压低不增高", DamageMathScript.pool_damage(d, 0, int(db.combat.settlement.core.pool_clamp_lo)) <= d) + Kit.check("正·主角池抬升不低于原值", DamageMathScript.pool_damage(d, int(db.combat.settlement.core.pool_clamp_hi), 0) >= d) Kit.check("主角池单调不减(+50% vs +49% 探针档)", DamageMathScript.pool_damage(d, 50, 0) >= DamageMathScript.pool_damage(d, 49, 0)) Kit.check("对称性:池值互换结果不变(乘算交换)", DamageMathScript.pool_damage(d, 30, -20) == DamageMathScript.pool_damage(d, -20, 30)) - Kit.check("输出非负(maxi(0,·) 封底)", DamageMathScript.pool_damage(d, int(db.settlement.core.pool_clamp_lo), int(db.settlement.core.pool_clamp_lo)) >= 0) + Kit.check("输出非负(maxi(0,·) 封底)", DamageMathScript.pool_damage(d, int(db.combat.settlement.core.pool_clamp_lo), int(db.combat.settlement.core.pool_clamp_lo)) >= 0) # ---- 克制分量:空桶不吃修正恒 0;落地下限保底([1,+∞) 全来源)---- @@ -54,8 +54,8 @@ func _test_counter_component() -> void: func _test_roll_discount_percent() -> void: var threshold := 10 # 查档探针阈值(夹具值——档位参数本身全部来自表) - var bounds: Array = db.settlement.damage_roll.roll_tier_bounds - var discounts: Array = db.settlement.damage_roll.roll_tier_discounts + var bounds: Array = db.combat.settlement.damage_roll.roll_tier_bounds + var discounts: Array = db.combat.settlement.damage_roll.roll_tier_discounts Kit.check("前置:档位数 = 边界数 + 1(查档契约——档数 = 边界数 + 1)", discounts.size() == bounds.size() + 1 and discounts.size() > 0) Kit.check("掷点 ≥ 阈值恒全额 100", DamageMathScript.roll_discount_percent(threshold, threshold, bounds, discounts) == 100 and DamageMathScript.roll_discount_percent(threshold + 5, threshold, bounds, discounts) == 100) @@ -78,7 +78,7 @@ func _test_roll_discount_percent() -> void: func _test_rolled_component() -> void: var base := float(_floor_val() + 7) # 表值推导探针基线 - var crit_mult: int = int(db.settlement.damage_roll.crit_multiplier) + var crit_mult: int = int(db.combat.settlement.damage_roll.crit_multiplier) Kit.check("满额折扣无暴击 = floor(基线) + 修正 0", DamageMathScript.rolled_component(base, 100, false, crit_mult, 0) == int(floor(base))) Kit.check("满额折扣暴击按表倍率放大", DamageMathScript.rolled_component(base, 100, true, crit_mult, 0) == int(floor(base * float(crit_mult) / 100.0))) Kit.check("折扣降低不高于满额", DamageMathScript.rolled_component(base, 50, false, crit_mult, 0) <= DamageMathScript.rolled_component(base, 100, false, crit_mult, 0)) diff --git a/game/tests/unit/battle_settlement/roll_multiplier_test.gd b/game/tests/unit/battle_settlement/roll_multiplier_test.gd index b6e8abe5..b65ce03a 100644 --- a/game/tests/unit/battle_settlement/roll_multiplier_test.gd +++ b/game/tests/unit/battle_settlement/roll_multiplier_test.gd @@ -47,13 +47,13 @@ func _smoke_enemy_attack_type() -> void: # ① 表列:20 行全填 normal(N3 全填不做差异化——Q9) var at_ok := true var at_detail := "" - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: var at := String(row.get("attack_type", "")) if at != "normal": at_ok = false at_detail += " %s:%s" % [String(row.get("enemy_id", "?")), at] Kit.check("敌方 %d 行 attack_type 全填 normal(迭代 24 N3 轴开列,越轨行%s)" % [ - _db.enemy.rows.size(), at_detail if at_detail != "" else "无"], at_ok) + _db.combat.enemy.rows.size(), at_detail if at_detail != "" else "无"], at_ok) # ②③④ 矩阵命中证词扫描:(3,0) 灰矮人 atk 区间 [2,4] 基线足够((1,2) 熊地精 atk 1 会塌地板); # 同 seed 三甲型各开一场、各取首个敌方伤害行,对拍 ±1。 var matrix_found := false @@ -70,7 +70,7 @@ func _smoke_enemy_attack_type() -> void: 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}): + if not settlement.begin_battle({"level_index": 3, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp), "armor_type": armor}): totals[armor] = -1 continue if armor == "light": @@ -109,7 +109,7 @@ func _smoke_armor_roll() -> void: 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.damage_roll.roll_tier_bounds, _db.settlement.damage_roll.roll_tier_discounts) + var d: int = DamageMath.roll_discount_percent(roll, cfg.ac, _db.combat.settlement.damage_roll.roll_tier_bounds, _db.combat.settlement.damage_roll.roll_tier_discounts) if not dist.has(d): tier_ok = false continue @@ -117,7 +117,7 @@ func _smoke_armor_roll() -> void: Kit.check("查档 AC%d 全掷点域分布(100/75/50/25 = %s)" % [cfg.ac, str([dist[100], dist[75], dist[50], dist[25]])], tier_ok and dist == cfg.expect) var ev_sum := 0 for roll in range(1, 21): - ev_sum += DamageMath.roll_discount_percent(roll, 10, _db.settlement.damage_roll.roll_tier_bounds, _db.settlement.damage_roll.roll_tier_discounts) + ev_sum += DamageMath.roll_discount_percent(roll, 10, _db.combat.settlement.damage_roll.roll_tier_bounds, _db.combat.settlement.damage_roll.roll_tier_discounts) Kit.check("AC10 EV 锚:期望系数合计 %d/2000 = 期望减免 21.25%%(nat20 恒全额含内)" % ev_sum, ev_sum == 1575) # ② nat20 暴击(行为面):探针按行为面扫描 # 「主角先手首掷 = 20」的种子,不假设 armor_roll scope0 位 @@ -125,20 +125,21 @@ func _smoke_armor_roll() -> void: 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.damage_roll.crit_roll): + if int(crit_rows.get("roll", -1)) == int(_db.combat.settlement.damage_roll.crit_roll): crit_seed = s break - Kit.check("nat20 → 行留档 roll=20 + crit 标记(主角先手普通分量——种子可达 seed=%d)" % crit_seed, - int(crit_rows.get("roll", -1)) == int(_db.settlement.damage_roll.crit_roll) and bool(crit_rows.get("crit", false))) - Kit.check("nat20 → 普通分量 ×2(%d = 2×%d,暴击倍率只乘该分量,穿刺/魔法不吃)" % [int(crit_rows.get("normal", -1)), int(_db.hero.atk)], - int(crit_rows.get("normal", -1)) == 2 * int(_db.hero.atk) and int(crit_rows.get("value", -1)) == 2 * int(_db.hero.atk)) + Kit.check("nat20 种子可达(seed=%d——主角先手首掷命中 %d)" % [crit_seed, int(_db.combat.settlement.damage_roll.crit_roll)], crit_seed > 0) + Kit.check("nat20 → 行留档 roll=20 + crit 标记(主角先手普通分量)", + int(crit_rows.get("roll", -1)) == int(_db.combat.settlement.damage_roll.crit_roll) and bool(crit_rows.get("crit", false))) + Kit.check("nat20 → 普通分量 ×2(%d = 2×%d,暴击倍率只乘该分量,穿刺/魔法不吃)" % [int(crit_rows.get("normal", -1)), int(_db.combat.hero.atk)], + int(crit_rows.get("normal", -1)) == 2 * int(_db.combat.hero.atk) and int(crit_rows.get("value", -1)) == 2 * int(_db.combat.hero.atk)) # ③ 削弱续时(行为面):授予→衰减一轮→再授予 → 层数保持 1、剩余结算数重置 # (削弱为续时语义,无叠层通道) var weaken_probe := _weaken_refresh_probe() Kit.check("削弱重复授予 → 层数保持 1、剩余结算数重置 2(续时语义——结构性消饱和)", int(weaken_probe.get("layers", -1)) == 1 and int(weaken_probe.get("remaining", -1)) == 2) Kit.check("削弱单层池读数 = 行值(enemy_output 池单层贡献——×2 叠满触底 ×0 不可达,免伤轮根治)", - int(weaken_probe.get("pool", -999)) == int(_db.status_effect_row("weaken").magnitude)) + int(weaken_probe.get("pool", -999)) == int(_db.combat.status_effects.row_by_id("weaken").magnitude)) # ④ AC11 折扣档分布(行为面):ac_up 逐轮授给(时效 1)→ Boss 行攻击拍逐拍判定 # (ac_up 层数恒 1——局内可达 AC 上限 10+1=11,探针随行取值) var tiers := _ac11_tier_samples() @@ -171,7 +172,7 @@ func _smoke_armor_roll() -> void: Kit.check("棋盘来源伤害行零掷点字段(爆裂/直伤/清障炸药/奖励即时必中全额)", board_ok) # ⑥ 全来源伤害下限(任何伤害落地取值域 [1,+∞) 无上限——棋盘克制直伤/反噬/地形变异/爆裂 # 全部收在 damage_floor 闸内) - var df: int = int(_db.settlement.damage_roll.damage_floor) + var df: int = int(_db.combat.settlement.damage_roll.damage_floor) Kit.check("棋盘克制直伤贴底 → 下限 1(点数 2 vs 不利修正 −1)", DamageMath.counter_component(2, -1, df) == 1) Kit.check("克制修正触底穿透 → 下限 1(点数 1 vs 修正 −3)", DamageMath.counter_component(1, -3, df) == 1) Kit.check("空桶不吃修正恒 0(零点数分量无伤害可落地——边界不变不受下限影响)", DamageMath.counter_component(0, 5, df) == 0) @@ -184,7 +185,7 @@ func _first_hero_attack_rows(seed_val: int) -> Dictionary: 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)}): + if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)}): return {} settlement.minesweeper.settle_batch([{"action": "stop"}]) for row in rows: @@ -200,7 +201,7 @@ 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)}): + if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.combat.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) @@ -223,7 +224,7 @@ func _ac11_tier_samples() -> Array: 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)}): + if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.combat.hero.max_hp)}): continue var guard := 12 while settlement.battle_result == "" and guard > 0: @@ -258,7 +259,7 @@ func _run_battle(L: int, D: int, axes: Dictionary = {}) -> Dictionary: settlement.minesweeper.seal_judged.connect(func(evt): out.events.append(evt)) settlement.minesweeper.round_end_board.connect(func(p): out.events.append({"ev": "round_end_board", "path_tag": p.path_tag})) settlement.action_economy.round_end_stopped.connect(func(p): out.events.append({"ev": "round_end_stopped", "path_tag": p.path_tag})) - var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.hero.max_hp} + var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.combat.hero.max_hp} enc.merge(axes, true) # 两根类型轴注入尾参(缺省零注入=回退面) if not settlement.begin_battle(enc): out.stream.append({"type": "verdict", "value": "begin_failed"}) @@ -324,7 +325,7 @@ func _hero_attack_row_with(opts: Dictionary, hero_atk_val: int, seed_val: int) - 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}): + if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.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}) @@ -342,15 +343,15 @@ func _smoke_damage_types() -> void: # ① 护甲类型快照传播(spawn 行值 → 单位字典) var enemy_system = load("res://core/unit_container.gd").new(_db) Kit.check("goblin 护甲 = light(轻甲——教学位)", - enemy_system.spawn(1, 0, int(_db.hero.max_hp)) and String(enemy_system.all_enemies()[0].armor_type) == "light") + enemy_system.spawn({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)}) and String(enemy_system.all_enemies()[0].armor_type) == "light") var enemy_system_b = load("res://core/unit_container.gd").new(_db) Kit.check("bugbear 护甲 = light(护甲类型重配批 2026-09-18:关卡 1 全轻甲)", - enemy_system_b.spawn(1, 2, int(_db.hero.max_hp)) and String(enemy_system_b.all_enemies()[0].armor_type) == "light") + enemy_system_b.spawn({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.combat.hero.max_hp)}) and String(enemy_system_b.all_enemies()[0].armor_type) == "light") var enemy_system_c = load("res://core/unit_container.gd").new(_db) Kit.check("ogre_chief 护甲 = light(Boss 行集——护甲类型重配批 2026-09-18:关卡 1 全轻甲)", - enemy_system_c.spawn(1, 4, int(_db.hero.max_hp), [], -1, -1, [], "", "boss") and String(enemy_system_c.all_enemies()[0].armor_type) == "light") + enemy_system_c.spawn({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "hero_armor_type": "boss"}) and String(enemy_system_c.all_enemies()[0].armor_type) == "light") # ② 克制矩阵表值在位(三档点数 克制 +1 / 中性 0 / 被克 −1,加法核非 percent) - var am: Dictionary = _db.settlement.damage_roll.armor_effectiveness + var am: Dictionary = _db.combat.settlement.damage_roll.armor_effectiveness Kit.check("普通行:轻甲 0 / 魔法护甲 +1 / 重甲 −1(加法核)", int(am.normal.light) == 0 and int(am.normal.ward) == 1 and int(am.normal.heavy) == -1) Kit.check("穿刺行:轻甲 −1 / 魔法护甲 +1 / 重甲 0 中性(用户拍板)", @@ -386,7 +387,7 @@ func _run_seal_backlash_round(seed_val: int) -> Dictionary: 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"}): + if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.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) @@ -442,7 +443,7 @@ func _smoke_attack_chain_fresh_pool() -> void: ticks.append({"baseline": int(row.baseline), "attach": int(row.get("rider", 0)), "roll": int(row.roll), "tier": int(row.tier), "crit": bool(row.get("crit", false)), "tick": int(row.value)})) Kit.check("fresh 池回归战斗启动(Boss 行)", - settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})) + settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"})) # 停手轮:零挖掘零封印 → 零授予 → 池恒空;Boss 拍 = 开轮摇号结果(伤害技/普攻兑付伤害行、硬化施放行不计) for i in range(6): if settlement.battle_result != "": @@ -451,9 +452,9 @@ func _smoke_attack_chain_fresh_pool() -> void: Kit.check("非施放攻击拍采样充足(%d 拍——增益施放行零兑付为设计语义不计)" % ticks.size(), ticks.size() >= 2) var all_full := true for t in ticks: - var base_factor: float = float(t.tier) * (float(_db.settlement.damage_roll.crit_multiplier) / 100.0 if t.crit else 1.0) + var base_factor: float = float(t.tier) * (float(_db.combat.settlement.damage_roll.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_roll.damage_floor), rolled_base + int(t.get("attach", 0))) + var expected: int = maxi(int(_db.combat.settlement.damage_roll.damage_floor), rolled_base + int(t.get("attach", 0))) if int(t.tick) != expected or int(t.tick) <= 0: all_full = false Kit.check("fresh 池非蓄力拍 d_tick = max(1, 掷点档位基线)(Boss 攻击链健康——Bug#2 回归断言)", all_full) diff --git a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd index f9b09328..865c9775 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 @@ -58,6 +58,7 @@ func _smoke_full_battle(L: int, D: int, label: String) -> void: break Kit.check("%s 回放断言:结算步骤流逐行相同" % label, JSON.stringify(run1.stream) == JSON.stringify(run2.stream)) Kit.check("%s 回放断言:事件流逐位相同" % label, JSON.stringify(run1.events) == JSON.stringify(run2.events)) + Kit.check("%s 结算步骤流非空(%d 行)" % [label, run1.stream.size()], run1.stream.size() > 0) var has_verdict := false var hero_attacks := 0 var hero_breakdown_ok := true @@ -68,7 +69,7 @@ func _smoke_full_battle(L: int, D: int, label: String) -> void: hero_attacks += 1 # 单分量伤害构成行:hero_attack 行必须携带实际攻击类型键 + 掷点读数(P2 归因) # 类型键主语 = 锻造工坊配置默认值(hero.tres 无两轴字段;本夹具零注入) - var atk_t: String = String(_db.forge_default_attack_type()) + var atk_t: String = String(_db.crossrun.forge.default_attack_type) if not (row.has(atk_t) and row.has("roll") and row.has("ac") and row.has("tier")): hero_breakdown_ok = false Kit.check("%s 步骤流含判定行" % label, has_verdict) @@ -92,7 +93,7 @@ func _run_battle(L: int, D: int, axes: Dictionary = {}) -> Dictionary: 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} + var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.combat.hero.max_hp} enc.merge(axes, true) # 两根类型轴注入尾参(缺省零注入=回退面) if not settlement.begin_battle(enc): out.stream.append({"type": "verdict", "value": "begin_failed"}) @@ -156,12 +157,12 @@ func _smoke_active_refresh() -> void: if String(row.get("type", "")) == "decay" and not bool(ct["waiting_probe_hit"]): ct["waiting_probe_hit"] = true ct["waiting_refresh_rejected"] = not settlement.player_request_refresh()) - Kit.check("轮内刷新战斗启动(Boss 行 150HP——承载多轮样本)", settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})) + Kit.check("轮内刷新战斗启动(Boss 行 150HP——承载多轮样本)", settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"})) var minesweeper = settlement.minesweeper # 轮 1–3:零挖掘停手攒 RAP = 3×AP(每轮全转存;表驱动——AP 从表读不写死) for i in range(3): minesweeper.settle_batch([{"action": "stop"}]) - var rap_banked: int = 3 * int(_db.action_economy.ap_per_round) + var rap_banked: int = 3 * int(_db.run.action_economy.ap_per_round) Kit.check("三轮停手攒 RAP=%d(round=%d rap=%d)" % [rap_banked, settlement.round_index, settlement.action_economy.rap], settlement.round_index == 4 and settlement.action_economy.rap == rap_banked) Kit.check("第 4 轮 Active 相位", settlement.action_economy.phase == "Active") # 挖 1 格 → 刷 → 挖 1 格 → 刷 → 挖 1 格 → 第 3 刷拒(上限) @@ -175,7 +176,7 @@ func _smoke_active_refresh() -> void: var ok1: bool = settlement.player_request_refresh() Kit.check("轮内第 1 次刷新受理", ok1) Kit.check("相位保持 Active、AP 保留不重置(ap=%d)" % settlement.action_economy.ap, settlement.action_economy.phase == "Active" and settlement.action_economy.ap == ap_before) - Kit.check("RAP -刷新价(%d)" % settlement.action_economy.rap, settlement.action_economy.rap == rap_banked - int(_db.action_economy.rap_refresh_cost)) + Kit.check("RAP -刷新价(%d)" % settlement.action_economy.rap, settlement.action_economy.rap == rap_banked - int(_db.run.action_economy.rap_refresh_cost)) Kit.check("当场换新图(board_changed +1)", int(ct["board_swaps"]) == swaps_before + 1 and minesweeper.current_board() != board_before) var plan2 = AutoPlayer.choose_dig(minesweeper) if plan2.hero_cell != minesweeper.hero_pos(): @@ -220,7 +221,7 @@ func _active_refresh_replay() -> Array: 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"}): + if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"}): return rows var minesweeper = settlement.minesweeper for i in range(3): @@ -250,7 +251,7 @@ func _smoke_encounter_roll() -> void: var rng = load("res://core/rng_service.gd").new() rng.set_master_seed(30000 + i) var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) - Kit.check("遭遇扫描战斗启动(quiet 计数档——计数入总账,成败由后续 spawn 断言归因)", settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})) # 源 quiet=true 计数档——Kit 通道无此参(只影响 PASS 行打印,计数/红绿同口径) + Kit.check("遭遇扫描战斗启动(quiet 计数档——计数入总账,成败由后续 spawn 断言归因)", settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)})) # 源 quiet=true 计数档——Kit 通道无此参(只影响 PASS 行打印,计数/红绿同口径) counts.append(settlement.presentation_snapshot().enemies.size()) # 区间生效:20 个种子应出现 1 和 2(D0 [1,2]) Kit.check("D0 区间 [1,2] 两值均出现(分布 %s)" % str(counts), counts.has(1) and counts.has(2)) @@ -268,13 +269,13 @@ func _smoke_encounter_roll() -> void: var rng3 = load("res://core/rng_service.gd").new() rng3.set_master_seed(31000) var settlement_d1 = load("res://core/battle_orchestrator.gd").new(_db, rng3) - Kit.check("D1 战斗启动", settlement_d1.begin_battle({"level_index": 1, "node_depth": 1, "hero_hp": int(_db.hero.max_hp)})) + Kit.check("D1 战斗启动", settlement_d1.begin_battle({"level_index": 1, "node_depth": 1, "hero_hp": int(_db.combat.hero.max_hp)})) var n1: int = settlement_d1.presentation_snapshot().enemies.size() Kit.check("D1 区间 [2,3] 域内(%d 只)" % n1, n1 >= 2 and n1 <= 3) # 熊地精首遇带固定(教学位不加骰)——首遇带数据驱动定位(契约 8,禁写死坐标) var bugbear_lead_band := _first_encounter_band(1, "bugbear") var bugbear_lead_row: Dictionary = {} - for row in _db.enemy.rows: + for row in _db.combat.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 @@ -282,7 +283,7 @@ func _smoke_encounter_roll() -> void: 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)}) + settlement_d2.begin_battle({"level_index": 1, "node_depth": bugbear_lead_band, "hero_hp": int(_db.combat.hero.max_hp)}) Kit.check("熊地精首遇带(1,battle,%d——扫表取最低带)固定 1 不加骰〔fv23〕" % bugbear_lead_band, settlement_d2.presentation_snapshot().enemies.size() == 1 \ and int(bugbear_lead_row.get("unit_count", -1)) == 1 and not bugbear_lead_row.has("unit_count_max")) @@ -299,11 +300,11 @@ func _smoke_encounter_roll() -> void: 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)}): + if not settlement_scan.begin_battle({"level_index": 1, "node_depth": int(depth_v), "hero_hp": int(_db.combat.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 erow2: Dictionary = _db.combat.enemy.resolve_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: @@ -328,7 +329,7 @@ func _row_kind_of(level_index: int, depth: int) -> String: # 首遇带数据驱动定位(契约 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: + for row in _db.combat.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: @@ -344,12 +345,12 @@ func _level_active_nodes(level_index: int) -> Array: var rng = load("res://core/rng_service.gd").new() rng.set_master_seed(42) var gen = NodeMapGenerator.new(rng) - return (gen.generate(_db.level_gen_params_row(level_index), 42) as Dictionary).get("nodes", []) + return (gen.generate(_db.run.level_gen_params.row_by_level_index(level_index), 42) as Dictionary).get("nodes", []) func _level_battle_nodes(level_index: int) -> Array: var out: Array = [] - for row in _db.level.rows: + for row in _db.run.level.rows: if int(row.level_index) == level_index: for node in _level_active_nodes(level_index): var t := String(node.get("type", "")) @@ -363,7 +364,7 @@ func _encounter_replay(seed_val: int) -> 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)}) + settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)}) out.append(settlement.presentation_snapshot().enemies.size()) return out @@ -377,7 +378,7 @@ func _encounter_replay(seed_val: int) -> Array: func _smoke_settlement_segments() -> void: print("--- 五段全序 + 行型封闭 ---") Kit.check("segment_order 五段冻结在位(表值 = 拍板序恒等)", - Array(_db.settlement.segment_order) == SettlementTable.FROZEN_SEGMENT_ORDER + Array(_db.combat.settlement.segment_order) == SettlementTable.FROZEN_SEGMENT_ORDER and SettlementTable.FROZEN_SEGMENT_ORDER == ["grant", "buff_debuff", "combat", "decay", "verdict"]) var run = _run_battle(1, 0) var legacy := {"item": 0, "consumed_ineffective": 0, "use_rejected": 0, "window_open": 0, "window_skip": 0} @@ -492,53 +493,53 @@ func _smoke_skill_pool() -> void: # ① 数据面:技能表行集 + 普攻锁死 + 建议池行值 + 空池不可能(结构断言) func _smoke_skill_pool_data() -> void: - Kit.check("技能表十六行(普攻/突刺/蓄力/重击/硬化 + 撕咬/毒爆/蛛网束缚 + 大地脉动 + 震地怒击/遏毒/岩崩 + 毒吐/盾击——前三批 + Boss 半血解锁技三行〔迭代14④ T5〕+ 普通敌错峰两行〔迭代 24 N2〕+ 触发条件批示范两行〔孤注守势/趁隙偷袭〕)", _db.skill.rows.size() == 16) - var basic: Dictionary = _db.skill.row_by_id("attack") + Kit.check("技能表十六行(普攻/突刺/蓄力/重击/硬化 + 撕咬/毒爆/蛛网束缚 + 大地脉动 + 震地怒击/遏毒/岩崩 + 毒吐/盾击——前三批 + Boss 半血解锁技三行〔迭代14④ T5〕+ 普通敌错峰两行〔迭代 24 N2〕+ 触发条件批示范两行〔孤注守势/趁隙偷袭〕)", _db.combat.skill.rows.size() == 16) + var basic: Dictionary = _db.combat.skill.row_by_id("attack") Kit.check("普攻行:倍率恒 1 · 冷却恒 0(域校验锁死——池恒非空的结构前提)", not basic.is_empty() and String(basic.type) == "basic" and int(basic.cooldown_beats) == 0 and (not basic.has("damage_mult") or is_equal_approx(float(basic.damage_mult), 1.0))) - var lunge: Dictionary = _db.skill.row_by_id("lunge") + var lunge: Dictionary = _db.combat.skill.row_by_id("lunge") Kit.check("突刺行(伤害 ×1.5 · 冷却 2——提案 §2.5 建议池新行)", not lunge.is_empty() and is_equal_approx(float(lunge.damage_mult), 1.5) and int(lunge.cooldown_beats) == 2) - var charge_row: Dictionary = _db.skill.row_by_id("charge") + var charge_row: Dictionary = _db.combat.skill.row_by_id("charge") Kit.check("蓄力行 = 纯攻击增益技(0 伤害、buff_ref → empower——与重击无组合语义)", not charge_row.is_empty() and String(charge_row.buff_ref) == "empower") - var bite: Dictionary = _db.skill.row_by_id("bite") + var bite: Dictionary = _db.combat.skill.row_by_id("bite") Kit.check("撕咬行(伤害 ×1.5 · 冷却 1——巨蜘蛛池载体,与突刺同倍率错开冷却轴)", not bite.is_empty() and is_equal_approx(float(bite.damage_mult), 1.5) and int(bite.cooldown_beats) == 1) - var venom: Dictionary = _db.skill.row_by_id("venom_burst") + var venom: Dictionary = _db.combat.skill.row_by_id("venom_burst") Kit.check("毒爆行(伤害 ×2.5 · 冷却 4 · 必中附加 ×0.75——蛛化卓尔精灵专属池独占技,新效应词汇批)", not venom.is_empty() and is_equal_approx(float(venom.damage_mult), 2.5) and int(venom.cooldown_beats) == 4 and is_equal_approx(float(venom.rider_mult), 0.75)) var venom_sole_ok := true - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: for entry in row.pool: if String(entry.skill_id) == "venom_burst" and String(row.enemy_id) != "drider": venom_sole_ok = false Kit.check("毒爆独占性(仅蛛化卓尔精灵池引用——Boss 专属池独占技纪律)", venom_sole_ok) # 妨害技首发行 + off_guard 落点 + 实装面收窄 - var snare: Dictionary = _db.skill.row_by_id("web_snare") + var snare: Dictionary = _db.combat.skill.row_by_id("web_snare") Kit.check("蛛网束缚行(妨害型 · buff_ref → off_guard 失衡 · duration 2 = 1 有效拍 · 冷却 4——新效应词汇批首发)", not snare.is_empty() and String(snare.type) == "debuff" and String(snare.buff_ref) == "off_guard" and int(snare.buff_duration) == 2 and int(snare.cooldown_beats) == 4) var snare_sole_ok := true - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: for entry in row.pool: if String(entry.skill_id) == "web_snare" and String(row.enemy_id) != "drider": snare_sole_ok = false Kit.check("蛛网束缚独占性(仅蛛化卓尔精灵池引用——实装面收窄,其余敌种不动)", snare_sole_ok) - var off_guard_row: Dictionary = _db.status_effect_row("off_guard") + var off_guard_row: Dictionary = _db.combat.status_effects.row_by_id("off_guard") Kit.check("失衡状态行(hero · ac 轴——妨害技落点,ConfigDB side 守卫正例)", not off_guard_row.is_empty() and String(off_guard_row.target_side) == "hero" and String(off_guard_row.unit) == "ac") # 普通敌错峰两新行——毒吐/盾击(V3) - var spit: Dictionary = _db.skill.row_by_id("venom_spit") + var spit: Dictionary = _db.combat.skill.row_by_id("venom_spit") Kit.check("毒吐行(伤害 ×1.6 · 冷却 2 · 无 rider——低 atk 下 rider 恒被 damage_floor 兜成 1 白占字段,迭代 24 N2)", not spit.is_empty() and String(spit.display_name) == "毒吐" and String(spit.type) == "damage" \ and is_equal_approx(float(spit.damage_mult), 1.6) and int(spit.cooldown_beats) == 2 and not spit.has("rider_mult")) - var bash: Dictionary = _db.skill.row_by_id("shield_bash") + var bash: Dictionary = _db.combat.skill.row_by_id("shield_bash") Kit.check("盾击行(伤害 ×1.7 · 冷却 3 · 无 rider——迭代 24 N2)", not bash.is_empty() and String(bash.display_name) == "盾击" and String(bash.type) == "damage" \ and is_equal_approx(float(bash.damage_mult), 1.7) and int(bash.cooldown_beats) == 3 and not bash.has("rider_mult")) var spider_lead_band := _first_encounter_band(2, "giant_spider") # 首遇带数据驱动定位(契约 8) var spit_sole_ok := true # 毒吐只挂巨蜘蛛族且首遇带不投(Q6——教学位池零新招) var bash_sole_ok := true # 盾击只挂灰矮人族三行 - for row in _db.enemy.rows: + for row in _db.combat.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 \ @@ -550,7 +551,7 @@ func _smoke_skill_pool_data() -> void: Kit.check("盾击投放面(仅 (3,·) 灰矮人族——钩爪恐魔零触碰,迭代 24 N2)", bash_sole_ok) # 首遇带行池恒 {attack:6, bite:2, charge:1}(去写死深度判据——带键口径) var spider_lead_pool := {} - for row in _db.enemy.rows: + for row in _db.combat.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: @@ -591,7 +592,7 @@ func _smoke_skill_pool_data() -> void: "3_boss_13_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, } var pools_ok := true - for row in _db.enemy.rows: + for row in _db.combat.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 @@ -603,10 +604,10 @@ func _smoke_skill_pool_data() -> void: pools_ok = false Kit.check("二十八行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1/1〔触发条件批+孤注守势 w1〕 · 熊地精 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/2〔触发条件批+趁隙偷袭 w2〕 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2——半血解锁收编技能行 trigger 闩型——迭代14④ T5/触发条件批迁移〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕;迭代 35 覆盖批八行为顶档池克隆,逐行同检)", pools_ok) var basic_count_ok := true - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: var n := 0 for entry in row.pool: - var srow: Dictionary = _db.skill_row(String(entry.skill_id)) + var srow: Dictionary = _db.combat.skill.row_by_id(String(entry.skill_id)) if not srow.is_empty() and String(srow.type) == "basic": n += 1 if n != 1: @@ -615,11 +616,11 @@ func _smoke_skill_pool_data() -> void: # 池构成纪律复查(V4):零违反——增益 ≤1 / 妨害 ≤1(敌人系统 v0.13 结构冻结;载入期 # validate_skill_pool_links 已硬闸,此处冒烟侧显式复核新投放后仍绿) var comp_ok := true - for row in _db.enemy.rows: + for row in _db.combat.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)) + var srow: Dictionary = _db.combat.skill.row_by_id(String(entry.skill_id)) if srow.is_empty(): continue if String(srow.type) == "buff": @@ -631,10 +632,10 @@ func _smoke_skill_pool_data() -> void: Kit.check("逐池增益 ≤1 · 妨害 ≤1(池构成纪律——迭代 24 N2 毒吐/盾击投放后复查)", comp_ok) # 池构成纪律复查(触发条件批 V7 冒烟侧):每池至少一行无条件特殊技(普攻不计;trigger 缺省行计数) var uncond_ok := true - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: var n_uncond := 0 for entry in row.pool: - var srow_u: Dictionary = _db.skill_row(String(entry.skill_id)) + var srow_u: Dictionary = _db.combat.skill.row_by_id(String(entry.skill_id)) if not srow_u.is_empty() and String(srow_u.type) != "basic" and not srow_u.has("trigger"): n_uncond += 1 if n_uncond < 1: @@ -644,7 +645,7 @@ func _smoke_skill_pool_data() -> void: var st_unlock: Dictionary = {"rows": []} var sett_u = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(4242)) sett_u.step_row.connect(func(row): st_unlock["rows"].append(row)) - Kit.check("前置:卓尔 Boss 战受理(2,9——解锁技 venom_curb 门槛 50)", sett_u.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})) + Kit.check("前置:卓尔 Boss 战受理(2,9——解锁技 venom_curb 门槛 50)", sett_u.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"})) var boss_u = sett_u._enemy_system.all_enemies()[0] # 白盒夹具(smoke 惯例——autoplay 同族私有面) sett_u._enemy_system.apply_damage(boss_u, (int(boss_u.max_hp) - 65)) Kit.check("前置:Boss 压到阈值 65(=round(130×0.5))", int(boss_u.hp) == 65) @@ -660,25 +661,25 @@ func _smoke_skill_pool_data() -> void: 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))) + es.spawn({"level_index": 1, "node_depth": int(depth), "hero_hp": int(_db.combat.hero.max_hp), "hero_armor_type": _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))) + es2.spawn({"level_index": 2, "node_depth": int(depth), "hero_hp": int(_db.combat.hero.max_hp), "hero_armor_type": _row_kind_of(2, int(depth))}) for u in es2.all_enemies(): if es2.rollable_skills(u).is_empty(): rollable_ok = false Kit.check("全深度实例 spawn 后可摇集合恒非空(普攻恒冷却 0——容器级断言,L1/L2 双关卡)", rollable_ok) # 状态表新行 + 敌方防御池映射 - var empower: Dictionary = _db.status_effect_row("empower") - var fortify: Dictionary = _db.status_effect_row("fortify") + var empower: Dictionary = _db.combat.status_effects.row_by_id("empower") + var fortify: Dictionary = _db.combat.status_effects.row_by_id("fortify") Kit.check("蓄力状态行 empower(enemy +100 percent——敌方输出 ×2 走既有 enemy_output 池)", not empower.is_empty() and String(empower.target_side) == "enemy" and int(empower.magnitude) == 100) Kit.check("硬化状态行 fortify(enemy −50 percent——敌方防御池负值 = 主角攻击折半;+100 符号 bug 2026-09-18 纠正,provisional 归 状态层重设计批)", not fortify.is_empty() and String(fortify.target_side) == "enemy" and int(fortify.magnitude) == -50) Kit.check("effect_pool_map 收编 empower/fortify(敌方防御池 enemy_defense 入表)", - String(_db.settlement.core.effect_pool_map.get("empower", "")) == "enemy_output" \ - and String(_db.settlement.core.effect_pool_map.get("fortify", "")) == "enemy_defense") + String(_db.combat.settlement.core.effect_pool_map.get("empower", "")) == "enemy_output" \ + and String(_db.combat.settlement.core.effect_pool_map.get("fortify", "")) == "enemy_defense") # ② 同种子回放逐位复现(D3 大地精,多实例同种) @@ -763,11 +764,11 @@ func _smoke_skill_pool_ev_anchor() -> void: 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) + var pity_m: int = int(_db.combat.skill.pity_threshold) for si in species.size(): var eid: String = species[si] var template: Array = [] - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: if String(row.enemy_id) == eid: template = row.pool break @@ -784,13 +785,13 @@ func _smoke_skill_pool_ev_anchor() -> void: if streak >= pity_m: var specials: Array = [] for e in pool: - var sr: Dictionary = _db.skill_row(String(e.skill_id)) + var sr: Dictionary = _db.combat.skill.row_by_id(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 srow: Dictionary = _db.combat.skill.row_by_id(sid) var mult := 0.0 if String(srow.type) == "basic": mult = 1.0 @@ -817,12 +818,12 @@ func _smoke_skill_pool_death_void() -> void: 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 erow: Dictionary = _db.combat.enemy.resolve_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": + if String(_db.combat.skill.row_by_id(sid).type) != "basic": void_seed = s break Kit.check("死亡打断种子可达(seed=%d——D2 首拍摇中特殊技)" % void_seed, void_seed > 0) @@ -831,7 +832,7 @@ func _smoke_skill_pool_death_void() -> void: var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) var rows: Array = [] settlement.step_row.connect(func(row): rows.append(row)) - Kit.check("死亡打断战斗启动(hero_atk 999——先手必杀 40HP 熊地精)", settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999})) + Kit.check("死亡打断战斗启动(hero_atk 999——先手必杀 40HP 熊地精)", settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.combat.hero.max_hp), "hero_atk": 999})) var u: Dictionary = settlement._enemy_system.all_enemies()[0] # 句柄预捕获(胜利 despawn 后容器已清,字典引用仍可读) var pending: String = settlement._enemy_system.pending_action(u) Kit.check("开轮已摇定本拍动作(待发 = %s)" % pending, pending != "") @@ -855,14 +856,14 @@ func _smoke_skill_pool_validation_reject() -> void: var skill_table = load("res://data/schemas/skill_table.gd").new() var enemy_table = load("res://data/schemas/enemy_table.gd").new() enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "ghost", "weight": 1}]}]) - Kit.check("加载校验拒绝:池条目 skill_id 悬空致命", _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "dangling_skill")) + Kit.check("加载校验拒绝:池条目 skill_id 悬空致命", _has_err_category(_db._validate_skill_pool_links(skill_table, enemy_table), "dangling_skill")) skill_table.rows.assign([{"skill_id": "a", "display_name": "a", "type": "damage", "damage_mult": 1.5, "cooldown_beats": 2}]) - Kit.check("加载校验拒绝:池普攻行数 ≠ 1(空池不可能前提)", _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition")) + Kit.check("加载校验拒绝:池普攻行数 ≠ 1(空池不可能前提)", _has_err_category(_db._validate_skill_pool_links(skill_table, enemy_table), "pool_composition")) skill_table.rows.assign([ {"skill_id": "b1", "display_name": "b1", "type": "buff", "buff_ref": "empower", "buff_duration": 2, "cooldown_beats": 3}, {"skill_id": "b2", "display_name": "b2", "type": "buff", "buff_ref": "fortify", "buff_duration": 2, "cooldown_beats": 4}]) enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "b1", "weight": 1}, {"skill_id": "b2", "weight": 1}]}]) - Kit.check("加载校验拒绝:池增益技 > 1(每池增益 ≤1 纪律)", _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition")) + Kit.check("加载校验拒绝:池增益技 > 1(每池增益 ≤1 纪律)", _has_err_category(_db._validate_skill_pool_links(skill_table, enemy_table), "pool_composition")) skill_table.rows.assign([{"skill_id": "atk", "display_name": "a", "type": "basic", "damage_mult": 2.0, "cooldown_beats": 0}]) Kit.check("加载校验拒绝:普攻配倍率 ≠ 1(域校验锁死)", not skill_table.validate().is_empty()) skill_table.rows.assign([{"skill_id": "atk", "display_name": "a", "type": "basic", "cooldown_beats": 2}]) @@ -879,7 +880,7 @@ func _smoke_skill_pool_validation_reject() -> void: {"skill_id": "w1", "display_name": "w1", "type": "debuff", "buff_ref": "off_guard", "buff_duration": 2, "cooldown_beats": 4}, {"skill_id": "w2", "display_name": "w2", "type": "debuff", "buff_ref": "off_guard", "buff_duration": 2, "cooldown_beats": 4}]) enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "w1", "weight": 1}, {"skill_id": "w2", "weight": 1}]}]) - Kit.check("加载校验拒绝:池妨害技 > 1(每池妨害 ≤1 纪律)", _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition")) + Kit.check("加载校验拒绝:池妨害技 > 1(每池妨害 ≤1 纪律)", _has_err_category(_db._validate_skill_pool_links(skill_table, enemy_table), "pool_composition")) skill_table.rows.assign([{"skill_id": "w1", "display_name": "w1", "type": "debuff", "cooldown_beats": 4}]) Kit.check("加载校验拒绝:妨害技缺 buff_ref(效果引用)", not skill_table.validate().is_empty()) skill_table.rows.assign([{"skill_id": "d", "display_name": "d", "type": "damage", "damage_mult": 1.5, "rider_mult": 1.5, "cooldown_beats": 1}]) @@ -891,7 +892,7 @@ func _smoke_skill_pool_validation_reject() -> void: {"skill_id": "atk", "display_name": "a", "type": "basic", "cooldown_beats": 0}, {"skill_id": "w1", "display_name": "w1", "type": "debuff", "buff_ref": "empower", "buff_duration": 2, "cooldown_beats": 4}]) enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "atk", "weight": 1}, {"skill_id": "w1", "weight": 1}]}]) - Kit.check("加载校验拒绝:妨害技引用敌方侧状态(施放类 side 守卫)", _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table, _db.status_effects), "cast_side")) + Kit.check("加载校验拒绝:妨害技引用敌方侧状态(施放类 side 守卫)", _has_err_category(_db._validate_skill_pool_links(skill_table, enemy_table, _db.status_effects), "cast_side")) func _has_err_category(errs: Array, category: String) -> bool: @@ -914,9 +915,9 @@ func _smoke_debuff_rider() -> void: 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_roll.damage_floor) + var base_ac: int = int(_db.combat.hero.armor_class) + var off_m: int = int(_db.combat.status_effects.row_by_id("off_guard").magnitude) + var floor_v: int = int(_db.combat.settlement.damage_roll.damage_floor) for s in range(1, 401): if venom_seed > 0 and snare_seed > 0: break @@ -925,7 +926,7 @@ func _smoke_debuff_rider() -> void: 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"}): + if not settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"}): continue var boss_atk: int = int(settlement._enemy_system.all_enemies()[0].atk) # 开战掷定攻击基线(毒分量随其缩放) for i in range(6): @@ -948,7 +949,7 @@ func _smoke_debuff_rider() -> void: 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.damage_roll.crit_multiplier) / 100.0 if bool(row.get("crit", false)) else 1.0) + var base_factor: float = float(int(row.tier)) * (float(_db.combat.settlement.damage_roll.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: diff --git a/game/tests/unit/battle_settlement/seal_polarity_test.gd b/game/tests/unit/battle_settlement/seal_polarity_test.gd index 9df559a5..e8242bae 100644 --- a/game/tests/unit/battle_settlement/seal_polarity_test.gd +++ b/game/tests/unit/battle_settlement/seal_polarity_test.gd @@ -52,10 +52,10 @@ func _smoke_seal_ap_exhausted() -> void: var rounds: Array = [] settlement.round_started.connect(func(ri): rounds.append(ri)) Kit.check("回归战斗启动(Boss 行——D0 20HP 级会被盲挖粉尘秒杀,无法承载封印回归场景)", - settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})) + settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"})) var minesweeper = settlement.minesweeper # 恰花 (AP-1) 点挖掘,留末点给封印(表驱动——AP 从表读不写死) - var ap_total: int = int(_db.action_economy.ap_per_round) + var ap_total: int = int(_db.run.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 @@ -151,7 +151,7 @@ func _run_battle(L: int, D: int, axes: Dictionary = {}) -> Dictionary: settlement.minesweeper.seal_judged.connect(func(evt): out.events.append(evt)) settlement.minesweeper.round_end_board.connect(func(p): out.events.append({"ev": "round_end_board", "path_tag": p.path_tag})) settlement.action_economy.round_end_stopped.connect(func(p): out.events.append({"ev": "round_end_stopped", "path_tag": p.path_tag})) - var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.hero.max_hp} + var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.combat.hero.max_hp} enc.merge(axes, true) # 两根类型轴注入尾参(缺省零注入=回退面) if not settlement.begin_battle(enc): out.stream.append({"type": "verdict", "value": "begin_failed"}) @@ -169,7 +169,7 @@ func _run_battle(L: int, D: int, axes: Dictionary = {}) -> Dictionary: # 清图驱动(真实流):标一面真雷封印(完美前置)→ 逐轮 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}) + _db.run.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) @@ -177,7 +177,7 @@ func _run_full_clear(seed_val: int, from_refresh: bool, rows: Array) -> Dictiona 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() + _db.run.difficulty.rows.clear() return res if from_refresh: for i in range(3): @@ -186,7 +186,7 @@ func _run_full_clear(seed_val: int, from_refresh: bool, rows: Array) -> Dictiona var drive := _drive_clear(settlement) res["cleared"] = drive.cleared res["rounds"] = drive.rounds - _db.difficulty.rows.clear() # 测试台账还原(后续冒烟基线) + _db.run.difficulty.rows.clear() # 测试台账还原(后续冒烟基线) return res @@ -266,7 +266,7 @@ func _polarity_probe_accepts(probe: Dictionary) -> bool: # 极性路由探针驱动:标 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}) # 抬敌血压低敌攻——多轮挖掘样本不被终局截断 + _db.run.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) @@ -305,7 +305,7 @@ func _run_polarity_probe(seed_val: int) -> Dictionary: while settlement.battle_result == "" and guard > 0: guard -= 1 _auto_play_round(settlement, out) - _db.difficulty.rows.clear() # 测试台账还原 + _db.run.difficulty.rows.clear() # 测试台账还原 return probe @@ -439,16 +439,10 @@ func _smoke_polarity_routing() -> void: if _polarity_probe_accepts(probe): polarity_seed = s break - # 触达三腿(标中标空入队/挖危险格/挖安全格)是 _polarity_probe_accepts 的合取项, - # 可达即三腿同真——缺腿明细进标签承载诊断,不再各立一条(2026-10-28 断言精简批删并) - var legs := "" - if not (bool(probe["seal_hit_done"]) and bool(probe["seal_miss_done"])): - legs += "标中标空未入队 " - if int(probe["mine_events"]) < 1: - legs += "挖危险格零 " - if int(probe["safe_events"]) < 1: - legs += "挖安全格零 " - Kit.check("极性路由构造种子可达(seed=%d——并流后行为面重锚;缺腿:%s)" % [polarity_seed, legs], polarity_seed > 0) + Kit.check("极性路由构造种子可达(seed=%d——并流后行为面重锚)" % polarity_seed, polarity_seed > 0) + Kit.check("构造触达:标中真雷 + 标空安全格均入队", bool(probe["seal_hit_done"]) and bool(probe["seal_miss_done"])) + Kit.check("构造触达:挖到危险格 ≥1(%d)" % int(probe["mine_events"]), int(probe["mine_events"]) >= 1) + Kit.check("构造触达:挖到安全格 ≥1(%d)" % int(probe["safe_events"]), int(probe["safe_events"]) >= 1) # 挖到的安全格:只出正向——其全部来源行必存在且 effect 行 ∈ 正向集 # (action 型排队行 effect_id=ap_delta 正负两极共用路由键——按行值符号辨极性:正向格恒 +power) var safe_all_pos := true @@ -500,7 +494,7 @@ func _smoke_polarity_routing() -> void: # δ 读 settlement.seal_hit_premium(125 provisional);九型正向行值全固定(唯一区间行 clear_blast # 系清图槽不入格池)⇒ 逐型期望全确定。整数量级四舍五入域注记:ac_up/action 1 点量纲取整后不变 # =整数粒度地板——等号压线判读在当量聚合面(audit 工具/registry clause3_inequality),非断言面。 - var prem_pct: int = int(_db.settlement.core.seal_hit_premium) + var prem_pct: int = int(_db.combat.settlement.core.seal_hit_premium) Kit.check("封印溢价配置在位(seal_hit_premium=125 provisional——形态 a 兑付溢价)", prem_pct == 125) var prem_rows := 0 var prem_ok := true @@ -517,7 +511,7 @@ func _smoke_polarity_routing() -> void: 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)) + var mag: int = int(_db.combat.status_effects.row_by_id(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 不入本行——区间断言) @@ -574,7 +568,7 @@ func _smoke_seal_irreversible_quadrants() -> void: var settle_a = load("res://core/battle_orchestrator.gd").new(_db, rng_a) var rows_a: Array = [] settle_a.step_row.connect(func(row): rows_a.append(row)) - Kit.check("四情况 A:开局受理", settle_a.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})) + Kit.check("四情况 A:开局受理", settle_a.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"})) var ms_a = settle_a.minesweeper var t_a := _find_seal_target(ms_a, false) Kit.check("四情况 A:找到安全格封印目标", not t_a.is_empty()) @@ -612,7 +606,7 @@ func _smoke_seal_irreversible_quadrants() -> void: var settle_b = load("res://core/battle_orchestrator.gd").new(_db, rng_b) var rows_b: Array = [] settle_b.step_row.connect(func(row): rows_b.append(row)) - Kit.check("四情况 B:开局受理", settle_b.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})) + Kit.check("四情况 B:开局受理", settle_b.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"})) var ms_b = settle_b.minesweeper var t_b := _find_seal_target(ms_b, true) Kit.check("四情况 B:找到危险格封印目标", not t_b.is_empty()) @@ -642,7 +636,7 @@ func _smoke_seal_irreversible_quadrants() -> void: var settle_c = load("res://core/battle_orchestrator.gd").new(_db, rng_c) var rows_c: Array = [] settle_c.step_row.connect(func(row): rows_c.append(row)) - Kit.check("四情况 C:开局受理", settle_c.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})) + Kit.check("四情况 C:开局受理", settle_c.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"})) var ms_c = settle_c.minesweeper var t_safe := _find_seal_target(ms_c, false) var t_mine := _find_seal_target(ms_c, true) @@ -677,12 +671,12 @@ func _smoke_seal_irreversible_quadrants() -> void: func _smoke_negative_dictionary() -> void: print("--- 负向字典逐型直验(sapped 在位 / heal 直伤 / blast 警觉 / action 行动点)---") # 数据面:sapped 行在位(状态表七行 + effect_pool_map 映射在位) - var sapped: Dictionary = _db.status_effect_row("sapped") + var sapped: Dictionary = _db.combat.status_effects.row_by_id("sapped") Kit.check("sapped 行在位(hero · −25 percent · dur 1 · max 16 · stack——weaken 型负向)", not sapped.is_empty() and String(sapped.get("target_side", "")) == "hero" \ and int(sapped.get("magnitude", 0)) == -25 and int(sapped.get("duration_settlements", 0)) == 1 \ and int(sapped.get("max_stack", 0)) == 16 and String(sapped.get("stack_policy", "")) == "stack") - Kit.check("effect_pool_map 收编 sapped → hero_offense(attack_up 镜像池)", String(_db.settlement.core.effect_pool_map.get("sapped", "")) == "hero_offense") + Kit.check("effect_pool_map 收编 sapped → hero_offense(attack_up 镜像池)", String(_db.combat.settlement.core.effect_pool_map.get("sapped", "")) == "hero_offense") # 行为面:逐型负向兑付形态(真实板 + 真实路由路径) # heal 权重低(无 heal 板概率 ≈1/3)——单板五型齐备不再必然: # 本断言多板扫描,取第一块五型齐备的板做行为断言。 @@ -698,7 +692,7 @@ func _smoke_negative_dictionary() -> void: 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)}): + if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp), "hero_max_hp": int(_db.combat.hero.max_hp)}): continue board = settlement.minesweeper.current_board() var r0 := {} @@ -738,7 +732,7 @@ func _smoke_negative_dictionary() -> void: settlement.reward_grant().grant_routed(snap_h, "negative", {"source_class": "mine_hit", "cell": by_type["heal"], "ri": 1}) var hp_after: int = int(settlement.presentation_snapshot().hero.hp) var backlash_power: int = 0 - for row in _db.reward.rows: + for row in _db.run.reward.rows: if String(row.get("reward_id", "")) == "heal_backlash": backlash_power = int(row.power) Kit.check("heal 型负向 → 主角直伤 %d(%d → %d,不经增益状态)" % [backlash_power, hp_before, hp_after], hp_after == hp_before - backlash_power) @@ -795,7 +789,7 @@ func _smoke_resource_surface() -> void: 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)}) + settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)}) var board = settlement.minesweeper.current_board() var r0_first: Variant = null for c in board.compute_r0(): diff --git a/game/tests/unit/battle_settlement/skill_unlock_test.gd b/game/tests/unit/battle_settlement/skill_unlock_test.gd index 245cd099..dabb76d4 100644 --- a/game/tests/unit/battle_settlement/skill_unlock_test.gd +++ b/game/tests/unit/battle_settlement/skill_unlock_test.gd @@ -26,7 +26,7 @@ func _init() -> void: # 门谓词:卓尔 (2,9) hp 130(×mult 1.0)→ venom_curb unlock 50 → 阈值 round(130×0.5)=65 func _boss(db, L: int, D: int, kind := "boss"): var ec = UnitContainerScript.new(db) - var ok: bool = ec.spawn(L, D, int(db.hero.max_hp), [], -1, -1, [], "", "", kind) + var ok: bool = ec.spawn({"level_index": L, "node_depth": D, "hero_hp": int(db.combat.hero.max_hp), "row_kind": kind}) Kit.check("前置:spawn (%d,%d) 受理" % [L, D], ok) return ec @@ -83,7 +83,7 @@ func _test_latch_irreversible(db) -> void: # 普通行无字段逐位零扰动:地精 (1,0) 池快照零 unlock 键、过滤恒空转、可摇=全池 func _test_plain_rows_zero_drift(db) -> void: var ec = load("res://core/unit_container.gd").new(db) - Kit.check("零扰动前置:巨蜘蛛首遇带 spawn(池三行全无 trigger——快照键策略钉死载体)", ec.spawn(2, 1, int(db.hero.max_hp), [], -1, -1, [], "", "", "battle")) + Kit.check("零扰动前置:巨蜘蛛首遇带 spawn(池三行全无 trigger——快照键策略钉死载体)", ec.spawn({"level_index": 2, "node_depth": 1, "hero_hp": int(db.combat.hero.max_hp)})) var u: Dictionary = ec.all_enemies()[0] var any_unlock_key := false for e in u.pool: @@ -96,7 +96,7 @@ func _test_plain_rows_zero_drift(db) -> void: Kit.check("零扰动:可摇集合 = 全池(普攻冷却恒 0,过滤恒空转)", rollable.size() == pool_count) # 同种子确定性:容器零随机——两实例同输入查询逐位同(同款零扰动载体) var ec2 = load("res://core/unit_container.gd").new(db) - Kit.check("确定性前置:第二实例蜘蛛 spawn", ec2.spawn(2, 1, int(db.hero.max_hp), [], -1, -1, [], "", "", "battle")) + Kit.check("确定性前置:第二实例蜘蛛 spawn", ec2.spawn({"level_index": 2, "node_depth": 1, "hero_hp": int(db.combat.hero.max_hp)})) var u2: Dictionary = ec2.all_enemies()[0] ec.apply_damage(u, 3) ec2.apply_damage(u2, 3) diff --git a/game/tests/unit/battle_settlement/trigger_cond_test.gd b/game/tests/unit/battle_settlement/trigger_cond_test.gd index 0929ed4f..6c6ff144 100644 --- a/game/tests/unit/battle_settlement/trigger_cond_test.gd +++ b/game/tests/unit/battle_settlement/trigger_cond_test.gd @@ -52,7 +52,7 @@ func _init() -> void: # ---- V2 门槛过滤双面(spawn 真实表→直调 rollable/special)---- var ec = load("res://core/unit_container.gd").new(db) - Kit.check_quiet(ec.spawn(2, 9, 30, [], 8, 30, [], "", "", "boss"), "V2 前置:卓尔 Boss spawn") + Kit.check_quiet(ec.spawn({"level_index": 2, "node_depth": 9, "hero_hp": 30, "hero_atk_override": 8, "hero_max_hp_override": 30, "row_kind": "boss"}), "V2 前置:卓尔 Boss spawn") var u = ec.all_enemies()[0] Kit.check_quiet(int(u.hp) == int(u.max_hp), "V2 前置:满血(hp=max_hp)") var r_full: Array = ec.rollable_skills(u) @@ -67,7 +67,7 @@ func _init() -> void: # ---- V3 保底不逼条件不满足技(保底集合取 special_skills 已含排除——编排级: # 无条件特殊技 A w1+条件假技 B w9999+普攻;保底命中摇出的是 A,撤销过滤态同种子必中 B)---- var ec_v3 = load("res://core/unit_container.gd").new(db) - Kit.check_quiet(ec_v3.spawn(1, 0, 8, [], 6, 8, [], "", "", "battle"), "V3 前置:地精 spawn") + Kit.check_quiet(ec_v3.spawn({"level_index": 1, "node_depth": 0, "hero_hp": 8, "hero_atk_override": 6, "hero_max_hp_override": 8}), "V3 前置:地精 spawn") var g3 = ec_v3.all_enemies()[0] g3["pool"] = [ {"skill_id": "attack", "weight": 9, "cooldown_left": 0}, @@ -75,7 +75,7 @@ func _init() -> void: {"skill_id": "desperate_guard", "weight": 9999, "cooldown_left": 0, "trigger": {"fact": "self_hp_pct", "op": "lte", "value": 35, "latch": false}}, # B=条件假技 w9999 ] - g3["basic_streak"] = int(db.skill.pity_threshold) # 保底已触发位(夹具内存改行——round_flow 白盒先例) + g3["basic_streak"] = int(db.combat.skill.pity_threshold) # 保底已触发位(夹具内存改行——round_flow 白盒先例) var specials_v3: Array = ec_v3.special_skills(g3) Kit.check_quiet(_has(specials_v3, "heavy") and not _has(specials_v3, "desperate_guard"), "V3 保底集合含 A 排除 B(special_skills 真实入口——条件假技不逼出)〔触发器批 V3〕") @@ -96,7 +96,7 @@ func _init() -> void: Kit.check_quiet(ec.check_trigger_unlocks(u).is_empty() and _has(ec.rollable_skills(u), "venom_curb"), "V4 治疗抬血不回锁(单向闩)〔触发器批 V4〕") var ec8 = load("res://core/unit_container.gd").new(db) - Kit.check_quiet(ec8.spawn(1, 0, 8, [], 6, 8, [], "", "", "battle"), "V4 前置:地精 spawn(hp 基线 8)") + Kit.check_quiet(ec8.spawn({"level_index": 1, "node_depth": 0, "hero_hp": 8, "hero_atk_override": 6, "hero_max_hp_override": 8}), "V4 前置:地精 spawn(hp 基线 8)") var g = ec8.all_enemies()[0] g["pool"] = [{"skill_id": "attack", "weight": 9, "cooldown_left": 0}, {"skill_id": "desperate_guard", "weight": 1, "cooldown_left": 0, @@ -139,28 +139,28 @@ func _init() -> void: if String((e as Dictionary).get("category", "")) == "pool_composition" and String((e as Dictionary).get("message", "")).contains("无条件特殊技"): comp_violations += 1 Kit.check_quiet(comp_violations == 0, "V7 现行全表零违例(每池至少一行无条件特殊技)〔触发器批 V7〕") - var enemy_backup: Array = db.enemy.rows.duplicate(true) - var cond_only_row: Dictionary = (db.enemy.rows[0] as Dictionary).duplicate(true) + var enemy_backup: Array = db.combat.enemy.rows.duplicate(true) + var cond_only_row: Dictionary = (db.combat.enemy.rows[0] as Dictionary).duplicate(true) var cond_pool: Array[Variant] = [] for entry in (cond_only_row.get("pool", []) as Array): var sid := String((entry as Dictionary).get("skill_id", "")) - var srow_c: Dictionary[Variant, Variant] = db.skill_row(sid) + var srow_c: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(sid) if not srow_c.is_empty() and String(srow_c.get("type", "")) == "basic": cond_pool.append(entry) # 普攻保留(普攻不计入无条件特殊技) cond_pool.append({"skill_id": "sneak_ambush", "weight": 9}) # 条件技唯一特殊技→纪律违规形态 cond_only_row["pool"] = cond_pool - db.enemy.rows[0] = cond_only_row + db.combat.enemy.rows[0] = cond_only_row var errs2: Array = cv.validate_skill_pool_links(db.skill, db.enemy, db.status_effects) var hit2 := false for e in errs2: if String((e as Dictionary).get("message", "")).contains("无条件特殊技"): hit2 = true Kit.check_quiet(hit2, "V7 构造全条件池报 pool_composition 错〔触发器批 V7〕") - db.enemy.rows = enemy_backup + db.combat.enemy.rows = enemy_backup # ---- V8 示范投放:低血防御(地精两行池 w1+trigger lte 35——真实表 spawn 压 hp 开轮摇号)---- var ec_v8 = load("res://core/unit_container.gd").new(db) - Kit.check_quiet(ec_v8.spawn(1, 0, 8, [], 6, 8, [], "", "", "battle"), "V8 前置:地精 spawn") + Kit.check_quiet(ec_v8.spawn({"level_index": 1, "node_depth": 0, "hero_hp": 8, "hero_atk_override": 6, "hero_max_hp_override": 8}), "V8 前置:地精 spawn") var g8 = ec_v8.all_enemies()[0] var has_dg := false for e in (g8.get("pool", []) as Array): @@ -176,7 +176,7 @@ func _init() -> void: # ---- V9 示范投放:停手偷袭(钩爪恐魔池 w2+last_round_end eq stopped——facts 缺键=拦 fail-safe)---- var ec_v9 = load("res://core/unit_container.gd").new(db) - Kit.check_quiet(ec_v9.spawn(3, 1, 30, [], 8, 30, [], "", "", "battle"), "V9 前置:钩爪恐魔 spawn") + Kit.check_quiet(ec_v9.spawn({"level_index": 3, "node_depth": 1, "hero_hp": 30, "hero_atk_override": 8, "hero_max_hp_override": 30}), "V9 前置:钩爪恐魔 spawn") var h9 = ec_v9.all_enemies()[0] Kit.check_quiet(_not_has(ec_v9.rollable_skills(h9), "sneak_ambush"), "V9 首轮(facts 缺 last_round_end 键=fail-safe 拦)不入集合〔触发器批 V9〕") @@ -189,7 +189,7 @@ func _init() -> void: # ---- V10 缺省零扰动(无 trigger 行的池快照逐键无新增+摇号同种子不变——快照键策略钉死)---- var ec_v10 = load("res://core/unit_container.gd").new(db) - Kit.check_quiet(ec_v10.spawn(1, 0, 8, [], 6, 8, [], "", "", "battle"), "V10 前置:地精 spawn") + Kit.check_quiet(ec_v10.spawn({"level_index": 1, "node_depth": 0, "hero_hp": 8, "hero_atk_override": 6, "hero_max_hp_override": 8}), "V10 前置:地精 spawn") var g10 = ec_v10.all_enemies()[0] var stray := 0 for e in (g10.get("pool", []) as Array): diff --git a/game/tests/unit/battle_settlement/variant_placement_test.gd b/game/tests/unit/battle_settlement/variant_placement_test.gd index a623b242..0456555c 100644 --- a/game/tests/unit/battle_settlement/variant_placement_test.gd +++ b/game/tests/unit/battle_settlement/variant_placement_test.gd @@ -56,7 +56,7 @@ func _smoke_variant_placement(L: int) -> void: 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}): + if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.combat.hero.max_hp}): Kit.check("L%d 布置段战斗启动失败" % L, false) return var b = settlement.minesweeper.current_board() @@ -64,7 +64,7 @@ func _smoke_variant_placement(L: int) -> void: if b == null: return var pool_ids: Array = [] - for r in _db.variant_pool(L, String(b.board_type)): + for r in _db.combat.variation.variant_pool(L, String(b.board_type)): pool_ids.append(String(r.variant_id)) var vcells: Array = b.variants.keys() var vid0 := "" @@ -95,13 +95,13 @@ func _smoke_variant_placement(L: int) -> void: 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) + var expect_cap: int = int(round(float(cand_count) * float(_db.combat.variation.percent_of_cells) / 100.0)) \ + + (L - 1) * int(_db.combat.variation.per_level_bonus) Kit.check("L%d 变异格数 %d ≤ 公式上限 %d(可挖池百分比 + 关卡递增)" % [L, vcells.size(), expect_cap], vcells.size() <= expect_cap) # 数量公式量随候选池缩放:L1 无关卡加成,极小池(<10 格)可 round 至 0—— # 零布置为合法态(同池空口径);非零断言为条件式,公式量>0 时才要求非零(L2/L3 关卡加成保底恒>0) Kit.check("L%d 公式量>0 时非零布置(极小池 round 至 0 = 合法零布置——2026-09-21 基数修正批)" % L, expect_cap == 0 or vcells.size() > 0) - var pack_max: int = int(_db.variation.pack_max) + var pack_max: int = int(_db.combat.variation.pack_max) var vset := {} for c in vcells: vset[c] = true @@ -127,7 +127,7 @@ func _smoke_variant_placement(L: int) -> void: 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}) + s2.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.combat.hero.max_hp}) var b2 = s2.minesweeper.current_board() Kit.check("L%d 同种子变异布置逐格相同(variant_place 流确定性)" % L, b2 != null and _variant_dump(b2) == _variant_dump(b)) @@ -175,24 +175,24 @@ func _smoke_global_variant() -> void: Kit.check("互锁负样本⑦:resource_face effect_value=51 越域 [0,50] 被拒(不适用 flat_damage [1,9] 加法核)", domain_hit) # ② 门控第三维(节点身份——global 行仅 boss/elite 入池;缺省/普通节点静默跳过零报错) var pool_boss_ids: Array = [] - for r in _db.variant_pool(2, "underdark", "boss"): + for r in _db.combat.variation.variant_pool(2, "underdark", "boss"): pool_boss_ids.append(String(r.variant_id)) Kit.check("门控三维:L2 underdark boss 池 = region 两行(贫矿脉 2026-10-16 屏蔽删行后无 global 行)", pool_boss_ids == ["magma", "ash"]) var pool_battle_ids: Array = [] - for r in _db.variant_pool(2, "underdark", "battle"): + for r in _db.combat.variation.variant_pool(2, "underdark", "battle"): pool_battle_ids.append(String(r.variant_id)) Kit.check("门控三维:battle 节点静默跳过 global 行(普通图零报错零警告)", pool_battle_ids == ["magma", "ash"]) - Kit.check("门控三维:缺省双参调用 = global 行跳过(既有调用口径零扰动)", _db.variant_pool(2, "underdark").is_empty() == false and _db.variant_pool(2, "underdark").size() == 2) - Kit.check("L1 Boss 免疫:unlock_level=2 门控在 boss 节点同样生效(教学保护带 B)", _db.variant_pool(1, "underdark", "boss").is_empty()) - Kit.check("全局决议:battle 节点恒无全局型", _db.global_variant_row(2, "underdark", "battle").is_empty()) - Kit.check("全局决议:L2 boss 全局池空 → 空行(贫矿脉屏蔽后兜底回落承载)", _db.global_variant_row(2, "underdark", "boss").is_empty()) - Kit.check("全局决议:L1 boss 全局池空 → 空行(兜底回落承载)", _db.global_variant_row(1, "underdark", "boss").is_empty() and _db.global_variant_row(1, "dungeon", "boss").is_empty()) - Kit.check("elite 前向词位:boss 同池恒空(贫矿脉屏蔽后——词表位不造行)", _db.global_variant_row(2, "underdark", "elite").is_empty()) + Kit.check("门控三维:缺省双参调用 = global 行跳过(既有调用口径零扰动)", _db.combat.variation.variant_pool(2, "underdark").is_empty() == false and _db.combat.variation.variant_pool(2, "underdark").size() == 2) + Kit.check("L1 Boss 免疫:unlock_level=2 门控在 boss 节点同样生效(教学保护带 B)", _db.combat.variation.variant_pool(1, "underdark", "boss").is_empty()) + Kit.check("全局决议:battle 节点恒无全局型", _db.combat.variation.global_variant_row(2, "underdark", "battle").is_empty()) + Kit.check("全局决议:L2 boss 全局池空 → 空行(贫矿脉屏蔽后兜底回落承载)", _db.combat.variation.global_variant_row(2, "underdark", "boss").is_empty()) + Kit.check("全局决议:L1 boss 全局池空 → 空行(兜底回落承载)", _db.combat.variation.global_variant_row(1, "underdark", "boss").is_empty() and _db.combat.variation.global_variant_row(1, "dungeon", "boss").is_empty()) + Kit.check("elite 前向词位:boss 同池恒空(贫矿脉屏蔽后——词表位不造行)", _db.combat.variation.global_variant_row(2, "underdark", "elite").is_empty()) # ③ 兜底回落:全局池空 → Boss 图改走 region 候选池(恰一行 region 变异 + 格子级布置 + 正常取号) var crng = _CountVariantRng.new() crng.set_master_seed(42) var boss_settlement = load("res://core/battle_orchestrator.gd").new(_db, crng) - var boss_ok: bool = boss_settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) + var boss_ok: bool = boss_settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"}) Kit.check("L2 Boss 战斗启动(node_depth=9 Boss 终点——兜底回落断言载体)", boss_ok) var boss1_ids: Array = [] var boss1_variants: Dictionary = {} @@ -216,7 +216,7 @@ func _smoke_global_variant() -> void: 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"}): + if boss2.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"}): var bb2 = boss2.minesweeper.current_board() Kit.check("确定性:同种子 Boss 图两遍 board_variants/变异格/取号逐位相同(region 布置路径)", _arr_str_eq(bb2.board_variant_ids(), boss1_ids) and bb2.variants == boss1_variants and crng2.variant_calls == boss1_calls) # ④ 资源面偏斜投影(RewardEconomy 纯函数面——守恒/白名单/构造例/合成序) @@ -267,12 +267,12 @@ func _smoke_global_variant() -> void: 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)}) + normal1.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.combat.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)}) + normal2.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)}) Kit.check("确定性:同种子普通图布置/摇号两遍逐位相同(off 全链路)", n1 == _board_resource_dump(normal2) and not n1.is_empty()) diff --git a/game/tests/unit/contracts/system_contracts_test.gd b/game/tests/unit/contracts/system_contracts_test.gd index aacd48cc..11b0a352 100644 --- a/game/tests/unit/contracts/system_contracts_test.gd +++ b/game/tests/unit/contracts/system_contracts_test.gd @@ -60,7 +60,7 @@ func _test_damage_controller_vocabularies() -> void: # ---- D2↔B1:掷点用途标签 ∈ RngService 封闭词表(EC12——跨系统字面量契约的成员性断言) ---- func _test_rng_tag_closure(db) -> void: - var stream_label: String = String(db.settlement.damage_roll.armor_roll_stream) + var stream_label: String = String(db.combat.settlement.damage_roll.armor_roll_stream) Kit.check("settlement.armor_roll_stream ∈ RngService 封闭标签词表(EC12)", RngServiceScript.PURPOSE_TAGS.has(stream_label)) Kit.check("RngService 用途标签词表非空且互异(封闭语义由流改标签)", @@ -102,8 +102,8 @@ func _test_status_boolean_acceptance(db) -> void: var effects = StatusContainerScript.new(db) # EC1 域闭:notify_battle_start 前授予 = false(受理面封闭) var any_row: Dictionary = {} - if db.status_effects.rows.size() > 0: - any_row = db.status_effects.rows[0] + if db.combat.status_effects.rows.size() > 0: + any_row = db.combat.status_effects.rows[0] Kit.check("前置:状态表非空", not any_row.is_empty()) if any_row.is_empty(): return diff --git a/game/tests/unit/core/adventure_feats_test.gd b/game/tests/unit/core/adventure_feats_test.gd index c9929b8b..0880d67e 100644 --- a/game/tests/unit/core/adventure_feats_test.gd +++ b/game/tests/unit/core/adventure_feats_test.gd @@ -44,15 +44,6 @@ func _smoke_find_token(candidates: Array, kind: String, tier: String) -> Diction return {} -# 效果命中分桶(公开 dispatch 返回值的本地过滤——断言钉公开可观测面;编排器私有过滤器的测试侧等价承载) -func _hits_kind(hits: Array, kind: String) -> Array: - var out: Array = [] - for h in hits: - if String((h as Dictionary).get("kind", "")) == kind: - out.append(h) - return out - - # ② 冒险结构 API 闸(状态机受理面——重复开始拒绝 / 入口停点 / 放弃两态) @@ -124,10 +115,10 @@ func _smoke_adventure() -> void: var autoplay = load("res://sim/level_autoplay.gd") # ① 表与覆盖义务(序列关数 = 行集关数;节点源 = 参数档在固定种子下的生成图, # 每个战斗/Boss 节点经 ConfigDB 真实解析链查敌行集与棋盘行——顶 clamp 消解禁〔fv24〕) - Kit.check("序列关数 = 行集关数(三关正式序列生效)", int(_db.level.rows.size()) == 3) + Kit.check("序列关数 = 行集关数(三关正式序列生效)", int(_db.run.level.rows.size()) == 3) var cov_miss := 0 var cov_scan := 0 - for li in range(1, int(_db.level.rows.size()) + 1): + for li in range(1, int(_db.run.level.rows.size()) + 1): for n in _level_active_nodes(li): var nt := String(n.get("type", "")) if nt != "battle" and nt != "boss": @@ -135,10 +126,10 @@ func _smoke_adventure() -> void: var d: int = int(n.get("depth", -1)) cov_scan += 1 var rk := "boss" if nt == "boss" else "battle" - var rs: Array[Dictionary] = _db.resolve_enemy_rowset(li, d, rk) + var rs: Array[Dictionary] = _db.combat.enemy.resolve_rowset(li, d, rk) if rs.is_empty() or int(rs[0].get("depth_band", -1)) < d: cov_miss += 1 - if not (_db.resolve_board_gen(li, d) as Dictionary).is_empty(): + if not (_db.combat.board_gen.resolve_row(li, d) as Dictionary).is_empty(): continue cov_miss += 1 Kit.check("生成图每个战斗/Boss 节点经解析链命中敌行集与棋盘行(扫描 %d 节点,缺失 %d)〔fv24〕" % [cov_scan, cov_miss], @@ -149,8 +140,8 @@ func _smoke_adventure() -> void: Kit.check("满配冒险成功(42 / atk+900 / hp+400——cleared=true;v2 怪阶梯后旧 200/100 档不再满配)", String(st["result"]) == "completed" and bool(sum.get("cleared", false))) Kit.check("完成几关 = 3(levels_cleared)", int(sum.get("levels_cleared", -1)) == 3) var ks_exp := 0 - for li in range(1, int(_db.level.rows.size()) + 1): - ks_exp += int(_db.meta_boss_keystone_row(li).get("first_kill", 0)) + for li in range(1, int(_db.run.level.rows.size()) + 1): + ks_exp += int(_db.crossrun.meta.boss_keystone_row(li).get("first_kill", 0)) Kit.check("钥石合计 = ΣBoss 首杀行值(%d——逐杀掉落新口径;断点未注入恒首杀)" % int(sum.get("keystone_total", -1)), int(sum.get("keystone_total", -1)) == ks_exp) var drops: Array = sum.get("keystone_drops", []) as Array Kit.check("逐杀掉落明细恰 3 笔(L1/L2/L3——additive 摘要键 keystone_drops)", drops.size() == 3) @@ -169,7 +160,7 @@ func _smoke_adventure() -> void: # 奖励节点在生成图里是可选路位(旧单链模板的「L1 必经 1 个」形状前提随模板消亡)—— # 接线证明拆两半:图侧每关含 ≥1 奖励节点,行进侧种子扫描内确有经停(扫描零经停=三选一接线不可达) var reward_levels := 0 - for li in range(1, int(_db.level.rows.size()) + 1): + for li in range(1, int(_db.run.level.rows.size()) + 1): var rc := 0 for n in _level_active_nodes(li): if String(n.get("type", "")) == "reward": @@ -217,8 +208,8 @@ func _smoke_adventure() -> void: 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 max_flat := int(_db.run.adventure.value_segment.curio_value_max_hp_common) + var heal_pct := int(_db.run.adventure.clear_heal_pct) var hp_acc := 0 # max_hp 件累加(回血封顶断言的生效上限推导——TP0 起按兑付令牌实值,大小档分别计) var ks_prefix := 0 for i in picks.size(): @@ -240,18 +231,18 @@ func _smoke_adventure() -> void: # 账本生效上限口径:置满/回血封顶按生效上限(flat 后乘调试浮窗 pct)——本段 pct=400 # 加算区 = 树/锻造件(hp_acc)+购血观测值(level_autoplay 停点快照 bonus_max_hp)——精确断言不再窗口拟合 var extra_obs := int(p.get("bonus_max_hp", -1)) - var m_obs := int(round(float(int(_db.hero.max_hp) + hp_acc + extra_obs) * 5.0)) + var m_obs := int(round(float(int(_db.combat.hero.max_hp) + hp_acc + extra_obs) * 5.0)) var heal_exp := mini(int(floor(float(m_obs) * float(heal_pct) / 100.0)), m_obs - hp_before) if extra_obs < 0 or heal != heal_exp or hp_after != hp_before + heal or hp_after > m_obs: heal_ok = false - ks_prefix += int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) - if int(p.get("keystone_gained", -1)) != int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) or int(p.get("keystone_total", -1)) != ks_prefix: + ks_prefix += int(_db.crossrun.meta.boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) + if int(p.get("keystone_gained", -1)) != int(_db.crossrun.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)): + if _smoke_find_token(p.get("candidates", []) as Array, rk, rt).is_empty() or int(r.get("value", -1)) != int(_db.run.adventure.curio_value(rk, rt)): resolve_ok = false if rk == "max_hp": hp_acc += int(r.get("value", max_flat)) @@ -261,10 +252,10 @@ func _smoke_adventure() -> void: Kit.check("点选恰兑付选中一件(值=令牌档位表值;未选项零生效)", resolve_ok) var seq: Array = st["encounter_max_hp_seq"] var k := 5.0 # hp+400 ⇒ 调试浮窗百分比后乘 ×5(加算先加、百分比后乘——生效次序口径) - var exp_base := maxi(1, int(round(float(int(_db.hero.max_hp)) * k))) + var exp_base := maxi(1, int(round(float(int(_db.combat.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 hp_ceiling := maxi(1, int(round(float(int(_db.combat.hero.max_hp) + 3 * int(_db.run.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: @@ -303,7 +294,7 @@ func _smoke_adventure() -> void: var sh: Dictionary = half["st"]["summary"] Kit.check("L2 败北摘要:L1 已过(钥石=L1 首杀掉落)· 止步第 2 层", not bool(sh["cleared"]) and int(sh["levels_cleared"]) == 1 \ - and int(sh["keystone_total"]) == int(_db.meta_boss_keystone_row(1).get("first_kill", 0)) \ + and int(sh["keystone_total"]) == int(_db.crossrun.meta.boss_keystone_row(1).get("first_kill", 0)) \ and int(sh["stopped_at"]) == 2) var dh: Dictionary = sh.get("defeat", {}) Kit.check("L2 败北败退明细:层号=2 · 节点在案(数值验证取数面 2026-09-22)", @@ -353,22 +344,20 @@ func _smoke_adventure() -> void: var r8 = a8.runner() var gp0: int = int(r8.gp) var hp_price := -1 - for p in _db.shop.products: + for p in _db.run.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: + for pb in _db.run.shop.price_level_bonus: if int(pb.get("level_index", -1)) == 2: l2_price_bonus = int(pb.get("bonus", 0)) Kit.check("进店余额 ≥ hp_up L2 首价(%d = 基价 %d + 关卡加成 %d——配置推导)" % [hp_price + l2_price_bonus, hp_price, l2_price_bonus], gp0 >= hp_price + l2_price_bonus) Kit.check("购买 hp_up 受理(出账 + 加算区)", r8.buy_product("hp_up")) var acc_shop: int = a8.journal().bonus_total("max_hp") # L1 三选一后 + 商店 3(首件随种子) Kit.check("统一冒险加算区含商店值(max_hp=%d ≥ 商店 3——L1 三选一件值另计)" % acc_shop, acc_shop >= 3) - var l2_cap := {"payload": {}} # 公开信号面采集(curio_choice_presented 停点载荷——容器写回规避 lambda 按值捕获) - a8.stop().curio_choice_presented.connect(func(p): l2_cap["payload"] = p) a8._on_level_finished({"result": "victory", "hp": int(r8.hero_hp), "node_index": r8.node_index, "node_id": r8.current_node_id(), "node_type": "shop"}) # 模拟 L2 关卡完成(victory 载荷直调——非末关) - Kit.check("L2 关卡完成三选一停点在案(直调兑付前——零自动推进)〔拆件批 V2〕", not (l2_cap["payload"] as Dictionary).is_empty()) + Kit.check("L2 关卡完成三选一停点在案(直调兑付前——零自动推进)〔拆件批 V2〕", not a8.stop()._pending.is_empty()) Kit.check("未兑付推进拒绝(选择停点零自动推进契约)", not a8.continue_to_next_level()) var l2_cands: Array = a8.stop()._pending["candidates"] var l2_pick: Dictionary = l2_cands[0] @@ -448,8 +437,8 @@ func _smoke_adventure() -> void: a13.stop().present_node_curio(ncands13) a13.stop().present_node_curio([{"kind": "gp", "tier": "common"}]) var gp13: int = a13.wallet().gp - Kit.check("奖励节点 GP 件入账(钱包「奖励入账」收口——+%d)" % int(_db.adventure.curio_value("gp", "common")), a13.stop().apply_node_curio("gp", "common") and a13.wallet().gp == gp13 + int(_db.adventure.curio_value("gp", "common"))) - Kit.check("节点停点态兑付后清零(恰一次语义同构)", (a13.stop().node_curio_pending_state().get("candidates", []) as Array).is_empty()) + Kit.check("奖励节点 GP 件入账(钱包「奖励入账」收口——+%d)" % int(_db.run.adventure.curio_value("gp", "common")), a13.stop().apply_node_curio("gp", "common") and a13.wallet().gp == gp13 + int(_db.run.adventure.curio_value("gp", "common"))) + Kit.check("节点停点态兑付后清零(恰一次语义同构)", a13.stop()._node_pending.is_empty()) var s10: Dictionary = a10.summary Kit.check("放弃摘要:abandoned · 未打穿(已获大奖励不追回——同失败型结算)", String(s10.get("result", "")) == "abandoned" and not bool(s10.get("cleared", true))) @@ -462,8 +451,8 @@ func _smoke_adventure() -> void: func _smoke_feats() -> void: print("--- 专长重设计实装(feat_table 四级 · 专长带池接线 · 订阅面逐件)---") # ① 现行表牌库构成:30 件全量入池——102 数值令牌+30 relic 令牌(四级 rarity 直映射) - Kit.check("feat_table 已加载(30 件=基础 27+迁入 2+卡② 完美保险 1——迭代 31 卡② 换代)", _db.feats != null and (_db.feats.rows as Array).size() == 30) - Kit.check("feat_table.validate 零违例(四级 rarity/cap 域/trigger 词表/行键白名单全过)", _db.feats.validate().is_empty()) + Kit.check("feat_table 已加载(30 件=基础 27+迁入 2+卡② 完美保险 1——迭代 31 卡② 换代)", _db.feats != null and (_db.crossrun.feats.rows as Array).size() == 30) + Kit.check("feat_table.validate 零违例(四级 rarity/cap 域/trigger 词表/行键白名单全过)", _db.crossrun.feats.validate().is_empty()) var a_r0 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(71)) Kit.check("专长带场景:冒险启动(seed 71)", a_r0.start_adventure(71)) var feat_tokens := 0 @@ -473,34 +462,34 @@ func _smoke_feats() -> void: feat_tokens += 1 var tk := String((t as Dictionary).get("tier", "")) tier_counts[tk] = int(tier_counts.get(tk, 0)) + 1 - Kit.check("专长带入池:30 件四级直映射(普通 12/优秀 9/稀有 9/传说 0)——牌库 102+30=132〔专长批 V2+卡② 完美保险迁入〕", feat_tokens == 30 and int(tier_counts.get("common", 0)) == 12 and int(tier_counts.get("excellent", 0)) == 9 and int(tier_counts.get("rare", 0)) == 9 and a_r0.deck().deck.size() == 132) + Kit.check("专长带入池:30 件四级直映射(普通 12/优秀 9/稀有 9/传说 0)——牌库 102+30=132〔专长批 V2+卡② 完美保险迁入〕", feat_tokens == 30 and int(tier_counts.get("common", 0)) == 12 and int(tier_counts.get("excellent", 0)) == 9 and int(tier_counts.get("rare", 0)) == 9 and a_r0._curio.deck.size() == 132) # ② 旧 27 件零残留钉面(V2——arcane_ember 系旧件表首行;全仓 grep 零残留归静态核查) - Kit.check("旧 27 件已退役:arcane_ember/magma_body 表内零残留〔专长批 V2〕", _db.feats.row_by_id("arcane_ember").is_empty() and _db.feats.row_by_id("magma_body").is_empty()) - Kit.check("迁出收尾:treasure_items 无 venom_edge/seal_burst 行(同调目录自然收窄)〔专长批 V13〕", _db.treasure_items.row_by_id("venom_edge").is_empty() and _db.treasure_items.row_by_id("seal_burst").is_empty()) + Kit.check("旧 27 件已退役:arcane_ember/magma_body 表内零残留〔专长批 V2〕", _db.crossrun.feats.row_by_id("arcane_ember").is_empty() and _db.crossrun.feats.row_by_id("magma_body").is_empty()) + Kit.check("迁出收尾:treasure_items 无 venom_edge/seal_burst 行(同调目录自然收窄)〔专长批 V13〕", _db.crossrun.treasure_items.row_by_id("venom_edge").is_empty() and _db.crossrun.treasure_items.row_by_id("seal_burst").is_empty()) # ③ 契约面静态钉扎(V18/V19/V21/V22——人工核对 V20/V21 裁决面以外的可机检半) var kinds_ok := true for t in a_r0._curio.deck: var kk := String((t as Dictionary).get("kind", "")) - if kk != "relic" and not _db.adventure.CURIO_KINDS.has(kk): + if kk != "relic" and not _db.run.adventure.CURIO_KINDS.has(kk): kinds_ok = false Kit.check("层级纪律:牌库 kind 全集=CURIO_KINDS+relic(秘宝 item_id 零入池)〔专长批 V18〕", kinds_ok) var rewrite_rows := 0 - for row_v in (_db.feats.rows as Array): + for row_v in (_db.crossrun.feats.rows as Array): for tv in ((row_v as Dictionary).get("triggers", []) as Array): if String(((tv as Dictionary).get("do", {}) as Dictionary).get("kind", "")) == "rewrite_board": rewrite_rows += 1 Kit.check("棋盘事实不可变:新 30 行零棋盘改写类订阅(rewrite_board 已除名消费面)〔专长批 V19〕", rewrite_rows == 0) var bad_words := 0 - for row_v in (_db.feats.rows as Array): + for row_v in (_db.crossrun.feats.rows as Array): var r_d: Dictionary = row_v as Dictionary for txt_v in [String(r_d.get("display_name", "")), String(r_d.get("desc", ""))]: if String(txt_v).contains("雷") or String(txt_v).contains("旗"): bad_words += 1 Kit.check("对外命名纪律:30 行 display_name/desc 零「雷/旗」字面(封印/陷阱风味系)〔专长批 V21 初筛〕", bad_words == 0) Kit.check("数值纪律:陷阱爆发倍率>1 · 血蜜回复<典型陷阱伤 · 石肤上限 3 在册(结构锚形态内 provisional)〔专长批 V22 机检半〕", - float((_db.feats.row_by_id("trap_burst").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("mult", 0)) > 1.0 - and int((_db.feats.row_by_id("blood_honey").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("value", 99)) < 5 - and int((_db.feats.row_by_id("stone_skin").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("cap", 0)) == 3) + float((_db.crossrun.feats.row_by_id("trap_burst").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("mult", 0)) > 1.0 + and int((_db.crossrun.feats.row_by_id("blood_honey").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("value", 99)) < 5 + and int((_db.crossrun.feats.row_by_id("stone_skin").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("cap", 0)) == 3) # ④ 临时表注入(新 schema 形态——零 archetype/cap 直用):5 行 relic 入牌库/兑付/踢池/断点往返 var rt := FeatTable.new() rt.rows = [ @@ -524,7 +513,7 @@ func _smoke_feats() -> void: for t in a_r._curio.deck: if String((t as Dictionary).get("kind", "")) == "relic": deck_relic += 1 - Kit.check("专长带入池:5 行 relic 每件 1 张(四级 rarity 直映射)——牌库 102+5=107", deck_relic == 5 and a_r.deck().deck.size() == 107) + Kit.check("专长带入池:5 行 relic 每件 1 张(四级 rarity 直映射)——牌库 102+5=107", deck_relic == 5 and a_r._curio.deck.size() == 107) # 渠道白名单断言(渠道轴定位:关卡完成渠道只出专长/节点渠道零专长) var feat_seen: Dictionary = {} var node_relic_seen := false @@ -558,12 +547,12 @@ func _smoke_feats() -> void: Kit.check("A5 专长面兑付受理(专长凭证开出候选可正常入持有集)", (a_t.feats_held() as Array).size() == feats_n + 1) # A6 保底:pity 直填 5 → 下次展示必出(第 6 次必出口径);限购满 → 恒不触发 a_t.stop()._deck.ticket_sold = 0 # A5 已兑付一枚专长凭证(sold=1=limit)——A6 段重置计数隔离验证 - a_t.stop()._deck.ticket_pity = int(a_t.db.adventure.feat_ticket_pity_step) + a_t.stop()._deck.ticket_pity = int(a_t.db.run.adventure.feat_ticket_pity_step) var rg_t: RandomNumberGenerator = a_t.rng.open_stream("reward_pick") Kit.check("A6 保底触发(pity=5 → 第 6 次展示必出——概率无关)", a_t.deck().feat_ticket_consider(rg_t)) - Kit.check("A6 触发后清零", a_t.deck().ticket_pity == 0) - a_t.stop()._deck.ticket_sold = int(a_t.db.adventure.feat_ticket_limit) - a_t.stop()._deck.ticket_pity = int(a_t.db.adventure.feat_ticket_pity_step) + Kit.check("A6 触发后清零", a_t.stop()._deck.ticket_pity == 0) + a_t.stop()._deck.ticket_sold = int(a_t.db.run.adventure.feat_ticket_limit) + a_t.stop()._deck.ticket_pity = int(a_t.db.run.adventure.feat_ticket_pity_step) Kit.check("A6 限购满不再触发(保底冻结)", not a_t.deck().feat_ticket_consider(rg_t)) a_t.stop()._deck.ticket_sold = 0 a_t.stop()._deck.ticket_pity = 0 @@ -604,13 +593,13 @@ func _smoke_feats() -> void: fx.rebuild(["geologist"], [], []) var geo_hits: Array = fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {"new_resource_type": true}) apply_fx.call(geo_hits) - Kit.check("地质学家:首开新类型集 1 点〔专长批 V3〕", _hits_kind(geo_hits, "counter_add").size() == 1) + Kit.check("地质学家:首开新类型集 1 点〔专长批 V3〕", orch._hits_of(geo_hits, "counter_add").size() == 1) EffectEngine.counter_add(orch.fx_host().counters_ctx, "geo_types", 2) # 手动基数补齐(row1 已落账 1+2=3——满 4 种的预评估现值) var geo_hits2: Array = fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {"new_resource_type": true}) apply_fx.call(geo_hits2) - Kit.check("地质学家:满 4 种(预评估现值 3+本格)→揭示 1 格·计数清零〔专长批 V3〕", _hits_kind(geo_hits2, "reveal_tiles").size() == 1 and EffectEngine.counter(orch.fx_host().counters_ctx, "geo_types") == 0) + Kit.check("地质学家:满 4 种(预评估现值 3+本格)→揭示 1 格·计数清零〔专长批 V3〕", orch._hits_of(geo_hits2, "reveal_tiles").size() == 1 and EffectEngine.counter(orch.fx_host().counters_ctx, "geo_types") == 0) var geo_hits3: Array = fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {"new_resource_type": false}) - Kit.check("地质学家反例:同类型重复首开不叠点〔专长批 V3〕", _hits_kind(geo_hits3, "counter_add").is_empty()) + Kit.check("地质学家反例:同类型重复首开不叠点〔专长批 V3〕", orch._hits_of(geo_hits3, "counter_add").is_empty()) fx.rebuild(["hard_hat"], [], []) apply_fx.call(fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {})) apply_fx.call(fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {})) @@ -624,29 +613,29 @@ func _smoke_feats() -> void: fx.rebuild(["skip_mining"], [], []) var sk_zero: Array = fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {"is_blank_zero": true}) apply_fx.call(sk_zero) - Kit.check("跳格采掘:空白 0 格攒 1 点〔专长批 V3〕", _hits_kind(sk_zero, "counter_add").size() == 1) + Kit.check("跳格采掘:空白 0 格攒 1 点〔专长批 V3〕", orch._hits_of(sk_zero, "counter_add").size() == 1) EffectEngine.counter_add(orch.fx_host().counters_ctx, "skip_pts", 1) # 手动基数补齐(已落账 1+1=2——满 3 点的预评估现值) var sk_full: Array = fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {"is_blank_zero": true}) apply_fx.call(sk_full) - Kit.check("跳格采掘:满 3 点→揭示 1 格〔专长批 V3〕", _hits_kind(sk_full, "reveal_tiles").size() == 1) - Kit.check("跳格采掘反例:普通开格不计点〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {"is_blank_zero": false}), "counter_add").is_empty()) + Kit.check("跳格采掘:满 3 点→揭示 1 格〔专长批 V3〕", orch._hits_of(sk_full, "reveal_tiles").size() == 1) + Kit.check("跳格采掘反例:普通开格不计点〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {"is_blank_zero": false}), "counter_add").is_empty()) fx.rebuild(["blast_accounting"], [], []) for i in 5: var ba_hits: Array = fx.dispatch(orch.fx_host().counters_ctx, "dig_paid", {"ap_cost": 1}) for h in ba_hits: if String(h.get("kind", "")) == "counter_add": EffectEngine.counter_add(orch.fx_host().counters_ctx, String(h.get("counter_id", "")), 1) - Kit.check("爆破会计:本轮挖格花费≥5→+1 GP〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "round_end", {}), "grant_gp").size() == 1) + Kit.check("爆破会计:本轮挖格花费≥5→+1 GP〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "round_end", {}), "grant_gp").size() == 1) fx.rebuild(["frontier_miner"], [], []) Kit.check("边陲矿工:边缘格产出 +1·中心格反例零产出〔专长批 V3〕", - _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {"is_edge_cell": true}), "grant_material").size() == 1 - and _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {"is_edge_cell": false}), "grant_material").is_empty()) + orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {"is_edge_cell": true}), "grant_material").size() == 1 + and orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {"is_edge_cell": false}), "grant_material").is_empty()) # 封印系 fx.rebuild(["seal_echo"], [], []) apply_fx.call(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {})) apply_fx.call(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {})) var se_hits: Array = fx.dispatch(orch.fx_host().counters_ctx, "round_end", {}) - Kit.check("封印回响:标中计数≥2→轮末返 1 AP(下轮 queue)〔专长批 V3〕", _hits_kind(se_hits, "grant_ap").size() == 1 and String((_hits_kind(se_hits, "grant_ap")[0] as Dictionary).get("mode", "")) == "queue") + Kit.check("封印回响:标中计数≥2→轮末返 1 AP(下轮 queue)〔专长批 V3〕", orch._hits_of(se_hits, "grant_ap").size() == 1 and String((orch._hits_of(se_hits, "grant_ap")[0] as Dictionary).get("mode", "")) == "queue") fx.rebuild(["seal_smith"], [], []) var sm_remove := 0 for i in 3: @@ -658,10 +647,10 @@ func _smoke_feats() -> void: Kit.check("封印匠:标中每第 3 次→中立移除恰一次(频率闸死)〔专长批 V3〕", sm_remove == 1) fx.rebuild(["seal_stake"], [], []) Kit.check("封印桩:标中本轮 AC +N(round 域——round_start 清零)〔专长批 V3〕", - _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}), "ac_bonus").size() == 1 - and String((_hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}), "ac_bonus")[0] as Dictionary).get("mode", "")) == "round") + orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}), "ac_bonus").size() == 1 + and String((orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}), "ac_bonus")[0] as Dictionary).get("mode", "")) == "round") fx.rebuild(["warning_beacon"], [], []) - Kit.check("示警烟火:标中揭示 3×3 资源类型(不揭安危位置)〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}), "preview_area").size() == 1) + Kit.check("示警烟火:标中揭示 3×3 资源类型(不揭安危位置)〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}), "preview_area").size() == 1) # 踩雷/受伤系 fx.rebuild(["pain_memory"], [], []) apply_fx.call(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {})) @@ -677,13 +666,13 @@ func _smoke_feats() -> void: for h in fw_hits: if String(h.get("kind", "")) == "shield": fw_shield += int(h.get("value", 0)) - Kit.check("保险丝反例:单次 <5 不攒点不触发〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "damage_taken", {"kind": "terrain", "amount": 3}), "shield").is_empty()) + Kit.check("保险丝反例:单次 <5 不攒点不触发〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "damage_taken", {"kind": "terrain", "amount": 3}), "shield").is_empty()) for h in fx.dispatch(orch.fx_host().counters_ctx, "damage_taken", {"kind": "terrain", "amount": 6}): if String(h.get("kind", "")) == "counter_add": EffectEngine.counter_add(orch.fx_host().counters_ctx, String(h.get("counter_id", "")), int(h.get("value", 0))) Kit.check("保险丝:首点不出盾(2 点才出——预评估口径)〔专长批 V3〕", fw_shield == 0) fx.rebuild(["blood_insight"], [], []) - Kit.check("见血开窍:踩雷揭示 1 格〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {}), "reveal_tiles").size() == 1) + Kit.check("见血开窍:踩雷揭示 1 格〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {}), "reveal_tiles").size() == 1) fx.rebuild(["trap_instinct"], [], []) apply_fx.call(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {})) var ti_hits: Array = fx.dispatch(orch.fx_host().counters_ctx, "attack_commit", {"atk": 10, "target": "敌", "matchup": "", "dig_streak": 0, "nest_count": 0}) @@ -699,67 +688,67 @@ func _smoke_feats() -> void: fx.rebuild(["trap_burst"], [], []) var tb_hits: Array = fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {"damage": 8}) Kit.check("陷阱爆发:全体敌雷伤×倍率直伤(blast_scaled——施加面值=damage×mult)〔专长批 V3〕", - _hits_kind(tb_hits, "blast_scaled").size() == 1 - and float((_hits_kind(tb_hits, "blast_scaled")[0] as Dictionary).get("mult", 0)) > 1.0) + orch._hits_of(tb_hits, "blast_scaled").size() == 1 + and float((orch._hits_of(tb_hits, "blast_scaled")[0] as Dictionary).get("mult", 0)) > 1.0) fx.rebuild(["blood_honey"], [], []) - Kit.check("血蜜:踩雷回复 N 生命〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {}), "heal").size() == 1) + Kit.check("血蜜:踩雷回复 N 生命〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {}), "heal").size() == 1) fx.rebuild(["stone_skin"], [], []) var ss_hits: Array = fx.dispatch(orch.fx_host().counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 1}) Kit.check("石肤:受伤 AC+1(本场域 cap 3——达上限不再涨由累积器封顶)〔专长批 V3〕", - _hits_kind(ss_hits, "ac_bonus").size() == 1 - and int((_hits_kind(ss_hits, "ac_bonus")[0] as Dictionary).get("cap", 0)) == 3) + orch._hits_of(ss_hits, "ac_bonus").size() == 1 + and int((orch._hits_of(ss_hits, "ac_bonus")[0] as Dictionary).get("cap", 0)) == 3) fx.rebuild(["last_stand"], [], []) Kit.check("背水:生命≤50% 减伤·过半血反例零减伤〔专长批 V3〕", - _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 1, "hp_ratio": 40}), "damage_reduce").size() == 1 - and _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 2, "hp_ratio": 80}), "damage_reduce").is_empty()) + orch._hits_of(fx.dispatch(orch.fx_host().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.fx_host().counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 2, "hp_ratio": 80}), "damage_reduce").is_empty()) # 跨族桥 fx.rebuild(["breaker_strike"], [], []) Kit.check("破封一击:挖开带封印危险格 +1 层·无封印反例零触发〔专长批 V3〕", - _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {"dug_under_seal": true}), "counter_add").size() == 1 - and _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {"dug_under_seal": false}), "counter_add").is_empty()) + orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {"dug_under_seal": true}), "counter_add").size() == 1 + and orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {"dug_under_seal": false}), "counter_add").is_empty()) # 节奏系 fx.rebuild(["map_exchange"], [], []) var me_hits: Array = fx.dispatch(orch.fx_host().counters_ctx, "dig_stop", {"ap_left": 4}) apply_fx.call(me_hits) EffectEngine.counter_add(orch.fx_host().counters_ctx, "stop_swap", 4) # value_event="ap_left" 解析归施加面(action_economy 载荷取值)——冒烟手动落账 - Kit.check("弃图兑换:停手剩余 AP≥3 入账〔专长批 V3〕", _hits_kind(me_hits, "counter_add").size() == 1) + Kit.check("弃图兑换:停手剩余 AP≥3 入账〔专长批 V3〕", orch._hits_of(me_hits, "counter_add").size() == 1) var me_atk := 0 for h in orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "attack_commit", {"atk": 10, "target": "敌", "matchup": "", "dig_streak": 0, "nest_count": 0}), "attack_bonus"): me_atk = EffectEngine.counter(orch.fx_host().counters_ctx, String(h.get("value_counter", ""))) Kit.check("弃图兑换:折为攻击伤害=剩余 AP 全额·用后清零〔专长批 V3〕", me_atk == 4) - Kit.check("弃图兑换反例:剩余 AP<3 不触发〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "dig_stop", {"ap_left": 2}), "counter_add").is_empty()) + Kit.check("弃图兑换反例:剩余 AP<3 不触发〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "dig_stop", {"ap_left": 2}), "counter_add").is_empty()) fx.rebuild(["last_drop"], [], []) var ld_hits: Array = fx.dispatch(orch.fx_host().counters_ctx, "round_start", {"round": 2, "ap_exhausted_last": true}) - Kit.check("一滴不剩:上轮 AP 耗尽→+1 AP〔专长批 V3〕", _hits_kind(ld_hits, "grant_ap").size() == 1) - Kit.check("一滴不剩反例:上轮有余不发〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "round_start", {"round": 3, "ap_exhausted_last": false}), "grant_ap").is_empty()) + Kit.check("一滴不剩:上轮 AP 耗尽→+1 AP〔专长批 V3〕", orch._hits_of(ld_hits, "grant_ap").size() == 1) + Kit.check("一滴不剩反例:上轮有余不发〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "round_start", {"round": 3, "ap_exhausted_last": false}), "grant_ap").is_empty()) fx.rebuild(["market_close"], [], []) for i in 8: for h in fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {}): if String(h.get("kind", "")) == "counter_add": EffectEngine.counter_add(orch.fx_host().counters_ctx, String(h.get("counter_id", "")), 1) - Kit.check("收盘:本轮开格≥8→+1 GP〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "round_end", {}), "grant_gp").size() == 1) + Kit.check("收盘:本轮开格≥8→+1 GP〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "round_end", {}), "grant_gp").size() == 1) # 战斗系 fx.rebuild(["execution_scholar"], [], []) - Kit.check("斩首学者:击杀揭示 1 格〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "kill", {"target": "敌", "overflow": 0}), "reveal_tiles").size() == 1) + Kit.check("斩首学者:击杀揭示 1 格〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "kill", {"target": "敌", "overflow": 0}), "reveal_tiles").size() == 1) fx.rebuild(["executioner"], [], []) var ex_hits: Array = fx.dispatch(orch.fx_host().counters_ctx, "enemy_hp_threshold", {"unit": "e1", "pct": 30, "is_boss": false}) Kit.check("处刑台:敌血量≤30% 挂单位标记加伤·阈值外反例零命中〔专长批 V3〕", - _hits_kind(ex_hits, "target_attack_bonus").size() == 1 - and _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "enemy_hp_threshold", {"unit": "e1", "pct": 60, "is_boss": false}), "target_attack_bonus").is_empty()) + orch._hits_of(ex_hits, "target_attack_bonus").size() == 1 + and orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "enemy_hp_threshold", {"unit": "e1", "pct": 60, "is_boss": false}), "target_attack_bonus").is_empty()) fx.rebuild(["field_dressing"], [], []) - Kit.check("战地包扎:战斗胜利回复 N 生命(battle_won 新挂点)〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "battle_won", {"round": 2}), "heal").size() == 1) + Kit.check("战地包扎:战斗胜利回复 N 生命(battle_won 新挂点)〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "battle_won", {"round": 2}), "heal").size() == 1) # 契约尖点 fx.rebuild(["blood_tax"], [], []) Kit.check("血税:双向同局账本(踩陷阱−2max_hp/封印命中+1atk——bonus_delta 战末汇入)〔专长批 V3〕", - _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {}), "bonus_delta").size() == 1 - and String((_hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}), "bonus_delta")[0] as Dictionary).get("axis", "")) == "atk") + orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {}), "bonus_delta").size() == 1 + and String((orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}), "bonus_delta")[0] as Dictionary).get("axis", "")) == "atk") # 迁入 2 件 fx.rebuild(["venom_edge"], [], []) Kit.check("淬毒刃纹(迁入):克制正面(magic_vs_heavy 矩阵值>0)追加·中性面反例零触发〔专长批 V3〕", - _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "clamp_check", {"matchup": "magic_vs_heavy"}), "modify_ticket").size() == 1 - and _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "clamp_check", {"matchup": "normal_vs_light"}), "modify_ticket").is_empty()) + orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "clamp_check", {"matchup": "magic_vs_heavy"}), "modify_ticket").size() == 1 + and orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "clamp_check", {"matchup": "normal_vs_light"}), "modify_ticket").is_empty()) fx.rebuild(["seal_burst"], [], []) - Kit.check("封印爆裂(迁入):标中全体敌群伤〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}), "open_ticket").size() == 1) + Kit.check("封印爆裂(迁入):标中全体敌群伤〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}), "open_ticket").size() == 1) # 零持有 var orch0 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(74)) orch0.fx_host().rebuild_subscriptions([], [], []) @@ -910,4 +899,4 @@ func _fresh_rng(seed_val: int): # 生成图取数面(图 = f(关卡行参数档, 种子 42)——与 runner 起关同图;节点键 id/type/depth/next) func _level_active_nodes(level_index: int) -> Array: var gen = NodeMapGenerator.new(_fresh_rng(42)) - return (gen.generate(_db.level_gen_params_row(level_index), 42) as Dictionary).get("nodes", []) + return (gen.generate(_db.run.level_gen_params.row_by_level_index(level_index), 42) as Dictionary).get("nodes", []) diff --git a/game/tests/unit/core/board_gen_reward_test.gd b/game/tests/unit/core/board_gen_reward_test.gd index 64c285ca..4e481a60 100644 --- a/game/tests/unit/core/board_gen_reward_test.gd +++ b/game/tests/unit/core/board_gen_reward_test.gd @@ -105,7 +105,7 @@ func _smoke_reward_identity_unit() -> void: var cs2: Dictionary = reward_economy.grant_clear() Kit.check("清图批尾身份 = clear_blast(clear_default 唯一活行)", String(cs1.cell_type) == "blast" and String(cs1.reward_id) == "clear_blast") var cb_range := [-1, -1] - for row in _db.reward.rows: + for row in _db.run.reward.rows: if String(row.reward_id) == "clear_blast": cb_range = [int(row.get("power_min", row.power)), int(row.get("power_max", row.power))] Kit.check("清图奖励 power ∈ 表区间 [%d, %d](roll 值 %d——配置推导)" % [cb_range[0], cb_range[1], int(cs1.power)], @@ -120,9 +120,13 @@ func _smoke_reward_identity_unit() -> void: # 板行覆盖桩(cap 压线行为断言;接口面 = resolve_board_gen 单点) class _BoardRowStub: - var row: Dictionary - func resolve_board_gen(_L: int, _D: int) -> Dictionary: - return row + class _StubBoardGen: + var row: Dictionary + func resolve_row(_L: int, _D: int) -> Dictionary: + return row + class _StubCombat: + var board_gen := _StubBoardGen.new() + var combat := _StubCombat.new() func status_effect_row(_id: String) -> Dictionary: return {} @@ -131,27 +135,27 @@ 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) + var row = _db.combat.board_gen.resolve_row(L, 0) Kit.check("BoardGen 行解析 (%d,0)" % L, not row.is_empty()) if L == 2: # 迭代 35 生成图深度扩面覆盖批随动(声明):旧口径「(2,3) 无精确行→ceil 上取到 4」的判据对象 # 是模板时代键域缺口;覆盖批补齐 0–13 后 (2,3) 有精确行,生产表上不再存在可走 ceil 的空位。 # 判据不丢——精确命中改判在册,ceil 能力用「临时摘除该行」的夹具面保留,随后原样还原。 - var exact_row: Dictionary = _db.resolve_board_gen(2, 3) + var exact_row: Dictionary = _db.combat.board_gen.resolve_row(2, 3) Kit.check("BoardGen 精确命中 (2,3)(覆盖批键域补齐)", int(exact_row.get("node_depth", -1)) == 3) var gap_idx := -1 - for i in _db.board_gen.rows.size(): - if int(_db.board_gen.rows[i].get("level_index", -1)) == 2 and int(_db.board_gen.rows[i].get("node_depth", -1)) == 3: + for i in _db.combat.board_gen.rows.size(): + if int(_db.combat.board_gen.rows[i].get("level_index", -1)) == 2 and int(_db.combat.board_gen.rows[i].get("node_depth", -1)) == 3: gap_idx = i break - var saved: Dictionary = _db.board_gen.rows[gap_idx] - var rows_before: int = _db.board_gen.rows.size() - _db.board_gen.rows.remove_at(gap_idx) - var ceil_row: Dictionary = _db.resolve_board_gen(2, 3) + var saved: Dictionary = _db.combat.board_gen.rows[gap_idx] + var rows_before: int = _db.combat.board_gen.rows.size() + _db.combat.board_gen.rows.remove_at(gap_idx) + var ceil_row: Dictionary = _db.combat.board_gen.resolve_row(2, 3) Kit.check("R1 ceil 能力不失(夹具摘除 (2,3)→向上取整到 4·偏难口径)", int(ceil_row.get("node_depth", -1)) == 4) - _db.board_gen.rows.insert(gap_idx, saved) + _db.combat.board_gen.rows.insert(gap_idx, saved) Kit.check("夹具表原样还原(行数与解析回到覆盖批现值)", - _db.board_gen.rows.size() == rows_before and int(_db.resolve_board_gen(2, 3).get("node_depth", -1)) == 3) + _db.combat.board_gen.rows.size() == rows_before and int(_db.combat.board_gen.resolve_row(2, 3).get("node_depth", -1)) == 3) var b1 = board_generator.generate(L, 0, _rng.open_stream("board_gen"), _db) Kit.check("L%d 生成产物非空" % L, b1 != null) if b1 == null: @@ -172,6 +176,8 @@ func _smoke_board_generator() -> void: b1.degraded_product == (mode == "accept_best")) Kit.check("L%d 非法产物标记 = (accept_mode==accept_worst)(%s→%s)" % [L, mode, str(b1.illegal_product)], b1.illegal_product == (mode == "accept_worst")) + Kit.check("L%d 降级/非法产物标记互斥(两出口不可同真)" % L, + not b1.degraded_product or not b1.illegal_product) Kit.check("L%d 产物断言:实际雷数 %d ≤ 目标雷数 N=%d(先布后删只减不增)" % [L, int(b1.metrics.actual_mines), int(b1.metrics.target_mines)], int(b1.metrics.actual_mines) <= int(b1.metrics.target_mines)) Kit.check("L%d attempts_used ∈ [1,%d]" % [L, int(row.max_gen_attempts)], @@ -200,7 +206,7 @@ func _smoke_board_generator() -> void: row9["r0_max_cap"] = BoardData.SPAWN_AREA_CELLS row9["max_gen_attempts"] = 4 var stub9 := _BoardRowStub.new() - stub9.row = row9 + stub9.combat.board_gen.row = row9 var b9 = board_generator.generate(1, 0, _mk_stream("board_gen", 909), stub9) Kit.check("L1 cap 压线 9:出图 R₀=%d %s(三分出口诚实降级——超阈即 accept_worst 非法标记,玩家永不卡死)" % [int(b9.metrics.r0_count), "≤9" if int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS else ">9→illegal_product"], b9 != null and (int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS or b9.illegal_product)) @@ -227,7 +233,7 @@ func _smoke_power_interval() -> void: # 行清单与区间值全部随表走——现行区间行 heal_minor/clear_blast 只是恰然,不再是锚。 var interval_rows := {} # rid → [lo, hi](读表) var anchors_ok := true - for row in _db.reward.rows: + for row in _db.run.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)) @@ -297,7 +303,7 @@ func _smoke_power_interval() -> void: func _row_power(rid: String) -> int: - for row in _db.reward.rows: + for row in _db.run.reward.rows: if String(row.reward_id) == rid: return int(row.power) return -1 diff --git a/game/tests/unit/core/board_generator_test.gd b/game/tests/unit/core/board_generator_test.gd index 3e7cc062..e4340644 100644 --- a/game/tests/unit/core/board_generator_test.gd +++ b/game/tests/unit/core/board_generator_test.gd @@ -41,7 +41,7 @@ func _test_generate_shape_and_mines() -> void: if b == null: Kit.check("生成返回棋盘", false) return - var row: Dictionary = _db.resolve_board_gen(1, 0) + var row: Dictionary = _db.combat.board_gen.resolve_row(1, 0) var want_w: int = int(row.get("board_width", 0)) var want_h: int = int(row.get("board_height", 0)) Kit.check("棋盘尺寸与 board_gen 表一致(%dx%d)" % [want_w, want_h], @@ -60,7 +60,7 @@ func _test_target_mine_count_single_source() -> void: var bad := 0 var checked := 0 for L in [1, 2, 3]: - var row: Dictionary = _db.resolve_board_gen(L, 0) + var row: Dictionary = _db.combat.board_gen.resolve_row(L, 0) if row.is_empty(): continue var params := { @@ -77,7 +77,7 @@ func _test_target_mine_count_single_source() -> void: if b == null: Kit.check("生成返回棋盘(雷数单点段)", false) return - var row1: Dictionary = _db.resolve_board_gen(1, 0) + var row1: Dictionary = _db.combat.board_gen.resolve_row(1, 0) Kit.check("产物断言读数 = target_mine_count 同源(metrics.target_mines)〔wd14〕", int(b.metrics.get("target_mines", -1)) == BoardGeneratorScript.target_mine_count({ "w": int(row1.board_width), "h": int(row1.board_height), "density": float(row1.mine_density)})) diff --git a/game/tests/unit/core/breakpoint_resume_test.gd b/game/tests/unit/core/breakpoint_resume_test.gd index 14e0b478..ec28fabe 100644 --- a/game/tests/unit/core/breakpoint_resume_test.gd +++ b/game/tests/unit/core/breakpoint_resume_test.gd @@ -110,13 +110,13 @@ func _init() -> void: Kit.check_quiet(adr_f.resume_adventure(bp_f, params_e), "P5 续玩受理(选择态分支)") Kit.check_quiet(adr_f.runner() == null, "P5 续玩不重跑关卡(三选一停点直回,_start_current_level 未走)") Kit.check_quiet(got_cands["cands"] == cands_e, "P5 续玩候选不重抽(逐位一致)") - Kit.check_quiet(int(adr_f.deck().taken["attack"]) == 1, "P5 次数计数恢复一致(牌库剔除态续接)") + Kit.check_quiet(int(adr_f._curio.taken["attack"]) == 1, "P5 次数计数恢复一致(牌库剔除态续接)") Kit.check_quiet(not adr_f.continue_to_next_level(), "P5 未兑付推进拒绝(续玩态零自动推进同契约)") var pick_e: Dictionary = cands_e[0] Kit.check_quiet(adr_f.stop().pick_curio(String(pick_e.get("kind", "")), String(pick_e.get("tier", "")), String(pick_e.get("feat_id", ""))), "P5 点选兑付受理") var picked_e: String = String(adr_f.stop()._resolved.get("picked", "")) - if adr_f.deck().taken.has(picked_e): - Kit.check_quiet(int(adr_f.deck().taken[picked_e]) == (2 if picked_e == "attack" else 1), + if adr_f._curio.taken.has(picked_e): + Kit.check_quiet(int(adr_f._curio.taken[picked_e]) == (2 if picked_e == "attack" else 1), "P5 兑付计数一致(%s——预置 1 + 本次兑付)" % picked_e) Kit.check_quiet(sm_f.has_breakpoint(), "P5 兑付后停点快照在档(已兑付待推进态可中断)") var bp_g: Dictionary = sm_f.breakpoint_snapshot() diff --git a/game/tests/unit/core/config_db_cross_validator_test.gd b/game/tests/unit/core/config_db_cross_validator_test.gd index 8ad71acb..e2b7731c 100644 --- a/game/tests/unit/core/config_db_cross_validator_test.gd +++ b/game/tests/unit/core/config_db_cross_validator_test.gd @@ -67,6 +67,7 @@ func _test_dangling_skill_id() -> void: {"skill_id": "attack", "weight": 9}, {"skill_id": "not_in_table", "weight": 3}]}, ]) var errs: Array = ValidatorScript.new().validate_skill_pool_links(skills, enemies, null) + Kit.check("池内悬空 skill_id 被报(跨表护栏不静默放过)", not errs.is_empty()) Kit.check("悬空项的错误类别可定位(category 非空)", not errs.is_empty() and String((errs[0] as Dictionary).get("category", "")) != "") diff --git a/game/tests/unit/core/config_db_resolver_test.gd b/game/tests/unit/core/config_db_resolver_test.gd index ce8e97ae..ebec43b2 100644 --- a/game/tests/unit/core/config_db_resolver_test.gd +++ b/game/tests/unit/core/config_db_resolver_test.gd @@ -54,16 +54,17 @@ func _difficulty_table(): func _test_band_hit() -> void: - var hit: Array = _resolver.resolve_enemy_rowset(1, 4, "battle") + var hit: Array = _resolver.combat.enemy.resolve_rowset(1, 4, "battle") Kit.check("带命中取偏难一侧:请求深度 4 命中带 5(逐字不回带 0)〔fv1〕", hit.size() == 1 and String((hit[0] as Dictionary).get("enemy_id", "")) == "hobgoblin") - var exact: Array = _resolver.resolve_enemy_rowset(1, 5, "battle") + var exact: Array = _resolver.combat.enemy.resolve_rowset(1, 5, "battle") Kit.check("精确落带:请求深度 5 命中带 5〔fv1〕", exact.size() == 1 and String((exact[0] as Dictionary).get("enemy_id", "")) == "hobgoblin") func _test_rowset_order_preserved() -> void: - var rows: Array = _resolver.resolve_enemy_rowset(1, 0, "battle") + var rows: Array = _resolver.combat.enemy.resolve_rowset(1, 0, "battle") + Kit.check("行集返回同带全部行(2 行)〔fv2〕", rows.size() == 2) var ids: Array[String] = [] for r in rows: ids.append(String((r as Dictionary).get("enemy_id", ""))) @@ -73,7 +74,7 @@ func _test_rowset_order_preserved() -> void: func _test_top_band_clamp() -> void: _diags.clear() - var rows: Array = _resolver.resolve_enemy_rowset(1, 99, "battle") + var rows: Array = _resolver.combat.enemy.resolve_rowset(1, 99, "battle") Kit.check("越顶带 clamp 取顶带行集(battle 顶带 5)〔fv3〕", rows.size() == 1 and String((rows[0] as Dictionary).get("enemy_id", "")) == "hobgoblin") var clamp_hit := false @@ -82,13 +83,13 @@ func _test_top_band_clamp() -> void: clamp_hit = true Kit.check("越顶带发 warning resolve_depth_clamp〔fv3〕", clamp_hit) var warn_before: int = _diags.size() - _resolver.resolve_enemy_rowset(1, 99, "battle") + _resolver.combat.enemy.resolve_rowset(1, 99, "battle") Kit.check("同键重复请求警告去重(四元组注册表)〔fv3〕", _diags.size() == warn_before) func _test_row_kind_routing() -> void: - var battle_rows: Array = _resolver.resolve_enemy_rowset(1, 0, "battle") - var boss_rows: Array = _resolver.resolve_enemy_rowset(1, 0, "boss") + var battle_rows: Array = _resolver.combat.enemy.resolve_rowset(1, 0, "battle") + var boss_rows: Array = _resolver.combat.enemy.resolve_rowset(1, 0, "boss") Kit.check("行类路由:同请求深度 battle 与 boss 命中不同键域〔fv4〕", battle_rows.size() == 2 and boss_rows.size() == 1 and String((boss_rows[0] as Dictionary).get("enemy_id", "")) == "ogre_chief") @@ -96,13 +97,13 @@ func _test_row_kind_routing() -> void: func _test_request_oob() -> void: _diags.clear() - _resolver.resolve_enemy_rowset(0, 0, "battle") + _resolver.combat.enemy.resolve_rowset(0, 0, "battle") Kit.check("关卡序号 <1 → fatal request_oob〔fv5〕", _has_fatal_oob()) _diags.clear() - _resolver.resolve_enemy_rowset(1, -1, "battle") + _resolver.combat.enemy.resolve_rowset(1, -1, "battle") Kit.check("节点深度 <0 → fatal request_oob〔fv5〕", _has_fatal_oob()) _diags.clear() - _resolver.resolve_enemy_rowset(1, 0, "elite") + _resolver.combat.enemy.resolve_rowset(1, 0, "elite") Kit.check("row_kind 非法 → fatal request_oob〔fv5〕", _has_fatal_oob()) @@ -114,11 +115,11 @@ func _has_fatal_oob() -> bool: func _test_difficulty_clamp() -> void: - var m: Dictionary = _resolver.resolve_difficulty_modifiers(1, 0) + var m: Dictionary = _resolver.run.difficulty.resolve_modifiers(1, 0) Kit.check("难度乘数越域被 clamp:hp 99 → 上限 10", absf(float(m.get("hp_mult", 0.0)) - 10.0) < 0.001) Kit.check("难度乘数越域被 clamp:atk 0.01 → 下限 0.1", absf(float(m.get("atk_mult", 0.0)) - 0.1) < 0.001) Kit.check("未登记坐标返回空修饰(零值不伪装成 ×1)", - _resolver.resolve_difficulty_modifiers(3, 3).is_empty()) + _resolver.run.difficulty.resolve_modifiers(3, 3).is_empty()) class _FakeDifficulty: diff --git a/game/tests/unit/core/curio_refresh_paid_test.gd b/game/tests/unit/core/curio_refresh_paid_test.gd index 527ad078..4d9558f4 100644 --- a/game/tests/unit/core/curio_refresh_paid_test.gd +++ b/game/tests/unit/core/curio_refresh_paid_test.gd @@ -13,8 +13,8 @@ func _init() -> void: print("[curio_refresh_paid_test] FAIL — 数值表加载失败") quit(1) return - var base: int = int(db.adventure.shop_refresh_cost_base) - var curve: float = float(db.adventure.shop_refresh_cost_curve) + var base: int = int(db.run.adventure.shop_refresh_cost_base) + var curve: float = float(db.run.adventure.shop_refresh_cost_curve) Kit.check_quiet(base > 0 and curve > 1.0, "商店刷新旋钮在档(base=%d curve=%.2f——三选一付费档同源读取)" % [base, curve]) # ---- ① 停点在途谓词:无停点时免费/付费刷新皆拒绝(付费路径扣费前置校验防空转烧钱包)---- @@ -28,21 +28,20 @@ func _init() -> void: Kit.check_quiet(adr.stop().curio_stop_pending() == true, "关卡完成停点在途=谓词 true") adr.refresh_bank()._refresh_x = 1 adr.refresh_bank()._refresh_y = 2 - Kit.check_quiet(adr.stop().refresh_curio() and adr.refresh_bank().refresh_y() == 2 - and adr.refresh_bank().free_left() == adr.refresh_bank().refresh_y() + adr.refresh_bank().refresh_stock(), "X 优先消耗(x1y2→x0y2)") - Kit.check_quiet(adr.stop().refresh_curio() and adr.refresh_bank().refresh_y() == 1, "X 穷转 Y 池(y2→y1)") + Kit.check_quiet(adr.stop().refresh_curio() and adr.refresh_bank()._refresh_x == 0 and adr.refresh_bank()._refresh_y == 2, "X 优先消耗(x1y2→x0y2)") + Kit.check_quiet(adr.stop().refresh_curio() and adr.refresh_bank()._refresh_y == 1, "X 穷转 Y 池(y2→y1)") # ---- ③ GP 付费档:计数递增爬价 + 零触碰免费位 ---- Kit.check_quiet(adr.refresh_bank().gp_cost() == base, "首刷报价=基价(免费位尚余 y1,报价独立于免费位)") - Kit.check_quiet(adr.stop().refresh_curio_paid() and adr.refresh_bank().refresh_y() == 1, "付费刷新不触碰免费位(y1 保持)") + Kit.check_quiet(adr.stop().refresh_curio_paid() and adr.refresh_bank()._refresh_y == 1, "付费刷新不触碰免费位(y1 保持)") Kit.check_quiet(adr.refresh_bank().gp_cost() == int(ceil(float(base) * curve)), "二刷价爬升=ceil(基价×曲线)(已付费刷=1)") Kit.check_quiet(adr.stop().refresh_curio_paid() and adr.refresh_bank().gp_cost() == int(ceil(float(base) * curve * curve)), "三刷价再爬升=ceil(基价×曲线²)(已付费刷=2)") # ---- ④ 停点重建清零付费计数(对齐商店进店清零——不结转)---- adr.stop().present_node_curio(_cands()) - Kit.check_quiet(adr.refresh_bank().gp_cost() == base, "节点停点重建=付费计数清零回基价") + Kit.check_quiet(adr.refresh_bank()._paid_refreshes == 0 and adr.refresh_bank().gp_cost() == base, "节点停点重建=付费计数清零回基价") adr.stop().refresh_curio_paid() - Kit.check_quiet(adr.refresh_bank().gp_cost() == int(ceil(float(base) * curve)), "付费刷新计数在途递增(=1)") + Kit.check_quiet(adr.refresh_bank()._paid_refreshes == 1, "付费刷新计数在途递增(=1)") adr.stop().present_node_curio(_cands()) Kit.check_quiet(adr.refresh_bank().gp_cost() == base, "再碰三选一付费计数仍清零(同 X 余量不结转口径)") @@ -57,10 +56,10 @@ func _init() -> void: var bp_y := {"level_index": 1, "hero_hp": 10, "attune_refresh_y": 2, "graph_gen_v2": 1} var adr_r = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new()) Kit.check_quiet(adr_r.resume_adventure(bp_y, {"hero_override": {}, "attune_refresh_y": 3}), "续玩受理(最小断点+参数面)") - Kit.check_quiet(adr_r.refresh_bank().refresh_y() == 2, "断点有键=Y 余量随断点还原(花 1 剩 2——不再整额重注入=满血复活堵死)") + Kit.check_quiet(adr_r.refresh_bank()._refresh_y == 2, "断点有键=Y 余量随断点还原(花 1 剩 2——不再整额重注入=满血复活堵死)") var adr_o = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new()) Kit.check_quiet(adr_o.resume_adventure({"level_index": 1, "hero_hp": 10, "graph_gen_v2": 1}, {"hero_override": {}, "attune_refresh_y": 3}), "旧断点续玩受理(快照缺键)") - Kit.check_quiet(adr_o.refresh_bank().refresh_y() == 3, "旧断点缺键=回退参数面整额注入(旧档行为零变化)") + Kit.check_quiet(adr_o.refresh_bank()._refresh_y == 3, "旧断点缺键=回退参数面整额注入(旧档行为零变化)") quit(Kit.finish("curio_refresh_paid")) diff --git a/game/tests/unit/core/feat_vouchers_test.gd b/game/tests/unit/core/feat_vouchers_test.gd index 0a78989e..a5dec54e 100644 --- a/game/tests/unit/core/feat_vouchers_test.gd +++ b/game/tests/unit/core/feat_vouchers_test.gd @@ -32,9 +32,8 @@ func _init() -> void: # V9-1 刷新券:入存量账(冒险级)——反例=写进 _attune_refresh_x(X 每停点重置会吞存量) a.refresh_bank().reset_x() # X 归零基线(模拟新停点) - a.stop()._pending = {"candidates": [{"kind": "refresh", "tier": "excellent"}]} # 停点候选直写(构造面——pick_curio 真实入口前置) - Kit.check_quiet(a.stop().pick_curio("refresh", "excellent"), "刷新券兑付受理〔专长批 V9〕") - Kit.check_quiet(a.refresh_bank().refresh_stock() == 1 and a.refresh_bank().free_left() == a.refresh_bank().refresh_stock() + a.refresh_bank().refresh_y(), + Kit.check_quiet(a.stop()._apply_curio_pick("refresh", "excellent", 1), "刷新券兑付受理〔专长批 V9〕") + Kit.check_quiet(a.refresh_bank().refresh_stock() == 1 and a.refresh_bank()._refresh_x == 0, "F-1 修复:刷新券入存量账、X 账零写入(写错账反例不复发)〔专长批 V9〕") a.refresh_bank().reset_x() # 再碰停点——X 重置但存量不吞 Kit.check_quiet(a.refresh_bank().refresh_stock() == 1 and a.refresh_bank().free_left() == 1, @@ -51,50 +50,44 @@ func _init() -> void: Kit.check_quiet(deck_refresh_before == 0 or true, "牌库 refresh 令牌读数在域(投放 %d)" % deck_refresh_before) # V9-2 储备金袋:待注入账 + 战首注入(节点级) - a.stop()._pending = {"candidates": [{"kind": "rap", "tier": "rare"}]} # 停点候选直写(构造面——pick_curio 真实入口前置) - Kit.check_quiet(a.stop().pick_curio("rap", "rare"), "储备金袋兑付受理〔专长批 V9〕") + Kit.check_quiet(a.stop()._apply_curio_pick("rap", "rare", 3), "储备金袋兑付受理〔专长批 V9〕") Kit.check_quiet(a.stop()._pending_rap == 3, "储备金袋:待注入 RAP +3(节点级账)〔专长批 V9〕") # V9-3 时沙:下一战首轮 AP 队列(回合级池增量) - a.stop()._pending = {"candidates": [{"kind": "time_sand", "tier": "rare"}]} # 停点候选直写(构造面——pick_curio 真实入口前置) - Kit.check_quiet(a.stop().pick_curio("time_sand", "rare"), "时沙兑付受理〔专长批 V9〕") + Kit.check_quiet(a.stop()._apply_curio_pick("time_sand", "rare", 2), "时沙兑付受理〔专长批 V9〕") Kit.check_quiet(a.stop()._time_sand_queue == 2, "时沙:首轮 AP 队列 +2(池增量非上限)〔专长批 V9〕") # V23 两件出池(R1——同调电池删除/完美保险迁专长) - Kit.check_quiet(not db.adventure.CURIO_KINDS.has("attune_battery") - and not db.adventure.CURIO_KINDS.has("perfect_insurance"), + Kit.check_quiet(not db.run.adventure.CURIO_KINDS.has("attune_battery") + and not db.run.adventure.CURIO_KINDS.has("perfect_insurance"), "出池词表:CURIO_KINDS 零两件(三选一池物理不可再出)〔卡② R1〕") - Kit.check_quiet(not a.stop().consume_pool_purchase("attune_battery", "rare") - and not a.stop().consume_pool_purchase("perfect_insurance", "rare"), + Kit.check_quiet(not a.stop()._apply_curio_pick("attune_battery", "rare", 1) + and not a.stop()._apply_curio_pick("perfect_insurance", "rare", 1), "出池反例:两件兑付不再受理(牌库无令牌=拒绝零副作用)〔卡② R1〕") Kit.check_quiet(not a.has_method("choose_battery_target") and not a.has_method("battery_pending"), "电池符号清零:runner 无点选函数与 getter(编译级孤儿面)〔卡② R1〕") - Kit.check_quiet(int(db.adventure.curio_cap("attune_battery")) == 99, + Kit.check_quiet(int(db.run.adventure.curio_cap("attune_battery")) == 99, "出池后未知 kind 走分发表 default(cap 读数兜底 99——零崩溃面)〔卡② R1〕") # 账本保留(契约 1):主动件充能与每冒险种行入口零改动(单源化后=差额种行幂等——不吞装配) a._attunement = ["incense_stick"] a._loadout.set_attunement(["incense_stick"]) a._loadout_seeded = false # 模拟首关 bind 时点(分派走 seed_for_adventure 分支) a._loadout_on_level_inventory() # 种行(首关 bind 时点语义) - var incense_left := 0 - for r in a.runner().treasure_inventory.snapshot_inventory(): - if String(r.get("item_id", "")) == "incense_stick" and String(r.get("source_class", "")) == "attunement" and not bool(r.get("used", false)): - incense_left += 1 - Kit.check_quiet(incense_left == 1, + Kit.check_quiet(a._loadout.charges_left("incense_stick", a.runner().treasure_inventory) == 1, "账本保留:种行按装备表值投行(线香 battle 级 1——attune_charges 语义零变化)〔卡② R1〕") # V24 配额净删锚(R3——「净删」口径,rare 9→7/总量 104→102) var rare_sum := 0 var rare_kinds: Array[String] = [] - for k in db.adventure.CURIO_KINDS: - var q := int(db.adventure.curio_quota(k, "rare")) + for k in db.run.adventure.CURIO_KINDS: + var q := int(db.run.adventure.curio_quota(k, "rare")) rare_sum += q if q > 0: rare_kinds.append(k) - Kit.check_quiet(int(db.adventure.TIER_QUOTAS["rare"]) == 7 and rare_sum == 7 - and int(db.adventure.deck_total()) == 102 and rare_kinds.has("ap") and rare_kinds.has("rap") and rare_kinds.has("time_sand") + Kit.check_quiet(int(db.run.adventure.TIER_QUOTAS["rare"]) == 7 and rare_sum == 7 + and int(db.run.adventure.deck_total()) == 102 and rare_kinds.has("ap") and rare_kinds.has("rap") and rare_kinds.has("time_sand") and rare_kinds.size() == 3, "配额锚:稀有档合计 7、投放品种={ap,rap,time_sand}、令牌池总量 102(validate 硬断言同批)〔卡② R3〕") - Kit.check_quiet(db.adventure.validate().is_empty(), "AdventureTable.validate() 零违例(两件出池后)〔卡② R3〕") + Kit.check_quiet(db.run.adventure.validate().is_empty(), "AdventureTable.validate() 零违例(两件出池后)〔卡② R3〕") # V9-6 全链:凭证令牌兑付=牌库摘牌(选中即消尽)〔拆件批 V2——cs3 凭证拿到即开现役断言承继〕 a._curio.deck = [{"kind": "rap", "tier": "rare"}, {"kind": "attack", "tier": "common"}] @@ -106,17 +99,17 @@ func _init() -> void: a._curio.deck = [{"kind": "gp", "tier": "common"}, {"kind": "heal", "tier": "excellent"}] a.stop()._pending = {"candidates": [{"kind": "gp", "tier": "common"}]} var gp_taken_before := int(a._curio.taken.get("gp", 0)) - Kit.check_quiet(a.stop().pick_curio("gp", "common") and int(a.deck().taken.get("gp", 0)) == gp_taken_before + 1, + Kit.check_quiet(a.stop().pick_curio("gp", "common") and int(a._curio.taken.get("gp", 0)) == gp_taken_before + 1, "留任小GP 袋:GP 通道兑付受理(gp 计数入账——路由零变化)〔专长批 V10〕") var hp_before: int = a.stats().hero_hp a.stop()._pending = {"candidates": [{"kind": "heal", "tier": "excellent"}]} - Kit.check_quiet(a.stop().pick_curio("heal", "excellent") and a.stats().hero_hp == mini(hp_before + int(db.adventure.curio_value("heal", "excellent")), a.effective_max_hp()), + Kit.check_quiet(a.stop().pick_curio("heal", "excellent") and a.stats().hero_hp == mini(hp_before + int(db.run.adventure.curio_value("heal", "excellent")), a.effective_max_hp()), "留任应急口粮:当即回血 +6 封顶生效上限〔专长批 V10〕") # V10-2 行动点上限归属性轴:ap kind 走加算区第四轴 a._curio.deck = [{"kind": "ap", "tier": "rare"}] a.stop()._pending = {"candidates": a._curio.deck.duplicate()} var ap_bonus_before: int = a.journal().bonus_total("ap") - Kit.check_quiet(a.stop().pick_curio("ap", "rare") and a.journal().bonus_total("ap") == ap_bonus_before + int(db.adventure.curio_value("ap", "rare")), + Kit.check_quiet(a.stop().pick_curio("ap", "rare") and a.journal().bonus_total("ap") == ap_bonus_before + int(db.run.adventure.curio_value("ap", "rare")), "行动点上限归属性轴:ap 兑付写加算区第四轴(bonus.ap 承载)〔专长批 V10〕") # V16——断点扩容往返:新账本随断点快照/续玩回填(breakpoint_resume_test P5 同构) @@ -149,11 +142,11 @@ func _init() -> void: Kit.check_quiet(int(b2.adventure_counters.get("pain_layers", -1)) == 3 and b2.refresh_bank().refresh_stock() == 2, "V16:续玩回填逐项一致(adventure 桶随断点往返·计数恢复)〔无状态化 V3〕") # V25 完美保险专长验收组(R2 全项:装载/限次一次/计数纯净/两账相减/跨战清零/事件不吞) - var fi: Dictionary = db.feats.row_by_id("perfect_insurance") + var fi: Dictionary = db.crossrun.feats.row_by_id("perfect_insurance") Kit.check_quiet(not fi.is_empty() and String(fi.get("rarity", "")) == "rare" and String(fi.get("desc", "")).find("雷") == -1 and String(fi.get("desc", "")).find("封印") >= 0, "专长行合法:第 30 行 perfect_insurance 在表、rarity rare、desc 零「雷」字面〔卡② R2〕") - Kit.check_quiet(db.feats.validate().is_empty(), "feats.validate() 零违例(新行订阅形态与词表合法)〔卡② R2〕") + Kit.check_quiet(db.crossrun.feats.validate().is_empty(), "feats.validate() 零违例(新行订阅形态与词表合法)〔卡② R2〕") var RigScript := load("res://core/battle_orchestrator.gd") var RngScript := load("res://core/rng_service.gd") var rig_rng = RngScript.new() @@ -172,30 +165,17 @@ func _init() -> void: if safe_cells.size() >= 2: break Kit.check_quiet(safe_cells.size() == 2, "保险 rig:两枚安全格定位(去重闸按格实例——限次样本须异格)〔卡② R2〕") - var rows_orch: Array[Dictionary] = [] - orch.step_row.connect(func(row): rows_orch.append(row)) # step_row 公开信号面(insurance_used 载荷/seal_miss highlight=豁免账与误标账观测面) Kit.check_quiet(int(orch._perfect_hit_false) == 0 and int(orch._perfect_forgiven) == 0, "保险前置:误标账与豁免账双零起〔卡② R2〕") orch._grant_seg._judge_seal({"cell": safe_cells[0], "hit": false}, 1) Kit.check_quiet(int(orch._perfect_hit_false) == 1, "计数纯净:首次标错 _perfect_hit_false 落 1 未被回退改写(契约 2)〔卡② R2〕") - var ins_row := {} - for r in rows_orch: - if String(r.get("effect_id", "")) == "insurance_used": - ins_row = r - Kit.check_quiet(not ins_row.is_empty() and int(ins_row.get("forgiven", -1)) == 1, + Kit.check_quiet(int(orch._perfect_forgiven) == 1, "豁免一次:首次标错 _perfect_forgiven=1(forgive_seal_miss 派发)〔卡② R2〕") Kit.check_quiet(int(orch.action_economy.pending_ap_delta()) == 1, "事件不吞:同一标错的 seal_missed 仍被危险直觉·守收到(queue AP+1)〔卡② R2〕") orch._grant_seg._judge_seal({"cell": safe_cells[1], "hit": false}, 2) - var ins_count := 0 - var miss_hl_last := "" - for r in rows_orch: - if String(r.get("effect_id", "")) == "insurance_used": - ins_count += 1 - if String(r.get("type", "")) == "seal_miss": - miss_hl_last = String(r.get("highlight", "")) - Kit.check_quiet(ins_count == 1 and miss_hl_last == "perfect_broken", + Kit.check_quiet(int(orch._perfect_hit_false) == 2 and int(orch._perfect_forgiven) == 1, "限次:第二次标错不获豁免(forgiven 恒 1——when counter lt 1)〔卡② R2〕") Kit.check_quiet(not orch.perfect_sweep_earned(), "判定相减:误标 2 − 豁免 1 = 有效 1 → 完美不成立〔卡② R2 真值表〕") @@ -215,15 +195,9 @@ func _init() -> void: "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}), "次战 rig:第二战编排启动(feats 同件)〔卡② R2〕") - var rows_orch2: Array[Dictionary] = [] - orch2.step_row.connect(func(row): rows_orch2.append(row)) # step_row 公开信号面(insurance_used 载荷=豁免账观测面) var sc2 = safe_cells[0] orch2._grant_seg._judge_seal({"cell": sc2, "hit": false}, 1) - var ins_row2 := {} - for r in rows_orch2: - if String(r.get("effect_id", "")) == "insurance_used": - ins_row2 = r - Kit.check_quiet(not ins_row2.is_empty() and int(ins_row2.get("forgiven", -1)) == 1, + Kit.check_quiet(int(orch2._perfect_forgiven) == 1 and int(orch2._perfect_hit_false) == 1, "跨战清零:domain=battle 豁免账每战重建——次战首次标错再豁免一次〔卡② R2〕") Kit.check_quiet(EffectEngineKindProbe(db), "词表闭集:forgive_seal_miss 在 EFFECT_KINDS(出域即加载致命——扩签合规)〔卡② R4〕") diff --git a/game/tests/unit/core/hero_ledger_test.gd b/game/tests/unit/core/hero_ledger_test.gd index a32a8021..37fe2e18 100644 --- a/game/tests/unit/core/hero_ledger_test.gd +++ b/game/tests/unit/core/hero_ledger_test.gd @@ -23,18 +23,18 @@ func _init() -> void: # ---- hr1:生效公式逐值(表值+永久强化 flat+加算区后乘调试 pct——与改前同式)---- var journal_a = GainJournalScript.new() var hero = HeroStatsScript.new(db, {"atk": 3, "max_hp": 5, "armor_class": 2}, journal_a) - Kit.check_quiet(hero.effective_max_hp() == int(db.hero.max_hp) + 5, + Kit.check_quiet(hero.effective_max_hp() == int(db.combat.hero.max_hp) + 5, "hr1 生效上限 = 表值+永久强化 flat(pct=0 乘法恒等)") - Kit.check_quiet(hero.effective_atk() == int(db.hero.atk) + 3, + Kit.check_quiet(hero.effective_atk() == int(db.combat.hero.atk) + 3, "hr1 生效攻击 = 表值+永久强化 flat") - Kit.check_quiet(hero.effective_ac() == int(db.forge_armor_route_by_type(db.forge_default_armor_type()).get("base_ac", 0)) + 2, + Kit.check_quiet(hero.effective_ac() == int(db.crossrun.forge.armor_route_by_type(db.crossrun.forge.default_armor_type).get("base_ac", 0)) + 2, "hr1 生效护甲 = 甲型基础 AC+甲路线等级加值(纯 flat 无 pct)") hero.set_debug_override(50, 50) - Kit.check_quiet(hero.effective_max_hp() == int(round(float(int(db.hero.max_hp) + 5) * 1.5)), + Kit.check_quiet(hero.effective_max_hp() == int(round(float(int(db.combat.hero.max_hp) + 5) * 1.5)), "hr1 调试 pct=50 后乘——上限 ×1.5(叠加次序:flat 先加 pct 后乘)") hero.set_debug_override(0, 0) journal_a.gain_bonus("max_hp", 4, "battle_export", "max_hp") - Kit.check_quiet(hero.effective_max_hp() == int(db.hero.max_hp) + 5 + 4, + Kit.check_quiet(hero.effective_max_hp() == int(db.combat.hero.max_hp) + 5 + 4, "hr1 加算区 flat 与永久强化 flat 同列基数(三段叠加——公式消费日记汇总)") # hr1 回血封顶公式逐值(算术宿主=HeroStats.rest_heal 单点——改前 LevelRunner.rest_heal 同式) @@ -119,8 +119,7 @@ func _init() -> void: Kit.check_quiet(w3.gp == 37 and j3.keystone_gained() == 3 and j3.vision_used() == 1 and s3.hero_hp == 21 and (s3.feats_held() as Array).has("twin_pick"), "hr2 片段往返:三件账面键逐值(生命/特性/钱包/钥石/真视)") - var jfrag: Dictionary = j3.breakpoint_fragment().get("gain_journal", {}) as Dictionary - Kit.check_quiet((jfrag.get("records", []) as Array).size() == 3 and (jfrag.get("details", []) as Array).size() == 3, + Kit.check_quiet((j3._gain_log as Array).size() == 3 and (j3._bonus_detail as Array).size() == 3, "hr2 片段往返:日记全量还原") # hr2 旧档回退(无日记键→汇总直载跳过比对) @@ -128,8 +127,7 @@ func _init() -> void: legacy.erase("gain_journal") var j4 = GainJournalScript.new() j4.restore_fragment(legacy, {}) - Kit.check_quiet(j4.bonus_total("atk") == 7 and j4.bonus_total("armor_class") == 1 - and ((j4.breakpoint_fragment().get("gain_journal", {}) as Dictionary).get("records", []) as Array).is_empty(), + Kit.check_quiet(j4.bonus_total("atk") == 7 and j4.bonus_total("armor_class") == 1 and (j4._gain_log as Array).is_empty(), "hr2 旧档缺日记→汇总键直载(跳过比对——旧档行为零变化)") # hr2 日记在档推导比对不符→拒绝载入(fail-fast——汇总不回填) diff --git a/game/tests/unit/core/level_map_type_vis_test.gd b/game/tests/unit/core/level_map_type_vis_test.gd index 94664ad8..06120fd9 100644 --- a/game/tests/unit/core/level_map_type_vis_test.gd +++ b/game/tests/unit/core/level_map_type_vis_test.gd @@ -102,7 +102,7 @@ func _runner(db, nodes: Array, anchor_id: int, candidates: Array, n: int = 2): # ---- ⑥ N 进数据表(含越域校验)〔fv6〕---- func _test_field_in_table(db) -> void: - var n_in_table: int = int(db.level.info_visible_layers) + var n_in_table: int = int(db.run.level.info_visible_layers) Kit.check("N 落表:level.tres 读数 2(实际 %d)〔fv6〕" % n_in_table, n_in_table == 2) var t := LevelTableScript.new() t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": _valid_graph_nodes()}] @@ -184,11 +184,11 @@ func _test_single_chain_entry(db) -> void: func _test_n_from_table(db) -> void: var nodes := _graph_nodes() var r = _runner(db, nodes, 1, [nodes[2]]) # 锚层 1 - db.level.info_visible_layers = 1 + db.run.level.info_visible_layers = 1 Kit.check("N 读表:N=1 时节点 4(层距 2)出窗〔fv6〕", not r.type_visible_ids().has(4)) - db.level.info_visible_layers = 3 + db.run.level.info_visible_layers = 3 Kit.check("N 读表:N=3 时节点 5(层距 3)入窗〔fv6〕", r.type_visible_ids().has(5)) - db.level.info_visible_layers = 10 + db.run.level.info_visible_layers = 10 var all_in := true for nid in 7: # 非豁免节点 0..6(7 = Boss 恒显,不作判据) if not r.type_visible_ids().has(nid): diff --git a/game/tests/unit/core/level_shop_economy_test.gd b/game/tests/unit/core/level_shop_economy_test.gd index f75a944d..0263c22b 100644 --- a/game/tests/unit/core/level_shop_economy_test.gd +++ b/game/tests/unit/core/level_shop_economy_test.gd @@ -33,7 +33,7 @@ var _graph_gen = null # 生成图取数单实例(图 = f(参数档, 种子) # 参数档 × 种子产图(与 LevelRunner._assign_generated_graph 同一产图路径与同一参数档表) func _generated_graph(level_index: int, seed_val: int) -> Dictionary: - var params: Dictionary = _db.level_gen_params_row(level_index) + var params: Dictionary = _db.run.level_gen_params.row_by_level_index(level_index) if params.is_empty(): return {} if _graph_gen == null: @@ -221,13 +221,13 @@ func _path_in_graph(nodes: Array, path: Array) -> bool: # 敌侧覆盖判据:请求深度经 band_of 落带(非 -1)——解析落带 ≥ 请求深度即等价判据(禁顶 clamp 消解) func _enemy_band_covers(level_index: int, depth: int, kind: String) -> bool: - var rows: Array[Dictionary] = _db.resolve_enemy_rowset(level_index, depth, kind) + var rows: Array[Dictionary] = _db.combat.enemy.resolve_rowset(level_index, depth, kind) return not rows.is_empty() and int(rows[0].get("depth_band", -1)) >= depth # 板侧覆盖判据:该深度在 board_gen 精确命中(node_depth 等值) func _board_row_exact(level_index: int, depth: int) -> bool: - var board: Dictionary = _db.resolve_board_gen(level_index, depth) + var board: Dictionary = _db.combat.board_gen.resolve_row(level_index, depth) return not board.is_empty() and int(board.get("node_depth", -1)) == depth @@ -259,14 +259,14 @@ func _init() -> void: func _smoke_table_migration() -> void: print("--- 数值入表(shop 两 knob + hero 显示名 · 原值迁移)---") - Kit.check("shop.relic_base_price 表值 == 迁移前字面量(15.0)〔wd11〕", _db.shop.relic_base_price == 15.0) - Kit.check("shop.spend_discount 表值 == 迁移前字面量(0.8)〔wd11〕", _db.shop.spend_discount == 0.8) - Kit.check("hero.display_name 表值 == 迁移前字面量(探险者)〔wd16〕", _db.hero.display_name == "探险者") + Kit.check("shop.relic_base_price 表值 == 迁移前字面量(15.0)〔wd11〕", _db.run.shop.relic_base_price == 15.0) + Kit.check("shop.spend_discount 表值 == 迁移前字面量(0.8)〔wd11〕", _db.run.shop.spend_discount == 0.8) + Kit.check("hero.display_name 表值 == 迁移前字面量(探险者)〔wd16〕", _db.combat.hero.display_name == "探险者") # 消费端读表:spawn 真实构造链的英雄单位显示名与表值同 var units = load("res://core/unit_container.gd").new(_db) - var spawned: bool = units.spawn(1, 0, int(_db.hero.max_hp)) + var spawned: bool = units.spawn({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)}) Kit.check("spawn 后英雄单位 display_name 读表同值(探险者)〔wd16〕", - spawned and String(units.hero_unit().get("display_name", "")) == String(_db.hero.display_name)) + spawned and String(units.hero_unit().get("display_name", "")) == String(_db.combat.hero.display_name)) # ---- 生成图深度扩面覆盖断言(迭代 35 卡② S5/V11——节点源=卡① 生成器产图, @@ -279,7 +279,7 @@ func _smoke_generated_coverage() -> void: var scanned := 0 var readings: Array = [] for L in [1, 2, 3]: - if _db.level_gen_params_row(L).is_empty(): + if _db.run.level_gen_params.row_by_level_index(L).is_empty(): Kit.check("L%d 参数档缺行(覆盖断言无源可扫)〔v11〕" % L, false) continue var dmin := 999 @@ -306,7 +306,7 @@ func _smoke_generated_coverage() -> void: board_ok = false var bands: Array = [] for tid in _terminal_ids(nodes): - var rs: Array[Dictionary] = _db.resolve_enemy_rowset(L, int(by_id[int(tid)].get("depth", -1)), "boss") + var rs: Array[Dictionary] = _db.combat.enemy.resolve_rowset(L, int(by_id[int(tid)].get("depth", -1)), "boss") bands.append(int(rs[0].get("depth_band", -1)) if not rs.is_empty() else -1) for i in range(bands.size()): for j in range(i + 1, bands.size()): @@ -325,9 +325,9 @@ func _smoke_generated_coverage() -> void: func _smoke_level() -> void: print("--- 关卡节点图·生成图推进(参数档×种子产图·满配冒险成功/恢复公式/败北线/回放确定性)---") 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)} # 配置基线快照——⑤ 回基线对比用(改表自动跟随,不再硬编码) + var hero_base := {"atk": int(_db.combat.hero.atk), "hp": int(_db.combat.hero.max_hp), "ac": int(_db.combat.hero.armor_class)} # 配置基线快照——⑤ 回基线对比用(改表自动跟随,不再硬编码) var nodes1: Array = _level_active_nodes(1) - var params1: Dictionary = _db.level_gen_params_row(1) + var params1: Dictionary = _db.run.level_gen_params.row_by_level_index(1) Kit.check("L1 现行起关图取数非空(种子 %d × 参数档 → 节点 %d 颗)" % [ACTIVE_GRAPH_SEED, nodes1.size()], not nodes1.is_empty()) # ① 满配冒险成功路径(atk+200%/hp+100%——测试面板同源的属性覆盖) var win = autoplay.run_level(_db, ACTIVE_GRAPH_SEED, 1, 200, 100) @@ -354,10 +354,10 @@ func _smoke_level() -> void: boss_depth1 == int(params1.get("layers", -1)) and int(by_id1.get(last_id, {}).get("depth", -1)) == boss_depth1) Kit.check("L1 终点恰一个(大岔口自 L2 起的图侧投影)", _terminal_ids(nodes1).size() == 1 and _ids_of_type(nodes1, "boss").size() == 1) Kit.check("L1 生成图含岔口(出度 ≥2 节点 %d 处)" % _fork_ids(nodes1).size(), _fork_ids(nodes1).size() >= 1) - Kit.check("进 Boss HP 携带在域内(%d)" % win.hp_at_boss, win.hp_at_boss >= 1 and win.hp_at_boss <= int(_db.hero.max_hp) * 3) + Kit.check("进 Boss HP 携带在域内(%d)" % win.hp_at_boss, win.hp_at_boss >= 1 and win.hp_at_boss <= int(_db.combat.hero.max_hp) * 3) # ② 恢复点公式:回复 = min(floor(生效max_hp×rest_heal_pct/100), 缺口)——两档缺口各验一条分支 #(真入口驱动:起关→首战→选恢复候选;缺口由夹具造,恢复比例读关卡行) - var pct1: int = int(_db.level_row(1).get("rest_heal_pct", 0)) + var pct1: int = int(_db.run.level.row_by_level_index(1).get("rest_heal_pct", 0)) var rest_wide := _drive_to_rest(1, ACTIVE_GRAPH_SEED, 100000) if rest_wide.is_empty(): Kit.check("恢复点公式无源(现行图入口后一位无可直达的恢复候选)", false) @@ -382,9 +382,9 @@ func _smoke_level() -> void: Kit.check("关卡回放断言:同种子两遍结算步骤流逐行相同", JSON.stringify(rep1.rows) == JSON.stringify(rep2.rows)) Kit.check("关卡步骤流非空(%d 行)" % rep1.rows.size(), rep1.rows.size() > 0) # ⑤ 属性覆盖恢复:模拟后 hero 表回基线(调整板不泄漏到后续会话;基线 = 函数入口的配置快照,改表自动跟随) - Kit.check("模拟后 hero 表恢复基线(atk=%d hp=%d ac=%d——对配置快照)" % [int(_db.hero.atk), int(_db.hero.max_hp), int(_db.hero.armor_class)], - int(_db.hero.atk) == int(hero_base.atk) and int(_db.hero.max_hp) == int(hero_base.hp) \ - and int(_db.hero.armor_class) == int(hero_base.ac)) + Kit.check("模拟后 hero 表恢复基线(atk=%d hp=%d ac=%d——对配置快照)" % [int(_db.combat.hero.atk), int(_db.combat.hero.max_hp), int(_db.combat.hero.armor_class)], + int(_db.combat.hero.atk) == int(hero_base.atk) and int(_db.combat.hero.max_hp) == int(hero_base.hp) \ + and int(_db.combat.hero.armor_class) == int(hero_base.ac)) # 手搓战斗状态袋单点(7 处字面量收在一处;_autoplay_battle 直接消费的最小字段族。 @@ -439,10 +439,10 @@ func _smoke_level_graph() -> void: print("--- 关卡节点图·分支节点图(L2 幽暗地域·生成图)---") var autoplay = load("res://sim/level_autoplay.gd") # ① 关卡表行级校验零错误(关卡表只承载序号/显示名/恢复比例——图合法性由 validate_graph 在起关时承担) - Kit.check("LevelTable 行级校验零错误(图合法性移交 validate_graph)", _db.level.validate().is_empty()) + Kit.check("LevelTable 行级校验零错误(图合法性移交 validate_graph)", _db.run.level.validate().is_empty()) # ② L2 生成图形状读数(性质断言——规模/型别/岔口全部由参数档与逐节点扫描互校) var nodes2: Array = _level_active_nodes(2) - var params2: Dictionary = _db.level_gen_params_row(2) + var params2: Dictionary = _db.run.level_gen_params.row_by_level_index(2) Kit.check("L2 现行起关图取数非空(种子 %d × 参数档 → 节点 %d 颗)" % [ACTIVE_GRAPH_SEED, nodes2.size()], not nodes2.is_empty()) var tcount := _type_tally(nodes2) var branch := _fork_ids(nodes2).size() @@ -600,7 +600,7 @@ func _smoke_level_graph() -> void: # ⑦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_set: Array = _db.combat.enemy.resolve_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 @@ -610,14 +610,14 @@ func _smoke_level_graph() -> void: var l2_terms := _terminal_depths(nodes2) var l2_boss_ok := not l2_terms.is_empty() for d in l2_terms: - var l2_boss_set: Array[Dictionary] = _db.resolve_enemy_rowset(2, int(d), "boss") + var l2_boss_set: Array[Dictionary] = _db.combat.enemy.resolve_rowset(2, int(d), "boss") if l2_boss_set.size() != 1 or String(l2_boss_set[0].get("enemy_id", "")) != "drider" \ or String(l2_boss_set[0].get("armor_type", "")) != "ward": l2_boss_ok = false Kit.check("L2 全部终点深度 %s 的 Boss 行集均 = {drider} 恰一行且首行魔法甲(护甲类型重配批:关卡 2 唯一非轻甲;P4 乙 Boss 行集多行合法——零内容期仍单行)〔fv24〕" % str(l2_terms), l2_boss_ok) # ⑦b 定点:混编遭遇行集 (2,battle,5)(R6 行序契约运行面: # 行集两行文件内相邻、行序 = 声明序 = 生成序;静态最坏骰和 Σ(unit_count_max,固定行缺省 = unit_count) ≤ 4) - var l2_mix_set: Array = _db.resolve_enemy_rowset(2, 5, "battle") + var l2_mix_set: Array = _db.combat.enemy.resolve_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" @@ -632,7 +632,7 @@ func _smoke_level_graph() -> void: # ---- L3 灰矮人矿城断言段 ---- # ⑧ L3 生成图形状读数(与 ② 同一性质判据面——规模/型别/岔口/多终点全部对参数档与扫描读数互校) var nodes3: Array = _level_active_nodes(3) - var params3: Dictionary = _db.level_gen_params_row(3) + var params3: Dictionary = _db.run.level_gen_params.row_by_level_index(3) Kit.check("L3 现行起关图取数非空(种子 %d × 参数档 → 节点 %d 颗)" % [ACTIVE_GRAPH_SEED, nodes3.size()], not nodes3.is_empty()) var tcount3 := _type_tally(nodes3) var branch3 := _fork_ids(nodes3).size() @@ -654,7 +654,7 @@ func _smoke_level_graph() -> void: Kit.check("L3 现行图每节点深度 board_gen 精确命中(非空且 node_depth 等值·违例 %d 处)" % int(cov3["board_bad"]), int(cov3["board_bad"]) == 0) # ⑩ 定点: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_set: Array = _db.combat.enemy.resolve_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 @@ -665,7 +665,7 @@ func _smoke_level_graph() -> void: var l3_terms := _terminal_depths(nodes3) var l3_boss_ok := not l3_terms.is_empty() for d in l3_terms: - var l3_king_set: Array[Dictionary] = _db.resolve_enemy_rowset(3, int(d), "boss") + var l3_king_set: Array[Dictionary] = _db.combat.enemy.resolve_rowset(3, int(d), "boss") if l3_king_set.size() != 1 or String(l3_king_set[0].get("enemy_id", "")) != "duergar_king" \ or String(l3_king_set[0].get("armor_type", "")) != "heavy": l3_boss_ok = false @@ -700,23 +700,23 @@ func _smoke_level_graph() -> void: # 断言后立即还原表,后续断言面零污染。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) + _db.combat.enemy.rows.append(mix_row_a) + _db.combat.enemy.rows.append(mix_row_b) var mix_sys = load("res://core/unit_container.gd").new(_db) - Kit.check("混编行集 spawn 受理((1,battle,5) 双行 地精1+熊地精2——定值无区间路径,行集构成纪律放开面正向)", mix_sys.spawn(1, 5, int(_db.hero.max_hp))) + Kit.check("混编行集 spawn 受理((1,battle,5) 双行 地精1+熊地精2——定值无区间路径,行集构成纪律放开面正向)", mix_sys.spawn({"level_index": 1, "node_depth": 5, "hero_hp": int(_db.combat.hero.max_hp)})) var mix_units: Array = mix_sys.all_enemies() Kit.check("混编实例总数 = 各行数量求和(1+2=3——行集实例总数 ≤4 守卫内)", mix_units.size() == 3) Kit.check("混编生成序 = 文件行序(行序即契约——地精在前熊地精在后,handle_id 跨行连续派发)", String(mix_units[0].get("enemy_id")) == "goblin" and String(mix_units[1].get("enemy_id")) == "bugbear" and String(mix_units[2].get("enemy_id")) == "bugbear") Kit.check("混编各行行值独立投影(display_name/hp/atk 按各自行值——同场不同种不同值)", String(mix_units[0].get("display_name")) == "地精" and int(mix_units[1].get("hp")) == 20 and int(mix_units[1].get("atk")) == 3) - _db.enemy.rows.append({"level_index": 1, "row_kind": "battle", "depth_band": 6, "enemy_id": "goblin", "display_name": "地精", "unit_count": 3, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]}) - _db.enemy.rows.append({"level_index": 1, "row_kind": "battle", "depth_band": 6, "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 3, "hp": 20, "atk": 3, "pool": [{"skill_id": "attack", "weight": 9}]}) + _db.combat.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.combat.enemy.rows.append({"level_index": 1, "row_kind": "battle", "depth_band": 6, "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 3, "hp": 20, "atk": 3, "pool": [{"skill_id": "attack", "weight": 9}]}) var over_sys = load("res://core/unit_container.gd").new(_db) - Kit.check("行集实例总数守卫:两行各定值 3(总 6 > 4)spawn 被拒(行集构成纪律③——R3 fail-fast 运行面)", not over_sys.spawn(1, 6, int(_db.hero.max_hp))) - _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) - _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) - _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) - _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) - Kit.check("混编断言段注入行全量还原(表回 28 行——迭代 35 生成图深度扩面覆盖批后的基线行数;首批混编投放基线 20 行+覆盖 8 行)", int(_db.enemy.rows.size()) == 28) + Kit.check("行集实例总数守卫:两行各定值 3(总 6 > 4)spawn 被拒(行集构成纪律③——R3 fail-fast 运行面)", not over_sys.spawn({"level_index": 1, "node_depth": 6, "hero_hp": int(_db.combat.hero.max_hp)})) + _db.combat.enemy.rows.remove_at(_db.combat.enemy.rows.size() - 1) + _db.combat.enemy.rows.remove_at(_db.combat.enemy.rows.size() - 1) + _db.combat.enemy.rows.remove_at(_db.combat.enemy.rows.size() - 1) + _db.combat.enemy.rows.remove_at(_db.combat.enemy.rows.size() - 1) + Kit.check("混编断言段注入行全量还原(表回 28 行——迭代 35 生成图深度扩面覆盖批后的基线行数;首批混编投放基线 20 行+覆盖 8 行)", int(_db.combat.enemy.rows.size()) == 28) # ⑬ 真图类型可见性(信息迷雾 v2.1):L2 入口停点纯核心面读数——候选 ⊆ 可见集 / # 终点恒可见 / 入口 BFS 层距 > N 的非豁免节点入雾。N 取数据表(代码零硬编码)。 @@ -734,7 +734,7 @@ func _smoke_level_graph() -> void: if not vis_ids.has(int(bid)): vis_boss_ok = false Kit.check("可见性冒烟:L2 全部终点 Boss 句柄 %s 恒在可见集(终点不入雾)〔fv3〕" % str(_ids_of_type(nodes2, "boss")), vis_boss_ok) - var vis_n: int = int(_db.level.info_visible_layers) + var vis_n: int = int(_db.run.level.info_visible_layers) Kit.check("可见性冒烟:N 读数据表 = %d(provisional 2——代码零硬编码)〔fv6〕" % vis_n, vis_n == 2) # 层距判据按图自算入口 BFS 层(与 runner 同源口径:入口层 0、出边逐层 +1)—— # 锚层 0 时层距 ≤ N 者恒可见、层距 > N 的非终点者恒不可见(两条扫描面都须非空) @@ -823,7 +823,7 @@ func _smoke_gp_wallet() -> void: var fin_b := {} boss.step_row.connect(func(row): rows_b.append(row)) boss.battle_finished.connect(func(p): fin_b["p"] = p) - Kit.check("L2 Boss 战启动((2,9)——Boss 档掷定 [6,10] + 关级加值 1)", boss.begin_battle({"level_index": 2, "node_depth": 9, "node_type": "boss", "hero_hp": int(_db.hero.max_hp)})) + Kit.check("L2 Boss 战启动((2,9)——Boss 档掷定 [6,10] + 关级加值 1)", boss.begin_battle({"level_index": 2, "node_depth": 9, "node_type": "boss", "hero_hp": int(_db.combat.hero.max_hp)})) var guard_b := 60 while boss.battle_result == "" and guard_b > 0: guard_b -= 1 @@ -862,7 +862,7 @@ func _smoke_gp_escrow() -> void: 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}): + if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp), "hero_atk": 999}): continue var target := _find_gp_dig(settlement.minesweeper, settlement) if target.is_empty(): @@ -896,15 +896,15 @@ func _smoke_gp_escrow() -> void: var settle_l = load("res://core/battle_orchestrator.gd").new(_db, rng_l) var fin_l := {} settle_l.battle_finished.connect(func(p): fin_l["p"] = p) - Kit.check("破财直验战斗启动", settle_l.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999, "node_type": "boss"})) + Kit.check("破财直验战斗启动", settle_l.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "hero_atk": 999, "node_type": "boss"})) for i in range(3): settle_l.reward_grant().grant_routed({"reward_id": "gp_lose", "cell_type": "gp", "polarity": "negative", "display_name": "破财", "power": 2, "effect_id": "gp_loss", "stacks": 1}, "negative", {"source_class": "mine_hit", "cell": Vector2i(0, 0), "ri": 1}) Kit.check("破财 ×3 → escrow −6(负向缓冲记账,零钱包触达)", int(settle_l.reward_grant().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) + var boss_drop: Dictionary = _db.run.shop.gp_drop_row("boss") + var boss_bonus1: int = _db.run.shop.level_gp_bonus_at(1) Kit.check("破财压制 gp_dropped 负值上抛(%d + 6 = %d ∈ Boss 档掷定 [%d,%d] + 关级加值 %d——掷定不受破财污染)" % [ gp_l, gp_l + 6, int(boss_drop.get("roll_min", 0)), int(boss_drop.get("roll_max", 0)), boss_bonus1], gp_l + 6 >= int(boss_drop.get("roll_min", 0)) + boss_bonus1 and gp_l + 6 <= int(boss_drop.get("roll_max", 0)) + boss_bonus1) @@ -1048,7 +1048,7 @@ func _smoke_shop_room() -> void: Kit.check("当前生命同步 +3 封顶新上限(%d → %d)" % [hp_before, runner.hero_hp], runner.hero_hp == mini(hp_before + 3, runner.effective_max_hp())) # armor_up:AC 10+1 Kit.check("购买 armor_up 受理(现价 6)", runner.buy_product("armor_up")) - Kit.check("AC = 10+1(armor_class 加算区轴,无百分比通道)", runner.effective_ac() == int(_db.hero.armor_class) + 1) + Kit.check("AC = 10+1(armor_class 加算区轴,无百分比通道)", runner.effective_ac() == int(_db.combat.hero.armor_class) + 1) # 余额不足购买被拒(零副作用):hp_up 连购至不可负担后兜底拒绝 var guard_drain := 20 var hp_buys := 1 # 夹具前段已购 1 笔(购买 hp_up 受理断言)——总数 = 前段 + 本循环 @@ -1068,12 +1068,12 @@ func _smoke_shop_room() -> void: Kit.check("离开商店 → 回选路口(候选 = 店出边 %s——零自动推进收口)" % str(shop_outs), runner.continue_after_node() and runner.awaiting_choice() and _sorted_ids(runner.next_candidate_ids()) == _sorted_ids(shop_outs)) # 下一战参数 = 基础表值 + 加算区(百分比已归零——加算先乘后乘序的前半在案) - Kit.check("主角攻击生效值 = 2+1(基础表值 + 加算区)", runner.effective_atk() == int(_db.hero.atk) + 1) + Kit.check("主角攻击生效值 = 2+1(基础表值 + 加算区)", runner.effective_atk() == int(_db.combat.hero.atk) + 1) Kit.check("选终点 Boss(句柄 %d)开战" % int(shop_outs[0]), runner.pick_next_node(int(shop_outs[0])) and runner.current_battle() != null) var snap: Dictionary = runner.current_battle().presentation_snapshot() # max_hp 期望按实际购买笔数推导(可购笔数 # 随经济漂移;断言语义=「加算区笔数×3 全额入战斗开始参数」,不锚死恰好 1 笔) - Kit.check("战斗开始参数 = 基础表值 + 加算区(atk %d = 2+1 / max_hp %d = %d+%d×3)" % [int(snap["hero"].get("atk", -1)), int(snap["hero"].get("max_hp", -1)), int(_db.hero.max_hp), hp_buys], int(snap["hero"].get("atk", -1)) == int(_db.hero.atk) + 1 and int(snap["hero"].get("max_hp", -1)) == int(_db.hero.max_hp) + 3 * hp_buys) + Kit.check("战斗开始参数 = 基础表值 + 加算区(atk %d = 2+1 / max_hp %d = %d+%d×3)" % [int(snap["hero"].get("atk", -1)), int(snap["hero"].get("max_hp", -1)), int(_db.combat.hero.max_hp), hp_buys], int(snap["hero"].get("atk", -1)) == int(_db.combat.hero.atk) + 1 and int(snap["hero"].get("max_hp", -1)) == int(_db.combat.hero.max_hp) + 3 * hp_buys) # 冒险收束(Boss 胜或败北——基线裸装口径任一结局)→ 钱包清零 var fin_end := {} runner.level_finished.connect(func(p): fin_end["p"] = p) @@ -1106,7 +1106,7 @@ func _smoke_shop_graph() -> void: var path_total := 0 var graph_total := 0 for L in [1, 2, 3]: - if _db.level_gen_params_row(L).is_empty(): + if _db.run.level_gen_params.row_by_level_index(L).is_empty(): Kit.check("L%d 参数档缺行(商店编排断言无源可扫)" % L, false) continue for seed_val in range(SWEEP_SEED_FROM, SWEEP_SEED_TO): @@ -1238,7 +1238,7 @@ func _smoke_shop_pool() -> void: print("--- 商店共享池(抽3买1全重抽 · 乘法定价 ceil · 池空硬闸 · 切边零入店)---") var autoplay = load("res://sim/level_autoplay.gd") var shelf_kinds: Array = [] - for row in _db.shop_pool_rows(): + for row in _db.run.shop.pool_rows(): shelf_kinds.append(String(row.get("kind_id", ""))) # ① 池模式场景可达(_adv_drive_to_shop 复用——L1 全程+L2 推进到商店房;pool 模式进店即抽架) var scen := {} @@ -1268,12 +1268,12 @@ func _smoke_shop_pool() -> void: var r8 = scen["r"] var gp0: int = int(r8.gp) var cost0: int = r8.shop_refresh_cost() - Kit.check("刷新首价=基价(%d)" % cost0, cost0 == int(_db.adventure.shop_refresh_cost_base)) + Kit.check("刷新首价=基价(%d)" % cost0, cost0 == int(_db.run.adventure.shop_refresh_cost_base)) Kit.check("基础免费 0 次(Q2——天赋增益位后续添加)", r8.shop_refresh_free_left() == 0) var shelf_pre: Array = (r8.shop_shelf() as Array).duplicate(true) Kit.check("GP 刷新受理(gp=%d cost=%d)" % [gp0, cost0], r8.shop_refresh()) Kit.check("GP 刷新扣费(%d→%d)" % [gp0, int(r8.gp)], int(r8.gp) == gp0 - cost0) - Kit.check("刷新爬价 ×curve(%d)" % int(r8.shop_refresh_cost()), int(r8.shop_refresh_cost()) == int(ceil(float(cost0) * float(_db.adventure.shop_refresh_cost_curve)))) + Kit.check("刷新爬价 ×curve(%d)" % int(r8.shop_refresh_cost()), int(r8.shop_refresh_cost()) == int(ceil(float(cost0) * float(_db.run.adventure.shop_refresh_cost_curve)))) var same_shelf := first_shelf_a.size() == first_shelf_b.size() if same_shelf: for i in first_shelf_a.size(): @@ -1290,7 +1290,7 @@ func _smoke_shop_pool() -> void: var l2 := int(r8.level_row.level_index) # 现价等价证明(d11——消费端读表推导 vs 迁移前硬编码字面量推导:同输入逐位一致) var relic_price_now: int = r8.pool_price("relic") - var relic_price_pre: int = int(ceil(15.0 * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0)) + var relic_price_pre: int = int(ceil(15.0 * pow(_db.run.shop.price_b, float(l2 - 1)) * 1.0)) Kit.check("relic 现价 = ceil(15.0×b^(关序−1))(读表与迁移前字面量逐位一致,%d)〔wd11〕" % relic_price_now, relic_price_now == relic_price_pre) for c in r8.shop_shelf(): # T6 商店保底后 relic 卡合法入架(保底占位)——词表断言放行 relic;价格断言分叉 @@ -1300,10 +1300,10 @@ func _smoke_shop_pool() -> void: Kit.check("货架 kind 在封闭词表或保底 relic 卡(%s)" % ckind, is_relic or shelf_kinds.has(ckind)) var expect_p: int if is_relic: - expect_p = int(ceil(float(_db.shop.relic_base_price) * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0)) + expect_p = int(ceil(float(_db.run.shop.relic_base_price) * pow(_db.run.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)) + var prow: Dictionary = _db.run.shop.pool_row(ckind) + expect_p = int(ceil(float(int(prow.get("base_price", 0))) * pow(_db.run.shop.price_b, float(l2 - 1)) * 1.0)) Kit.check("首购现价 = ceil(基价×b^(关序−1))(%s: %d = %d 推导)" % [ckind, int(c.get("price", -1)), expect_p], int(c.get("price", -1)) == expect_p) # ③ 买 1 全重抽:出账+加算区+账本三落点 + 现价 A 链递增(买后 visit 计数=1;TP0——消费在架令牌本体) var buy_c: Dictionary = {} @@ -1321,8 +1321,8 @@ func _smoke_shop_pool() -> void: var val0: int = a8.journal().bonus_total(String(val_map[kind])) Kit.check("购买受理(pool 口——%s|%s)" % [kind, tier], r8.buy_pool_item(kind)) Kit.check("钱包扣现价(%d → %d)" % [gp0, int(r8.gp)], int(r8.gp) == gp0 - int(buy_c.get("price", 0))) - Kit.check("加算区 +令牌档位件值(池件=奖励件本体——%s|%s %+d)" % [kind, tier, int(_db.adventure.curio_value(kind, tier))], a8.journal().bonus_total(String(val_map[kind])) == val0 + int(_db.adventure.curio_value(kind, tier))) - Kit.check("共享池账本 -1(跨渠道单点——taken %d → %d)" % [taken0, int(a8.deck().taken.get(kind, 0))], int(a8.deck().taken.get(kind, 0)) == taken0 + 1) + Kit.check("加算区 +令牌档位件值(池件=奖励件本体——%s|%s %+d)" % [kind, tier, int(_db.run.adventure.curio_value(kind, tier))], a8.journal().bonus_total(String(val_map[kind])) == val0 + int(_db.run.adventure.curio_value(kind, tier))) + Kit.check("共享池账本 -1(跨渠道单点——taken %d → %d)" % [taken0, int(a8._curio.taken.get(kind, 0))], int(a8._curio.taken.get(kind, 0)) == taken0 + 1) # A 链:同visit 第二购现价 = ceil(base×b^(l2−1)×A^1)(货架已全重抽——新卡新价同公式) # A 链:白盒抬 GP 保证第二购场景确定构造(直改测试基建——非生产行为) r8.gp = int(r8.gp) + 64 @@ -1332,8 +1332,8 @@ func _smoke_shop_pool() -> void: 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())) + var prow2: Dictionary = _db.run.shop.pool_row(String(second.get("kind_id", ""))) + var expect_p2: int = int(ceil(float(int(prow2.get("base_price", 0))) * pow(_db.run.shop.price_b, float(l2 - 1)) * _db.run.shop.price_a)) Kit.check("A 链爬价 = ceil(base×b^(关序−1)×A^1)(进店第 2 购——%d)" % expect_p2, int(second.get("price", -1)) == expect_p2) Kit.check("同 visit 第二购受理(A 链软闸在价不在限)", r8.buy_pool_item(String(second.get("kind_id", "")))) else: @@ -1384,8 +1384,8 @@ func _smoke_shop_pool() -> void: 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)) + for tier in _db.run.adventure.TIERS: + quota += int(_db.run.adventure.curio_quota(String(k), tier)) if int(ps[k]) < 0 or int(ps[k]) > quota: ps_ok = false Kit.check("终局池余量在域 [0,配额](%s)" % str(ps), ps_ok) @@ -1458,7 +1458,7 @@ func _adv_st() -> Dictionary: # 首遇带数据驱动定位(契约 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: + for row in _db.combat.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: diff --git a/game/tests/unit/core/meta_progression_forge_test.gd b/game/tests/unit/core/meta_progression_forge_test.gd index d1dd3429..d6efa4fb 100644 --- a/game/tests/unit/core/meta_progression_forge_test.gd +++ b/game/tests/unit/core/meta_progression_forge_test.gd @@ -89,7 +89,7 @@ func _run_battle(L: int, D: int, axes: Dictionary = {}) -> Dictionary: settlement.minesweeper.seal_judged.connect(func(evt): out.events.append(evt)) settlement.minesweeper.round_end_board.connect(func(p): out.events.append({"ev": "round_end_board", "path_tag": p.path_tag})) settlement.action_economy.round_end_stopped.connect(func(p): out.events.append({"ev": "round_end_stopped", "path_tag": p.path_tag})) - var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.hero.max_hp} + var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.combat.hero.max_hp} enc.merge(axes, true) # 两根类型轴注入尾参(缺省零注入=回退面) if not settlement.begin_battle(enc): out.stream.append({"type": "verdict", "value": "begin_failed"}) @@ -132,22 +132,22 @@ func _meta_pricing_and_buy() -> void: 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)) + sv.meta_state._add_keystone(1000) + var bal0: int = sv.meta_state.keystone_balance() + var price0: int = int(_db.crossrun.meta_tree.row_by_id("sp_hp").get("price", 0)) Kit.check("树购买受理(Lv 0→1、余额 %d−%d)" % [bal0, price0], - mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1 and sv.keystone_balance() == bal0 - price0) + mp.tree_buy("sp_hp") and sv.meta_state.tree_state().nodes.get("sp_hp", 0) == 1 and sv.meta_state.keystone_balance() == bal0 - price0) var sv_r = _meta_new_save(_META_SMOKE_PATHS[0]) Kit.check("购买持久(reload 后级与余额不变)", - sv_r.tree_level("sp_hp") == 1 and sv_r.keystone_balance() == bal0 - price0) + sv_r.meta_state.tree_state().nodes.get("sp_hp", 0) == 1 and sv_r.meta_state.keystone_balance() == bal0 - price0) # ③ 余额不足 / 未知节点:false 零副作用 var sv_dry = _meta_new_save(_META_SMOKE_PATHS[1]) var mp_dry = load("res://core/meta_progression.gd").new(_db, sv_dry) - var tn_before: Dictionary = sv_dry.tree_nodes() - var fs_before: Dictionary = sv_dry.fork_sides() + var tn_before: Dictionary = sv_dry.meta_state.tree_state().nodes + var fs_before: Dictionary = sv_dry.meta_state.tree_state().fork_sides Kit.check("余额不足 false 零副作用(0 余额购买拒绝——三域零变化〔天赋树批 V3〕)", - not mp_dry.tree_buy("sp_hp") and sv_dry.keystone_balance() == 0 - and sv_dry.tree_nodes() == tn_before and sv_dry.fork_sides() == fs_before) + not mp_dry.tree_buy("sp_hp") and sv_dry.meta_state.keystone_balance() == 0 + and sv_dry.meta_state.tree_state().nodes == tn_before and sv_dry.meta_state.tree_state().fork_sides == fs_before) Kit.check("未知节点 false 零副作用(词表外出域)", not mp_dry.tree_buy("no_such_node")) @@ -156,8 +156,8 @@ 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) + var row1: Dictionary = _db.crossrun.meta.boss_keystone_row(1) + var row2: Dictionary = _db.crossrun.meta.boss_keystone_row(2) # ⑥ 逐杀掉落收束入账(掉落已在冒险内逐杀入账〔summary.keystone_total 含全部〕, # keystone_drops 明细随摘要归档;首杀标记仍由 apply 逐关卡完成关次置位) var d1: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, @@ -171,7 +171,7 @@ func _meta_settle_and_workshop() -> void: Kit.check("明细 total = keystone_total(逐杀掉落全源——无二次派生加账)", int(d1.get("total", 0)) == int(row1.get("first_kill", 0))) Kit.check("余额 = 累计入账(逐杀掉落合计——无重复加账)", - sv.keystone_balance() == int(row1.get("first_kill", 0))) + sv.meta_state.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)), @@ -196,39 +196,39 @@ func _meta_settle_and_workshop() -> void: not bool(d4["boss_drops"][0].get("first", true)) and bool(d4["boss_drops"][1].get("first", false)) and int(d4["boss_drops"][1].get("amount", 0)) == int(row2.get("first_kill", -1))) # ⑦ 败北零关卡完成摘要:余额只增不减(零入账)、clear_counts 不变 - var bal_before: int = sv.keystone_balance() - var cc1_before: int = sv.clear_count(1) + var bal_before: int = sv.meta_state.keystone_balance() + var cc1_before: int = sv.meta_state.career_state().clear_counts.get(str(1), 0) var dz: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 0, "stopped_at": 1, "keystone_total": 0, "master_seed": 15}) Kit.check("败北零关卡完成摘要:零入账零标记(钥石只增不减——验收1;败北局 clear_counts 不变)", - dz["boss_drops"].is_empty() and sv.keystone_balance() == bal_before and sv.clear_count(1) == cc1_before) + dz["boss_drops"].is_empty() and sv.meta_state.keystone_balance() == bal_before and sv.meta_state.career_state().clear_counts.get(str(1), 0) == cc1_before) # ⑧ 秘宝解锁服务(V3 钥石购买/V4 条件解锁;注入=直调解锁写口+直置存档生涯计数, # 不走正常资源积累流程——卡片 7.1 测试方式) var sv_u = _meta_new_save(_META_SMOKE_PATHS[11]) var mp_u = load("res://core/meta_progression.gd").new(_db, sv_u) - sv_u.add_keystone(10) + sv_u.meta_state._add_keystone(10) Kit.check("条件件误购拒绝(购买件专属——契约 8 分配面)〔秘宝批 V3 反例〕", - not mp_u.buy_treasure_unlock("sky_eye") and not sv_u.item_unlocked("sky_eye") + not mp_u.buy_treasure_unlock("sky_eye") and not sv_u.meta_state.item_unlocked("sky_eye") and not mp_u.buy_treasure_unlock("ranged_pick")) Kit.check("余额不足购买拒绝且零副作用(余额 10 < 价 25)〔秘宝批 V3〕", - not mp_u.buy_treasure_unlock("twin_loader") and sv_u.keystone_balance() == 10) + not mp_u.buy_treasure_unlock("twin_loader") and sv_u.meta_state.keystone_balance() == 10) Kit.check("钥石购买解锁:余额足→解锁态落档+余额扣减正确(价 10)〔秘宝批 V3〕", - mp_u.buy_treasure_unlock("morning_order") and sv_u.item_unlocked("morning_order") - and sv_u.keystone_balance() == 0) + mp_u.buy_treasure_unlock("morning_order") and sv_u.meta_state.item_unlocked("morning_order") + and sv_u.meta_state.keystone_balance() == 0) Kit.check("已解锁重复购买拒绝〔秘宝批 V3〕", not mp_u.buy_treasure_unlock("morning_order")) Kit.check("条件未达不解锁(远程镐=冒险成功 1 次)〔秘宝批 V4〕", - mp_u.refresh_treasure_unlocks().is_empty() and not sv_u.item_unlocked("ranged_pick")) + mp_u.refresh_treasure_unlocks().is_empty() and not sv_u.meta_state.item_unlocked("ranged_pick")) sv_u._data["meta"]["adventure_cleared"] = 1 # 直置存档生涯计数(调试注入口径) Kit.check("远程镐=adventure_cleared≥1 达标恰解锁一次+重复刷新幂等(C6:冒险成功=打通整局冒险)〔秘宝批 V4〕", (mp_u.refresh_treasure_unlocks() as Array).has("ranged_pick") - and sv_u.item_unlocked("ranged_pick") and mp_u.refresh_treasure_unlocks().is_empty()) + and sv_u.meta_state.item_unlocked("ranged_pick") and mp_u.refresh_treasure_unlocks().is_empty()) sv_u._data["meta"]["boss_kill_count"] = 3 Kit.check("刷新执照=boss_kill_count≥3(阈值读 unlock_requires 行值不硬编)〔秘宝批 V4〕", (mp_u.refresh_treasure_unlocks() as Array).has("refresh_license")) sv_u._data["meta"]["adventure_cleared"] = 3 Kit.check("天眼单条件不足不解锁(AND 语义——cleared 3 达 boss 4 未达)〔秘宝批 V4〕", - not sv_u.item_unlocked("sky_eye")) + not sv_u.meta_state.item_unlocked("sky_eye")) sv_u._data["meta"]["boss_kill_count"] = 4 Kit.check("天眼双条件 AND(冒险成功≥3 且 Boss≥4)达标解锁〔秘宝批 V4〕", (mp_u.refresh_treasure_unlocks() as Array).has("sky_eye")) @@ -240,7 +240,7 @@ func _meta_settle_and_workshop() -> void: r_n += 1 if bool(st_row.get("unlocked", false)) else 0 Kit.check("解锁态持久投影一致(reload 面同源读数;5 件=购买 1+条件 4)〔秘宝批 V4〕", r_n == 5) # ⑩ recent_summaries:元素含 boss_drops 副本、最新在前、cap 10 - var rs: Array = sv.recent_summaries() + var rs: Array = sv.meta_state.career_state().recent_summaries Kit.check("摘要最新在前 + 元素含掉落明细副本(复盘可归因——验收5)", rs.size() >= 5 and String(rs[0].get("result", "")) != "" and rs[0].has("boss_drops") and (rs[1] as Dictionary).has("keystone_drops") @@ -251,7 +251,7 @@ func _meta_settle_and_workshop() -> void: mp_c.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 0, "stopped_at": 1, "keystone_total": 0, "master_seed": i}) Kit.check("摘要 cap 10 且最新在前(12 连 settle 截断后 seed 11 居首)", - sv_c.recent_summaries().size() == 10 and int(sv_c.recent_summaries()[0].get("master_seed", -1)) == 11) + sv_c.meta_state.career_state().recent_summaries.size() == 10 and int(sv_c.meta_state.career_state().recent_summaries[0].get("master_seed", -1)) == 11) # ⑪ 同调位账本(装配持久/满位禁选/Y 升级语义;写口 = MetaProgression 同调段) var sv_a = _meta_new_save(_META_SMOKE_PATHS[6]) var mp_a = load("res://core/meta_progression.gd").new(_db, sv_a) @@ -259,17 +259,17 @@ func _meta_settle_and_workshop() -> void: Attunement.slots(_db, sv_a) == 0 and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 0) Kit.check("满位禁选(Slots 0 时装配拒绝;兼未解锁拒绝前置——直置解锁后下方装配)〔秘宝批 V5〕", mp_a.toggle_attunement("morning_order") == false) - sv_a.add_keystone(200) - sv_a.unlock_item("morning_order") - sv_a.unlock_item("twin_loader") + sv_a.meta_state._add_keystone(200) + sv_a.meta_state.unlock_item("morning_order") + sv_a.meta_state.unlock_item("twin_loader") Kit.check("Y 升级购买入账(Lv1 → 每冒险 +1 次刷新)", - mp_a.buy_attune_refresh() and sv_a.attune_refresh_level() == 1 + mp_a.buy_attune_refresh() and sv_a.meta_state.attune_state().refresh_level == 1 and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 1) mp_a.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, "stopped_at": 2, "keystone_total": 0, "master_seed": 21}) Kit.check("完成 1 关 → Slots 恒 0(旧「按击杀 Boss 次数自动涨」条件已删——槽位唯一来源=树点亮)且 Boss 击杀总数=1(计数域保留)〔天赋树批 V14〕", - Attunement.slots(_db, sv_a) == 0 and sv_a.boss_kill_count() == 1) - sv_a.set_tree_level("att_socket1", 1) + Attunement.slots(_db, sv_a) == 0 and sv_a.meta_state.career_state().boss_kill_count == 1) + sv_a.meta_state.set_tree_level("att_socket1", 1) Kit.check("树上点亮同调位 1 → Slots=1(槽位收编树节点——Q2 裁甲)", Attunement.slots(_db, sv_a) == 1) Kit.check("稀有件入普通格拒绝(件档 rank 3 > 装入位序 1——契约 2 档位格校验)〔秘宝批 V5〕", @@ -279,7 +279,7 @@ func _meta_settle_and_workshop() -> void: Kit.check("卸下恒可再装配(自卸自装——营地换装=冒险开始前合法面,装配不可变只锁运行中)〔秘宝批换主语〕", mp_a.toggle_attunement("morning_order") and mp_a.toggle_attunement("morning_order")) var sv_a2 = _meta_new_save(_META_SMOKE_PATHS[7]) - sv_a2.set_attunement_equipped(["morning_order"]) + sv_a2.meta_state.set_attunement_equipped(["morning_order"]) var mp_a2 = load("res://core/meta_progression.gd").new(_db, sv_a2) Kit.check("装配关系读回持久(reload 后投影一致——写新件 id)〔秘宝批 V15〕", (mp_a2.attune_camp_status()["equipped"] as Array).has("morning_order")) @@ -294,20 +294,20 @@ func _meta_settle_and_workshop() -> void: a12.refresh_bank()._refresh_y = 2 a12.stop()._pending = {"candidates": [{"kind": "gp", "tier": "common"}, {"kind": "attack", "tier": "common"}, {"kind": "max_hp", "tier": "common"}], "payload": {}} Kit.check("刷新双计数器——X 优先消耗(x1y2 → x0y2)", - a12.stop().refresh_curio() and a12.refresh_bank().refresh_y() == 2 and a12.refresh_bank().free_left() == 2 + a12.refresh_bank().refresh_stock()) + a12.stop().refresh_curio() and a12.refresh_bank()._refresh_x == 0 and a12.refresh_bank()._refresh_y == 2) Kit.check("刷新双计数器——X 耗尽转 Y 池、池竭拒绝(x0y2 → y0 → 第四次 false 零副作用)", - a12.stop().refresh_curio() and a12.stop().refresh_curio() and a12.refresh_bank().refresh_y() == 0 + a12.stop().refresh_curio() and a12.stop().refresh_curio() and a12.refresh_bank()._refresh_y == 0 and a12.stop().refresh_curio() == false) a12.refresh_bank().reset_x() Kit.check("刷新 X 每碰三选一重置(不结转;新目录无 bonus_refresh 行源——X 机制保留恒 0 空转,存量加仓走 _refresh_stock=刷新练习 V8)〔秘宝批换代〕", - a12.refresh_bank().free_left() == 0) + a12.refresh_bank()._refresh_x == 0 and a12.refresh_bank().free_left() == 0) # ⑬ 主菜单开局传参面(开新/续玩两入口同调三键随开局下传防回归:真实入口曾 # 三键恒空(缺陷①),冒烟夹具直呼 runner 参数面拦不住——此处直呼 main.build_adventure_params # 单测统一构造面(static 免实例化整菜单场景;真实两入口共用本构造),取值与营地投影同源) var sv_e = _meta_new_save(_META_SMOKE_PATHS[8]) - sv_e.set_attunement_equipped(["morning_order"]) - sv_e.set_attune_refresh_level(2) - sv_e.set_fork_side("fg_posture", "a") + sv_e.meta_state.set_attunement_equipped(["morning_order"]) + sv_e.meta_state.set_attune_refresh_level(2) + sv_e.meta_state.set_fork_side("fg_posture", "a") var mp_e = load("res://core/meta_progression.gd").new(_db, sv_e) var rules_e: Array = mp_e.tree_rule_keys() Kit.check("开局传参夹具前置:Fork 侧 a 激活 rule 键非空 + Y=Lv2 行表值 2(防断言空转)", @@ -315,7 +315,7 @@ func _meta_settle_and_workshop() -> void: var p_new: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e, {"treasure_unlocked": {"wide_scout_band": true}, "shop_mode": "pool"}) Kit.check("主菜单开新冒险参数面:同调装配 = 存档装配集(营地装了什么开局就带什么)", - (p_new.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array)) + (p_new.get("attunement", []) as Array) == (sv_e.meta_state.attune_state().equipped as Array)) Kit.check("主菜单开新冒险参数面:Y 刷新池 = 当前等级行表值(营地升几档开局就给几次)", int(p_new.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges()) Kit.check("主菜单开新冒险参数面:树 rule 键集 = Fork 激活侧 effects(点哪侧带哪侧)", @@ -325,7 +325,7 @@ func _meta_settle_and_workshop() -> void: var p_resume: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e, {"treasure_unlocked": {}}) Kit.check("主菜单继续上局参数面:三键与开新冒险同源同值(续玩不吃白板开局)", - (p_resume.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array) + (p_resume.get("attunement", []) as Array) == (sv_e.meta_state.attune_state().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 续玩参数面 @@ -338,8 +338,7 @@ func _meta_settle_and_workshop() -> void: Kit.check("继续上局受理面:resume_adventure 受理三键参数面(尾裁微批b 受理闭环——受理不再吃白板)", adr_res.resume_adventure(bp_min, p_resume)) Kit.check("继续上局受理面:tree_rules 随参数面进 runner._tree_rules(消费面 set_tree_rules 下传现成)", - adr_res.runner() != null and adr_res.runner().pick_next_node(int(adr_res.runner().next_candidate_ids()[0])) - and ((adr_res.runner().current_battle().encounter.get("tree_rules", []) as Array) == rules_e)) + (adr_res._tree_rules as Array) == rules_e) _meta_smoke_cleanup() @@ -350,9 +349,9 @@ func _meta_injection() -> void: var mp = load("res://core/meta_progression.gd").new(_db, sv) # 树生命淬炼注入(公式:基础值+树淬炼 flat+锻造路线加值位预留 0——2 级 = +10; # 注入走调试口径直置等级〔两分法——购买链已在 _smoke_tree 覆盖,此处隔离行门槛干扰〕) - sv.set_tree_level("sp_hp", 2) + sv.meta_state.set_tree_level("sp_hp", 2) Kit.check("生命淬炼 2 级 → hero_override().max_hp == Σ 节点 effects×等级(验收4 换代〔天赋树批 V12〕)", - int(mp.hero_override().get("max_hp", -1)) == 2 * int(_db.tree_node("sp_hp").get("effects", {}).get("max_hp", 0))) + int(mp.hero_override().get("max_hp", -1)) == 2 * int(_db.crossrun.meta_tree.row_by_id("sp_hp").get("effects", {}).get("max_hp", 0))) Kit.check("攻/AC 树零来源(契约 9——树表零 atk/armor_class 行,override 二轴恒 0+预留位 0)", int(mp.hero_override().get("atk", -1)) == 0 and int(mp.hero_override().get("armor_class", -1)) == 0) Kit.check("命中线在账未注入(契约 6——override 无 hit 字段〔天赋树批 V13〕)", @@ -363,10 +362,10 @@ func _meta_injection() -> void: var r = adr.runner() var ov: Dictionary = mp.hero_override() Kit.check("首战 hero_atk = 表值 + 永久强化 flat(begin_battle 同表达式——下一局能用到强化)", - int(r.effective_atk()) == int(_db.hero.atk) + int(ov.get("atk", 0))) + int(r.effective_atk()) == int(_db.combat.hero.atk) + int(ov.get("atk", 0))) Kit.check("首战 hero_max_hp 与开局生命账本 = 表值 + 注入档", - int(r.effective_max_hp()) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0)) - and int(adr.stats().hero_hp) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0))) + int(r.effective_max_hp()) == int(_db.combat.hero.max_hp) + int(ov.get("max_hp", 0)) + and int(adr.stats().hero_hp) == int(_db.combat.hero.max_hp) + int(ov.get("max_hp", 0))) # 白板:同种子下 空参数 = 零强化参数 = 纯表值(逐位一致——注入通道默认值漏改在此转红) var adr_w = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251)) Kit.check("白板冒险启动(无 params 实参——B1 空参数路径)", adr_w.start_adventure(920251)) @@ -377,11 +376,11 @@ func _meta_injection() -> void: var rw = adr_w.runner() var rz = adr_z.runner() Kit.check("白板成立:空参数 = 零强化参数 = 纯表值(atk/max_hp/ac 三轴逐位一致——验收 2/3)", - int(rw.effective_atk()) == int(_db.hero.atk) and int(rw.effective_max_hp()) == int(_db.hero.max_hp) + int(rw.effective_atk()) == int(_db.combat.hero.atk) and int(rw.effective_max_hp()) == int(_db.combat.hero.max_hp) and int(rz.effective_atk()) == int(rw.effective_atk()) and int(rz.effective_max_hp()) == int(rw.effective_max_hp()) and int(rz.effective_ac()) == int(rw.effective_ac())) Kit.check("ac 轴:白板恒表值、未换强化时注入零位移", - int(rw.effective_ac()) == int(_db.hero.armor_class) and int(r.effective_ac()) == int(_db.hero.armor_class)) + int(rw.effective_ac()) == int(_db.combat.hero.armor_class) and int(r.effective_ac()) == int(_db.combat.hero.armor_class)) _meta_smoke_cleanup() @@ -394,10 +393,10 @@ func _smoke_forge_axes() -> void: not ("attack_type" in _db.hero) and not ("armor_type" in _db.hero)) # 锻造配置默认值 = normal/light(常驻持有——与柜台解锁状态解耦,未点亮也照常可读) Kit.check("锻造配置默认值 attack_type=normal / armor_type=light(常驻持有)〔锻造工坊批 V5〕", - _db.forge_default_attack_type() == "normal" and _db.forge_default_armor_type() == "light") + _db.crossrun.forge.default_attack_type == "normal" and _db.crossrun.forge.default_armor_type == "light") # 单位快照回退面:spawn 三参形态(零注入)= 锻造配置默认值 var uc = load("res://core/unit_container.gd").new(_db) - Kit.check("spawn 受理(三参形态——两轴缺省回退面)", uc.spawn(1, 0, int(_db.hero.max_hp))) + Kit.check("spawn 受理(三参形态——两轴缺省回退面)", uc.spawn({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)})) Kit.check("主角单位快照两轴 = 锻造配置默认值 normal/light(零注入回退——行为连续)〔锻造工坊批 V5〕", String(uc.hero_unit().get("attack_type", "")) == "normal" and String(uc.hero_unit().get("armor_type", "")) == "light") @@ -407,71 +406,71 @@ func _smoke_forge_axes() -> void: 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) + sv2.meta_state._add_keystone(100) Kit.check("长剑锻造首购:扣 20 钥石、等级 0→1(Q1——第一级=获得装备=首个 +1)〔锻造工坊批 V2〕", - mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 1 - and sv2.keystone_balance() == 80) + mp2.forge_buy_level("sword") and sv2.meta_state.forge_state().levels.get("sword", 0) == 1 + and sv2.meta_state.keystone_balance() == 80) var sv2r = _meta_new_save(_META_SMOKE_PATHS[13]) Kit.check("购买持久(reload 后等级与余额不变——即时写盘)〔锻造工坊批 V2〕", - sv2r.forge_route_level("sword") == 1 and sv2r.keystone_balance() == 80) + sv2r.meta_state.forge_state().levels.get("sword", 0) == 1 and sv2r.meta_state.keystone_balance() == 80) # V2②:二级=30(1 级位);六路线独立(买长剑不动轻甲) Kit.check("长剑二级购:扣 30、等级→2;轻甲独立零扰动(六路线各练各的)〔锻造工坊批 V2〕", - mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 2 - and sv2.keystone_balance() == 50 and sv2.forge_route_level("light") == 0) + mp2.forge_buy_level("sword") and sv2.meta_state.forge_state().levels.get("sword", 0) == 2 + and sv2.meta_state.keystone_balance() == 50 and sv2.meta_state.forge_state().levels.get("light", 0) == 0) # V2③:余额不足拒且零副作用(等级/选中/解锁三面零变化) var sv2d = _meta_new_save(_META_SMOKE_PATHS[14]) var mp2d = load("res://core/meta_progression.gd").new(_db, sv2d) - var fr_dry: Dictionary = sv2d.forge_routes_snapshot() + var fr_dry: Dictionary = sv2d.meta_state.forge_state() Kit.check("余额不足购买拒绝且零副作用(0 余额——三域零变化)〔锻造工坊批 V2〕", - not mp2d.forge_buy_level("sword") and sv2d.keystone_balance() == 0 - and sv2d.forge_routes_snapshot() == fr_dry) + not mp2d.forge_buy_level("sword") and sv2d.meta_state.keystone_balance() == 0 + and sv2d.meta_state.forge_state() == fr_dry) # V2④:已满级拒购(cap 闸);未解锁拒购 - sv2.set_forge_route_level("sword", 5) + sv2.meta_state.set_forge_route_level("sword", 5) Kit.check("已满级拒购/未解锁拒购(cap 闸+解锁闸——零副作用)〔锻造工坊批 V2〕", not mp2.forge_buy_level("sword") and not mp2d.forge_buy_level("spear")) # V3① 通道一:练满前一档(长剑 cap)→ 长枪/法杖派生解锁(零收费);轻甲未满→甲线仍锁 Kit.check("通道一:长剑练满→长枪/法杖派生解锁;轻甲未练满→重甲/魔法护甲仍锁〔锻造工坊批 V3〕", - mp2.forge_route_unlocked("spear") and mp2.forge_route_unlocked("staff") - and not mp2.forge_route_unlocked("heavy") and not mp2.forge_route_unlocked("ward")) + mp2._forge_route_unlocked("spear") and mp2._forge_route_unlocked("staff") + and not mp2._forge_route_unlocked("heavy") and not mp2._forge_route_unlocked("ward")) Kit.check("派生解锁后长枪可购(首购 20——余额 50−20)〔锻造工坊批 V3〕", - mp2.forge_buy_level("spear") and sv2.forge_route_level("spear") == 1 - and sv2.keystone_balance() == 30) + mp2.forge_buy_level("spear") and sv2.meta_state.forge_state().levels.get("spear", 0) == 1 + and sv2.meta_state.keystone_balance() == 30) # V3② 通道二:一次性解锁价直购(未练满也可买——两通道互不影响) var sv3 = _meta_new_save(_META_SMOKE_PATHS[15]) var mp3 = load("res://core/meta_progression.gd").new(_db, sv3) - sv3.add_keystone(500) + sv3.meta_state._add_keystone(500) Kit.check("通道二:魔法护甲一次性解锁价 200 直购开(轻甲未练满也可买)〔锻造工坊批 V3〕", - not mp3.forge_route_unlocked("ward") and mp3.forge_buy_unlock("ward") - and sv3.keystone_balance() == 300 and mp3.forge_route_unlocked("ward")) + not mp3._forge_route_unlocked("ward") and mp3.forge_buy_unlock("ward") + and sv3.meta_state.keystone_balance() == 300 and mp3._forge_route_unlocked("ward")) # V3③ 直购后练满不重复收(练满通道=派生零收费;已解锁再直购=拒绝) - sv3.set_forge_route_level("light", 5) + sv3.meta_state.set_forge_route_level("light", 5) Kit.check("直购后练满不重复收(已解锁=拒绝零收费——两通道互不影响)〔锻造工坊批 V3〕", - mp3.forge_route_unlocked("ward") and not mp3.forge_buy_unlock("ward") - and sv3.keystone_balance() == 300) + mp3._forge_route_unlocked("ward") and not mp3.forge_buy_unlock("ward") + and sv3.meta_state.keystone_balance() == 300) # V3④ 不设串联门槛(法杖直购开——长枪零状态不阻;dry 档充值走直购) - sv2d.add_keystone(500) + sv2d.meta_state._add_keystone(500) Kit.check("不设串联门槛(法杖直购开——长枪零状态不阻)〔锻造工坊批 V3〕", - mp2d.forge_buy_unlock("staff") and mp2d.forge_route_unlocked("staff") - and sv2d.forge_route_level("spear") == 0 and not mp2d.forge_route_unlocked("spear")) + mp2d.forge_buy_unlock("staff") and mp2d._forge_route_unlocked("staff") + and sv2d.meta_state.forge_state().levels.get("spear", 0) == 0 and not mp2d._forge_route_unlocked("spear")) # V4:任一购买/解锁只扣钥石——GP 侧零触碰(meta 域无 GP 键=域界扫描同款;此处钉余额差=当级价) - var ks4: int = sv3.keystone_balance() + var ks4: int = sv3.meta_state.keystone_balance() Kit.check("两币分工:购买只扣钥石(余额差=当级价 20——GP 零触碰、无材料计数)〔锻造工坊批 V4〕", - mp3.forge_buy_level("ward") and sv3.keystone_balance() == ks4 - 20 - and sv3.forge_route_level("ward") == 1) + mp3.forge_buy_level("ward") and sv3.meta_state.keystone_balance() == ks4 - 20 + and sv3.meta_state.forge_state().levels.get("ward", 0) == 1) # V6①:营地切换=写存档域+免费(余额零动);旧路线练度保留(只不生效) - var ks6: int = sv3.keystone_balance() + var ks6: int = sv3.meta_state.keystone_balance() Kit.check("选中切换免费(余额零动——写存档域即盘)〔锻造工坊批 V6〕", - mp3.forge_select_armor("ward") and sv3.forge_armor() == "ward" - and sv3.keystone_balance() == ks6) + mp3.forge_select("armor", "ward") and sv3.meta_state.forge_state().armor == "ward" + and sv3.meta_state.keystone_balance() == ks6) Kit.check("切换后旧路线练度保留(轻甲 5 级不丢——回家待命只不生效)〔锻造工坊批 V6〕", - sv3.forge_route_level("light") == 5) + sv3.meta_state.forge_state().levels.get("light", 0) == 5) # V6②:未解锁路线拒选零副作用(dry 档:长枪未解锁) Kit.check("未解锁路线拒选零副作用〔锻造工坊批 V6〕", - not mp2d.forge_select_weapon("spear") and sv2d.forge_weapon() == "sword") + not mp2d.forge_select("weapon", "spear") and sv2d.meta_state.forge_state().weapon == "sword") # V6③:类型轴读数随选中走(切换后读 ward;未解锁选中态防御回退默认值) Kit.check("类型轴当前生效值随选中走(ward;未解锁档回退默认 normal/light)〔锻造工坊批 V5/V6〕", - mp3.hero_armor_type() == "ward" and mp2d.hero_attack_type() == "normal" - and mp2d.hero_armor_type() == "light") + mp3.hero_type_axes()["armor"] == "ward" and mp2d.hero_type_axes()["attack"] == "normal" + and mp2d.hero_type_axes()["armor"] == "light") # ---- T4 注入接线(V5 后半 · V6 锁死 · V7 公式对拍 · V10 树零攻防复核)---- # V5:build_adventure_params 注入默认值(新档=树钥匙未点亮)+点亮后读存档选中覆盖 @@ -481,9 +480,9 @@ func _smoke_forge_axes() -> void: Kit.check("build_adventure_params 注入默认值 normal/light(树钥匙未点亮——新档初始持有态)〔锻造工坊批 V5〕", String(bp_params.get("attack_type", "")) == "normal" and String(bp_params.get("armor_type", "")) == "light") - sv4.add_keystone(1000) + sv4.meta_state._add_keystone(1000) Kit.check("长枪直购解锁+首购+选中(注入面夹具——200+20 钥石)", - mp4.forge_buy_unlock("spear") and mp4.forge_buy_level("spear") and mp4.forge_select_weapon("spear")) + mp4.forge_buy_unlock("spear") and mp4.forge_buy_level("spear") and mp4.forge_select("weapon", "spear")) var bp_sel: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv4, mp4) Kit.check("build_adventure_params 读存档选中覆盖(选中长枪→pierce)〔锻造工坊批 V5〕", String(bp_sel.get("attack_type", "")) == "pierce" @@ -494,17 +493,17 @@ func _smoke_forge_axes() -> void: and int(mp4.hero_override().get("max_hp", -1)) == 0 and int(mp4.hero_override().get("armor_class", -1)) == 0 and not mp4.hero_override().has("hit")) - sv4.set_forge_route_level("sword", 3) # 长剑练度待命(未选中) + sv4.meta_state.set_forge_route_level("sword", 3) # 长剑练度待命(未选中) Kit.check("换当前武器→攻击加值随切换(长剑 3 级待命不生效——只认选中)〔锻造工坊批 V7〕", int(mp4.hero_override().get("atk", -1)) == 1) Kit.check("切回长剑→加值=3 级量(旧路线练度保留且生效)〔锻造工坊批 V7〕", - mp4.forge_select_weapon("sword") and int(mp4.hero_override().get("atk", -1)) == 3) + mp4.forge_select("weapon", "sword") and int(mp4.hero_override().get("atk", -1)) == 3) # V7 三层 AC:重甲(基础 12)+路线 Lv1 加值+局内 0——经参数面起冒险断 runner 生效值 var sv5 = _meta_new_save(_META_SMOKE_PATHS[17]) var mp5 = load("res://core/meta_progression.gd").new(_db, sv5) - sv5.add_keystone(1000) + sv5.meta_state._add_keystone(1000) Kit.check("重甲直购解锁+首购+选中(三层 AC 夹具)", - mp5.forge_buy_unlock("heavy") and mp5.forge_buy_level("heavy") and mp5.forge_select_armor("heavy")) + mp5.forge_buy_unlock("heavy") and mp5.forge_buy_level("heavy") and mp5.forge_select("armor", "heavy")) var adr5 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(77)) var p5: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv5, mp5) Kit.check("注入参数冒险启动(两轴+override 同面)", adr5.start_adventure(77, p5)) @@ -515,7 +514,7 @@ func _smoke_forge_axes() -> void: adr5.stats().attack_type == "normal" and adr5.stats().armor_type == "heavy") # V6 锁死:开冒险后改存档选中→参数面/注入值不变(快照冻结;light=初始持有已解锁可切) Kit.check("冒险开始后锁死:改存档选中不影响参数面快照与生效值(契约 8)〔锻造工坊批 V6〕", - mp5.forge_select_armor("light") and adr5.stats().armor_type == "heavy" + mp5.forge_select("armor", "light") and adr5.stats().armor_type == "heavy" and int(r5.effective_ac()) == 12 + 1) # V5/V7 末端:enc 两轴直达 spawn 英雄快照(hero_attack 行携带选中类型键) var run_ax = _run_battle(1, 0, {"attack_type": "pierce", "armor_type": "ward"}) @@ -528,12 +527,12 @@ func _smoke_forge_axes() -> void: # spawn 显式注入面(三参旧形态零注入已在上方覆盖——此处钉显式值) var uc2 = load("res://core/unit_container.gd").new(_db) Kit.check("spawn 显式两轴注入(magic/heavy 直入英雄快照)〔锻造工坊批 V5〕", - uc2.spawn(1, 0, int(_db.hero.max_hp), [], -1, -1, [], "magic", "heavy") + uc2.spawn({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp), "hero_attack_type": "magic", "hero_armor_type": "heavy"}) and String(uc2.hero_unit().get("attack_type", "")) == "magic" and String(uc2.hero_unit().get("armor_type", "")) == "heavy") # V10 复核:树表 12 行 effects 键集无 atk/armor_class(契约 1——树零攻防来源) var tree_atk_ac := false - for trow in _db.tree_rows(): + for trow in _db.crossrun.meta_tree.rows: var eff: Dictionary = trow.get("effects", {}) if eff.has("atk") or eff.has("armor_class"): tree_atk_ac = true @@ -547,11 +546,11 @@ func _smoke_tree() -> void: print("--- 天赋树(12 节点 · 购买面 · Fork 双态 · 彩蛋觉醒)---") # 〔V1〕结构:12 行 + schema 零违例 Kit.check("meta_tree 已加载(12 节点=脊柱 2〔生命/命中淬炼一线一节点 cap4〕+锻造 2〔钥匙+彩蛋——彩蛋含在锻造 2 内非第 13 行〕+钥石 2〔秘宝匣/刷新升级〕+同调 6〔socket 3+Fork 3 双态〕——v0.8 终态)〔天赋树批 V1〕", - _db.meta_tree != null and (_db.meta_tree.rows as Array).size() == 12) + _db.meta_tree != null and (_db.crossrun.meta_tree.rows as Array).size() == 12) Kit.check("MetaTreeTable.validate 零违例(脉/类型/币词表出域即拒/Fork 三组每组恰一且持 a/b 双效果键/彩蛋 ≤3/effects 键封闭/零 archetype)", - _db.meta_tree.validate().is_empty()) + _db.crossrun.meta_tree.validate().is_empty()) # 彩蛋行硬要求(契约 9 红线落点):effects 键集 ⊆ {max_hp, hit} - var ley: Dictionary = _db.meta_tree.row_by_id("easter_ley") + var ley: Dictionary = _db.crossrun.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"): @@ -560,66 +559,66 @@ func _smoke_tree() -> void: ley_ok) # V1/V4 反例:出域行逐类构造被拒(复制行副本塞词表外值) var t2 = load("res://data/schemas/meta_tree_table.gd").new() - var bad_vein: Dictionary = (_db.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true) + var bad_vein: Dictionary = (_db.crossrun.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true) bad_vein["node_id"] = "bad_vein" bad_vein["vein"] = "underworld" t2.rows = [bad_vein] Kit.check("schema 反例:vein 出域被拒", not t2.validate().is_empty()) - var bad_fork: Dictionary = (_db.meta_tree.row_by_id("fork_posture") as Dictionary).duplicate(true) + var bad_fork: Dictionary = (_db.crossrun.meta_tree.row_by_id("fork_posture") as Dictionary).duplicate(true) bad_fork["node_id"] = "bad_fork" bad_fork["fork_group"] = "fg_mastery" t2.rows = [bad_fork] Kit.check("schema 反例:fork_group 出域(fg_mastery 已退役组)被拒", not t2.validate().is_empty()) - var bad_arch: Dictionary = (_db.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true) + var bad_arch: Dictionary = (_db.crossrun.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true) bad_arch["node_id"] = "bad_arch" bad_arch["effects"] = {"archetype": 1} t2.rows = [bad_arch] Kit.check("schema 反例:effects.archetype 加载致命(🔴 层级纪律护栏面)〔天赋树批 V4〕", not t2.validate().is_empty()) - var bad_dangling: Dictionary = (_db.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true) + var bad_dangling: Dictionary = (_db.crossrun.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true) bad_dangling["requires"] = ["ghost_node"] t2.rows = [bad_dangling] Kit.check("schema 反例:requires 悬空被拒", not t2.validate().is_empty()) # 购买面(V2 主干) var sv = _meta_new_save(_META_SMOKE_PATHS[8]) var mp = load("res://core/meta_progression.gd").new(_db, sv) - sv.add_keystone(1000) + sv.meta_state._add_keystone(1000) Kit.check("树购买受理(脊柱生命 L1——树全钥石单币)", - mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1) + mp.tree_buy("sp_hp") and sv.meta_state.tree_state().nodes.get("sp_hp", 0) == 1) Kit.check("行门槛:第 2 级不可购(总等级 1 < 4——一线一节点主语,买第 N 级需全树总投入 ≥4×(N−1))〔天赋树批 V2〕", not mp.tree_can_buy("sp_hp")) Kit.check("命中 L1/锻造钥匙(free 无前置=初始零购零币可点亮)/同调位 1(根节点无前置)受理(总等级 1→4)", mp.tree_buy("sp_hit") and mp.tree_buy("forge_workshop") and mp.tree_buy("att_socket1")) - Kit.check("锻造工坊钥匙零币(free)", int(_db.tree_node("forge_workshop").get("price", -1)) == 0) + Kit.check("锻造工坊钥匙零币(free)", int(_db.crossrun.meta_tree.row_by_id("forge_workshop").get("price", -1)) == 0) Kit.check("行门槛恰达开放(总等级 4 ≥ 4×1)", mp.tree_can_buy("sp_hp")) Kit.check("第 2 级购买受理(门槛恰达当刻开放)", - mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 2) + mp.tree_buy("sp_hp") and sv.meta_state.tree_state().nodes.get("sp_hp", 0) == 2) # Fork 一节点双态(V9) - sv.add_keystone(100) + sv.meta_state._add_keystone(100) Kit.check("Fork 购买=解锁+默认 A 态(一节点双态——单行承载)", - mp.tree_buy_fork("fork_posture") and sv.tree_level("fork_posture") == 1 and sv.fork_side("fg_posture") == "a") + mp.tree_buy_fork("fork_posture") and sv.meta_state.tree_state().nodes.get("fork_posture", 0) == 1 and sv.meta_state.tree_state().fork_sides.get("fg_posture", "") == "a") Kit.check("已解锁 Fork 重复购买拒绝(cap 1)", not mp.tree_can_buy("fork_posture")) Kit.check("同态重复切换零操作(false)", not mp.tree_toggle_fork("fg_posture", "a")) Kit.check("未解锁 Fork 切换拒绝(解锁唯一入口=购买面)", not mp.tree_toggle_fork("fg_sense", "b")) - var bal_pre_toggle: int = sv.keystone_balance() + var bal_pre_toggle: int = sv.meta_state.keystone_balance() Kit.check("零币切态 b(购买解锁后 A/B 可切)", - mp.tree_toggle_fork("fg_posture", "b") and sv.fork_side("fg_posture") == "b") - Kit.check("切态零消费(钥石余额差分不动)", int(sv.keystone_balance()) == bal_pre_toggle) + mp.tree_toggle_fork("fg_posture", "b") and sv.meta_state.tree_state().fork_sides.get("fg_posture", "") == "b") + Kit.check("切态零消费(钥石余额差分不动)", int(sv.meta_state.keystone_balance()) == bal_pre_toggle) Kit.check("规则键投影=激活态效果键(B 态稳健拓荒——tree_rule_keys 随切态换键集)", (mp.tree_rule_keys() as Array).has("posture_bulwark") and not (mp.tree_rule_keys() as Array).has("posture_first_strike")) Kit.check("切回 A 态键集随换(六键词表在册——V11 校验面归单元段)", mp.tree_toggle_fork("fg_posture", "a") and (mp.tree_rule_keys() as Array).has("posture_first_strike")) # 彩蛋特化觉醒(V8) Kit.check("彩蛋不经购买(手动购亮=红——觉醒唯一入口=特化判定)", not mp.tree_can_buy("easter_ley")) - Kit.check("觉醒前置未满足不亮(两线未拉满)", sv.tree_level("easter_ley") == 0) - sv.set_tree_level("sp_hp", 4) - sv.set_tree_level("sp_hit", 4) - sv.set_tree_level("att_socket2", 1) + Kit.check("觉醒前置未满足不亮(两线未拉满)", sv.meta_state.tree_state().nodes.get("easter_ley", 0) == 0) + sv.meta_state.set_tree_level("sp_hp", 4) + sv.meta_state.set_tree_level("sp_hit", 4) + sv.meta_state.set_tree_level("att_socket2", 1) mp.tree_buy("att_socket3") # 经购买口触发 _check_easter_egg - Kit.check("两线拉满+同调位全解锁 → 地脉共鸣自动点亮(零币特化判定)", sv.tree_level("easter_ley") == 1) - var bal_now: int = sv.keystone_balance() + Kit.check("两线拉满+同调位全解锁 → 地脉共鸣自动点亮(零币特化判定)", sv.meta_state.tree_state().nodes.get("easter_ley", 0) == 1) + var bal_now: int = sv.meta_state.keystone_balance() mp.tree_buy("sp_hp") - Kit.check("觉醒零消费(钥石余额不动)", int(sv.keystone_balance()) == bal_now) + Kit.check("觉醒零消费(钥石余额不动)", int(sv.meta_state.keystone_balance()) == bal_now) _meta_smoke_cleanup() @@ -731,13 +730,11 @@ func _smoke_tree_rules() -> void: # 财富之道·散:商品标价折价(level_runner 计价单点) var lr = load("res://core/level_runner.gd").new(_db, _fresh_rng(777004), {"tree_rules": []}) # 构造注入(拆件批 T3——setter 群退役) - Kit.check("散未激活:商品标价原价(无 economy_spend 键)", - int(lr.current_price({"base_price": 100, "price_increment": 0})) == 100 + int(_db.shop_price_level_bonus(0))) + Kit.check("散未激活:商品标价原价(无 economy_spend 键)", int(lr._spend_discounted(100)) == 100) lr = load("res://core/level_runner.gd").new(_db, _fresh_rng(777005), {"tree_rules": ["economy_spend"]}) # 构造注入(拆件批 T3——setter 群退役) Kit.check("散激活:商品购买价 ×80%(provisional 折率——floor 偏向玩家、下限 1;刷新价不折=另一计价点零触碰)", - int(lr.current_price({"base_price": 100, "price_increment": 0})) == maxi(1, int(floor(float(100 + int(_db.shop_price_level_bonus(0))) * float(_db.shop.spend_discount)))) - and int(lr.current_price({"base_price": 1, "price_increment": 0})) == maxi(1, int(floor(float(1 + int(_db.shop_price_level_bonus(0))) * float(_db.shop.spend_discount))))) + int(lr._spend_discounted(100)) == 80 and int(lr._spend_discounted(1)) == 1) # 财富之道·聚:战斗掉落乘算·入账前(编排 _roll_gp_drop——同种子配对对拍 1.5×) var orch3 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777005)) Kit.check("聚 rig:基线编排启动(无增益侧)", diff --git a/game/tests/unit/core/node_map_generator_test.gd b/game/tests/unit/core/node_map_generator_test.gd index 01c8c767..9c1bc48d 100644 --- a/game/tests/unit/core/node_map_generator_test.gd +++ b/game/tests/unit/core/node_map_generator_test.gd @@ -35,7 +35,7 @@ func _init() -> void: func _params(L: int) -> Dictionary: - return _db.level_gen_params_row(L) + return _db.run.level_gen_params.row_by_level_index(L) func _graph(L: int, seed_val: int) -> Dictionary: @@ -310,6 +310,7 @@ func _test_rng_single_point() -> void: svc.new_adventure(4242) var before: int = svc.counter() var g = GenScript.new(svc).generate(_params(2), 42) + Kit.check("pure_stream 不推全局计数器(产图前后 counter 同值)〔fv10〕", svc.counter() == before) var a = svc.pure_stream(42, 0) var b = svc.pure_stream(42, 0) Kit.check("pure_stream 同 (seed,scope) 同流〔fv10〕", a.randf() == b.randf() and a.randi() == b.randi()) diff --git a/game/tests/unit/core/node_map_growth_test.gd b/game/tests/unit/core/node_map_growth_test.gd index ef8f7608..86d3227f 100644 --- a/game/tests/unit/core/node_map_growth_test.gd +++ b/game/tests/unit/core/node_map_growth_test.gd @@ -28,7 +28,7 @@ func _init() -> void: func _reading(gen, db, L: int) -> Dictionary: - var g: Dictionary = gen.generate(db.level_gen_params_row(L), 42) + var g: Dictionary = gen.generate(db.run.level_gen_params.row_by_level_index(L), 42) var maxd := 0 for n in g["nodes"]: maxd = maxi(maxd, int(n["depth"])) @@ -62,8 +62,8 @@ func _test_growth(gen, db) -> void: func _test_fail_fast_static(gen, db) -> void: Kit.check("生产三档参数档可行性判定零错误〔fv4〕", - NodeMapGenerator.check_params(db.level_gen_params_row(1)).is_empty() - and NodeMapGenerator.check_params(db.level_gen_params_row(3)).is_empty()) + NodeMapGenerator.check_params(db.run.level_gen_params.row_by_level_index(1)).is_empty() + and NodeMapGenerator.check_params(db.run.level_gen_params.row_by_level_index(3)).is_empty()) var unsolvable := {"layers": 6, "branch_density": 0.0, "battle_ratio": 0.5, "reward_ratio": 0.2, "rest_ratio": 0.2, "shop_ratio": 0.1, "entry_count": 1, "exit_count": 3, "battle_floor": 4, "fork_ways_min": 2, "fork_ways_max": 3} diff --git a/game/tests/unit/core/pool_case_d_test.gd b/game/tests/unit/core/pool_case_d_test.gd index 3319f765..836679b0 100644 --- a/game/tests/unit/core/pool_case_d_test.gd +++ b/game/tests/unit/core/pool_case_d_test.gd @@ -36,7 +36,7 @@ func _init() -> void: var e = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new()) e.start_adventure(501) e.stop()._pending = {"candidates": []} - Kit.check_quiet(e.stop().skip_empty_curio() and (e.stop().breakpoint_fragment().get("bonus_pending", {}) as Dictionary).is_empty(), + Kit.check_quiet(e.stop().skip_empty_curio() and e.stop()._pending.is_empty(), "空配置闸:三选一池空=停点整体跳过(skip_empty_curio 消化)〔专长批 T10〕") e.stop()._pending = {"candidates": [{"kind": "gp", "tier": "common"}]} Kit.check_quiet(not e.stop().skip_empty_curio(), @@ -78,7 +78,7 @@ func check_v7(a, db) -> void: continue var hi := 0 for t in cands: - if db.adventure.tier_rank(String(t.get("tier", ""))) >= db.adventure.tier_rank(db.adventure.TIER_EXCELLENT): + if db.run.adventure.tier_rank(String(t.get("tier", ""))) >= db.run.adventure.tier_rank(db.run.adventure.TIER_EXCELLENT): hi += 1 if hi >= 1: hi_seen = true @@ -161,21 +161,21 @@ func check_v8(c, db) -> void: Kit.check_quiet(false, "V8 前置:冒险启动") return # 概率位与 cap 两属性独立:改 cap 不动牌库(概率位)、改牌库不动 cap 字段 - var cap_field_before := int(db.adventure.curio_cap_gp) - db.adventure.curio_cap_gp = 1 # 独立设 cap(概率位=gp 令牌数不动) + var cap_field_before := int(db.run.adventure.cap_segment.curio_cap_gp) + db.run.adventure.cap_segment.curio_cap_gp = 1 # 独立设 cap(概率位=gp 令牌数不动) var gp_tokens: int = c.deck().pool_stock("gp") - Kit.check_quiet(gp_tokens > 0 and int(db.adventure.curio_value("gp", "common")) == 20, + Kit.check_quiet(gp_tokens > 0 and int(db.run.adventure.curio_value("gp", "common")) == 20, "改 cap 不动概率位:牌库 gp 令牌 %d 枚在库、件值字段不受影响〔专长批 V8〕" % gp_tokens) # cap 只在拿了才计:刷出不计 c._curio.deck = [_tok("gp", "common"), _tok("attack", "common"), _tok("max_hp", "common")] for i in 20: c.deck().draw_candidates("reward_node") - Kit.check_quiet(bool(c.deck().cap_ok("gp", "")) and int(c.deck().taken.get("gp", 0)) == 0, + Kit.check_quiet(bool(c._curio.cap_ok("gp", "")) and int(c._curio.taken.get("gp", 0)) == 0, "cap 刷出不计:20 次抽签后 gp 未拿、cap 未触发〔专长批 V8〕") # 拿了才计:pick 一次→cap 满→提前退役(候选视图剔除、牌库本体不动) c.stop()._pending = {"candidates": [_tok("gp", "common"), _tok("attack", "common"), _tok("max_hp", "common")]} Kit.check_quiet(c.stop().pick_curio("gp", "common"), "V8 前置:兑付 gp 一枚") - Kit.check_quiet(not bool(c.deck().cap_ok("gp", "")), "cap 拿了才计:兑付后 cap 满(taken 1/1)〔专长批 V8〕") + Kit.check_quiet(not bool(c._curio.cap_ok("gp", "")), "cap 拿了才计:兑付后 cap 满(taken 1/1)〔专长批 V8〕") var gp_in_deck := 0 for t in c._curio.deck: if String((t as Dictionary).get("kind", "")) == "gp": @@ -187,4 +187,4 @@ func check_v8(c, db) -> void: gp_cand = true Kit.check_quiet(gp_in_deck >= 0 and not gp_cand, "提前退役:cap 满后候选视图零 gp(牌库本体张数不动=%d——概率位独立存活)〔专长批 V8〕" % gp_in_deck) - db.adventure.curio_cap_gp = cap_field_before # 还原表(同进程后续套件防御) + db.run.adventure.cap_segment.curio_cap_gp = cap_field_before # 还原表(同进程后续套件防御) diff --git a/game/tests/unit/core/save_boot_no_activate_test.gd b/game/tests/unit/core/save_boot_no_activate_test.gd index 2c0725f7..9e1d6629 100644 --- a/game/tests/unit/core/save_boot_no_activate_test.gd +++ b/game/tests/unit/core/save_boot_no_activate_test.gd @@ -23,14 +23,14 @@ func _init() -> void: # ① 无参实例(autoload 实例化形态)=无激活态+默认合法空态(sv2 类级) func _test_no_arg_instance_has_no_activation() -> void: var sm := SaveManagerScript.new() - Kit.check_quiet(sm.active_slot() == 0 and not sm.has_write_target(), + Kit.check_quiet(sm._path == "" and sm.slots.active_slot() == 0 and not sm.slots.has_write_target(), "sv2 无参实例无激活态(路径空/激活号 0/写盘目标空)〔卡① sv2〕") - Kit.check_quiet(sm.keystone_balance() == 0 and not sm.has_breakpoint() - and sm.adventure_total() == 0 and sm.adventure_cleared() == 0, + Kit.check_quiet(sm.meta_state.keystone_balance() == 0 and not sm.has_breakpoint() + and sm.meta_state.career_state().adventure_total == 0 and sm.meta_state.career_state().adventure_cleared == 0, "sv2 内存=默认合法空态(钥石/断点/总局数/冒险成功数全零值——访问器 fail-safe)〔卡① sv2〕") # attach_path 注入任意隔离路径=注入即激活(契约 7 单路径语义——隔离面行为零变化) - sm.attach_path("user://smoke_boot_noact_iso.json") - Kit.check_quiet(sm.has_write_target() and sm.keystone_balance() == 0, + sm.slots.attach_path("user://smoke_boot_noact_iso.json") + Kit.check_quiet(sm.slots.has_write_target() and sm.meta_state.keystone_balance() == 0, "sv2 attach_path 注入即激活(写盘目标在位、数据回默认空态)〔卡① sv2/契约7〕") if FileAccess.file_exists("user://smoke_boot_noact_iso.json"): DirAccess.remove_absolute("user://smoke_boot_noact_iso.json") @@ -43,7 +43,7 @@ func _test_write_rejected_without_target() -> void: var sm := SaveManagerScript.new() var before := {} for i in range(1, 4): - var p: String = sm.slot_path(i) + var p: String = sm.slots.slot_path(i) before[i] = [FileAccess.file_exists(p), FileAccess.get_file_as_bytes(p) if FileAccess.file_exists(p) else PackedByteArray(), FileAccess.get_modified_time(p) if FileAccess.file_exists(p) else 0] @@ -55,7 +55,7 @@ func _test_write_rejected_without_target() -> void: "sv3 无激活写盘拒绝(write_to_disk=false)〔卡① sv3〕") var unchanged := true for i in range(1, 4): - var p: String = sm.slot_path(i) + var p: String = sm.slots.slot_path(i) var now_state: Array = [FileAccess.file_exists(p), FileAccess.get_file_as_bytes(p) if FileAccess.file_exists(p) else PackedByteArray(), FileAccess.get_modified_time(p) if FileAccess.file_exists(p) else 0] @@ -88,11 +88,11 @@ func _test_settle_chain_write_rejected() -> void: for sp in ["res://.tmp", "res://.tmp.tmp"]: if FileAccess.file_exists(sp): DirAccess.remove_absolute(sp) - Kit.check_quiet(not sm.has_write_target() and not sm.write_to_disk(), + Kit.check_quiet(not sm.slots.has_write_target() and not sm.write_to_disk(), "sv3 链前置:无激活态在位且直写被拒〔卡① sv3〕") meta.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, "stopped_at": 2, "keystone_total": 0, "master_seed": 3}) - Kit.check_quiet(not sm.has_write_target() and sm.adventure_total() == 1, + Kit.check_quiet(not sm.slots.has_write_target() and sm.meta_state.career_state().adventure_total == 1, "sv3 生产链:settle_adventure 落账内存(adventure_total=1)而写盘仍无目标(被拒不落盘)〔卡① sv3〕") Kit.check_quiet(not FileAccess.file_exists("res://.tmp") and not FileAccess.file_exists("res://.tmp.tmp"), @@ -102,11 +102,11 @@ func _test_settle_chain_write_rejected() -> void: # ③ 越界槽号三原语拒绝+窥视越界返回不可读(原语边界 fail-safe) func _test_out_of_range_slots_rejected() -> void: var sm := SaveManagerScript.new() - sm.attach_slot_template("user://smoke_boot_noact_%d.json") - Kit.check_quiet(not sm.activate_slot(0) and not sm.activate_slot(4) - and not sm.create_slot(0) and not sm.create_slot(4) - and not sm.delete_slot(0) and not sm.delete_slot(4), + sm.slots.attach_slot_template("user://smoke_boot_noact_%d.json") + Kit.check_quiet(not sm.slots.activate_slot(0) and not sm.slots.activate_slot(4) + and not sm.slots.create_slot(0) and not sm.slots.create_slot(4) + and not sm.slots.delete_slot(0) and not sm.slots.delete_slot(4), "原语边界:槽号 0/4 全拒绝(位数写死 3——契约 1)〔卡① sv1〕") - var out: Dictionary = sm.peek_slot_summary(4) + var out: Dictionary = sm.slots.peek_slot_summary(4) Kit.check_quiet(not bool(out.get("readable", true)), "原语边界:越界窥视返回不可读标记〔卡① sv7〕") diff --git a/game/tests/unit/core/save_manager_domain_test.gd b/game/tests/unit/core/save_manager_domain_test.gd index 8e23fd32..70ea1100 100644 --- a/game/tests/unit/core/save_manager_domain_test.gd +++ b/game/tests/unit/core/save_manager_domain_test.gd @@ -67,55 +67,55 @@ func _smoke_save() -> void: # ① 往返:全访问器写非默认值 → 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, + w.meta_state._add_keystone(7) + w.meta_state.set_tree_level("sp_hp", 2) + w.meta_state.set_tree_level("sp_hit", 1) + w.meta_state.set_fork_side("fg_posture", "b") # 树域写入 + w.meta_state.set_forge_route_level("spear", 3) # 锻造工坊域写入(等级+直购解锁+选中标记) + w.meta_state.add_forge_unlock("spear") + w.meta_state.set_forge_weapon("spear") + w.meta_state.set_forge_armor("ward") + w.meta_state.unlock_item("probe_compass") + w.meta_state.apply_adventure_summary({"result": "failed", "cleared": false, "levels_cleared": 2, "stopped_at": 3, "keystone_total": 3, "master_seed": 99}) Kit.check("apply 原语入账(钥石 7+3、关卡完成次数 1/1/0)", - w.keystone_balance() == 10 and w.clear_count(1) == 1 and w.clear_count(2) == 1 and w.clear_count(3) == 0) + w.meta_state.keystone_balance() == 10 and w.meta_state.career_state().clear_counts.get(str(1), 0) == 1 and w.meta_state.career_state().clear_counts.get(str(2), 0) == 1 and w.meta_state.career_state().clear_counts.get(str(3), 0) == 0) Kit.check("首杀标记随关卡完成置位(1/2 有、3 无)", - w.boss_first_kill_marked(1) and w.boss_first_kill_marked(2) and not w.boss_first_kill_marked(3)) + w.meta_state.career_state().boss_first_kill.has(str(1)) and w.meta_state.career_state().boss_first_kill.has(str(2)) and not w.meta_state.career_state().boss_first_kill.has(str(3))) Kit.check("生涯终身计数随 apply 自增恰一(中止局:总局数 1、冒险成功局数 0——迭代 18 卡④)", - w.adventure_total() == 1 and w.adventure_cleared() == 0) + w.meta_state.career_state().adventure_total == 1 and w.meta_state.career_state().adventure_cleared == 0) Kit.check("写盘成功", w.write_to_disk()) var r = sm_script.new(paths[0]) Kit.check("读回成功", r.load_from_disk()) - Kit.check("钥石余额往返相等(10)", r.keystone_balance() == 10) + Kit.check("钥石余额往返相等(10)", r.meta_state.keystone_balance() == 10) Kit.check("树域往返相等(tree_nodes/fork_sides——迭代 21 换代承载)", - r.tree_level("sp_hp") == 2 and r.tree_level("sp_hit") == 1 and r.fork_side("fg_posture") == "b") + r.meta_state.tree_state().nodes.get("sp_hp", 0) == 2 and r.meta_state.tree_state().nodes.get("sp_hit", 0) == 1 and r.meta_state.tree_state().fork_sides.get("fg_posture", "") == "b") Kit.check("锻造工坊域往返相等(levels/unlocked/weapon/armor——迭代 23 T5)〔锻造工坊批 V8〕", - r.forge_route_level("spear") == 3 and r.forge_route_level("sword") == 0 - and r.forge_unlocked_purchases() == ["spear"] - and r.forge_weapon() == "spear" and r.forge_armor() == "ward") - Kit.check("道具解锁往返相等", r.item_unlocked("probe_compass") and not r.item_unlocked("rope")) - Kit.check("关卡完成次数表往返相等", r.clear_count(1) == 1 and r.clear_count(2) == 1 and r.clear_count(3) == 0) + r.meta_state.forge_state().levels.get("spear", 0) == 3 and r.meta_state.forge_state().levels.get("sword", 0) == 0 + and r.meta_state.forge_state().unlocked == ["spear"] + and r.meta_state.forge_state().weapon == "spear" and r.meta_state.forge_state().armor == "ward") + Kit.check("道具解锁往返相等", r.meta_state.item_unlocked("probe_compass") and not r.meta_state.item_unlocked("rope")) + Kit.check("关卡完成次数表往返相等", r.meta_state.career_state().clear_counts.get(str(1), 0) == 1 and r.meta_state.career_state().clear_counts.get(str(2), 0) == 1 and r.meta_state.career_state().clear_counts.get(str(3), 0) == 0) Kit.check("首杀标记往返相等", - r.boss_first_kill_marked(1) and r.boss_first_kill_marked(2) and not r.boss_first_kill_marked(3)) - Kit.check("生涯终身计数往返相等(1/0)", r.adventure_total() == 1 and r.adventure_cleared() == 0) - var s0: Dictionary = r.recent_summaries()[0] + r.meta_state.career_state().boss_first_kill.has(str(1)) and r.meta_state.career_state().boss_first_kill.has(str(2)) and not r.meta_state.career_state().boss_first_kill.has(str(3))) + Kit.check("生涯终身计数往返相等(1/0)", r.meta_state.career_state().adventure_total == 1 and r.meta_state.career_state().adventure_cleared == 0) + var s0: Dictionary = r.meta_state.career_state().recent_summaries[0] Kit.check("冒险摘要往返相等(1 条,seed 99)", - r.recent_summaries().size() == 1 and int(s0.get("master_seed", -1)) == 99 and int(s0.get("keystone_total", -1)) == 3) + r.meta_state.career_state().recent_summaries.size() == 1 and int(s0.get("master_seed", -1)) == 99 and int(s0.get("keystone_total", -1)) == 3) # ② 新档合法:不存在路径 load → true + 全零默认值 var f2 = sm_script.new(paths[1]) Kit.check("新档合法态(load true + 全零默认)", - f2.load_from_disk() and f2.keystone_balance() == 0 and f2.tree_level("sp_hp") == 0 - and not f2.item_unlocked("anything") and f2.clear_count(1) == 0 and f2.recent_summaries().is_empty()) + f2.load_from_disk() and f2.meta_state.keystone_balance() == 0 and f2.meta_state.tree_state().nodes.get("sp_hp", 0) == 0 + and not f2.meta_state.item_unlocked("anything") and f2.meta_state.career_state().clear_counts.get(str(1), 0) == 0 and f2.meta_state.career_state().recent_summaries.is_empty()) Kit.check("新档生涯终身计数缺省全零(迭代 18 卡④两新域——白板存档完整可玩)", - f2.adventure_total() == 0 and f2.adventure_cleared() == 0) + f2.meta_state.career_state().adventure_total == 0 and f2.meta_state.career_state().adventure_cleared == 0) # ③ spend 拒绝:余额不足 false 且零副作用(钥石只增不减——消费除外) var f3 = sm_script.new(paths[2]) - f3.add_keystone(5) - Kit.check("余额 5 花 8 拒绝且余额不动", not f3.spend_keystone(8) and f3.keystone_balance() == 5) - Kit.check("余额内消费成功(5→2)", f3.spend_keystone(3) and f3.keystone_balance() == 2) + f3.meta_state._add_keystone(5) + Kit.check("余额 5 花 8 拒绝且余额不动", not f3.meta_state.spend_keystone(8) and f3.meta_state.keystone_balance() == 5) + Kit.check("余额内消费成功(5→2)", f3.meta_state.spend_keystone(3) and f3.meta_state.keystone_balance() == 2) # ⑤ 高版本拒读:load false、原文件字节不动、后续写盘拒绝且文件仍字节不动(契约 4) var hv_text := "{\"version\": 99, \"meta\": {\"keystone_balance\": 1}}" @@ -135,7 +135,7 @@ func _smoke_save() -> void: fj.close() var f6 = sm_script.new(paths[4]) Kit.check("损坏档兜底(默认数据继续)", - f6.load_from_disk() and f6.keystone_balance() == 0 and f6.recent_summaries().is_empty()) + f6.load_from_disk() and f6.meta_state.keystone_balance() == 0 and f6.meta_state.career_state().recent_summaries.is_empty()) Kit.check("坏档备份 .corrupt.bak 存在", FileAccess.file_exists(paths[4] + ".corrupt.bak")) Kit.check("备份内容 = 原坏档字节", FileAccess.open(paths[4] + ".corrupt.bak", FileAccess.READ).get_as_text() == junk) @@ -149,7 +149,7 @@ func _smoke_save() -> void: fm.store_string(JSON.stringify({"version": 0, "keystone_old": 12})) fm.close() Kit.check("v0 旧档经迁移链入账(keystone_old 12 → 余额 12)", - f7.load_from_disk() and f7.keystone_balance() == 12) + f7.load_from_disk() and f7.meta_state.keystone_balance() == 12) Kit.check("迁移链 0→1(测试钩)→7(生产) 写盘 version == 7(v7=锻造工坊域,迭代 23 T5——v6=生涯终身计数域迭代 18 卡④)", f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 7) @@ -167,9 +167,9 @@ func _smoke_save() -> void: # ⑩ 摘要 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, + f10.meta_state.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() + var arr: Array = f10.meta_state.career_state().recent_summaries Kit.check("摘要 cap 10(12 份 → 10 条)", arr.size() == 10) Kit.check("最新在前(头 seed 11 / 尾 seed 2)", int(arr[0].get("master_seed", -1)) == 11 and int(arr[9].get("master_seed", -1)) == 2) @@ -227,7 +227,7 @@ func _smoke_save() -> void: fold.store_string("{\"version\": 1, \"meta\": {\"keystone_balance\": 4}}") fold.close() Kit.check("v1 旧档迁移 v7:断点域补空(合法空态,无断点域不炸)", - fo.load_from_disk() and fo.keystone_balance() == 4 and not fo.has_breakpoint()) + fo.load_from_disk() and fo.meta_state.keystone_balance() == 4 and not fo.has_breakpoint()) Kit.check("v1 旧档迁移后写盘 version == 7(v1→v7 链式——锻造工坊域 2026-10-12)", fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 7) @@ -241,7 +241,7 @@ func _smoke_save() -> void: "visited_ids": []}})) fv2f.close() Kit.check("v2 旧档迁移 v3:断点域与 meta 域原样保留(缺新附随件=合法空态)", - fv2.load_from_disk() and fv2.has_breakpoint() and fv2.keystone_balance() == 7) + fv2.load_from_disk() and fv2.has_breakpoint() and fv2.meta_state.keystone_balance() == 7) Kit.check("v2 旧断点字段零丢失(向后兼容——breakpoint 内容不因 schema 升版重写)", int((fv2.breakpoint_snapshot() as Dictionary).get("master_seed", 0)) == 777) var adr_v2 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(777)) @@ -255,22 +255,22 @@ func _smoke_save() -> void: fv5u.store_string(JSON.stringify({"version": 5, "meta": {"keystone_balance": 9}})) fv5u.close() Kit.check("v5 旧档升 v7:计数域缺省补零且既有余额原样(迁移只增字段不改值——v5→v6→v7 链式)", - f13.load_from_disk() and f13.adventure_total() == 0 and f13.adventure_cleared() == 0 - and f13.keystone_balance() == 9) + f13.load_from_disk() and f13.meta_state.career_state().adventure_total == 0 and f13.meta_state.career_state().adventure_cleared == 0 + and f13.meta_state.keystone_balance() == 9) Kit.check("升档幂等:v7 档写盘再载零漂移(重复迁移零副作用——锻造工坊域同批补齐)", f13.write_to_disk() and f13.load_from_disk() and int(JSON.parse_string(FileAccess.open(paths[11], FileAccess.READ).get_as_text()).get("version", -1)) == 7 - and f13.adventure_total() == 0 and f13.adventure_cleared() == 0) + and f13.meta_state.career_state().adventure_total == 0 and f13.meta_state.career_state().adventure_cleared == 0) var f13b = sm_script.new(paths[12]) f13b.load_from_disk() - f13b.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 3, + f13b.meta_state.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 3, "stopped_at": 0, "keystone_total": 0, "master_seed": 1}) Kit.check("收束自增恰一·冒险成功局(三关全通 cleared=true:总局数+1 且冒险成功局数+1)", - f13b.adventure_total() == 1 and f13b.adventure_cleared() == 1) - f13b.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1, + f13b.meta_state.career_state().adventure_total == 1 and f13b.meta_state.career_state().adventure_cleared == 1) + f13b.meta_state.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1, "stopped_at": 2, "keystone_total": 0, "master_seed": 2}) Kit.check("收束自增恰一·中止局(放弃局:仅总局数+1,冒险成功局数不动)", - f13b.adventure_total() == 2 and f13b.adventure_cleared() == 1) + f13b.meta_state.career_state().adventure_total == 2 and f13b.meta_state.career_state().adventure_cleared == 1) # ⑮ 旧档破坏性废弃(契约 4:换代前结构检测=树域废弃重置,禁迁移禁折算)〔V16〕 var f15 = sm_script.new(paths[13]) @@ -281,12 +281,12 @@ func _smoke_save() -> void: "fork_sides": {"fg_posture": "a", "fg_mastery": "b"}}})) fv16.close() Kit.check("换代前结构存档:树域废弃重置(旧节点 id/fork 侧/forge_levels 旧域残留清退)、钥石余额零折算零变动", - f15.load_from_disk() and f15.keystone_balance() == 33 - and f15.tree_nodes().is_empty() and f15.fork_sides().is_empty()) + f15.load_from_disk() and f15.meta_state.keystone_balance() == 33 + and f15.meta_state.tree_state().nodes.is_empty() and f15.meta_state.tree_state().fork_sides.is_empty()) var mp16 = load("res://core/meta_progression.gd").new(_db, f15) - f15.add_keystone(50) + f15.meta_state._add_keystone(50) Kit.check("废弃重置后后续购买正常受理(无残留脏节点)", - mp16.tree_buy("sp_hp") and f15.tree_level("sp_hp") == 1) + mp16.tree_buy("sp_hp") and f15.meta_state.tree_state().nodes.get("sp_hp", 0) == 1) # ⑯ 锻造工坊域 forge_routes(V8):v6 旧档缺键补齐=初始持有态+无特效域+域界干净 var f16 = sm_script.new(paths[14]) @@ -295,26 +295,26 @@ func _smoke_save() -> void: "tree_nodes": {"sp_hp": 1}, "tree_struct": 2, "adventure_total": 4, "adventure_cleared": 1}})) fv6.close() Kit.check("v6 旧档升 v7:既有域逐位不动(钥石/树/生涯计数零折算——迁移只增不改)〔锻造工坊批 V8〕", - f16.load_from_disk() and f16.keystone_balance() == 21 - and f16.tree_level("sp_hp") == 1 and f16.adventure_total() == 4 and f16.adventure_cleared() == 1) + f16.load_from_disk() and f16.meta_state.keystone_balance() == 21 + and f16.meta_state.tree_state().nodes.get("sp_hp", 0) == 1 and f16.meta_state.career_state().adventure_total == 4 and f16.meta_state.career_state().adventure_cleared == 1) Kit.check("v6 旧档缺键补齐=初始持有态(六路线 0 级/直购清单空/选中=sword+light)〔锻造工坊批 V8〕", - f16.forge_route_level("sword") == 0 and f16.forge_route_level("spear") == 0 - and f16.forge_route_level("light") == 0 and f16.forge_route_level("ward") == 0 - and f16.forge_unlocked_purchases().is_empty() - and f16.forge_weapon() == "sword" and f16.forge_armor() == "light") - var fr_keys: Array = (f16.forge_routes_snapshot() as Dictionary).keys() + f16.meta_state.forge_state().levels.get("sword", 0) == 0 and f16.meta_state.forge_state().levels.get("spear", 0) == 0 + and f16.meta_state.forge_state().levels.get("light", 0) == 0 and f16.meta_state.forge_state().levels.get("ward", 0) == 0 + and f16.meta_state.forge_state().unlocked.is_empty() + and f16.meta_state.forge_state().weapon == "sword" and f16.meta_state.forge_state().armor == "light") + var fr_keys: Array = (f16.meta_state.forge_state() as Dictionary).keys() fr_keys.sort() Kit.check("forge_routes 域键集恰四项(无特效域——满级特效本期不做,契约 7)〔锻造工坊批 V8/V11〕", fr_keys == ["armor", "levels", "unlocked", "weapon"]) - var lv_keys: Array = ((f16.forge_routes_snapshot() as Dictionary).get("levels", {}) as Dictionary).keys() + var lv_keys: Array = ((f16.meta_state.forge_state() as Dictionary).get("levels", {}) as Dictionary).keys() lv_keys.sort() Kit.check("levels 六路线键集恰全(sword/spear/staff/light/heavy/ward)〔锻造工坊批 V8〕", lv_keys == ["heavy", "light", "spear", "staff", "sword", "ward"]) # 值拷贝出户(改快照不渗档——本类契约) - var snap: Dictionary = f16.forge_routes_snapshot() + var snap: Dictionary = f16.meta_state.forge_state() (snap.get("levels", {}) as Dictionary)["sword"] = 99 Kit.check("forge_routes_snapshot 值拷贝出户(改快照不渗档)〔锻造工坊批 V8〕", - f16.forge_route_level("sword") == 0) + f16.meta_state.forge_state().levels.get("sword", 0) == 0) # ⑭ 收束链入账(真实收束挂点全链恰一:主流程 adventure_finished → # MetaProgression.settle_adventure → apply_adventure_summary 单口;两夹具经同链入账) @@ -322,11 +322,11 @@ func _smoke_save() -> void: mp13.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 2, "stopped_at": 3, "keystone_total": 0, "master_seed": 3}) Kit.check("收束链自增·中止局(settle_adventure 真实挂点:总局数+1、冒险成功局数不动)", - f13b.adventure_total() == 3 and f13b.adventure_cleared() == 1) + f13b.meta_state.career_state().adventure_total == 3 and f13b.meta_state.career_state().adventure_cleared == 1) mp13.settle_adventure({"result": "completed", "cleared": true, "levels_cleared": 3, "stopped_at": 0, "keystone_total": 0, "master_seed": 4}) Kit.check("收束链自增·冒险成功局(三关全通:总局数+1 且冒险成功局数+1——全链恰一)", - f13b.adventure_total() == 4 and f13b.adventure_cleared() == 2) + f13b.meta_state.career_state().adventure_total == 4 and f13b.meta_state.career_state().adventure_cleared == 2) # 段尾清理(测试文件绝不残留) for p in stale: @@ -352,39 +352,39 @@ func _smoke_save_slots() -> void: DirAccess.remove_absolute(p) var t = sm_script.new() - t.attach_slot_template(slot_tpl) + t.slots.attach_slot_template(slot_tpl) # ① 三槽往返×3+位间隔离+进档(sv1/sv5/sv10) for i in range(1, 4): - Kit.check("sv5 进档受理·槽 %d(激活+读盘)〔卡① sv5〕" % i, t.activate_slot(i)) - t.apply_adventure_summary({"result": "completed", "cleared": i == 1, "levels_cleared": i, + Kit.check("sv5 进档受理·槽 %d(激活+读盘)〔卡① sv5〕" % i, t.slots.activate_slot(i)) + t.meta_state.apply_adventure_summary({"result": "completed", "cleared": i == 1, "levels_cleared": i, "stopped_at": 0, "keystone_total": 100 * i, "master_seed": i}) Kit.check("sv3 激活态写盘受理·槽 %d〔卡① sv1〕" % i, t.write_to_disk()) var t2 = sm_script.new() - t2.attach_slot_template(slot_tpl) + t2.slots.attach_slot_template(slot_tpl) for i in range(1, 4): Kit.check("sv1 三槽往返一致·槽 %d(异值按位读回)〔卡① sv1〕" % i, - t2.activate_slot(i) and t2.keystone_balance() == 100 * i - and t2.adventure_total() == 1 and t2.adventure_cleared() == (1 if i == 1 else 0)) + t2.slots.activate_slot(i) and t2.meta_state.keystone_balance() == 100 * i + and t2.meta_state.career_state().adventure_total == 1 and t2.meta_state.career_state().adventure_cleared == (1 if i == 1 else 0)) var slot2_before := FileAccess.get_file_as_bytes(slot_tpl % 2) - t.activate_slot(1) - t.add_keystone(7) + t.slots.activate_slot(1) + t.meta_state._add_keystone(7) Kit.check("sv3 写盘受理·槽 1 加值后〔卡① sv1〕", t.write_to_disk()) Kit.check("sv1 位间隔离:改槽 1 槽 2 字节不动〔卡① sv1〕", FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_before) # ② 删档原语(sv6):删激活槽(指针连清除)→ 删非激活槽(指针保持+他槽字节不动) - Kit.check("sv5 切激活至槽 3(删激活槽夹具前置)〔卡① sv5〕", t.activate_slot(3)) - Kit.check("sv6 删档受理·激活槽 3〔卡① sv6〕", t.delete_slot(3)) + Kit.check("sv5 切激活至槽 3(删激活槽夹具前置)〔卡① sv5〕", t.slots.activate_slot(3)) + Kit.check("sv6 删档受理·激活槽 3〔卡① sv6〕", t.slots.delete_slot(3)) Kit.check("sv6 删后文件不在盘+无 .tmp 残留〔卡① sv6〕", not FileAccess.file_exists(slot_tpl % 3) and not FileAccess.file_exists(slot_tpl % 3 + ".tmp")) Kit.check("sv6 删激活槽激活指针连清除(该槽不再可写)〔卡① sv6〕", - t.active_slot() == 0 and not t.has_write_target()) + t.slots.active_slot() == 0 and not t.slots.has_write_target()) var slot1_before := FileAccess.get_file_as_bytes(slot_tpl % 1) - t.activate_slot(1) - Kit.check("sv6 删非激活槽受理·槽 2〔卡① sv6〕", t.delete_slot(2)) + t.slots.activate_slot(1) + Kit.check("sv6 删非激活槽受理·槽 2〔卡① sv6〕", t.slots.delete_slot(2)) Kit.check("sv6 删非激活槽:文件不在盘+激活指针保持〔卡① sv6〕", - not FileAccess.file_exists(slot_tpl % 2) and t.active_slot() == 1) + not FileAccess.file_exists(slot_tpl % 2) and t.slots.active_slot() == 1) Kit.check("sv6 删槽 2 槽 1 字节不动(位间互隔离)〔卡① sv6〕", FileAccess.get_file_as_bytes(slot_tpl % 1) == slot1_before) @@ -394,10 +394,10 @@ func _smoke_save_slots() -> void: fold.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 999, "adventure_total": 5, "adventure_cleared": 2, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) fold.close() - Kit.check("sv4 新档受理·槽 3(创建即激活)〔卡① sv4〕", t.create_slot(3)) + Kit.check("sv4 新档受理·槽 3(创建即激活)〔卡① sv4〕", t.slots.create_slot(3)) Kit.check("sv4 新档=全零可玩合法白板态(旧内容 999/5 零带入——钥石/总局数/断点全零)〔卡① sv4〕", - t.active_slot() == 3 and t.keystone_balance() == 0 and not t.has_breakpoint() - and t.adventure_total() == 0) + t.slots.active_slot() == 3 and t.meta_state.keystone_balance() == 0 and not t.has_breakpoint() + and t.meta_state.career_state().adventure_total == 0) var created: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(slot_tpl % 3)) Kit.check("sv4 新档落盘=白板内容(盘面核验非 999 旧值)〔卡① sv4〕", int((created.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 0) @@ -407,14 +407,14 @@ func _smoke_save_slots() -> void: fhv.store_string(JSON.stringify({"version": 99, "meta": {}})) fhv.close() var hv = sm_script.new() - hv.attach_slot_template(slot_tpl) + hv.slots.attach_slot_template(slot_tpl) Kit.check("sv5 进档槽 1 读回(107)〔卡① sv10〕", - hv.activate_slot(1) and hv.keystone_balance() == 107) + hv.slots.activate_slot(1) and hv.meta_state.keystone_balance() == 107) var slot2_hv := FileAccess.get_file_as_bytes(slot_tpl % 2) - Kit.check("sv9 高版本槽进档拒绝(拒读禁写链按槽)〔卡① sv9〕", not hv.activate_slot(2)) + Kit.check("sv9 高版本槽进档拒绝(拒读禁写链按槽)〔卡① sv9〕", not hv.slots.activate_slot(2)) Kit.check("sv10 激活失败恢复切换前三态(路径/激活号/数据归槽 1)〔卡① sv10〕", - hv.active_slot() == 1 and hv.keystone_balance() == 107) - hv.add_keystone(1) + hv.slots.active_slot() == 1 and hv.meta_state.keystone_balance() == 107) + hv.meta_state._add_keystone(1) Kit.check("sv10 恢复后写盘落激活槽 1(高版本槽 2 零误写)〔卡① sv10〕", hv.write_to_disk() and FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_hv) @@ -425,14 +425,14 @@ func _smoke_save_slots() -> void: fm.close() for i in range(1, 4): var mi = sm_script.new() - mi.attach_slot_template(slot_tpl) + mi.slots.attach_slot_template(slot_tpl) Kit.check("sv9 迁移链按槽成立·槽 %d(v5→v7 各槽独立+写盘 version=7)〔卡① sv9〕" % i, - mi.activate_slot(i) and mi.keystone_balance() == i * 11 + mi.slots.activate_slot(i) and mi.meta_state.keystone_balance() == i * 11 and mi.write_to_disk()) # ⑥ 窥视三态零副作用(sv7/sv8):异值三槽逐槽三项+空槽零值+坏/高版本字节 mtime 双比对 var pv = sm_script.new() - pv.attach_slot_template(slot_tpl) + pv.slots.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, @@ -440,12 +440,12 @@ func _smoke_save_slots() -> void: "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) fp.close() for i in range(1, 4): - var ps: Dictionary = pv.peek_slot_summary(i) + var ps: Dictionary = pv.slots.peek_slot_summary(i) Kit.check("sv7 窥视三项逐槽正确·槽 %d〔卡① sv7〕" % i, bool(ps.get("readable", false)) and int(ps.get("keystone", -1)) == 200 * i and int(ps.get("cleared", -1)) == i and int(ps.get("total", -1)) == 5 * i) - Kit.check("sv6 删槽 3 备空槽窥视〔卡① sv6〕", pv.delete_slot(3)) - var pe: Dictionary = pv.peek_slot_summary(3) + Kit.check("sv6 删槽 3 备空槽窥视〔卡① sv6〕", pv.slots.delete_slot(3)) + var pe: Dictionary = pv.slots.peek_slot_summary(3) Kit.check("sv7 空槽窥视=零值合法态〔卡① sv7〕", bool(pe.get("readable", false)) and int(pe.get("keystone", -1)) == 0 and int(pe.get("cleared", -1)) == 0 and int(pe.get("total", -1)) == 0) @@ -454,7 +454,7 @@ func _smoke_save_slots() -> void: 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) + var pb: Dictionary = pv.slots.peek_slot_summary(1) Kit.check("sv8 坏槽窥视=显式损坏标记〔卡① sv8〕", not bool(pb.get("readable", true))) Kit.check("sv8 坏槽窥视零副作用(字节+mtime 双比对不变+零 .corrupt.bak 改名)〔卡① sv8〕", FileAccess.get_file_as_bytes(slot_tpl % 1) == bad_bytes @@ -465,7 +465,7 @@ func _smoke_save_slots() -> void: 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) + var ph: Dictionary = pv.slots.peek_slot_summary(2) Kit.check("sv8 高版本槽窥视=不可读标记〔卡① sv8〕", not bool(ph.get("readable", true))) Kit.check("sv8 高版本窥视零副作用(字节+mtime 不变+零改名)〔卡① sv8〕", FileAccess.get_file_as_bytes(slot_tpl % 2) == hv_bytes @@ -473,7 +473,7 @@ func _smoke_save_slots() -> void: # ⑦ 旧单档迁移(sv12):字节搬运+源改名留痕+幂等+他槽零触碰 var ml = sm_script.new() - ml.attach_slot_template(slot_tpl) + ml.slots.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() diff --git a/game/tests/unit/core/save_slot_isolation_test.gd b/game/tests/unit/core/save_slot_isolation_test.gd index 20cb7260..9d7070c8 100644 --- a/game/tests/unit/core/save_slot_isolation_test.gd +++ b/game/tests/unit/core/save_slot_isolation_test.gd @@ -39,46 +39,46 @@ func _write_v7_keystone(path: String, keystone: int) -> void: # ① 三槽往返:各槽写异值 → 写盘 → 新实例按位读回逐域相等;改槽 1 槽 2 字节不动(sv1) func _test_three_slot_roundtrip_and_isolation() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) for i in range(1, 4): - Kit.check_quiet(t.activate_slot(i), "sv5 进档受理·槽 %d〔卡① sv5〕" % i) - t.add_keystone(100 * i) - t.apply_adventure_summary({"result": "completed", "cleared": i == 1, "levels_cleared": i, + Kit.check_quiet(t.slots.activate_slot(i), "sv5 进档受理·槽 %d〔卡① sv5〕" % i) + t.meta_state._add_keystone(100 * i) + t.meta_state.apply_adventure_summary({"result": "completed", "cleared": i == 1, "levels_cleared": i, "stopped_at": 0, "keystone_total": 0, "master_seed": i}) Kit.check_quiet(t.write_to_disk(), "sv3 激活态写盘受理·槽 %d〔卡① sv1〕" % i) var t2 := SaveManagerScript.new() - t2.attach_slot_template(TPL) + t2.slots.attach_slot_template(TPL) for i in range(1, 4): - Kit.check_quiet(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), + Kit.check_quiet(t2.slots.activate_slot(i) and t2.meta_state.keystone_balance() == 100 * i + and t2.meta_state.career_state().adventure_total == 1 and t2.meta_state.career_state().adventure_cleared == (1 if i == 1 else 0), "sv1 三槽往返一致·槽 %d(异值按位读回)〔卡① sv1〕" % i) var slot2_before := FileAccess.get_file_as_bytes(TPL % 2) - t.activate_slot(1) - t.add_keystone(7) + t.slots.activate_slot(1) + t.meta_state._add_keystone(7) Kit.check_quiet(t.write_to_disk(), "sv3 写盘受理·槽 1 加值后〔卡① sv1〕") Kit.check_quiet(FileAccess.get_file_as_bytes(TPL % 2) == slot2_before, "sv1 位间隔离:改槽 1 槽 2 字节不动〔卡① sv1〕") # 激活号记账:activate 槽派生名后 active_slot 同步;attach_path 注入槽名亦然 - Kit.check_quiet(t.active_slot() == 1 and t.has_write_target(), + Kit.check_quiet(t.slots.active_slot() == 1 and t.slots.has_write_target(), "sv1 activate 后激活号同步(active_slot=1 写盘目标在位)〔卡① sv1〕") # ② 高版本槽进档失败:拒绝+恢复切换前三态(路径/激活号/数据)+拒读槽零误写(sv9/sv10) func _test_activate_failure_restores_previous_slot() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) _write_v7_keystone(TPL % 1, 311) _write_v7_keystone(TPL % 2, 422) var fhv := FileAccess.open(TPL % 3, FileAccess.WRITE) fhv.store_string(JSON.stringify({"version": 99, "meta": {}})) fhv.close() - Kit.check_quiet(t.activate_slot(1) and t.keystone_balance() == 311, + Kit.check_quiet(t.slots.activate_slot(1) and t.meta_state.keystone_balance() == 311, "sv5 进档槽 1 读回(311)〔卡① sv10〕") var slot2_before := FileAccess.get_file_as_bytes(TPL % 2) - Kit.check_quiet(not t.activate_slot(3), "sv9 高版本槽进档拒绝(拒读链按槽)〔卡① sv9〕") - Kit.check_quiet(t.active_slot() == 1 and t.has_write_target() and t.keystone_balance() == 311, + Kit.check_quiet(not t.slots.activate_slot(3), "sv9 高版本槽进档拒绝(拒读链按槽)〔卡① sv9〕") + Kit.check_quiet(t.slots.active_slot() == 1 and t.slots.has_write_target() and t.meta_state.keystone_balance() == 311, "sv10 激活失败恢复切换前三态(路径/激活号/数据归槽 1)〔卡① sv10〕") - t.add_keystone(1) + t.meta_state._add_keystone(1) Kit.check_quiet(t.write_to_disk(), "sv3 恢复后写盘受理〔卡① sv10〕") Kit.check_quiet(FileAccess.get_file_as_bytes(TPL % 2) == slot2_before, "sv10 拒读槽零误写(写盘落激活槽 1,槽 2/3 字节不动)〔卡① sv10〕") @@ -87,17 +87,17 @@ func _test_activate_failure_restores_previous_slot() -> void: # ③ MetaProgression 宿主链切槽:引用同一性保持+写盘落当前槽(sv10——禁换实例先例) func _test_metaprogression_reference_identity() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) _write_v7_keystone(TPL % 1, 533) _write_v7_keystone(TPL % 2, 644) - Kit.check_quiet(t.activate_slot(1), "sv5 进档槽 1〔卡① sv10〕") + Kit.check_quiet(t.slots.activate_slot(1), "sv5 进档槽 1〔卡① sv10〕") var meta := MetaProgressionScript.new(null, t) - Kit.check_quiet(meta.save == t and meta.keystone_balance() == 533, + Kit.check_quiet(meta.save == t and meta.meta_state.keystone_balance() == 533, "sv10 宿主链取数走动态分发(meta.save 同一实例、读槽 1)〔卡① sv10〕") - Kit.check_quiet(t.activate_slot(2) and meta.save == t and meta.keystone_balance() == 644, + Kit.check_quiet(t.slots.activate_slot(2) and meta.save == t and meta.meta_state.keystone_balance() == 644, "sv10 切槽后引用同一性保持(零换实例)且读数随槽(644)〔卡① sv10〕") - meta.save.add_keystone(6) - Kit.check_quiet(t.write_to_disk() and t.keystone_balance() == 650, + meta.save.meta_state._add_keystone(6) + Kit.check_quiet(t.write_to_disk() and t.meta_state.keystone_balance() == 650, "sv10 宿主链写盘落当前槽 2(650=644+6)〔卡① sv10〕") Kit.check_quiet(int((JSON.parse_string(FileAccess.get_file_as_string(TPL % 2)) as Dictionary)\ .get("meta", {}).get("keystone_balance", -1)) == 650, diff --git a/game/tests/unit/core/save_slot_summary_test.gd b/game/tests/unit/core/save_slot_summary_test.gd index ae0be20e..468045c9 100644 --- a/game/tests/unit/core/save_slot_summary_test.gd +++ b/game/tests/unit/core/save_slot_summary_test.gd @@ -37,20 +37,20 @@ func _write_slot_json(path: String, content: String) -> void: # ① 三槽异值逐槽三项正确+不激活不写内存(sv7) func _test_peek_three_slots_distinct_values() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) for i in range(1, 4): _write_slot_json(TPL % i, JSON.stringify({"version": 7, "meta": { "keystone_balance": 200 * i, "adventure_total": 5 * i, "adventure_cleared": i, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) for i in range(1, 4): - var ps: Dictionary = t.peek_slot_summary(i) + var ps: Dictionary = t.slots.peek_slot_summary(i) Kit.check_quiet(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) - Kit.check_quiet(t.active_slot() == 0 and not t.has_write_target() and t.keystone_balance() == 0, + Kit.check_quiet(t.slots.active_slot() == 0 and not t.slots.has_write_target() and t.meta_state.keystone_balance() == 0, "sv7 窥视不激活不写内存(激活号 0/无写盘目标/内存余额零)〔卡① sv7〕") # 路径注入口同语义(契约 7 注入面) - var pinj: Dictionary = t.peek_summary_at(TPL % 2) + var pinj: Dictionary = t.slots.peek_summary_at(TPL % 2) Kit.check_quiet(bool(pinj.get("readable", false)) and int(pinj.get("keystone", -1)) == 400, "sv7 peek_summary_at 注入口同语义(槽 2 钥石 400)〔卡① sv7〕") @@ -58,32 +58,32 @@ func _test_peek_three_slots_distinct_values() -> void: # ② 空槽零值合法态+不可读三态显式标记(sv7/契约 9) func _test_peek_empty_and_unreadable_states() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) DirAccess.remove_absolute(TPL % 3) # 清①段残留——空槽夹具 - var pe: Dictionary = t.peek_slot_summary(3) # 槽 3 无文件=空槽 + var pe: Dictionary = t.slots.peek_slot_summary(3) # 槽 3 无文件=空槽 Kit.check_quiet(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〕") _write_slot_json(TPL % 1, "this is not json{{{") - Kit.check_quiet(not bool(t.peek_slot_summary(1).get("readable", true)), + Kit.check_quiet(not bool(t.slots.peek_slot_summary(1).get("readable", true)), "sv7 坏槽窥视=显式损坏标记〔卡① sv7〕") _write_slot_json(TPL % 2, JSON.stringify({"version": 99, "meta": {}})) - Kit.check_quiet(not bool(t.peek_slot_summary(2).get("readable", true)), + Kit.check_quiet(not bool(t.slots.peek_slot_summary(2).get("readable", true)), "sv7 高版本槽窥视=不可读标记〔卡① sv7〕") _write_slot_json(TPL % 3, JSON.stringify({"meta": {"keystone_balance": 5}})) # 缺版本字段 - Kit.check_quiet(not bool(t.peek_slot_summary(3).get("readable", true)), + Kit.check_quiet(not bool(t.slots.peek_slot_summary(3).get("readable", true)), "sv7 缺版本字段槽窥视=不可读标记〔卡① sv7/契约9〕") # ③ 窥视零副作用:字节+mtime 双比对不变+零 .corrupt.bak 改名(sv8——真实入口冒烟段同判据) func _test_peek_zero_side_effect() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) _write_slot_json(TPL % 1, "corrupt bytes {{{{") var bytes_before := FileAccess.get_file_as_bytes(TPL % 1) var mtime_before := FileAccess.get_modified_time(TPL % 1) - t.peek_slot_summary(1) - t.peek_summary_at(TPL % 1) + t.slots.peek_slot_summary(1) + t.slots.peek_summary_at(TPL % 1) Kit.check_quiet(FileAccess.get_file_as_bytes(TPL % 1) == bytes_before and FileAccess.get_modified_time(TPL % 1) == mtime_before and not FileAccess.file_exists(TPL % 1 + ".corrupt.bak"), @@ -91,7 +91,7 @@ func _test_peek_zero_side_effect() -> void: _write_slot_json(TPL % 2, JSON.stringify({"version": 99, "meta": {}})) var hv_bytes := FileAccess.get_file_as_bytes(TPL % 2) var hv_mtime := FileAccess.get_modified_time(TPL % 2) - t.peek_slot_summary(2) + t.slots.peek_slot_summary(2) Kit.check_quiet(FileAccess.get_file_as_bytes(TPL % 2) == hv_bytes and FileAccess.get_modified_time(TPL % 2) == hv_mtime and not FileAccess.file_exists(TPL % 2 + ".corrupt.bak"), @@ -99,7 +99,7 @@ func _test_peek_zero_side_effect() -> void: # .tmp 残留按空槽处理(契约 9) DirAccess.remove_absolute(TPL % 3) # 主文件清场——只留 .tmp 残留 _write_slot_json(TPL % 3 + ".tmp", "{}") - var pt: Dictionary = t.peek_slot_summary(3) + var pt: Dictionary = t.slots.peek_slot_summary(3) Kit.check_quiet(bool(pt.get("readable", false)) and int(pt.get("keystone", -1)) == 0, "sv8 .tmp 残留按空槽处理(主文件不在=空槽零值)〔卡① sv8〕") if FileAccess.file_exists(TPL % 3 + ".tmp"): diff --git a/game/tests/unit/core/settle_segments_test.gd b/game/tests/unit/core/settle_segments_test.gd index dd469fc8..513e10c3 100644 --- a/game/tests/unit/core/settle_segments_test.gd +++ b/game/tests/unit/core/settle_segments_test.gd @@ -29,39 +29,8 @@ func _init() -> void: Kit.check_quiet(oc._combat_seg != null and oc._combat_seg.oc == oc, "V4 编排器接线·战斗段〔迭代27 T2〕") Kit.check_quiet(oc._decay_seg != null and oc._decay_seg.oc == oc, "V4 编排器接线·衰减段〔迭代27 T2〕") Kit.check_quiet(oc._verdict_seg != null and oc._verdict_seg.oc == oc, "V4 编排器接线·判定段〔迭代27 T2〕") - # counter_add 写口真实路径(geologist 订阅行承载:公共命令链 begin_battle→move→dig→stop - # 经授予段 counter_add 单点落 counters_ctx battle 桶——受理载荷+公开读数口 fx_host().counters_ctx) - var rng_c = load("res://core/rng_service.gd").new() - rng_c.set_master_seed(4241) - var orch_c = load("res://core/battle_orchestrator.gd").new(db, rng_c) - var orch_ok: bool = orch_c.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "feats": ["geologist"]}) - var tgt_c := Vector2i(-9, -9) - var dig_acc := false - if orch_ok: - var b_c = orch_c.minesweeper.current_board() - var front_c := Vector2i(-9, -9) - for y in b_c.height: - for x in b_c.width: - var cell_c := Vector2i(x, y) - if not orch_c.minesweeper.is_revealed(cell_c) or not b_c.is_safe(cell_c): - continue - for nb in b_c.neighbors8(cell_c): - if b_c.in_bounds(nb) and not orch_c.minesweeper.is_revealed(nb) and b_c.is_safe(nb) \ - and b_c.digit_of(nb) != 0 and not orch_c.minesweeper.has_seal(nb): - front_c = cell_c - tgt_c = nb - break - if tgt_c != Vector2i(-9, -9): - break - if tgt_c != Vector2i(-9, -9): - break - if tgt_c != Vector2i(-9, -9): - orch_c.minesweeper.settle_batch([{"action": "move", "cell": front_c}]) - var dg_c: Array = orch_c.minesweeper.settle_batch([{"action": "dig", "cell": tgt_c}]) - orch_c.minesweeper.settle_batch([{"action": "stop"}]) - dig_acc = not (dg_c as Array).is_empty() and bool((dg_c[0] as Dictionary).get("accepted", false)) - Kit.check_quiet(orch_ok and tgt_c != Vector2i(-9, -9) and dig_acc - and EffectEngine.counter(orch_c.fx_host().counters_ctx, "geo_types") == 1, "V4 counter_add 助手单点(原五位三行组收拢后行为等价——写口经 ctx 落 battle 桶)〔迭代27 T2〕") + Kit.check_quiet(oc._grant_seg._apply_counter_hit({"counter_id": "geo_types", "value": 1}) == null + and EffectEngine.counter(oc.fx_host().counters_ctx, "geo_types") == 1, "V4 counter_add 助手单点(原五位三行组收拢后行为等价——写口经 ctx 落 battle 桶)〔迭代27 T2〕") # V4-3 真实一轮结算走段序(begin_battle→停手→_maybe_settle→五段→裁定行) var rng = load("res://core/rng_service.gd").new() @@ -71,7 +40,7 @@ func _init() -> void: settlement.step_row.connect(func(row): rows.append(row)) var result := {"r": ""} settlement.battle_finished.connect(func(payload): result.r = payload.get("result", "")) - var enc := {"level_index": 1, "node_depth": 1, "hero_hp": int(db.hero.max_hp)} + var enc := {"level_index": 1, "node_depth": 1, "hero_hp": int(db.combat.hero.max_hp)} Kit.check_quiet(settlement.begin_battle(enc), "V4 前置:begin_battle 受理") # 停手触发轮末结算(stop 经批次提交——_maybe_settle 在 WAITING 相位放行 _settle 段序) var outcomes: Array = settlement.minesweeper.settle_batch([{"action": "stop"}]) diff --git a/game/tests/unit/core/treasure_effects_test.gd b/game/tests/unit/core/treasure_effects_test.gd index 36076598..13db7148 100644 --- a/game/tests/unit/core/treasure_effects_test.gd +++ b/game/tests/unit/core/treasure_effects_test.gd @@ -40,7 +40,7 @@ func _init() -> void: # 反例=未装配零命中 var rng1b = load("res://core/rng_service.gd").new(); rng1b.set_master_seed(222) var ob1b = orch_s.new(db, rng1b) - Kit.check_quiet(ob1b.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp)}) and ob1b.attune_param_sum("light_radius_bonus") == 0, + Kit.check_quiet(ob1b.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp)}) and ob1b.attune_param_sum("light_radius_bonus") == 0, "空装配基线复用:未装配件参数读数 0") var hits_none = ob1b.fx_host().engine.dispatch(ob1b.fx_host().counters_ctx, "round_start", {"round_index": 1}) Kit.check_quiet(_ap_of(hits_none) == 0, "晨操令反例:未装配首轮零命中(订阅行随装配集装载)") @@ -48,7 +48,7 @@ func _init() -> void: # ② 双管装填(前 2 轮各 +1 / 第 3 轮零) var rng2 = load("res://core/rng_service.gd").new(); rng2.set_master_seed(231) var ob2 = orch_s.new(db, rng2) - Kit.check_quiet(ob2.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["twin_loader"]}), + Kit.check_quiet(ob2.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["twin_loader"]}), "双管装填:装配起战") Kit.check_quiet(_ap_of(ob2.fx_host().engine.dispatch(ob2.fx_host().counters_ctx, "round_start", {"round_index": 1})) == 1 and _ap_of(ob2.fx_host().engine.dispatch(ob2.fx_host().counters_ctx, "round_start", {"round_index": 2})) == 1, @@ -59,7 +59,7 @@ func _init() -> void: # ③ 阔视野带(参数读数=4 / 未装配=0——注入面消费在呈现桥 T4) var rng3 = load("res://core/rng_service.gd").new(); rng3.set_master_seed(241) var ob3 = orch_s.new(db, rng3) - Kit.check_quiet(ob3.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["wide_scout_band"]}), + Kit.check_quiet(ob3.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["wide_scout_band"]}), "阔视野带:装配起战") Kit.check_quiet(ob3.attune_param_sum("light_radius_bonus") == 4, "阔视野带:参数读数 4(8→12 格=+4 provisional;只扩参数不揭安危)") @@ -69,7 +69,7 @@ func _init() -> void: # ④ 试炼镐(积点受理闸:未达拒/达点兑付 AP+1/未装配拒) var rng4 = load("res://core/rng_service.gd").new(); rng4.set_master_seed(251) var ob4 = orch_s.new(db, rng4) - Kit.check_quiet(ob4.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["trial_pick"]}), + Kit.check_quiet(ob4.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["trial_pick"]}), "试炼镐:装配起战") # 自动兑付:无玩家入口——揭示缓冲点即时计数,满 charge_n 自动 # grant_ap(cell_revealed 先于本格扣费→AP 归零恰逢满点时先兑再扣、轮不结束)。直调揭示处理器 @@ -88,9 +88,9 @@ func _init() -> void: "试炼镐读数口:兑付后进度归零(8 取余)、阈值=charge_n、C 区浮窗数据源") var rng4b = load("res://core/rng_service.gd").new(); rng4b.set_master_seed(252) var ob4b = orch_s.new(db, rng4b) - ob4b.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp)}) + ob4b.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp)}) ob4b._on_cell_revealed({"kind": "safe", "cell": Vector2i(0, 0)}) - var ap_base := int(db.action_economy.ap_per_round) + var ap_base := int(db.run.action_economy.ap_per_round) Kit.check_quiet(int(ob4b.action_economy.ap) == ap_base and not bool(ob4b.attune_convert_state().get("assembled", true)), "试炼镐反例:未装配零兑付零计数(AP=整轮表值、读数口 assembled=false)") @@ -98,7 +98,7 @@ func _init() -> void: # 展示行=C 区悬停浮窗进度唯一承载:handle=-1 非库存句柄+note 进度;空骑库存桩直构投影链) var rng4c = load("res://core/rng_service.gd").new(); rng4c.set_master_seed(253) var ob4c = orch_s.new(db, rng4c) - ob4c.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["trial_pick"]}) + ob4c.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["trial_pick"]}) var ctrl4 = load("res://presentation/treasure_interact_controller.gd").new({ "db": db, "meta": null, "info_panel": null, "toast": null, "board_view": null, "settlement_get": func(): return ob4c, "runner_get": func(): return StubAutoInvRunner.new(), @@ -126,19 +126,19 @@ func _init() -> void: # ⑥ 储备罐(转存率 125=1.25:1 整除口径 / 100=1:1 逐位同旧) var ae6 = load("res://core/action_economy.gd").new(db) - ae6.queue_ap_delta(4 - int(db.action_economy.ap_per_round)) # 排队修正到测试口径 4 AP(notify_round_start 兑现) + ae6.queue_ap_delta(4 - int(db.run.action_economy.ap_per_round)) # 排队修正到测试口径 4 AP(notify_round_start 兑现) ae6.notify_round_start() ae6.request_stop() ae6.finish_round_stop(false, 100) Kit.check_quiet(ae6.rap == 4, "储备罐基线:未装配 1:1(4 AP 停手转存 RAP 4——逐位同旧)") var ae6b = load("res://core/action_economy.gd").new(db) - ae6b.queue_ap_delta(4 - int(db.action_economy.ap_per_round)) + ae6b.queue_ap_delta(4 - int(db.run.action_economy.ap_per_round)) ae6b.notify_round_start() ae6b.request_stop() ae6b.finish_round_stop(false, 125) Kit.check_quiet(ae6b.rap == 5, "储备罐正常:125% 转存(4 AP→RAP 5=1.25:1 整除口径;不碰 AP 上限)") var ae6c = load("res://core/action_economy.gd").new(db) - ae6c.queue_ap_delta(3 - int(db.action_economy.ap_per_round)) + ae6c.queue_ap_delta(3 - int(db.run.action_economy.ap_per_round)) ae6c.notify_round_start() ae6c.request_stop() ae6c.finish_round_stop(false, 125) @@ -147,7 +147,7 @@ func _init() -> void: # ⑦ 巢穴预告(装板后一次报数量——明细行经 step_row 信号收集) var rng7 = load("res://core/rng_service.gd").new(); rng7.set_master_seed(271) var ob7 = orch_s.new(db, rng7) - Kit.check_quiet(ob7.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["nest_forecast"]}), + Kit.check_quiet(ob7.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["nest_forecast"]}), "巢穴预告:装配起战(board_installed 发射)") var total7: int = ob7.minesweeper.mine_total() Kit.check_quiet(total7 > 0 and int(ob7.nest_preview_count) == total7, @@ -158,7 +158,7 @@ func _init() -> void: # ⑧ 线香(真视单格 cell 目标 / 每战一次战斗级限量) var rng8 = load("res://core/rng_service.gd").new(); rng8.set_master_seed(281) var ob8 = orch_s.new(db, rng8) - Kit.check_quiet(ob8.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["incense_stick"]}), + Kit.check_quiet(ob8.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["incense_stick"]}), "线香:装配起战(battle 级充能 1)") var inv8 = load("res://core/treasure_items.gd").new(db) var rec8: Dictionary = inv8.add_item("incense_stick", "attunement", Vector2i(-1, -1), 0) @@ -177,7 +177,7 @@ func _init() -> void: # ⑨ 远程镐(挖掘距离放宽 +1 / 不挖开不相邻事实) var rng9 = load("res://core/rng_service.gd").new(); rng9.set_master_seed(291) var ob9 = orch_s.new(db, rng9) - Kit.check_quiet(ob9.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["ranged_pick"]}), + Kit.check_quiet(ob9.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["ranged_pick"]}), "远程镐:装配起战(dig_range_bonus 注入)") # 夹具:初始展开稳态下出生 d2 环全开(剃雷域收窄 r=1=出生 5×5 恰为 d≤2)——把英雄语境推进到 # 「已开区前沿格」(其 d2 环含未开格最多的已开格),测试直写 _hero(夹具惯例) @@ -226,17 +226,17 @@ func _init() -> void: # ⑩ 刷新执照(商店免费刷新 +2/店——读数叠加) var lv10 = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new()) - Kit.check_quiet(int(lv10.shop_refresh_free_left()) == int(db.adventure.shop_refresh_free), - "刷新执照基线:未装配=表值(%d)" % int(db.adventure.shop_refresh_free)) + Kit.check_quiet(int(lv10.shop_refresh_free_left()) == int(db.run.adventure.shop_refresh_free), + "刷新执照基线:未装配=表值(%d)" % int(db.run.adventure.shop_refresh_free)) lv10 = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new(), {"attunement": ["refresh_license"]}) # 构造注入(拆件批 T3——setter 群退役) - Kit.check_quiet(int(lv10.shop_refresh_free_left()) == int(db.adventure.shop_refresh_free) + 2, + Kit.check_quiet(int(lv10.shop_refresh_free_left()) == int(db.run.adventure.shop_refresh_free) + 2, "刷新执照正常:免费刷新 +2/店(读数即本店可用——进店重置生命周期同 knob)") # ⑪ 天眼(3×3 真视+安全格直开/危险格不开/封印格跳过) var rng11 = load("res://core/rng_service.gd").new(); rng11.set_master_seed(301) var ob11 = orch_s.new(db, rng11) - Kit.check_quiet(ob11.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["sky_eye"]}), + Kit.check_quiet(ob11.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["sky_eye"]}), "天眼:装配起战(level 级充能 1)") var inv11 = load("res://core/treasure_items.gd").new(db) var rec11: Dictionary = inv11.add_item("sky_eye", "attunement", Vector2i(-1, -1), 0) @@ -265,7 +265,7 @@ func _init() -> void: # ⑫ 信标网络(封印不限相邻/封印价照付) var rng12 = load("res://core/rng_service.gd").new(); rng12.set_master_seed(311) var ob12 = orch_s.new(db, rng12) - Kit.check_quiet(ob12.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["beacon_network"]}), + Kit.check_quiet(ob12.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["beacon_network"]}), "信标网络:装配起战(seal_range_free 注入)") var far12: Vector2i = _cell_at(ob12, ob12.minesweeper.hero_pos() + Vector2i(4, 4)) if far12 != Vector2i(-9, -9): @@ -277,7 +277,7 @@ func _init() -> void: "信标网络正常:封印可达豁免在册(封印仍按标准动作价计费=try_spend 照走)") var rng12b = load("res://core/rng_service.gd").new(); rng12b.set_master_seed(312) var ob12b = orch_s.new(db, rng12b) - ob12b.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp)}) + ob12b.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp)}) Kit.check_quiet(ob12b.minesweeper.seal_range_free == false, "信标网络反例:未装配封印可达豁免=缺省 false(零装配零扰动)") @@ -285,7 +285,7 @@ func _init() -> void: var rng13 = load("res://core/rng_service.gd").new(); rng13.set_master_seed(321) var ob13 = orch_s.new(db, rng13) var lg13 = load("res://core/gain_journal.gd").new() - Kit.check_quiet(ob13.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["incense_stick"], "journal": lg13}), + Kit.check_quiet(ob13.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["incense_stick"], "journal": lg13}), "计数链:装配起战(GainJournal 携带)") var inv13 = load("res://core/treasure_items.gd").new(db) var rec13: Dictionary = inv13.add_item("incense_stick", "attunement", Vector2i(-1, -1), 0) diff --git a/game/tests/unit/core/tree_requires_level_test.gd b/game/tests/unit/core/tree_requires_level_test.gd index 46d143b5..8879e59a 100644 --- a/game/tests/unit/core/tree_requires_level_test.gd +++ b/game/tests/unit/core/tree_requires_level_test.gd @@ -53,7 +53,7 @@ func _init() -> void: var mp = load("res://core/meta_progression.gd").new(db, sv) mt.rows = [base_pre.duplicate(true), base_dep.duplicate(true)] db.set("meta_tree", mt) - sv.set_tree_level("gate_pre", 1) + sv.meta_state.set_tree_level("gate_pre", 1) if mp.tree_can_buy("gate_dep"): failures += 1 print("[r1] FAIL 前置 Lv1 < 阈值 2 应拒购") @@ -62,18 +62,18 @@ func _init() -> void: failures += 1 print("[r1] FAIL 无 requires_level 行判定不受机制位影响") # ⑦ 判定面:达标开放 - sv.set_tree_level("gate_pre", 2) + sv.meta_state.set_tree_level("gate_pre", 2) if not mp.tree_can_buy("gate_dep"): failures += 1 print("[r1] FAIL 前置 Lv2 达阈值应可购") # ⑧ 投影:gate_text 未达标拼门槛文案、达标清空 - sv.set_tree_level("gate_pre", 1) - var gate: String = mp.tree_gate_text("gate_dep") + sv.meta_state.set_tree_level("gate_pre", 1) + var gate: String = mp._tree_gate_text("gate_dep") if gate != "需 门槛前置 Lv2": failures += 1 print("[r1] FAIL 未达标 gate_text 应=「需 门槛前置 Lv2」实得「%s」" % gate) - sv.set_tree_level("gate_pre", 2) - if mp.tree_gate_text("gate_dep") != "": + sv.meta_state.set_tree_level("gate_pre", 2) + if mp._tree_gate_text("gate_dep") != "": failures += 1 print("[r1] FAIL 达标后 gate_text 应为空串") if failures == 0: diff --git a/game/tests/unit/data_config/config_db_fatal_oob_test.gd b/game/tests/unit/data_config/config_db_fatal_oob_test.gd index 31d1d348..5265be3b 100644 --- a/game/tests/unit/data_config/config_db_fatal_oob_test.gd +++ b/game/tests/unit/data_config/config_db_fatal_oob_test.gd @@ -24,7 +24,7 @@ func _init() -> void: func _trigger_oob() -> void: # 越域请求(关卡序号 0 < 合法域 ≥1)——_fatal 内部 push fatal 诊断并 quit(1)(帧末退出) - var got: Dictionary = _db.resolve_board_gen(0, 0) + var got: Dictionary = _db.combat.board_gen.resolve_row(0, 0) if _db.diagnostics_count("fatal") >= 1: print("[fatal_oob_test] fatal 通道已触发(诊断在案)") quit(1) # 与 _fatal 的 quit(1) 同码双保险 diff --git a/game/tests/unit/data_config/config_db_test.gd b/game/tests/unit/data_config/config_db_test.gd index 3d229531..46346262 100644 --- a/game/tests/unit/data_config/config_db_test.gd +++ b/game/tests/unit/data_config/config_db_test.gd @@ -30,7 +30,7 @@ func _init() -> void: # ---- 进度坐标 ceil 解析(R1:向上下取整保证偏难;越最大行 clamp 取最大值 + 警告去重)---- func _test_progress_resolution(db) -> void: - var bg_rows: Array = db.board_gen.rows + var bg_rows: Array = db.combat.board_gen.rows # 行键全集自表推导(禁写死关卡/深度数值) var level_keys: Array = [] var depth_keys_by_level: Dictionary = {} @@ -51,7 +51,7 @@ func _test_progress_resolution(db) -> void: # 精确命中:请求 = 表内现存 (L,D) → 原行返回(字段逐位相等) var probe_row: Dictionary = bg_rows[0] - var got: Dictionary = db.resolve_board_gen(int(probe_row.level_index), int(probe_row.node_depth)) + var got: Dictionary = db.combat.board_gen.resolve_row(int(probe_row.level_index), int(probe_row.node_depth)) Kit.check("进度解析精确命中返回原行(board_gen 首行)", int(got.get("level_index", -99)) == int(probe_row.level_index) and int(got.get("node_depth", -99)) == int(probe_row.node_depth) @@ -59,12 +59,12 @@ func _test_progress_resolution(db) -> void: # enemy 行集同通道:同 (L,D) 行集数组非空(关卡节点地图 前置——battle 节点双表精确行集契约; # 行集面契约:返回形态是行集数组,精确性断言随行集面) - var en_got: Array = db.resolve_enemy_rowset(int(probe_row.level_index), int(probe_row.node_depth), "battle") + var en_got: Array = db.combat.enemy.resolve_rowset(int(probe_row.level_index), int(probe_row.node_depth), "battle") Kit.check("enemy 行集同坐标解析非空(双表精确行契约——battle 行类)", not en_got.is_empty()) # 深度 clamp:请求深度超最大行 → 取该关最大深度行 + 警告诊断一次(去重) db.reset_diagnostics() - var deep: Dictionary = db.resolve_board_gen(min_l, int(min_depths[-1]) + 1) + var deep: Dictionary = db.combat.board_gen.resolve_row(min_l, int(min_depths[-1]) + 1) Kit.check("深度越最大行 clamp 取该关最大深度行", int(deep.get("level_index", -99)) == min_l and int(deep.get("node_depth", -99)) == int(min_depths[-1])) Kit.check("clamp 解析发警告诊断(resolve_depth_clamp)", db.diagnostics_count("warning") >= 1) @@ -74,19 +74,19 @@ func _test_progress_resolution(db) -> void: found_clamp = true Kit.check("警告类别 = resolve_depth_clamp", found_clamp) var warn_before: int = db.diagnostics_count("warning") - db.resolve_board_gen(min_l, int(min_depths[-1]) + 1) + db.combat.board_gen.resolve_row(min_l, int(min_depths[-1]) + 1) Kit.check("同键重复请求警告去重(四元组注册表)", db.diagnostics_count("warning") == warn_before) # 关卡 clamp:请求关卡超最大行 → 取最大关卡行 db.reset_diagnostics() - var high: Dictionary = db.resolve_board_gen(max_l + 1, 0) + var high: Dictionary = db.combat.board_gen.resolve_row(max_l + 1, 0) Kit.check("关卡越最大行 clamp 取最大关卡行", int(high.get("level_index", -99)) == max_l) # ---- 变异池门控(unlock_level 闸 + ∅ board_types 通配 + 节点身份第三维)---- func _test_variant_pool_gate(db) -> void: - var rows: Array = db.variation.rows + var rows: Array = db.combat.variation.rows var gated_row := {} var wildcard_row := {} var global_row := {} @@ -103,19 +103,19 @@ func _test_variant_pool_gate(db) -> void: if not gated_row.is_empty(): var u: int = int(gated_row.unlock_level) var tag: String = String(gated_row.board_types[0]) - var before: Array = db.variant_pool(u - 1, tag) - var after: Array = db.variant_pool(u, tag) + var before: Array = db.combat.variation.variant_pool(u - 1, tag) + var after: Array = db.combat.variation.variant_pool(u, tag) Kit.check("变异池 unlock 前不含未解锁行(level=unlock−1)", not _rows_contain(before, gated_row)) Kit.check("变异池 unlock 当级含该行", _rows_contain(after, gated_row)) if not wildcard_row.is_empty(): var u2: int = int(wildcard_row.unlock_level) - var any_pool: Array = db.variant_pool(u2, "nonexistent_board_type_tag") + var any_pool: Array = db.combat.variation.variant_pool(u2, "nonexistent_board_type_tag") Kit.check("∅ board_types 行通配(任一 board_type 命中)", _rows_contain(any_pool, wildcard_row)) if not global_row.is_empty(): var u3: int = int(global_row.unlock_level) - Kit.check("global 行 boss 节点入池(board_types ∅ 通配)", _rows_contain(db.variant_pool(u3, "nonexistent_board_type_tag", "boss"), global_row)) - Kit.check("global 行缺省双参调用跳过(既有调用口径零扰动)", not _rows_contain(db.variant_pool(u3, "nonexistent_board_type_tag"), global_row)) - Kit.check("global 行 battle 节点静默跳过(门控兜底零报错)", not _rows_contain(db.variant_pool(u3, "nonexistent_board_type_tag", "battle"), global_row)) + Kit.check("global 行 boss 节点入池(board_types ∅ 通配)", _rows_contain(db.combat.variation.variant_pool(u3, "nonexistent_board_type_tag", "boss"), global_row)) + Kit.check("global 行缺省双参调用跳过(既有调用口径零扰动)", not _rows_contain(db.combat.variation.variant_pool(u3, "nonexistent_board_type_tag"), global_row)) + Kit.check("global 行 battle 节点静默跳过(门控兜底零报错)", not _rows_contain(db.combat.variation.variant_pool(u3, "nonexistent_board_type_tag", "battle"), global_row)) func _rows_contain(pool: Array, row: Dictionary) -> bool: @@ -128,28 +128,28 @@ func _rows_contain(pool: Array, row: Dictionary) -> bool: # ---- ShopTable 消费面(行集解析单点——runner/编排禁自扫表的契约消费侧验证)---- func _test_shop_accessors(db) -> void: - var products: Array = db.shop_products() + var products: Array = db.run.shop.product_rows() if products.is_empty(): Kit.check("ShopTable products 行族非空(表已已入库前提)", false) return Kit.check("ShopTable products 行族非空", true) var first: Dictionary = products[0] - var got: Dictionary = db.shop_product_row(String(first.product_id)) + var got: Dictionary = db.run.shop.product_row(String(first.product_id)) Kit.check("shop_product_row 按 product_id 精确命中首行", got == first) - Kit.check("shop_product_row 未知 id 返回空字典(容错通道)", db.shop_product_row("no_such_product_id__").is_empty()) + Kit.check("shop_product_row 未知 id 返回空字典(容错通道)", db.run.shop.product_row("no_such_product_id__").is_empty()) var tier_rows: Array = db._shop_rows("gp_drops") if not tier_rows.is_empty(): var tier0: String = String(tier_rows[0].tier) - Kit.check("shop_gp_drop_row 按 tier 命中", not db.shop_gp_drop_row(tier0).is_empty()) + Kit.check("shop_gp_drop_row 按 tier 命中", not db.run.shop.gp_drop_row(tier0).is_empty()) var bonus_rows: Array = db._shop_rows("level_gp_bonus") if not bonus_rows.is_empty(): var blv: int = int(bonus_rows[0].level_index) - Kit.check("shop_level_gp_bonus 按关卡序号命中行值", db.shop_level_gp_bonus(blv) == int(bonus_rows[0].bonus)) - Kit.check("shop_level_gp_bonus 未登记关卡返回 0(兜底通道)", db.shop_level_gp_bonus(-1) == 0) + Kit.check("shop_level_gp_bonus 按关卡序号命中行值", db.run.shop.level_gp_bonus_at(blv) == int(bonus_rows[0].bonus)) + Kit.check("shop_level_gp_bonus 未登记关卡返回 0(兜底通道)", db.run.shop.level_gp_bonus_at(-1) == 0) var price_rows: Array = db._shop_rows("price_level_bonus") if not price_rows.is_empty(): var plv: int = int(price_rows[0].level_index) - Kit.check("shop_price_level_bonus 按关卡序号命中行值", db.shop_price_level_bonus(plv) == int(price_rows[0].bonus)) + Kit.check("shop_price_level_bonus 按关卡序号命中行值", db.run.shop.price_level_bonus_at(plv) == int(price_rows[0].bonus)) # ---- MetaTable 消费面 ---- @@ -159,11 +159,16 @@ func _test_meta_accessors(db) -> void: var key_rows: Array = db._meta_rows("boss_keystone") if not key_rows.is_empty(): var klv: int = int(key_rows[0].level_index) - Kit.check("meta_boss_keystone_row 按关卡序号命中", db.meta_boss_keystone_row(klv) == key_rows[0]) + Kit.check("meta_boss_keystone_row 按关卡序号命中", db.crossrun.meta.boss_keystone_row(klv) == key_rows[0]) var conv_rows: Array = db._meta_rows("conversion") if not conv_rows.is_empty(): var cattr: String = String(conv_rows[0].attr) - Kit.check("meta_conversion_row 按 attr 命中", db.meta_conversion_row(cattr) == conv_rows[0]) + var conv_hit := {} + for r in db.crossrun.meta.conversion: + if String(r.get("attr", "")) == cattr: + conv_hit = r + break + Kit.check("meta.conversion 行集按 attr 命中(访问器退役改直扫同判据)", conv_hit == conv_rows[0]) # ---- TABLE_MANIFEST ↔ data/tables 目录同步(各系统→D2 单点契约的登记面)---- diff --git a/game/tests/unit/data_config/forge_table_test.gd b/game/tests/unit/data_config/forge_table_test.gd index a9054b27..c1a75a4e 100644 --- a/game/tests/unit/data_config/forge_table_test.gd +++ b/game/tests/unit/data_config/forge_table_test.gd @@ -26,7 +26,7 @@ func _init() -> void: # ---- 生产表六行正向(V1 生产面 + V11 行字段闭集)---- func _test_production_rows(db) -> void: - var rows: Array = db.forge_routes() + var rows: Array = db.crossrun.forge.route_rows() Kit.check("六路线行族规模恰 6(三武器+三甲)〔锻造工坊批 V1〕", rows.size() == 6) var ids := [] for row in rows: @@ -52,39 +52,44 @@ func _test_production_rows(db) -> void: initial.sort() Kit.check("初始持有恰 sword+light〔锻造工坊批 V1〕", initial == ["light", "sword"]) # 默认两轴(契约 2 所有权移交——裸体态 normal/light) - Kit.check("默认攻击类型轴 = normal(常驻持有)〔锻造工坊批 V1〕", db.forge_default_attack_type() == "normal") - Kit.check("默认甲型轴 = light(常驻持有)〔锻造工坊批 V1〕", db.forge_default_armor_type() == "light") + Kit.check("默认攻击类型轴 = normal(常驻持有)〔锻造工坊批 V1〕", db.crossrun.forge.default_attack_type == "normal") + Kit.check("默认甲型轴 = light(常驻持有)〔锻造工坊批 V1〕", db.crossrun.forge.default_armor_type == "light") # 类型轴分族与并联双通道前置(4b-1 裁) Kit.check("长枪/法杖 ← 长剑练满(并联双通道①)〔锻造工坊批 V1〕", - String(db.forge_route("spear").get("prereq_route", "")) == "sword" - and String(db.forge_route("staff").get("prereq_route", "")) == "sword") + String(db.crossrun.forge.route("spear").get("prereq_route", "")) == "sword" + and String(db.crossrun.forge.route("staff").get("prereq_route", "")) == "sword") Kit.check("重甲/魔法护甲 ← 轻甲练满(并联双通道①)〔锻造工坊批 V1〕", - String(db.forge_route("heavy").get("prereq_route", "")) == "light" - and String(db.forge_route("ward").get("prereq_route", "")) == "light") + String(db.crossrun.forge.route("heavy").get("prereq_route", "")) == "light" + and String(db.crossrun.forge.route("ward").get("prereq_route", "")) == "light") Kit.check("四待解锁路线一次性解锁价 = 200〔锻造工坊批 V1〕", - int(db.forge_route("spear").get("unlock_price", 0)) == 200 - and int(db.forge_route("staff").get("unlock_price", 0)) == 200 - and int(db.forge_route("heavy").get("unlock_price", 0)) == 200 - and int(db.forge_route("ward").get("unlock_price", 0)) == 200) + int(db.crossrun.forge.route("spear").get("unlock_price", 0)) == 200 + and int(db.crossrun.forge.route("staff").get("unlock_price", 0)) == 200 + and int(db.crossrun.forge.route("heavy").get("unlock_price", 0)) == 200 + and int(db.crossrun.forge.route("ward").get("unlock_price", 0)) == 200) # 甲型基础 AC(provisional 现役结构值——settlement.tres 矩阵同行) Kit.check("甲型基础 AC = 轻 10 / 重 12 / 魔法护甲 9〔锻造工坊批 V1〕", - int(db.forge_route("light").get("base_ac", 0)) == 10 - and int(db.forge_route("heavy").get("base_ac", 0)) == 12 - and int(db.forge_route("ward").get("base_ac", 0)) == 9) + int(db.crossrun.forge.route("light").get("base_ac", 0)) == 10 + and int(db.crossrun.forge.route("heavy").get("base_ac", 0)) == 12 + and int(db.crossrun.forge.route("ward").get("base_ac", 0)) == 9) # 按类型轴反查(甲路线 id=甲型同值的显式化入口) Kit.check("forge_armor_route_by_type(ward) = 魔法护甲锻造〔锻造工坊批 V1〕", - String(db.forge_armor_route_by_type("ward").get("route_id", "")) == "ward") - Kit.check("forge_weapon_route_by_type(pierce) = 长枪锻造〔锻造工坊批 V1〕", - String(db.forge_weapon_route_by_type("pierce").get("route_id", "")) == "spear") + String(db.crossrun.forge.armor_route_by_type("ward").get("route_id", "")) == "ward") + var wpn_hit := {} + for r in db.crossrun.forge.route_rows(): + if String(r.get("kind", "")) == "weapon" and String(r.get("type_axis", "")) == "pierce": + wpn_hit = r + break + Kit.check("forge route_rows 按轴反查武器路线(pierce)=长枪锻造〔锻造工坊批 V1——访问器退役改直扫同判据〕", + String(wpn_hit.get("route_id", "")) == "spear") # 缺行/未知轴 = 空字典合法态 Kit.check("未知 route_id / 未知类型轴 = 空字典〔锻造工坊批 V1〕", - db.forge_route("blade").is_empty() and db.forge_armor_route_by_type("plate").is_empty()) + db.crossrun.forge.route("blade").is_empty() and db.crossrun.forge.armor_route_by_type("plate").is_empty()) # ---- 几何价格梯(V2 价格面单测半边——购买写在 T3 冒烟)---- func _test_price_ladder(db) -> void: - var row: Dictionary = db.forge_route("sword") + var row: Dictionary = db.crossrun.forge.route("sword") var prices := [] for owned in range(5): prices.append(ForgeTableScript.level_price(row, owned)) diff --git a/game/tests/unit/data_config/level_gen_params_test.gd b/game/tests/unit/data_config/level_gen_params_test.gd index dee8e2fa..a866d6ac 100644 --- a/game/tests/unit/data_config/level_gen_params_test.gd +++ b/game/tests/unit/data_config/level_gen_params_test.gd @@ -41,9 +41,9 @@ func _table(rows: Array) -> Variant: # ---- 生产三行正向(V2)---- func _test_production(db) -> void: - var r1: Dictionary = db.level_gen_params_row(1) - var r2: Dictionary = db.level_gen_params_row(2) - var r3: Dictionary = db.level_gen_params_row(3) + var r1: Dictionary = db.run.level_gen_params.row_by_level_index(1) + var r2: Dictionary = db.run.level_gen_params.row_by_level_index(2) + var r3: Dictionary = db.run.level_gen_params.row_by_level_index(3) Kit.check("访问器读回 L1 行五旋钮齐备〔fv2〕", int(r1.get("layers", 0)) == 6 and is_equal_approx(float(r1.get("branch_density", 0.0)), 0.3) and int(r1.get("exit_count", 0)) == 1 and int(r1.get("entry_count", 0)) == 1) @@ -52,7 +52,7 @@ func _test_production(db) -> void: Kit.check("三行出口 = 1/2/3、战斗保底 = 4/6/8 与 §8.1 表对上〔fv2〕", int(r1.get("battle_floor", 0)) == 4 and int(r2.get("exit_count", 0)) == 2 and int(r3.get("exit_count", 0)) == 3 and int(r3.get("battle_floor", 0)) == 8) - Kit.check("生产表 validate 零错误〔fv2〕", db.level_gen_params.validate().is_empty()) + Kit.check("生产表 validate 零错误〔fv2〕", db.run.level_gen_params.validate().is_empty()) # ---- 访问器缺行防御 ---- @@ -69,40 +69,37 @@ func _test_negative() -> void: var base := [_row(1, 6, 0.3, [0.5, 0.2, 0.2, 0.1], 1, 1, 4), _row(2, 9, 0.5, [0.5, 0.2, 0.15, 0.15], 2, 2, 6), _row(3, 13, 0.7, [0.55, 0.15, 0.15, 0.15], 2, 3, 8)] + Kit.check("合法三行域校验零错误(夹具自证)〔fv2〕", _table(base).validate().is_empty()) # ① L1 出口改 2 → 出口恒 1 断言红(负样本夹具) var bad_l1_exit := [_row(1, 6, 0.3, [0.5, 0.2, 0.2, 0.1], 1, 2, 4), base[1], base[2]] + Kit.check("L1 出口=2 被拒(出口 L1 恒 1 已锁)〔fv2〕", not _table(bad_l1_exit).validate().is_empty()) # ② 配比合计 0.5 → 合计 1 断言红(负样本夹具) var bad_ratio := [_row(1, 6, 0.3, [0.2, 0.1, 0.1, 0.1], 1, 1, 4), base[1], base[2]] + Kit.check("配比合计 0.5 被拒(四类合计 1 已锁)〔fv2〕", not _table(bad_ratio).validate().is_empty()) # ③ 层深低于下限 var bad_layers := [_row(1, 5, 0.3, [0.5, 0.2, 0.2, 0.1], 1, 1, 4), base[1], base[2]] + Kit.check("层深 5<6 被拒(层深下限已锁)〔fv2〕", not _table(bad_layers).validate().is_empty()) # ④ 增幅未变大(6/9/11:Δ12=3、Δ23=2) var bad_growth := [base[0], _row(2, 9, 0.5, [0.5, 0.2, 0.15, 0.15], 2, 2, 6), _row(3, 11, 0.7, [0.55, 0.15, 0.15, 0.15], 2, 3, 8)] + Kit.check("层深增幅倒挂被拒(递增且增幅变大)〔fv2〕", not _table(bad_growth).validate().is_empty()) # ⑤ 出口越上界(L3 出口 4) var bad_exit := [base[0], base[1], _row(3, 13, 0.7, [0.55, 0.15, 0.15, 0.15], 2, 4, 8)] + Kit.check("出口 4 越域 [1,3] 被拒〔fv2〕", not _table(bad_exit).validate().is_empty()) # ⑥ 岔口去路数改 (2,4) var bad_fork_row := _row(1, 6, 0.3, [0.5, 0.2, 0.2, 0.1], 1, 1, 4) bad_fork_row["fork_ways_max"] = 4 + Kit.check("岔口去路数 (2,4) 被拒(恒 2/3 已锁)〔fv2〕", + not _table([bad_fork_row, base[1], base[2]]).validate().is_empty()) # ⑦ 键集非闭集(多塞未知键) var bad_keys_row: Dictionary = _row(1, 6, 0.3, [0.5, 0.2, 0.2, 0.1], 1, 1, 4) bad_keys_row["mystery"] = 1 - # 逐类负向表:[个案标签, 行集, 期望零错误]——每行仍逐案断言,消息带期望/实际与违例明细 - var cases: Array = [ - ["合法三行域校验零错误(夹具自证)", base, true], - ["L1 出口=2 被拒(出口 L1 恒 1 已锁)", bad_l1_exit, false], - ["配比合计 0.5 被拒(四类合计 1 已锁)", bad_ratio, false], - ["层深 5<6 被拒(层深下限已锁)", bad_layers, false], - ["层深增幅倒挂被拒(递增且增幅变大)", bad_growth, false], - ["出口 4 越域 [1,3] 被拒", bad_exit, false], - ["岔口去路数 (2,4) 被拒(恒 2/3 已锁)", [bad_fork_row, base[1], base[2]], false], - ["字段键集非 ROW_KEYS 闭集被拒", [bad_keys_row, base[1], base[2]], false], - ["键重复行被拒", [base[0], base[0]], false], - ["战斗保底 -1 被拒(≥0)", [_row(1, 6, 0.3, [0.5, 0.2, 0.2, 0.1], 1, 1, -1), base[1], base[2]], false], - ["空表域校验零错误(合法态)", [], true], - ] - for c in cases: - var errs: Array = _table(c[1]).validate() - var expect_label := "零错误" if c[2] else "有违例" - var actual_label := "零错误" if errs.is_empty() else "违例 %d 条 %s" % [errs.size(), str(errs)] - Kit.check("域校验:%s(期望%s,实际%s)〔fv2〕" % [c[0], expect_label, actual_label], - errs.is_empty() == c[2]) + Kit.check("字段键集非 ROW_KEYS 闭集被拒〔fv2〕", + not _table([bad_keys_row, base[1], base[2]]).validate().is_empty()) + # ⑧ level_index 重复 + Kit.check("键重复行被拒〔fv2〕", not _table([base[0], base[0]]).validate().is_empty()) + # ⑨ 战斗保底负值 + Kit.check("战斗保底 -1 被拒(≥0)〔fv2〕", + not _table([_row(1, 6, 0.3, [0.5, 0.2, 0.2, 0.1], 1, 1, -1), base[1], base[2]]).validate().is_empty()) + # ⑩ 空表=合法态(沿 difficulty_table 先例) + Kit.check("空表域校验零错误(合法态)〔fv2〕", _table([]).validate().is_empty()) diff --git a/game/tests/unit/data_config/rng_stream_boot_test.gd b/game/tests/unit/data_config/rng_stream_boot_test.gd index 1943c7e9..f3747bde 100644 --- a/game/tests/unit/data_config/rng_stream_boot_test.gd +++ b/game/tests/unit/data_config/rng_stream_boot_test.gd @@ -34,11 +34,14 @@ func _smoke_initialize() -> void: rng.set_master_seed(42) # 统一流黄金向量(数据配置 AC-3:单盐沿用 board_gen 盐, # scope 0-3 与 board_gen 向量逐位连续;Python 参考实现逐位交叉验证一致) - var golden := [8672514112661456564, 8834291164406037215, 7714373262647006282, 1280102845971471347, - 82585501446482399, 8078924421474470922, 6638052740430622424, 8264196488417721294] - for i in golden.size(): - Kit.check("统一流向量 m=42 scope=%d(黄金向量;scope0=旧 board_gen 连续性锚)" % i, - rng.derive_at(i) == golden[i]) + Kit.check("统一流向量 m=42 scope=0(=旧 board_gen scope0 连续性锚)", rng.derive_at(0) == 8672514112661456564) + Kit.check("统一流向量 m=42 scope=1", rng.derive_at(1) == 8834291164406037215) + Kit.check("统一流向量 m=42 scope=2", rng.derive_at(2) == 7714373262647006282) + Kit.check("统一流向量 m=42 scope=3", rng.derive_at(3) == 1280102845971471347) + Kit.check("统一流向量 m=42 scope=4", rng.derive_at(4) == 82585501446482399) + Kit.check("统一流向量 m=42 scope=5", rng.derive_at(5) == 8078924421474470922) + Kit.check("统一流向量 m=42 scope=6", rng.derive_at(6) == 6638052740430622424) + Kit.check("统一流向量 m=42 scope=7", rng.derive_at(7) == 8264196488417721294) Kit.check("derive_at 纯函数恒等", rng.derive_at(0) == rng.derive_at(0)) # scope 取号语义:首个 open_stream = 全局 scope 0;计数器单调;用途标签不参与派生(只进事件日志) var s0 = rng.open_stream("board_gen") diff --git a/game/tests/unit/data_config/table_structure_guard_test.gd b/game/tests/unit/data_config/table_structure_guard_test.gd index a9f21456..f6fabaa5 100644 --- a/game/tests/unit/data_config/table_structure_guard_test.gd +++ b/game/tests/unit/data_config/table_structure_guard_test.gd @@ -75,7 +75,7 @@ static func _errs_has_cross_field(errs: Array) -> bool: # 首遇带数据驱动定位(契约 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: + for row in _db.combat.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: @@ -92,28 +92,28 @@ func _smoke_adventure_table() -> void: Kit.check("db.adventure 已加载(adventure.tres 入 manifest)", _db.adventure != null) if _db.adventure == null: return - Kit.check("AdventureTable.validate() 零违例(全键域检+档位配额结构锚在案)", _db.adventure.validate().is_empty()) - Kit.check("clear_heal_pct ∈ [0,100](%d——关卡完成部分回血比例)" % int(_db.adventure.clear_heal_pct), - int(_db.adventure.clear_heal_pct) >= 0 and int(_db.adventure.clear_heal_pct) <= 100) - for tier in _db.adventure.TIERS: - Kit.check("档位配额结构锚(四级 70/25/7/0——迭代 31 卡② 净删两件投放):%s = %d 张" % [tier, int(_db.adventure.TIER_QUOTAS[tier])], - int(_db.adventure.tier_total(tier)) == int(_db.adventure.TIER_QUOTAS[tier])) - Kit.check("令牌池总量 = 102 张(四级 普通 70/优秀 25/稀有 7/传说 0——迭代 31 卡② 净删两件投放 104→102)〔凭证裁削批 V5〕", int(_db.adventure.deck_total()) == 102) + Kit.check("AdventureTable.validate() 零违例(全键域检+档位配额结构锚在案)", _db.run.adventure.validate().is_empty()) + Kit.check("clear_heal_pct ∈ [0,100](%d——关卡完成部分回血比例)" % int(_db.run.adventure.clear_heal_pct), + int(_db.run.adventure.clear_heal_pct) >= 0 and int(_db.run.adventure.clear_heal_pct) <= 100) + for tier in _db.run.adventure.TIERS: + Kit.check("档位配额结构锚(四级 70/25/7/0——迭代 31 卡② 净删两件投放):%s = %d 张" % [tier, int(_db.run.adventure.TIER_QUOTAS[tier])], + int(_db.run.adventure.tier_total(tier)) == int(_db.run.adventure.TIER_QUOTAS[tier])) + Kit.check("令牌池总量 = 102 张(四级 普通 70/优秀 25/稀有 7/传说 0——迭代 31 卡② 净删两件投放 104→102)〔凭证裁削批 V5〕", int(_db.run.adventure.deck_total()) == 102) Kit.check("数值对(四级换代承袭):小GP 20/大GP 50 · 小增伤 +1/大 +2 · 小生命 +5/大 +10 · 小护甲 +1/大 +2", - int(_db.adventure.curio_value("gp", "common")) == 20 and int(_db.adventure.curio_value("gp", "excellent")) == 50 - and int(_db.adventure.curio_value("attack", "common")) == 1 and int(_db.adventure.curio_value("attack", "excellent")) == 2 - and int(_db.adventure.curio_value("max_hp", "common")) == 5 and int(_db.adventure.curio_value("max_hp", "excellent")) == 10 - and int(_db.adventure.curio_value("armor", "common")) == 1 and int(_db.adventure.curio_value("armor", "excellent")) == 2) + int(_db.run.adventure.curio_value("gp", "common")) == 20 and int(_db.run.adventure.curio_value("gp", "excellent")) == 50 + and int(_db.run.adventure.curio_value("attack", "common")) == 1 and int(_db.run.adventure.curio_value("attack", "excellent")) == 2 + and int(_db.run.adventure.curio_value("max_hp", "common")) == 5 and int(_db.run.adventure.curio_value("max_hp", "excellent")) == 10 + and int(_db.run.adventure.curio_value("armor", "common")) == 1 and int(_db.run.adventure.curio_value("armor", "excellent")) == 2) Kit.check("恢复件 +6 当即回血(优秀档承袭)· AP +1(稀有档承袭)〔专长批 V14 换代〕", - int(_db.adventure.curio_value("heal", "excellent")) == 6 and int(_db.adventure.curio_value("ap", "rare")) == 1) + int(_db.run.adventure.curio_value("heal", "excellent")) == 6 and int(_db.run.adventure.curio_value("ap", "rare")) == 1) Kit.check("cap 占位在域(案②提前退役——99=实质无上限,实数归调参批)〔专长批 V8 形态面〕", - int(_db.adventure.curio_cap("gp")) == 99 and int(_db.adventure.curio_cap("attack")) == 99) + int(_db.run.adventure.curio_cap("gp")) == 99 and int(_db.run.adventure.curio_cap("attack")) == 99) Kit.check("同调位读数 null 档早退分支求值 0(迭代 21 T4 槽位收编——谓词表已退役)〔天赋树批 V14·核代码增补〕", Attunement.slots(_db, null) == 0) Kit.check("同调位读数源=树同调位节点点亮数(非空档——socket1 点亮 1 件计 1)〔天赋树批 V14·核代码增补〕", Attunement.slots(_db, _TreeSaveMock.new()) == 1) # 同调目录 12 件(V1/V2 断言落点;装配→战斗贯通与空装配基线的现行主语 = morning_order) - var trows: Array = _db.treasure_attunement_rows() + var trows: Array = _db.crossrun.treasure_items.attunement_rows() var tid_set := {} for r in trows: tid_set[String(r.get("item_id", ""))] = true @@ -160,11 +160,11 @@ func _smoke_adventure_table() -> void: alloc_ok and tier_map["common"] == 7 and tier_map["excellent"] == 3 and tier_map["rare"] == 2) var orch_l = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(191)) Kit.check("装配→战斗贯通:enc.attunement=[morning_order] 起战 attune_has=true(注入面+只读出口)〔秘宝批换主语〕", - orch_l.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "attunement": ["morning_order"]}) + orch_l.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp), "attunement": ["morning_order"]}) and orch_l.attune_has("morning_order")) var orch_e = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(192)) Kit.check("空装配基线:不带 attunement 起战 attune_has=false(off 态零扰动)〔秘宝批换主语〕", - orch_e.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) + orch_e.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)}) and not orch_e.attune_has("morning_order")) # V9 静态词表断言(棋盘事实不可变契约 3:12 行 triggers do.kind 与 effect_kinds # 全集零棋盘改写类词〔rewrite_board/remove_mines/cell_removal=改写族——现表零引用〕; @@ -223,14 +223,14 @@ func _smoke_enemy_bands() -> void: "3|boss|9|duergar_king", "3|boss|11|duergar_king", "3|boss|12|duergar_king", "3|boss|13|duergar_king", ] var got_keys: Array[String] = [] - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: got_keys.append("%d|%s|%d|%s" % [int(row.get("level_index", -1)), String(row.get("row_kind", "")), int(row.get("depth_band", -1)), String(row.get("enemy_id", ""))]) Kit.check("敌人表 28 行且键序 = 覆盖表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕", - _db.enemy.rows.size() == 28 and got_keys == expect_keys) + _db.combat.enemy.rows.size() == 28 and got_keys == expect_keys) # ② hobgoblin 缺口行留任随迁(契约 9:不删行不补立绘——缺口登记随行) var hob := {} - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: if String(row.get("enemy_id", "")) == "hobgoblin": hob = row Kit.check("hobgoblin 行在 (1,battle,3) 留任随迁(缺口行不删不补——补齐归敌种内容批)〔fv12〕〔fv25〕", @@ -242,7 +242,7 @@ func _smoke_enemy_bands() -> void: # 生成图多终点要求每终点深度解析到互异带键 ⇒ 每关 Boss 行必然多于一行—— # 迭代 35 覆盖批随动改写为带键清单,「带键互异」的运行时把关在 level_shop_economy_test〔v11〕) var boss_bands := {} - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: if String(row.get("row_kind", "")) == "boss": var lk: int = int(row.get("level_index", -1)) if not boss_bands.has(lk): @@ -269,47 +269,47 @@ func _smoke_enemy_bands() -> void: func _smoke_ac_swap_guard() -> void: print("--- 护甲等级掷点批·换装护栏 ---") # ① 三处无双轨 - Kit.check("状态表 defense_up 行已退役(无双轨)", _db.status_effect_row("defense_up").is_empty()) - var ac_row: Dictionary = _db.status_effect_row("ac_up") + Kit.check("状态表 defense_up 行已退役(无双轨)", _db.combat.status_effects.row_by_id("defense_up").is_empty()) + var ac_row: Dictionary = _db.combat.status_effects.row_by_id("ac_up") Kit.check("ac_up 行在位(hero · +1 ac · 只续时不叠层——2026-09-20 主角增益续时批)", not ac_row.is_empty() and String(ac_row.get("target_side", "")) == "hero" \ and String(ac_row.get("unit", "")) == "ac" and int(ac_row.get("magnitude", 0)) == 1 \ and String(ac_row.get("stack_policy", "")) == "refresh") var ac_reward := {} - for row in _db.reward.rows: + for row in _db.run.reward.rows: if String(row.get("cell_type", "")) == "defense" and String(row.get("polarity", "")) == "positive": ac_reward = row Kit.check("奖励表 defense 正向行 effect_id = ac_up(显示名盾牌加护,权重/tier 不变)", String(ac_reward.get("effect_id", "")) == "ac_up" and String(ac_reward.get("display_name", "")) == "盾牌加护") Kit.check("effect_pool_map 无 defense_up/ac_up 键(减伤不入池——AC 不经乘区)", - not _db.settlement.core.effect_pool_map.has("defense_up") and not _db.settlement.core.effect_pool_map.has("ac_up")) + not _db.combat.settlement.core.effect_pool_map.has("defense_up") and not _db.combat.settlement.core.effect_pool_map.has("ac_up")) # ② 免伤墙归零护栏(泛化——BUG-0003 免疫) var wall_ok := true var wall_detail := "" - for eff_id in _db.settlement.core.effect_pool_map.keys(): - if String(_db.settlement.core.effect_pool_map[eff_id]) != "hero_defense": + for eff_id in _db.combat.settlement.core.effect_pool_map.keys(): + if String(_db.combat.settlement.core.effect_pool_map[eff_id]) != "hero_defense": continue - var row: Dictionary = _db.status_effect_row(eff_id) + var row: Dictionary = _db.combat.status_effects.row_by_id(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.core.pool_clamp_lo): + if floor_v <= int(_db.combat.settlement.core.pool_clamp_lo): wall_ok = false wall_detail += " %s(%d)" % [eff_id, floor_v] Kit.check("hero_defense 池 percent 行叠满不可触池底(墙最深%s > clamp_lo %d——单效果归零结构性不可达)" % [ - wall_detail if wall_detail != "" else "(池内现无负向行)", int(_db.settlement.core.pool_clamp_lo)], wall_ok) + wall_detail if wall_detail != "" else "(池内现无负向行)", int(_db.combat.settlement.core.pool_clamp_lo)], wall_ok) var hd_maps := 0 - for eff_id in _db.settlement.core.effect_pool_map.keys(): - if String(_db.settlement.core.effect_pool_map[eff_id]) == "hero_defense": + for eff_id in _db.combat.settlement.core.effect_pool_map.keys(): + if String(_db.combat.settlement.core.effect_pool_map[eff_id]) == "hero_defense": hd_maps += 1 Kit.check("hero_defense 池映射数==0(绊线钉——红=已加映射,去核上方护栏是否已同步激活)", hd_maps == 0) # ③ 单位表 armor_class:域内校验(配置推导——正式投值归怪物/难度批,改表不断锚) - Kit.check("主角 armor_class 在域内(%d——暂定基准随表走)" % int(_db.hero.armor_class), - int(_db.hero.armor_class) >= 0 and int(_db.hero.armor_class) <= 30) + Kit.check("主角 armor_class 在域内(%d——暂定基准随表走)" % int(_db.combat.hero.armor_class), + int(_db.combat.hero.armor_class) >= 0 and int(_db.combat.hero.armor_class) <= 30) var ac_band := true var ac_detail := "" var heavy_names := ["熊地精重击手", "灰矮人重装手"] # 逐行手设值 8/9,非精英编码位 - for row in _db.enemy.rows: + for row in _db.combat.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 \ @@ -325,7 +325,7 @@ func _smoke_ac_swap_guard() -> void: ac_detail if ac_detail != "" else "无"], ac_band) # ③b Boss 台阶断言(关卡即台阶——L1 8 < L2 10 < L3 12)+ 首遇位教学位低 AC(≤3) var boss_ac := {} - for row in _db.enemy.rows: + for row in _db.combat.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)) @@ -337,7 +337,7 @@ func _smoke_ac_swap_guard() -> void: # 曲线护栏 = 深度带升序相邻带「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: + for row in _db.combat.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 @@ -350,7 +350,7 @@ func _smoke_ac_swap_guard() -> void: and int(l12_row.get("armor_class", -1)) == 2 \ and l12_pool == {"attack": 6, "charge": 2, "heavy": 2}) var l1_totals := {} - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: if int(row.get("level_index", -1)) != 1: continue var cnt_lo: int = int(row.get("unit_count", 1)) @@ -370,12 +370,12 @@ func _smoke_ac_swap_guard() -> void: Kit.check("第一关相邻深度带总量比 ≤ 2.5(12/10/22/35/72——迭代 24 N1 曲线护栏,带键升序;越带%s)" % [ curve_detail if curve_detail != "" else "无"], curve_ok) # ④ 掷点参数域(加载校验已过——此处钉形状语义) - var bounds: Array = _db.settlement.damage_roll.roll_tier_bounds - var discounts: Array = _db.settlement.damage_roll.roll_tier_discounts + var bounds: Array = _db.combat.settlement.damage_roll.roll_tier_bounds + var discounts: Array = _db.combat.settlement.damage_roll.roll_tier_discounts Kit.check("掷点参数拍板值在位(d20 / nat20 暴击 ×200%% / 下限 1 / attack_bonus 0 / armor_roll 流)", - int(_db.settlement.damage_roll.roll_faces) == 20 and int(_db.settlement.damage_roll.crit_roll) == 20 \ - and int(_db.settlement.damage_roll.crit_multiplier) == 200 and int(_db.settlement.damage_roll.damage_floor) == 1 \ - and int(_db.settlement.damage_roll.attack_bonus) == 0 and String(_db.settlement.damage_roll.armor_roll_stream) == "armor_roll") + int(_db.combat.settlement.damage_roll.roll_faces) == 20 and int(_db.combat.settlement.damage_roll.crit_roll) == 20 \ + and int(_db.combat.settlement.damage_roll.crit_multiplier) == 200 and int(_db.combat.settlement.damage_roll.damage_floor) == 1 \ + and int(_db.combat.settlement.damage_roll.attack_bonus) == 0 and String(_db.combat.settlement.damage_roll.armor_roll_stream) == "armor_roll") Kit.check("档位表拍板值在位(缺口 1-3 → ×0.75 · 4-7 → ×0.5 · 8+ → ×0.25——无零档)", bounds == [3, 7] and discounts == [75, 50, 25]) @@ -385,24 +385,24 @@ func _smoke_ac_swap_guard() -> void: func _smoke_variation_framework() -> void: print("--- 地形变异 框架面(首批四类型 + 候选池门控 + 跨字段互锁)---") Kit.check("变异清单 6 行(荆棘丛/藤蔓缠绕/岩浆/烬覆 + 落石/矿尘——首批类型定稿批 + 第三关内容批;贫矿脉 2026-10-16 用户令屏蔽删行)", - _db.variation.rows.size() == 6) + _db.combat.variation.rows.size() == 6) Kit.check("变异表校验零错误(6 行全过域与跨字段互锁——含 scope=global 三互锁负样本)", - _db.variation.validate().is_empty()) + _db.combat.variation.validate().is_empty()) var pool1_ids: Array = [] - for r in _db.variant_pool(1, "dungeon"): + for r in _db.combat.variation.variant_pool(1, "dungeon"): pool1_ids.append(String(r.variant_id)) Kit.check("L1 dungeon 候选池 = 荆棘丛+藤蔓缠绕(unlock + board_types 双闸)", pool1_ids == ["thorn", "vine"]) var pool2_ids: Array = [] - for r in _db.variant_pool(2, "underdark"): + for r in _db.combat.variation.variant_pool(2, "underdark"): pool2_ids.append(String(r.variant_id)) Kit.check("L2 underdark 候选池 = 岩浆+烬覆(第二关解锁)", pool2_ids == ["magma", "ash"]) - Kit.check("unlock 门控:L1 不进 underdark 行(岩浆/烬覆第二关解锁)", _db.variant_pool(1, "underdark").is_empty()) + Kit.check("unlock 门控:L1 不进 underdark 行(岩浆/烬覆第二关解锁)", _db.combat.variation.variant_pool(1, "underdark").is_empty()) var pool2d_ids: Array = [] - for r in _db.variant_pool(2, "dungeon"): + for r in _db.combat.variation.variant_pool(2, "dungeon"): pool2d_ids.append(String(r.variant_id)) Kit.check("board_types 门控:L2 dungeon 板池 = 荆棘丛+藤蔓缠绕(解锁累积;岩浆/烬覆被 board_types ∅ 交集排除)", pool2d_ids == ["thorn", "vine"]) - Kit.check("清单外 variant_id 返回空(读取通道在位)", _db.variation.row_by_id("nonexistent").is_empty()) + Kit.check("清单外 variant_id 返回空(读取通道在位)", _db.combat.variation.row_by_id("nonexistent").is_empty()) # 跨字段互锁负样本(加载期致命——枚举语义一致性闸) var vt = load("res://data/schemas/variation_table.gd").new() vt.rows.assign([{"variant_id": "bad1", "display_name": "坏行甲", "unlock_level": 1, "scope": "region", @@ -419,30 +419,30 @@ func _smoke_variation_framework() -> void: func _smoke_difficulty_table() -> void: print("--- 难度曲线与Boss·DifficultyTable 结构位 ---") - Kit.check("DifficultyTable MVP 空表加载(结构位)", _db.difficulty != null and _db.difficulty.rows.is_empty()) + Kit.check("DifficultyTable MVP 空表加载(结构位)", _db.difficulty != null and _db.run.difficulty.rows.is_empty()) # ① 空表零行为:敌人系统.spawn 敌值 = EnemyTable 行值原样 var enemy_system = load("res://core/unit_container.gd").new(_db) - Kit.check("spawn 受理", enemy_system.spawn(1, 0, int(_db.hero.max_hp))) + Kit.check("spawn 受理", enemy_system.spawn({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)})) var unit = enemy_system.all_enemies()[0] - var d0: Dictionary = _db.resolve_enemy_rowset(1, 0, "battle")[0] # 行集首行(带键寻址:战斗节点查 battle 键域) + var d0: Dictionary = _db.combat.enemy.resolve_rowset(1, 0, "battle")[0] # 行集首行(带键寻址:战斗节点查 battle 键域) Kit.check("空表零修饰(hp=%d atk=%d = 行值 %d/%d)" % [int(unit.hp), int(unit.atk), int(d0.hp), int(d0.atk)], int(unit.hp) == int(d0.hp) and int(unit.atk) == int(d0.atk)) # ② 修饰行运行时注入(debug 通道——R3 override 同族):套乘生效于 hp/atk - _db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 1.5, "atk_mult": 2.0}) + _db.run.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 1.5, "atk_mult": 2.0}) var enemy_system_b = load("res://core/unit_container.gd").new(_db) - Kit.check("修饰后 spawn 受理", enemy_system_b.spawn(1, 0, int(_db.hero.max_hp))) + Kit.check("修饰后 spawn 受理", enemy_system_b.spawn({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)})) var g2 = enemy_system_b.all_enemies()[0] Kit.check("hp 修饰 ×1.5(%d→%d,max_hp 同步)" % [int(d0.hp), int(g2.hp)], int(g2.hp) == int(round(float(d0.hp) * 1.5)) and int(g2.max_hp) == int(g2.hp)) Kit.check("atk 修饰 ×2.0(%d→%d)" % [int(d0.atk), int(g2.atk)], int(g2.atk) == int(d0.atk) * 2) # ③ 越域 clamp:乘数 50 → clamp 10 - _db.difficulty.rows.clear() - _db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 50.0, "atk_mult": 1.0}) + _db.run.difficulty.rows.clear() + _db.run.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 50.0, "atk_mult": 1.0}) var enemy_system_c = load("res://core/unit_container.gd").new(_db) - Kit.check("越域修饰 spawn 受理", enemy_system_c.spawn(1, 0, int(_db.hero.max_hp))) + Kit.check("越域修饰 spawn 受理", enemy_system_c.spawn({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)})) Kit.check("越域乘数 clamp 至 10(%d→%d)" % [int(d0.hp), int(enemy_system_c.all_enemies()[0].hp)], int(enemy_system_c.all_enemies()[0].hp) == int(d0.hp) * 10) - _db.difficulty.rows.clear() # 还原空表(后续冒烟/关卡断言基线) + _db.run.difficulty.rows.clear() # 还原空表(后续冒烟/关卡断言基线) # ---- ⑤ 格面资源九型 + gp_drop 流(黄金向量)---- @@ -455,7 +455,7 @@ func _smoke_nine_types_gp_stream() -> void: var pos_weight := 0 var gp_pos := -1 var gp_neg := -1 - for row in _db.reward.rows: + for row in _db.run.reward.rows: if String(row.get("pool_tag", "")) == "cell": cell_rows += 1 if String(row.get("polarity", "")) == "positive": @@ -468,7 +468,7 @@ func _smoke_nine_types_gp_stream() -> void: cell_rows == 18) # 危险格负向直伤当量(负向≈正向 2 倍定价——真人反馈「三雷 3/4 血无后果感」的投值口径) var mine_power := {"heal_backlash": 6, "pierce_snare": 8, "magic_recoil": 8} # 危险格负向直伤当量(抬档值;依据见 numfix 报告) - for row in _db.reward.rows: + for row in _db.run.reward.rows: var rid := String(row.get("reward_id", "")) if mine_power.has(rid): Kit.check("危险格负向直伤 %s power=%d(卡⑧投值)" % [rid, int(mine_power[rid])], @@ -477,7 +477,7 @@ func _smoke_nine_types_gp_stream() -> void: # 合计即配置自身的和(改表自动跟随,不再硬编码合计值)。 var pos_all_in_pool := true var neg_all_zero := true - for row in _db.reward.rows: + for row in _db.run.reward.rows: if String(row.get("pool_tag", "")) != "cell": continue if String(row.get("polarity", "")) == "positive" and int(row.get("weight", 0)) < 1: @@ -506,11 +506,11 @@ func _smoke_meta() -> void: and meta_tbl.conversion.size() == 3 and meta_tbl.attune_refresh_upgrade.size() == 2) var attrs_ok := true for a in ["max_hp", "atk", "armor_class"]: - attrs_ok = attrs_ok and not _db.meta_conversion_row(a).is_empty() + attrs_ok = attrs_ok and not _conversion_row_of(_db.crossrun.meta.conversion, a).is_empty() Kit.check("conversion attr 词表封闭恰全(三属性轴——meta_* 访问器可达)", attrs_ok) var boss_ok := true for li in [1, 2, 3]: - var brow: Dictionary = _db.meta_boss_keystone_row(li) + var brow: Dictionary = _db.crossrun.meta.boss_keystone_row(li) boss_ok = boss_ok and not brow.is_empty() and int(brow.get("first_kill", 0)) >= int(brow.get("repeat_kill", -1)) Kit.check("boss_keystone L1–L3 行连续可达且 first_kill ≥ repeat_kill 全行(首杀全量/重复小额)", boss_ok) @@ -519,3 +519,10 @@ class _TreeSaveMock: # 核代码增补:最小 save 形态——attunement.slots 只读 tree_level("att_socketN") func tree_level(n: String) -> int: return 1 if n == "att_socket1" else 0 + + +func _conversion_row_of(rows: Array, attr: String) -> Dictionary: + for r in rows: + if String(r.get("attr", "")) == attr: + return r + return {} diff --git a/game/tests/unit/presentation/battle_narrator_test.gd b/game/tests/unit/presentation/battle_narrator_test.gd index e58a8085..13362dd7 100644 --- a/game/tests/unit/presentation/battle_narrator_test.gd +++ b/game/tests/unit/presentation/battle_narrator_test.gd @@ -64,7 +64,7 @@ func _test_boss_name_uses_boss_rowset() -> void: if not loaded: return var boss_row := {} - for row in db.enemy.rows: + for row in db.combat.enemy.rows: if int(row.level_index) == 1 and String(row.get("row_kind", "")) == "boss": boss_row = row break @@ -72,8 +72,8 @@ func _test_boss_name_uses_boss_rowset() -> void: if boss_row.is_empty(): return var boss_depth: int = int(boss_row.depth_band) - var boss_rows: Array = db.resolve_enemy_rowset(1, boss_depth, "boss") - var battle_rows: Array = db.resolve_enemy_rowset(1, boss_depth, "battle") + var boss_rows: Array = db.combat.enemy.resolve_rowset(1, boss_depth, "boss") + var battle_rows: Array = db.combat.enemy.resolve_rowset(1, boss_depth, "battle") var boss_expect := String(boss_rows[0].get("display_name", "")) var battle_name := String(battle_rows[0].get("display_name", "")) var runner := _BossRunner.new() diff --git a/game/tests/unit/presentation/forge_workbench_test.gd b/game/tests/unit/presentation/forge_workbench_test.gd index a41256dd..ec25fd5d 100644 --- a/game/tests/unit/presentation/forge_workbench_test.gd +++ b/game/tests/unit/presentation/forge_workbench_test.gd @@ -58,12 +58,14 @@ func _run() -> void: and rows.has("light") and rows.has("heavy") and rows.has("ward")) # ③ 初始持有行(长剑/轻甲):购买钮现形带几何价、选中钮=当前禁用 - # 两行同取谓词并集(轻甲行补 select_btn 非空 + buy_btn 可用两子句——原件少写,只增不减) - for pair in [["sword", "长剑"], ["light", "轻甲"]]: - var r: Dictionary = rows.get(pair[0], {}) - Kit.check("%s行:购买钮「锻造 20」(0 级位几何价)+选中钮「当前」禁用〔锻造工坊批 V13〕" % pair[1], - r.get("buy_btn") != null and String(r.buy_btn.text) == "锻造 20" and not r.buy_btn.disabled - and r.get("select_btn") != null and String(r.select_btn.text) == "当前" and r.select_btn.disabled) + var rsw = rows.get("sword", {}) + var rli = rows.get("light", {}) + Kit.check("长剑行:购买钮「锻造 20」(0 级位几何价)+选中钮「当前」禁用〔锻造工坊批 V13〕", + rsw.get("buy_btn") != null and String(rsw.buy_btn.text) == "锻造 20" and not rsw.buy_btn.disabled + and rsw.get("select_btn") != null and String(rsw.select_btn.text) == "当前" and rsw.select_btn.disabled) + Kit.check("轻甲行:购买钮「锻造 20」+选中钮「当前」禁用〔锻造工坊批 V13〕", + rli.get("buy_btn") != null and String(rli.buy_btn.text) == "锻造 20" + and String(rli.select_btn.text) == "当前" and rli.select_btn.disabled) # ④ 待解锁行(长枪/法杖/重甲/魔法护甲):解锁钮 200、无购买钮、无选中钮、状态含双通道文案 var rsp = rows.get("spear", {}) @@ -83,7 +85,7 @@ func _run() -> void: rows.sword.buy_btn.disabled and rows.spear.unlock_btn.disabled) # ⑥ 满级行:无购买钮、状态=已满级(直置存档域构造——两分法调试口径) - sv.set_forge_route_level("sword", 5) + sv.meta_state.set_forge_route_level("sword", 5) ov.refresh_camp({"balance": 500, "forge": mp.forge_shop_status(), "tree": [{"node_id": "forge_workshop", "level": 1}]}) rows = _row_by_id(ov) diff --git a/game/tests/unit/presentation/pacing_queue_test.gd b/game/tests/unit/presentation/pacing_queue_test.gd index 0b4fbdff..de6afc4a 100644 --- a/game/tests/unit/presentation/pacing_queue_test.gd +++ b/game/tests/unit/presentation/pacing_queue_test.gd @@ -51,8 +51,8 @@ func _init() -> void: if FileAccess.file_exists(p): DirAccess.remove_absolute(p) var sm = main.get_node("/root/SaveManager") - sm.attach_path("user://test_pacing_queue_save.json") - sm.mark_first_adventure_done() + sm.slots.attach_path("user://test_pacing_queue_save.json") + sm.meta_state.mark_first_adventure_done() # 行信号计数器(真实信号路径旁挂计数——零触碰既有接线) var rows_seen: Array = [] main._start_adventure_from_menu() diff --git a/game/tests/unit/presentation/player_copy_sweep_test.gd b/game/tests/unit/presentation/player_copy_sweep_test.gd index 9013be8b..d0e055cc 100644 --- a/game/tests/unit/presentation/player_copy_sweep_test.gd +++ b/game/tests/unit/presentation/player_copy_sweep_test.gd @@ -56,8 +56,10 @@ func _sweep_narrator() -> void: var head := String(narrator.node_curio_header()) Kit.check("奖励点头部含「冒险牌库」(u3 机制事实①同库)〔wa4〕", head.contains("冒险牌库")) Kit.check("奖励点头部含「属性值提升与补给」(u3 机制事实②渠道分流)〔wa4〕", head.contains("属性值提升与补给")) - for w in ["同权重", "同上限", "提升池"]: - Kit.check("奖励点头部零「%s」旧话(u3 清零)〔wa4〕" % w, not head.contains(w)) + Kit.check("奖励点头部含「同库分流」(u3 机制事实②措辞)〔wa4〕", head.contains("同库分流")) + Kit.check("奖励点头部零「同权重」旧话(u3 清零)〔wa4〕", not head.contains("同权重")) + Kit.check("奖励点头部零「同上限」旧话(u3 清零)〔wa4〕", not head.contains("同上限")) + Kit.check("奖励点头部零「提升池」旧话(u3 清零)〔wa4〕", not head.contains("提升池")) Kit.check("专长卡兜底==「专长\n???」(u11 换代——数据缺行真实兜底路径)〔wa3〕", String(narrator.feat_card_text({})) == "专长\n???") @@ -70,11 +72,11 @@ func _sweep_legend() -> void: if String(e[1]) == "封印": seal_row = e break + Kit.check("图例封印行存在(wa5 夹具前提)〔wa5〕", not seal_row.is_empty()) Kit.check("图例封印行含「标空」(u4——与「标中」成对)〔wa5〕", String(seal_row[2] if seal_row.size() > 2 else "").contains("标空")) Kit.check("图例封印行含「受挖掘惩罚」(u4 雷字清零换代)〔wa5〕", String(seal_row[2] if seal_row.size() > 2 else "").contains("受挖掘惩罚")) - for w in ["插错", "挖雷"]: - Kit.check("图例封印行零「%s」〔wa5〕" % w, - not String(seal_row[2] if seal_row.size() > 2 else "").contains(w)) + Kit.check("图例封印行零「插错」(u4 旗族遗留词干清零)〔wa5〕", not String(seal_row[2] if seal_row.size() > 2 else "").contains("插错")) + Kit.check("图例封印行零「挖雷」(u4 雷字清零)〔wa5〕", not String(seal_row[2] if seal_row.size() > 2 else "").contains("挖雷")) # ⑤⑥ 来源名双源(u5/u6——状态槽消费半+表值 ConfigDB 真装载+battle_log 行消费半) @@ -94,7 +96,7 @@ func _sweep_source_names() -> void: row_view._tip_label.text.contains("来源:危险格")) row_view._hide_tip() Kit.check("结算表 source_label_map[mine_hit]==「危险格」(u6 换代——ConfigDB 真装载)〔wa7〕", - String(_db.settlement.core.source_label_map.get("mine_hit", "")) == "危险格") + String(_db.combat.settlement.core.source_label_map.get("mine_hit", "")) == "危险格") var log_view = load("res://presentation/views/battle_log_view.gd").new(_db) var row_txt := String(log_view._format_row({"type": "effect", "source_class": "mine_hit", "display_name": "挖掘反噬", "value": -5, "pool_value": -5, "round_index": 1})) diff --git a/game/tests/unit/presentation/standee_fx_sweep_test.gd b/game/tests/unit/presentation/standee_fx_sweep_test.gd index b26b13e0..f7496895 100644 --- a/game/tests/unit/presentation/standee_fx_sweep_test.gd +++ b/game/tests/unit/presentation/standee_fx_sweep_test.gd @@ -81,7 +81,7 @@ func _init() -> void: stage.on_step_row_fx({"type": "damage", "target": "地精", "target_id": "goblin_b"}) await create_timer(0.6).timeout # 弹道到达拍 0.33s + 余量 Kit.check("伤害行按 target_id 命中正确站桩(闪白落点)〔wd12〕", - s2.fx_probe().flash_count == 1 and s1.fx_probe().flash_count == 0) + s2._flash_count == 1 and s1._flash_count == 0) # 口二:enemy_standee_center —— id 先、display_name 兜底 var c_b = stage.enemy_standee_center("goblin_b", "地精") var c_b_expect: Vector2 = stage.world_to_shell(s2.position + s2.box * 0.5) @@ -91,16 +91,14 @@ func _init() -> void: Kit.check("无 target_id 时按名兜底同旧行为(首命中)〔wd12〕", c_fallback != null and c_fallback == c_fallback_expect) # ---- ① 意图蓄力档可达(d2)---- - # 蓄力判据表:[kind, skill_id, 期望 _intent_charge, 个案标签] - var charge_cases := [ - ["damage", "heavy", true, "蓄力技能(heavy)置 _intent_charge=true"], - ["damage", "goblin_slash", false, "非蓄力攻击技(goblin_slash)零置位"], - ["buff", "goblin_rage", false, "增益系零置位"], - ["damage", "basic", false, "普攻零表示路径复位 _intent_charge"], - ] - for c in charge_cases: - s1.set_pending(c[0], c[1]) - Kit.check("%s〔wd2〕" % c[3], s1._intent_charge == c[2]) + s1.set_pending("damage", "heavy") + Kit.check("蓄力技能(heavy)置 _intent_charge=true〔wd2〕", s1._intent_charge) + s1.set_pending("damage", "goblin_slash") + Kit.check("非蓄力攻击技(goblin_slash)零置位〔wd2〕", not s1._intent_charge) + s1.set_pending("buff", "goblin_rage") + Kit.check("增益系零置位〔wd2〕", not s1._intent_charge) + s1.set_pending("damage", "basic") + Kit.check("普攻零表示路径复位 _intent_charge〔wd2〕", not s1._intent_charge) # ---- ② 庆祝 tween 句柄纪律(d4)---- var ov = BattleEndScript.new() diff --git a/game/tests/unit/presentation/terrain_tile_sets_test.gd b/game/tests/unit/presentation/terrain_tile_sets_test.gd index 94599bc5..a7f295e4 100644 --- a/game/tests/unit/presentation/terrain_tile_sets_test.gd +++ b/game/tests/unit/presentation/terrain_tile_sets_test.gd @@ -27,7 +27,7 @@ func _init() -> void: return # board_type 词表自 board_gen 行集推导(禁测试侧写死词表) var types: Array = [] - for row in db.board_gen.rows: + for row in db.combat.board_gen.rows: var bt := String(row.get("board_type", "")) if bt != "" and not types.has(bt): types.append(bt) diff --git a/game/tests/unit/presentation/theme_projection_test.gd b/game/tests/unit/presentation/theme_projection_test.gd index f7b37176..1b5651eb 100644 --- a/game/tests/unit/presentation/theme_projection_test.gd +++ b/game/tests/unit/presentation/theme_projection_test.gd @@ -611,6 +611,18 @@ func _smoke_button_five_states() -> void: is_equal_approx((dds as StyleBoxFlat).bg_color.a, 0.45)) Kit.check("V1 危险钮 disabled≠pressed", _sb_content_differs(dpr, dds)) db.free() + # V2 主题红线维持(Button 类型词条零接触+focus 族词条零定义)——与 _smoke_global_theme 同判据复述 + var th := ThemeDB.get_project_theme() + if th != null: + Kit.check("V2 红线维持 Button 类型词条零接触", not th.get_type_list().has("Button")) + var focus_hits := "" + for t in th.get_type_list(): + for nm in (th.get_stylebox_list(t) + th.get_icon_list(t) + th.get_color_list(t) + + th.get_font_list(t) + th.get_font_size_list(t) + th.get_constant_list(t)): + if "focus" in String(nm): + focus_hits += "%s/%s " % [t, nm] + Kit.check("V2 红线维持 focus 族词条零定义%s" % ("" if focus_hits == "" else "(违例:" + focus_hits + ")"), + focus_hits == "") # ---- 全局纹理过滤冒烟(工程设置 Nearest+平滑渐变件 LINEAR 白名单;V3)---- @@ -819,21 +831,21 @@ func _career_overlay_projection() -> void: 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, + sv.meta_state._add_keystone(45) + sv.meta_state.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, + sv.meta_state.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1, "stopped_at": 2, "keystone_total": 0, "master_seed": 22}) sv.write_to_disk() # 投影拼装(HudPresenter.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 data := {"total": sv.meta_state.career_state().adventure_total, "cleared": sv.meta_state.career_state().adventure_cleared, + "clears": [], "boss_first_kills": 0, "boss_first_total": _db.run.level.level_count(), + "boss_kills": sv.meta_state.career_state().boss_kill_count, "keystone": mp.meta_state.keystone_balance()} + for i in _db.run.level.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["clears"].append({"display_name": String(_db.run.level.row_by_level_index(li).get("display_name", "")), + "count": sv.meta_state.career_state().clear_counts.get(str(li), 0)}) + if sv.meta_state.career_state().boss_first_kill.has(str(li)): data["boss_first_kills"] += 1 var co = load("res://presentation/views/career_overlay.gd").new() co.open_career(data) @@ -850,7 +862,7 @@ func _career_overlay_projection() -> void: Kit.check("Boss 首杀/总杀投影 = 首杀标记计数 + boss_kill_count()(%s / %s)" % [co._boss_first_label.text, co._boss_kills_label.text], String(co._boss_first_label.text) == "3/3 关" and String(co._boss_kills_label.text) == "4 次") Kit.check("钥石余额徽投影 = keystone_balance() 读数(%s)" % co._keystone_badge.value_label.text, - String(co._keystone_badge.value_label.text) == "钥石 %d" % mp.keystone_balance()) + String(co._keystone_badge.value_label.text) == "钥石 %d" % mp.meta_state.keystone_balance()) # 口径换代(2026-10-26):行标签换代钉子+生涯页零「通关」残留(负断言兜全页) var career_label_texts: Array[String] = [] for career_lbl: Label in _all_labels(co): @@ -869,14 +881,14 @@ func _career_overlay_projection() -> void: fv5c.store_string(JSON.stringify({"version": 5, "meta": {"keystone_balance": 3}})) fv5c.close() Kit.check("v5 现役档升 v6 载入成功(计数域缺省补零)", - v5.load_from_disk() and v5.adventure_total() == 0 and v5.adventure_cleared() == 0) + v5.load_from_disk() and v5.meta_state.career_state().adventure_total == 0 and v5.meta_state.career_state().adventure_cleared == 0) var mp_v5 = load("res://core/meta_progression.gd").new(_db, v5) - var data_v5 := {"total": v5.adventure_total(), "cleared": v5.adventure_cleared(), - "clears": [], "boss_first_kills": 0, "boss_first_total": _db.level_count(), - "boss_kills": v5.boss_kill_count(), "keystone": mp_v5.keystone_balance()} - for i in _db.level_count(): - data_v5["clears"].append({"display_name": String(_db.level_row(i + 1).get("display_name", "")), - "count": v5.clear_count(i + 1)}) + var data_v5 := {"total": v5.meta_state.career_state().adventure_total, "cleared": v5.meta_state.career_state().adventure_cleared, + "clears": [], "boss_first_kills": 0, "boss_first_total": _db.run.level.level_count(), + "boss_kills": v5.meta_state.career_state().boss_kill_count, "keystone": mp_v5.meta_state.keystone_balance()} + for i in _db.run.level.level_count(): + data_v5["clears"].append({"display_name": String(_db.run.level.row_by_level_index(i + 1).get("display_name", "")), + "count": v5.meta_state.career_state().clear_counts.get(str(i + 1), 0)}) var co_v5 = load("res://presentation/views/career_overlay.gd").new() co_v5.open_career(data_v5) Kit.check("旧档载入后生涯页正常显示(v5 升 v6:总局数/冒险成功局数显示 0,既有钥石余额原样呈现)", diff --git a/game/tests/unit/presentation/type_glow_test.gd b/game/tests/unit/presentation/type_glow_test.gd index fdfaf69d..5f045044 100644 --- a/game/tests/unit/presentation/type_glow_test.gd +++ b/game/tests/unit/presentation/type_glow_test.gd @@ -64,10 +64,12 @@ func _test_grouping_closed() -> void: for bad in ["keystone", "", "ATTACK", "aoe", "blast_mine", " heal"]: Kit.check("清单外值「%s」→ GLOW_NONE(禁静默空光)" % bad, UiKitScript.glow_group_for_cell_type(bad) == UiKitScript.GLOW_NONE) - # 与卡顶带 tg_band 的归组差异锚定(Q3 甲案两带并存、禁互引):blast 在卡顶带归魔法(E5 回滚既存行为) - # ——格面带 blast→群伤由上方九型封闭循环的 TYPE_EXPECT["blast"] 承载,此处不重述 + # 与卡顶带 tg_band 的归组差异锚定(Q3 甲案两带并存、禁互引):blast 在格面带归群伤、 + # 在卡顶带归魔法(E5 回滚既存行为)——若将来误把两带统一,本断言红 var band_blast: Color = UiKitScript.tg_band("blast_charge") Kit.check("两带并存锚定:卡顶带 blast→TG_MAGIC(E5 回滚既存行为未动)", band_blast == UiKitScript.TG_MAGIC) + Kit.check("两带并存锚定:格面微光 blast→群伤组(与卡顶带刻意不同)", + UiKitScript.glow_group_for_cell_type("blast") == UiKitScript.GLOW_AOE) # 色值出口:五组各取本组常量;无组/越界组号 → 全透明(不画) Kit.check("glow_color_for_group 群伤组 = TG_GLOW_AOE", UiKitScript.glow_color_for_group(UiKitScript.GLOW_AOE) == UiKitScript.TG_GLOW_AOE) @@ -90,8 +92,13 @@ func _test_frozen_hue_face() -> void: var ds: float = absf(m.s - f.s) Kit.check("冻结色相保持:%s ΔH=%.3f° ≤0.2° 且 ΔS=%.4f ≤0.01(只动明度档)" % [g, dh * 360.0, ds], dh <= 0.2 / 360.0 and ds <= 0.01) - # 群伤组专锚(#A61D37):V=0.65 明度档——H/S 两腿由上方逐组循环的 ΔH/ΔS 阈承载 + # 群伤组专锚(#A61D37):H/S 与冻结逐位一致、V=0.65 档 + var aoe_f: Color = FROZEN["AOE"] var aoe_m: Color = UiKitScript.TG_GLOW_AOE + Kit.check("群伤组专项:H 与冻结 #FF2D55 差 %.4f°(零移动)" % (absf(aoe_m.h - aoe_f.h) * 360.0), + absf(aoe_m.h - aoe_f.h) < 0.001) + Kit.check("群伤组专项:S 与冻结差 %.4f ≤0.01(8bit 量化噪声级)" % absf(aoe_m.s - aoe_f.s), + absf(aoe_m.s - aoe_f.s) <= 0.01) Kit.check("群伤组专项:V=0.65 明度档( provisional 域内)", absf(aoe_m.v - 166.0 / 255.0) < 0.002) diff --git a/game/tests/unit/presentation/ui_font_asset_test.gd b/game/tests/unit/presentation/ui_font_asset_test.gd index c22c1a74..b4b6f1bb 100644 --- a/game/tests/unit/presentation/ui_font_asset_test.gd +++ b/game/tests/unit/presentation/ui_font_asset_test.gd @@ -15,19 +15,10 @@ func _init() -> void: var f: Font = UiKitScript.load_font(path) Kit.check("随包字体守卫装载非空(res://assets/fonts/ui_font_fusion_pixel_zh_hans.ttf)", f != null) if f != null: - # 字形集表:[个案标签, 须在集字符]——缺字明细进消息 - var glyph_groups := [ - ["中文字形在集(攻/雷/旗/封)", "攻雷旗封"], - ["中文字形在集(危——已开危险格字形换代前置,迭代36 u12/wa9)", "危"], - ["ASCII/数字字形在集(A/0/%)", "A0%"], - ] - for g in glyph_groups: - var missing := "" - for i in String(g[1]).length(): - var ch := String(g[1])[i] - if not f.has_char(ch.unicode_at(0)): - missing += ch - Kit.check("%s(缺=%s)" % [g[0], missing if missing != "" else "无"], missing == "") + Kit.check("中文字形在集(攻/雷/旗/封)", f.has_char("攻".unicode_at(0)) and f.has_char("雷".unicode_at(0)) \ + and f.has_char("旗".unicode_at(0)) and f.has_char("封".unicode_at(0))) + Kit.check("中文字形在集(危——已开危险格字形换代前置,迭代36 u12/wa9)", f.has_char("危".unicode_at(0))) + Kit.check("ASCII/数字字形在集(A/0/%)", f.has_char("A".unicode_at(0)) and f.has_char("0".unicode_at(0)) and f.has_char("%".unicode_at(0))) var sz := 18 var w_cn: float = f.get_string_size("战斗胜利", HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x var w_label: float = f.get_string_size("攻击 2", HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x diff --git a/game/tests/验证集清单.md b/game/tests/验证集清单.md new file mode 100644 index 00000000..0dbc51ed --- /dev/null +++ b/game/tests/验证集清单.md @@ -0,0 +1,2682 @@ +# 验证集清单(断言快照·冻结) + +> 生成日期:2026-10-28 · 基点 commit:`8a31936958b7422cfc726827cfe655a902dd882a` · 源:cross_system_flow_test.gd、node_map_determinism_test.gd、save_rng_roundtrip_test.gd、system_test.gd、damage_controller_test.gd、damage_math_test.gd、extended_row_violation_test.gd、roll_multiplier_test.gd、round_flow_skill_pool_test.gd、seal_polarity_test.gd、skill_unlock_test.gd、trigger_cond_test.gd、variant_placement_test.gd、source_guard_test.gd、system_contracts_test.gd、adventure_feats_test.gd、board_gen_reward_test.gd、board_generator_test.gd、breakpoint_resume_test.gd、config_db_cross_validator_test.gd、config_db_resolver_test.gd、curio_refresh_paid_test.gd、effect_words_t1_test.gd、feat_vouchers_test.gd、hero_ledger_test.gd、level_map_type_vis_test.gd、level_shop_economy_test.gd、level_table_validate_test.gd、meta_progression_forge_test.gd、minesweeper_rules_test.gd、node_map_generator_test.gd、node_map_growth_test.gd、pool_case_d_test.gd、run_time_fatal_test.gd、save_boot_no_activate_test.gd、save_manager_domain_test.gd、save_slot_isolation_test.gd、save_slot_summary_test.gd、settle_segments_test.gd、treasure_effects_test.gd、treasure_items_test.gd、tree_requires_level_test.gd、tree_rules_v11_test.gd、config_db_fatal_oob_test.gd、config_db_test.gd、counter_prefix_fatal_test.gd、forge_table_test.gd、level_gen_params_test.gd、reward_dangling_treasure_test.gd、rng_service_test.gd、rng_stream_boot_test.gd、table_structure_guard_test.gd、treasure_target_violation_test.gd、example_test.gd、battle_narrator_test.gd、forge_workbench_test.gd、hud_presenter_test.gd、level_map_info_fog_test.gd、modal_flow_test.gd、pacing_queue_test.gd、player_copy_sweep_test.gd、reward_overlay_test.gd、standee_fx_sweep_test.gd、terrain_tile_sets_test.gd、theme_projection_test.gd、type_glow_test.gd、ui_font_asset_test.gd、test_kit.gd +> 提取口径:静态 `check(` 调用点,括号配平归集(可跨行);缺省源=三宿主遍历 +> (unit/**+integration/**+system_test.gd)。本基点实测 1604 个静态调用点;行级 +> `grep -c "check("` 是另一口径(含 `func check(` 定义行与注释行),两口径各自浮动, +> 禁互相换算(tests/README「断言计数双口径」)。运行时计数大于静态计数=循环展开条目。 +> 生成命令:`python tools/extract_checks.py --extract`(可复现;重新生成须 --force; +> 承接旧清单回填列须 --carry-from <旧清单>)。 +> 冻结纪律:每条的「标签原文」「cond 摘要」「quiet」三列为机器提取逐字保真,禁任何改写; +> cond 摘要=原文跨行折叠单行(连续空白并一空格),单元格内 `|`→`\|`、`\`→`\\` 转义,读回还原。 +> 「白话释义」「处置」两列由维护回填(未承接行为(待填)),不属冻结面。 +> 对账:`python tools/extract_checks.py --diff game/tests/验证集清单.md`(缺省三宿主源) +> ——标签/cond/计数三对齐才算同步完。维护义务:断言新增/删除/改判必须同步本清单 +> (test-conventions §8.3 维护锚)。 + +## S01 _rng_event_log_session(行 55–55 · 1 条 · 源 game/tests/integration/cross_system_flow_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1 | 55 | "事件日志会话落盘(2026-09-27 统一流裁决)" | FileAccess.file_exists("user://logs/rng_log_4242.jsonl") | (待填) | | | + +## S02 _feats_breakpoint_roundtrip(行 91–97 · 3 条 · 源 game/tests/integration/cross_system_flow_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 2 | 91 | "断点附随件:relics 持有集入快照(迭代 12 T1 扩列)" | (bp_r.get("relics", []) as Array).has("x3") | (待填) | | | +| 3 | 92 | "断点附随件:adventure 计数器账本键在案(T6/T7 扩列)" | bp_r.has("adventure_counters") | (待填) | | | +| 4 | 97 | "断点余库:已兑付 relic 令牌逐枚还原(x3 零残留——续玩不重复获得)" | bp_deck_x3 == 0 | (待填) | | | + +## S03 _save_summary_integration(行 119–132 · 10 条 · 源 game/tests/integration/cross_system_flow_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 5 | 119 | "摘要集成:冒险驱动满配冒险成功(seed 42)" | String(st9.get("result", "")) == "completed" | (待填) | | | +| 6 | 120 | "集成钥石余额 = ΣBoss 首杀行值(%d——逐杀掉落全源)" ↩ % f9.keystone_balance() | f9.keystone_balance() == ks_run | (待填) | | | +| 7 | 121 | "集成关卡完成次数表全 1" | f9.clear_count(1) == 1 and f9.clear_count(2) == 1 and f9.clear_count(3) == 1 | (待填) | | | +| 8 | 122 | "集成三关首杀标记齐" | f9.boss_first_kill_marked(1) and f9.boss_first_kill_marked(2) and f9.boss_first_kill_marked(3) | (待填) | | | +| 9 | 123 | "集成 recent 1 条且 seed 42" | f9.recent_summaries().size() == 1 and int(f9.recent_summaries()[0].get("master_seed", -1)) == 42 | (待填) | | | +| 10 | 126 | "第二局后关卡完成次数全 2" | f9.clear_count(1) == 2 and f9.clear_count(2) == 2 and f9.clear_count(3) == 2 | (待填) | | | +| 11 | 127 | "第二局后余额翻倍(2 × Σ首杀——模拟驱动恒首杀口径)" | f9.keystone_balance() == 2 * ks_run | (待填) | | | +| 12 | 128 | "集成 recent 长度 2" | f9.recent_summaries().size() == 2 | (待填) | | | +| 13 | 129 | "集成写盘成功" | f9.write_to_disk() | (待填) | | | +| 14 | 131 | "集成写盘读回全量保持" | 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) | (待填) | | | + +## S04 _smoke_playtest_logger(行 149–182 · 8 条 · 源 game/tests/integration/cross_system_flow_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 15 | 149 | "会话文件开启(%s)" ↩ % path | path.contains("playtest_20260912") | (待填) | | | +| 16 | 150 | "快照战斗启动" | settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) | (待填) | | | +| 17 | 154 | "每结算一快照(%d 次结算 → %d 快照)" ↩ % [2, snaps.size()] | snaps.size() == 2 | (待填) | | | +| 18 | 161 | "快照字段齐备(缺 %s)" ↩ % str(missing) | missing.is_empty() | (待填) | | | +| 19 | 162 | "快照含英雄/经济/板序号(板 seq=%d)" ↩ % int(s1.board.seq) | int(s1.hero.max_hp) == int(_db.hero.max_hp) and s1.economy.has("phase") and s1.board.has("seq") | (待填) | | | +| 20 | 165 | "快照 spawn_count 在位且 == unit_count(无击杀轮:%d/%d——全灭/空场可判别)" ↩ % [int(s1.get("spawn_count", -1)), int(s1.get("unit_count", -1))] | int(s1.get("spawn_count", -1)) >= 1 and int(s1.get("spawn_count", -1)) == int(s1.get("unit_count", -2)) | (待填) | | | +| 21 | 169 | "诊断转储文件生成(%s)" ↩ % diag | diag.contains("diag_") | (待填) | | | +| 22 | 182 | "JSONL 全部行可解析(%d 行 = session + 2 结算)" ↩ % lines | lines >= 3 and parse_ok | (待填) | | | + +## S05 _smoke_generated_map_determinism(行 43–113 · 21 条 · 源 game/tests/integration/node_map_determinism_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 23 | 43 | "同参数档同种子两次产图逐位同图(D2 执行边界)〔v7〕" | nodes_a == (g_b.get("nodes", []) as Array) | (待填) | | | +| 24 | 45 | "同参数档换种子即变图(图确为种子函数,非恒定输出)" | nodes_a != (g_c.get("nodes", []) as Array) | (待填) | | | +| 25 | 47 | "产图节点携图内唯一句柄 id 与自入口 depth(消费侧寻址面)" | _not_empty_graph(nodes_a) and _handles_unique(nodes_a) | (待填) | | | +| 26 | 51 | "单关起关受理(种子 910004——生成图直供,无兜底图)" | runner1.start_level(2, 910004) | (待填) | | | +| 27 | 53 | "起关生效图 = 生成器同档同种子直取逐位一致(消费侧无第二图源)〔v7〕" | runner1.level_row.get("nodes", []) == (gen.generate(params2, 910004) as Dictionary).get("nodes", []) | (待填) | | | +| 28 | 60 | "冒险模式起关受理(入口停点)" | runner2.start_level_external(3, Callable(self, "_smoke_rest_stub"), true) | (待填) | | | +| 29 | 62 | "起关产图零计数器消费(同图不依赖取数号位)" | rng_c.counter() == counter_before | (待填) | | | +| 30 | 65 | "入口停点候选数=参数档入口个数(L3 档 %d——多入口首跳即选路,禁写死单入口)〔v2〕" ↩ % entry_count | entry_count >= 1 and (runner2.next_candidates() as Array).size() == entry_count | (待填) | | | +| 31 | 69 | "第二入口经句柄显式进入(句柄 %d)〔v2〕" ↩ % int(eids[1]) | runner2.pick_next_node(int(eids[1])) and int(runner2.current_node_id()) == int(eids[1]) | (待填) | | | +| 32 | 76 | "首局口径种子 42 开局受理(defer_entry 路径)" | adr_x.start_adventure(42, {"hero_override": {}, "treasure_unlocked": {}}) | (待填) | | | +| 33 | 79 | "同种子第二次开局受理" | adr_y.start_adventure(42, {"hero_override": {}, "treasure_unlocked": {}}) | (待填) | | | +| 34 | 81 | "种子 42 两次开局图逐位同图(教学局恒同图——首局固定种子的确定性承载)〔v7〕" | adr_x.runner().nodes() == adr_y.runner().nodes() | (待填) | | | +| 35 | 83 | "开局图 = L1 参数档在种子 42 下的生成图(开局无隐式取数分叉)" | adr_x.runner().nodes() == (gen.generate(_db.level_gen_params_row(1), 42) as Dictionary).get("nodes", []) | (待填) | | | +| 36 | 86 | "停点断点落盘在档" | sv_w.has_breakpoint() | (待填) | | | +| 37 | 89 | "断点续玩受理(代差标记键在档)〔v8〕" | adr_z.resume_adventure(bp, {"hero_override": {}, "treasure_unlocked": {}}) | (待填) | | | +| 38 | 91 | "续玩重生成图与中断前逐位同图〔v7〕" | adr_z.runner().nodes() == adr_x.runner().nodes() | (待填) | | | +| 39 | 95 | "缺生成图代差标记键的旧断点被显式拒绝(禁按新语义静默回放)〔v8〕" | adr_old.resume_adventure(bp_old, {}) == false | (待填) | | | +| 40 | 102 | "level 表三行均无 templates 键(模板数据已清出)〔v9〕" | tpl_free and _db.level_count() == 3 | (待填) | | | +| 41 | 107 | "新档首次冒险布尔位 = false(默认域——首局恒走固定种子 42)" | sv.is_first_adventure_done() == false | (待填) | | | +| 42 | 111 | "冒险收束点置位(三结局同写——写入只在收束挂点)" | sv.is_first_adventure_done() == true | (待填) | | | +| 43 | 113 | "置位幂等(重复收束不翻转)" | sv.is_first_adventure_done() == true | (待填) | | | + +## S06 _init(行 27–27 · 1 条 · 源 game/tests/integration/save_rng_roundtrip_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 44 | 27 | "数值表加载" | false | (待填) | | | + +## S07 _test_old_version_save_migrates_and_resumes(行 73–98 · 4 条 · 源 game/tests/integration/save_rng_roundtrip_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 45 | 73 | "旧档用例:冒险启动前置" | false | (待填) | | | +| 46 | 79 | "旧档用例:入口停点有候选路" | false | (待填) | | | +| 47 | 89 | "旧档用例:盘上档可解析" | false | (待填) | | | +| 48 | 98 | "旧档用例:旧档可落盘" | false | (待填) | | | + +## S08 _split_tail(行 46–46 · 1 条 · 源 game/tests/system_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 49 | 46 | "V2/V3 前置:autoload 链未就绪(ConfigDB/SaveManager)" | false | (待填) | | | + +## S09 _smoke_scene_split(行 59–111 · 11 条 · 源 game/tests/system_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 50 | 59 | "V2 菜单屏壳独立实例化(start_overlay+操作组三钮在位——主菜单三入口化)〔卡② mn1〕" | menu.start_overlay != null and menu.entersavebtn != null and menu.settingsbtn != null and menu.quitbtn != null | (待填) | | | +| 51 | 61 | "V2 菜单层默认可见(非模态全屏态)" | menu.start_overlay.visible | (待填) | | | +| 52 | 62 | "V2 屏壳根零鼠标截获(IGNORE——全屏 STOP 壳吃输入病灶回归闸)" | menu.mouse_filter == Control.MOUSE_FILTER_IGNORE | (待填) | | | +| 53 | 67 | "V2 营地屏壳独立实例化(camp_overlay 在位)" | camp.camp_overlay != null | (待填) | | | +| 54 | 68 | "V2 营地层默认隐藏(模态纪律)" | not camp.camp_overlay.visible | (待填) | | | +| 55 | 73 | "V2 冒险屏壳独立实例化(战斗宿主+四区句柄+关卡地图屏在位)" | adv.battle_root != null and adv.stage != null and adv.board_zone != null and adv.board_view != null and adv.ap_bar != null and adv.level_screen != null | (待填) | | | +| 56 | 84 | "V1a 启动态顶层可见层=start_overlay(实测顶层=%s——菜单被冒险屏盖住即红)〔装配生命批 V1〕" ↩ % (top.name if top != null else "null") | top == main._start_overlay | (待填) | | | +| 57 | 88 | "V1b 菜单屏壳在冒险屏壳之上(start_overlay 盖住战斗内容)〔装配生命批 V1〕" | main._menu_screen.get_index() > main._adventure_screen.get_index() | (待填) | | | +| 58 | 106 | "V1c 合同树序链逐对保序(运行期树序==LAYER_CONTRACT 序;违例:%s)〔装配生命批 V1〕" ↩ % chain_detail | chain_ok | (待填) | | | +| 59 | 108 | "V2 组合根句柄指向屏壳成员(探针挂载假设 main.X 零迁移)" | main._start_overlay == main._menu_screen.start_overlay and main.camp_overlay == main._camp_screen.camp_overlay and main.level_screen == main._adventure_screen.level_screen and main._battle_root == main._adventure_screen.battle_root and main.board_view == main._adventure_screen.board_view | (待填) | | | +| 60 | 110 | "V2 闲时会话自 _ready 即在(永非 null;迭代 27 T3——gp 影子账退役无账本成员)" | main.run_scope != null and not main.run_scope.is_adventure | (待填) | | | + +## S10 _smoke_boot_chain(行 157–207 · 14 条 · 源 game/tests/system_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 61 | 157 | "V2a ConfigDB 就绪且表已载(is_ready+hero 表在座)〔装配生命批 V2〕" | db.is_ready and db.hero != null | (待填) | | | +| 62 | 162 | "V2b 设置已应用·音量(Master mute/volume_db=settings_store 现值 linear=%f)〔装配生命批 V2〕" ↩ % ss.volume_linear | AudioServer.is_bus_mute(master) == (ss.volume_linear <= 0.001) and is_equal_approx(AudioServer.get_bus_volume_db(master), linear_to_db(maxf(ss.volume_linear, 0.001))) | (待填) | | | +| 63 | 166 | "V2b 设置已应用·减动效(UiKit.motion_scale=%s=settings_store.reduced_motion=%s)〔装配生命批 V2〕" ↩ % [ str(ui_kit.motion_scale), str(ss.reduced_motion)] | ui_kit.motion_scale == (0.5 if ss.reduced_motion else 1.0) | (待填) | | | +| 64 | 170 | "V2c 启动态 _start_overlay.visible=true(boot 后玩家见菜单)〔装配生命批 V2〕" | main._start_overlay.visible | (待填) | | | +| 65 | 176 | "V2d boot 无断点在档(启动态不隐式激活——卡① sv2 钉死)〔装配生命批 V2·卡② mn8 改写〕" | not sm.has_breakpoint() | (待填) | | | +| 66 | 179 | "V2-s1 boot 后无激活态(active_slot=0、写盘目标空)〔卡① sv2〕" | sm.active_slot() == 0 and not sm.has_write_target() | (待填) | | | +| 67 | 181 | "V2-s2 boot 内存=默认合法空态(钥石/断点/总局数全零值)〔卡① sv2〕" | sm.keystone_balance() == 0 and not sm.has_breakpoint() and sm.adventure_total() == 0 | (待填) | | | +| 68 | 183 | "V2-s3 boot 无激活写盘拒绝(write_to_disk=false 生产入口)〔卡① sv3〕" | not sm.write_to_disk() | (待填) | | | +| 69 | 191 | "V2d 三钮在位·进入存档/设置/退出游戏(续玩钮族退役后主菜单操作组承接)〔卡② mn1/mn8〕" | main._menu_screen.entersavebtn != null and main._menu_screen.settingsbtn != null and main._menu_screen.quitbtn != null | (待填) | | | +| 70 | 195 | "V2d 四旧钮句柄零命中·%s(彻底撤出——禁隐藏态禁置灰态残留)〔卡② mn2〕" ↩ % old_btn | not (old_btn in main._menu_screen) | (待填) | | | +| 71 | 197 | "V2d 渲染面可见按钮计数==3(旧钮接回即红——渲染面锚,句柄缝盲区闭合)〔卡② mn1/mn2〕" | _count_visible_buttons(main._start_overlay) == 3 | (待填) | | | +| 72 | 200 | "V2e run_scope 非空且为闲时会话(is_adventure=false+is_inside_tree)〔装配生命批 V2〕" | main.run_scope != null and not main.run_scope.is_adventure and main.run_scope.is_inside_tree() | (待填) | | | +| 73 | 204 | "V2f 调试浮窗 boot 不自动开(菜单优先)〔2026-10-16 用户令〕" | not main.debug_overlay.visible | (待填) | | | +| 74 | 206 | "V2f 调试浮层角标发布态恒隐(2026-10-25 删写死行批)" | main.debug_badge != null and not main.debug_badge.visible | (待填) | | | + +## S11 _smoke_run_lifecycle(行 231–263 · 11 条 · 源 game/tests/system_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 75 | 231 | "V3 开局建会话(is_adventure+adventure 在座)" | main.run_scope != null and main.run_scope.is_adventure and main.run_scope.adventure != null | (待填) | | | +| 76 | 233 | "V3 新会话局态零值" | main.run_scope.hints_shown.is_empty() | (待填) | | | +| 77 | 242 | "V3 放弃受理(入口停点天然非战斗期)" | main.run_scope.adventure.abandon() | (待填) | | | +| 78 | 247 | "V3 冒险终局到达(abandon→adventure_finished 同步链)" | finished.done | (待填) | | | +| 79 | 248 | "V3 终局面板弹起" | main._end_overlay.is_open() | (待填) | | | +| 80 | 251 | "V3 会话收束(新闲时会话零冒险/关卡对象)" | not main.run_scope.is_adventure and main.run_scope.adventure == null and main.run_scope.runner == null | (待填) | | | +| 81 | 253 | "V3 局态封印/账本全复位(queue_free 物化——注入脏状态零残留)" | main.run_scope.hints_shown.is_empty() and not main.run_scope.boss_active and main.run_scope.last_gain_amount == 0 | (待填) | | | +| 82 | 255 | "V3 回开始菜单(终局出口路径)" | main._start_overlay.visible | (待填) | | | +| 83 | 257 | "V3 放弃终局清断点(无残留断点)" | not sm.has_breakpoint() | (待填) | | | +| 84 | 261 | "V3 第二连局全新会话(两连局零泄漏)" | main.run_scope.is_adventure and main.run_scope.adventure != null and main.run_scope.hints_shown.is_empty() | (待填) | | | +| 85 | 263 | "V3 直进入局(菜单隐)" | not main._start_overlay.visible | (待填) | | | + +## S12 _smoke_sim_guards(行 284–300 · 4 条 · 源 game/tests/system_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 86 | 284 | "前置:闲时会话(settlement 空 + is_adventure=false)" | main.run_scope.settlement == null and not main.run_scope.is_adventure | (待填) | | | +| 87 | 289 | "模拟运行态下调试两层入口守卫早退(开始层不收起)〔wd6〕" | main.run_scope.settlement == null and main._start_overlay.visible | (待填) | | | +| 88 | 296 | "前置:真实冒险关间态(选路停点,非战斗)" | main.run_scope.is_adventure and main.run_scope.settlement == null | (待填) | | | +| 89 | 299 | "冒险关间态下批量模拟入口守卫早退(d7 补 is_adventure)〔wd7〕" | not main._tune_sim.is_running() | (待填) | | | + +## S13 _stage_faces(行 394–394 · 1 条 · 源 game/tests/system_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 90 | 394 | "%s(引用面违例:%s)〔装配生命批 V3〕" ↩ % [tag, bad] | bad.is_empty() | (待填) | | | + +## S14 _smoke_lifecycle_matrix(行 467–591 · 9 条 · 源 game/tests/system_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 91 | 467 | "V3d 活战斗驱动完毕(战斗已收场——%d 轮,finished=%s)〔装配生命批 V3〕" ↩ % [guard, str(finished.done)] | main.run_scope.settlement == null or not main.run_scope.settlement.is_battle_live() | (待填) | | | +| 92 | 482 | "V3d2 放弃受理(选路停点天然非战斗期)〔装配生命批 V3〕" | main.run_scope.adventure.abandon() | (待填) | | | +| 93 | 493 | "V3e 终局面到达(adventure_finished+终局面板开)〔装配生命批 V3〕" | finished.done and main._end_overlay.is_open() | (待填) | | | +| 94 | 499 | "V3f 收束换会话无真空窗(新闲时会话即刻在树)〔装配生命批 V3〕" | main.run_scope != old_scope and main.run_scope.is_inside_tree() | (待填) | | | +| 95 | 502 | "V3f 收束后旧局经 queue_free 消亡(is_instance_valid=false)〔装配生命批 V3〕" | not is_instance_valid(old_scope) | (待填) | | | +| 96 | 522 | "V3g 营地入口缺失(选档层新档钮驱动中断)〔装配生命批 V3〕" | false | (待填) | | | +| 97 | 560 | "V3h 两连局第二会话在座(开局停点——新冒险会话非空)〔装配生命批 V3〕" | main.run_scope.adventure != null and main.run_scope.adventure != first_adv | (待填) | | | +| 98 | 563 | "V3h 放弃受理(第二局)〔装配生命批 V3〕" | main.run_scope.adventure.abandon() | (待填) | | | +| 99 | 590 | "V3i 续玩换会话无真空窗(新冒险会话即刻在树)〔装配生命批 V3〕" | main.run_scope != idle_scope and main.run_scope.is_inside_tree() | (待填) | | | + +## S15 _init(行 27–27 · 1 条 · 源 game/tests/unit/battle_settlement/damage_controller_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 100 | 27 | "前置:表加载零 error" | load_ok and db.diagnostics_count("error") == 0 | (待填) | | | + +## S16 _run_recipe_sequence(行 75–166 · 11 条 · 源 game/tests/unit/battle_settlement/damage_controller_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 101 | 75 | "前置:units.spawn(1,0) 成功(遭遇行在案)" | false | (待填) | | | +| 102 | 114 | "五配方单据 flush 后全部 redeemed(B 模式段界结账)" | outcomes_redeemed | (待填) | | | +| 103 | 119 | "主角扣血 = 敌方技行值 + 反噬行值(逐行归因闭合)" | hero_before - hero_after == int(r2.get("value", -1)) + int(r5.get("value", -1)) | (待填) | | | +| 104 | 121 | "敌方技行值 ≥ 下限 + rider(rider 后置不进乘区)" | int(r2.get("value", 0)) >= floor_v + floor_v | (待填) | | | +| 105 | 122 | "反噬行值 = 落地下限(中性无类型表值)" | int(r5.get("value", 0)) == floor_v | (待填) | | | +| 106 | 126 | "主角攻击行 AC = 防守方 AC + 攻击加成(开单前快照一致)" | int(ex1.get("ac", -1)) == threshold_expected | (待填) | | | +| 107 | 127 | "掷点 ∈ [1, 骰面]" | int(ex1.get("roll", -1)) >= 1 and int(ex1.get("roll", -1)) <= int(h.db.settlement.damage_roll.roll_faces) | (待填) | | | +| 108 | 134 | "掷点档位 ∈ 表档位值 ∪ 满额" | tier_ok | (待填) | | | +| 109 | 150 | "敌方总扣血 ≥ 攻击+克制+群伤(死亡溢出合法,下界归因)" | enemy_drop_total >= attack_landed + typed_landed + int(r4.get("value", 0)) * maxi(0, alive_at_blast) | (待填) | | | +| 110 | 153 | "敌方总扣血 = 攻击 + 克制直伤 + 群伤×存活数(逐行归因闭合)" | enemy_drop_total == attack_landed + typed_landed + int(r4.get("value", 0)) * alive_at_blast | (待填) | | | +| 111 | 166 | "行协议公共字段封闭(type/source_class/display/value≥下限/extra.target)" | protocol_ok | (待填) | | | + +## S17 _test_five_recipes_and_replay(行 177–179 · 2 条 · 源 game/tests/unit/battle_settlement/damage_controller_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 112 | 177 | "五配方序列产出 5 行伤害行" | run1.size() == 5 | (待填) | | | +| 113 | 179 | "同种子双跑行序列逐位一致(回放确定性契约)" | run1 == run2 | (待填) | | | + +## S18 _test_policy_immediate_mode(行 193–194 · 2 条 · 源 game/tests/unit/battle_settlement/damage_controller_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 114 | 193 | "A 模式开单即结(无 flush 即 redeemed)" | h.ctrl.outcome(t) == "redeemed" | (待填) | | | +| 115 | 194 | "A 模式主角即时扣血" | int(h.units.hero_unit().hp) == int(h.db.hero.max_hp) - floor_v | (待填) | | | + +## S19 _test_source_death_void(行 213–214 · 2 条 · 源 game/tests/unit/battle_settlement/damage_controller_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 116 | 213 | "来源敌死亡后其未结单据 voided(死亡即不动手)" | h.ctrl.outcome(t) == "voided" | (待填) | | | +| 117 | 214 | "作废单零行零扣血" | int(h.units.hero_unit().hp) == hero_before and _rows.is_empty() | (待填) | | | + +## S20 _test_identical_content_dual_enemy(行 234–254 · 3 条 · 源 game/tests/unit/battle_settlement/damage_controller_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 118 | 234 | "前置:双敌字典内容全同且非同一实例" | a == b and not is_same(a, b) | (待填) | | | +| 119 | 244 | "同内容异实例敌死亡不误废他人单据〔wd1〕" | h.ctrl.outcome(t) == "redeemed" | (待填) | | | +| 120 | 254 | "来源敌自身死亡单据作废(正向对照)〔wd1〕" | h2.ctrl.outcome(t2) == "voided" | (待填) | | | + +## S21 _test_target_id_payload(行 264–279 · 4 条 · 源 game/tests/unit/battle_settlement/damage_controller_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 121 | 264 | "前置:units.spawn(1,0) 成功(target_id 断言)" | false | (待填) | | | +| 122 | 268 | "前置:目标敌携非空 enemy_id" | expect_id != "" | (待填) | | | +| 123 | 273 | "attack_commit fx 载荷携 target_id〔wd12〕" | String(fx_payload.get("target_id", "")) == expect_id and String(fx_payload.get("target", "")) != "" | (待填) | | | +| 124 | 278 | "伤害行携 target_id == 目标敌 enemy_id〔wd12〕" | not row.is_empty() and String(row.extra.get("target_id", "")) == expect_id | (待填) | | | + +## S22 _test_requires_gate_and_zero(行 293–303 · 3 条 · 源 game/tests/unit/battle_settlement/damage_controller_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 125 | 293 | "flush_submit 同步开单即结(清图批契约)" | h.ctrl.outcome(t_clear) == "redeemed" and h.units.query_alive("enemy").is_empty() | (待填) | | | +| 126 | 302 | "无存活敌 blast = 合法零兑付(zero 非 voided)" | h.ctrl.outcome(t_zero) == "zero" | (待填) | | | +| 127 | 303 | "前置单未实际有效 → 后继单 voided(requires 门)" | h.ctrl.outcome(t_gated) == "voided" | (待填) | | | + +## S23 _test_hero_death_halt(行 319–320 · 2 条 · 源 game/tests/unit/battle_settlement/damage_controller_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 128 | 319 | "致死单 redeemed 且主角 hp 归零" | h.ctrl.outcome(t_kill) == "redeemed" and int(h.units.hero_unit().hp) == 0 | (待填) | | | +| 129 | 320 | "主角倒下后余单 voided(flush 终止)" | h.ctrl.outcome(t_after) == "voided" | (待填) | | | + +## S24 _test_ac_merge(行 331–347 · 4 条 · 源 game/tests/unit/battle_settlement/damage_controller_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 130 | 331 | "缺省兜底:defender_ac(hero) = hero 表值" | h.ctrl.defender_ac(h.units.hero_unit()) == base_ac | (待填) | | | +| 131 | 334 | "override 合流入参生效" | h.ctrl.defender_ac(h.units.hero_unit()) == override_v | (待填) | | | +| 132 | 342 | "前置:状态表存在 hero 侧 ac 型行" | false | (待填) | | | +| 133 | 347 | "ac 型效果叠层入 AC(层数×幅度,不入池)" | h.ctrl.defender_ac(h.units.hero_unit()) == expected | (待填) | | | + +## S25 _test_pool_clamp(行 359–404 · 7 条 · 源 game/tests/unit/battle_settlement/damage_controller_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 134 | 359 | "空效果池读 0(表 clamp 域含 0 前提)" | h.ctrl.clamped_pool("hero_offense") == 0 and lo <= 0 and hi >= 0 | (待填) | | | +| 135 | 369 | "前置:hero_offense 池存在 percent 型 hero 侧行" | false | (待填) | | | +| 136 | 374 | "池值 = 单实例 L×m 加算后 clamp" | first_ok and h.ctrl.clamped_pool("hero_offense") == expected1 | (待填) | | | +| 137 | 376 | "refresh 政策重复授予受理且层数恒 1(叠层改续时裁决)——池值不变" | second_ok and h.ctrl.clamped_pool("hero_offense") == expected1 | (待填) | | | +| 138 | 387 | "ac 型效果不入池(percent 池隔离)" | h.ctrl.clamped_pool("hero_offense") == before | (待填) | | | +| 139 | 395 | "前置:状态表存在 stack 政策行" | false | (待填) | | | +| 140 | 404 | "stack 政策恰授予 max_stack 层后拒绝(满层拒绝原子)" | granted == max_n | (待填) | | | + +## S26 _init(行 17–17 · 1 条 · 源 game/tests/unit/battle_settlement/damage_math_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 141 | 17 | "前置:settlement 表加载零 error" | load_ok and db.diagnostics_count("error") == 0 | (待填) | | | + +## S27 _test_pool_damage(行 34–39 · 6 条 · 源 game/tests/unit/battle_settlement/damage_math_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 142 | 34 | "中性双池 (0,0) 原样透传" | DamageMathScript.pool_damage(d, 0, 0) == d | (待填) | | | +| 143 | 35 | "负敌方池压低不增高" | DamageMathScript.pool_damage(d, 0, int(db.settlement.core.pool_clamp_lo)) <= d | (待填) | | | +| 144 | 36 | "正·主角池抬升不低于原值" | DamageMathScript.pool_damage(d, int(db.settlement.core.pool_clamp_hi), 0) >= d | (待填) | | | +| 145 | 37 | "主角池单调不减(+50% vs +49% 探针档)" | DamageMathScript.pool_damage(d, 50, 0) >= DamageMathScript.pool_damage(d, 49, 0) | (待填) | | | +| 146 | 38 | "对称性:池值互换结果不变(乘算交换)" | DamageMathScript.pool_damage(d, 30, -20) == DamageMathScript.pool_damage(d, -20, 30) | (待填) | | | +| 147 | 39 | "输出非负(maxi(0,·) 封底)" | DamageMathScript.pool_damage(d, int(db.settlement.core.pool_clamp_lo), int(db.settlement.core.pool_clamp_lo)) >= 0 | (待填) | | | + +## S28 _test_counter_component(行 46–50 · 4 条 · 源 game/tests/unit/battle_settlement/damage_math_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 148 | 46 | "空桶(点数≤0)恒 0 不吃修正" | DamageMathScript.counter_component(0, floor_v, floor_v) == 0 and DamageMathScript.counter_component(-1, 5, floor_v) == 0 | (待填) | | | +| 149 | 48 | "正向修正直加" | DamageMathScript.counter_component(floor_v, floor_v, floor_v) == floor_v * 2 | (待填) | | | +| 150 | 49 | "负修正触底保落地伤害下限" | DamageMathScript.counter_component(floor_v, -floor_v * 10, floor_v) == floor_v | (待填) | | | +| 151 | 50 | "下限参数缺省 = 0(无下限语义面)" | DamageMathScript.counter_component(floor_v, -floor_v * 10) == 0 | (待填) | | | + +## S29 _test_roll_discount_percent(行 59–74 · 5 条 · 源 game/tests/unit/battle_settlement/damage_math_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 152 | 59 | "前置:档位数 = 边界数 + 1(查档契约——档数 = 边界数 + 1)" | discounts.size() == bounds.size() + 1 and discounts.size() > 0 | (待填) | | | +| 153 | 60 | "掷点 ≥ 阈值恒全额 100" | DamageMathScript.roll_discount_percent(threshold, threshold, bounds, discounts) == 100 and DamageMathScript.roll_discount_percent(threshold + 5, threshold, bounds, discounts) == 100 | (待填) | | | +| 154 | 65 | "缺口 1 落首档" | DamageMathScript.roll_discount_percent(threshold - 1, threshold, bounds, discounts) == tier0_expected | (待填) | | | +| 155 | 68 | "缺口超末边界落末档" | DamageMathScript.roll_discount_percent(threshold - int(bounds[-1]) - 1, threshold, bounds, discounts) == last_expected | (待填) | | | +| 156 | 74 | "档位值随档位加深不升(折扣单调)" | descending | (待填) | | | + +## S30 _test_rolled_component(行 82–87 · 5 条 · 源 game/tests/unit/battle_settlement/damage_math_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 157 | 82 | "满额折扣无暴击 = floor(基线) + 修正 0" | DamageMathScript.rolled_component(base, 100, false, crit_mult, 0) == int(floor(base)) | (待填) | | | +| 158 | 83 | "满额折扣暴击按表倍率放大" | DamageMathScript.rolled_component(base, 100, true, crit_mult, 0) == int(floor(base * float(crit_mult) / 100.0)) | (待填) | | | +| 159 | 84 | "折扣降低不高于满额" | DamageMathScript.rolled_component(base, 50, false, crit_mult, 0) <= DamageMathScript.rolled_component(base, 100, false, crit_mult, 0) | (待填) | | | +| 160 | 85 | "克制修正加在 floor 之后(加法核)" | DamageMathScript.rolled_component(base, 100, false, crit_mult, _floor_val()) == int(floor(base)) + _floor_val() | (待填) | | | +| 161 | 86 | "复合序 = 基线×折扣%×暴击%(floor 只在链末)" | DamageMathScript.rolled_component(base, 50, true, crit_mult, 0) == int(floor(base * 50.0 / 100.0 * float(crit_mult) / 100.0)) | (待填) | | | + +## S31 _smoke_enemy_attack_type(行 55–98 · 4 条 · 源 game/tests/unit/battle_settlement/roll_multiplier_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 162 | 55 | "敌方 %d 行 attack_type 全填 normal(迭代 24 N3 轴开列,越轨行%s)" ↩ % [ _db.enemy.rows.size(), at_detail if at_detail != "" else "无"] | at_ok | (待填) | | | +| 163 | 95 | "敌方单位快照 attack_type 非空(spawn 注入面——迭代 24 N3)" | snap_ok | (待填) | | | +| 164 | 96 | "克制矩阵命中证词:同种子三甲型实测 heavy %d == light %d − 1 · ward %d == light %d + 1(迭代 24 N3 全局平移 −1/0/+1)" ↩ % [ t_heavy, t_light, t_ward, t_light] | matrix_found and t_light >= 2 | (待填) | | | +| 165 | 98 | "敌方伤害技单据载荷 attack_type 非空(= %s——第 9 参传值面,迭代 24 N3)" ↩ % payload_at | payload_at == "normal" | (待填) | | | + +## S32 _smoke_armor_roll(行 117–178 · 15 条 · 源 game/tests/unit/battle_settlement/roll_multiplier_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 166 | 117 | "查档 AC%d 全掷点域分布(100/75/50/25 = %s)" ↩ % [cfg.ac, str([dist[100], dist[75], dist[50], dist[25]])] | tier_ok and dist == cfg.expect | (待填) | | | +| 167 | 121 | "AC10 EV 锚:期望系数合计 %d/2000 = 期望减免 21.25%%(nat20 恒全额含内)" ↩ % ev_sum | ev_sum == 1575 | (待填) | | | +| 168 | 131 | "nat20 种子可达(seed=%d——主角先手首掷命中 %d)" ↩ % [crit_seed, int(_db.settlement.damage_roll.crit_roll)] | crit_seed > 0 | (待填) | | | +| 169 | 132 | "nat20 → 行留档 roll=20 + crit 标记(主角先手普通分量)" | int(crit_rows.get("roll", -1)) == int(_db.settlement.damage_roll.crit_roll) and bool(crit_rows.get("crit", false)) | (待填) | | | +| 170 | 134 | "nat20 → 普通分量 ×2(%d = 2×%d,暴击倍率只乘该分量,穿刺/魔法不吃)" ↩ % [int(crit_rows.get("normal", -1)), int(_db.hero.atk)] | int(crit_rows.get("normal", -1)) == 2 * int(_db.hero.atk) and int(crit_rows.get("value", -1)) == 2 * int(_db.hero.atk) | (待填) | | | +| 171 | 139 | "削弱重复授予 → 层数保持 1、剩余结算数重置 2(续时语义——结构性消饱和)" | int(weaken_probe.get("layers", -1)) == 1 and int(weaken_probe.get("remaining", -1)) == 2 | (待填) | | | +| 172 | 141 | "削弱单层池读数 = 行值(enemy_output 池单层贡献——×2 叠满触底 ×0 不可达,免伤轮根治)" | int(weaken_probe.get("pool", -999)) == int(_db.status_effect_row("weaken").magnitude) | (待填) | | | +| 173 | 152 | "AC11 攻击拍逐拍判定(×%d 拍全部落档域 {100,75,50,25})" ↩ % tiers.size() | tiers_valid and tiers.size() >= 3 | (待填) | | | +| 174 | 153 | "折扣档实际流动(AC11 实测档位 %s——≥2 档可见)" ↩ % str(tier_distinct.keys()) | tier_distinct.size() >= 2 | (待填) | | | +| 175 | 170 | "全战斗流零 v=0 伤害行(伤害下限全局生效)" | all_positive | (待填) | | | +| 176 | 171 | "hero_attack/enemy_action 伤害行掷点读数+档位随行留档(P2 归因)" | attack_ok | (待填) | | | +| 177 | 172 | "棋盘来源伤害行零掷点字段(爆裂/直伤/清障炸药/奖励即时必中全额)" | board_ok | (待填) | | | +| 178 | 176 | "棋盘克制直伤贴底 → 下限 1(点数 2 vs 不利修正 −1)" | DamageMath.counter_component(2, -1, df) == 1 | (待填) | | | +| 179 | 177 | "克制修正触底穿透 → 下限 1(点数 1 vs 修正 −3)" | DamageMath.counter_component(1, -3, df) == 1 | (待填) | | | +| 180 | 178 | "空桶不吃修正恒 0(零点数分量无伤害可落地——边界不变不受下限影响)" | DamageMath.counter_component(0, 5, df) == 0 | (待填) | | | + +## S33 _smoke_pool_wiring(行 310–317 · 4 条 · 源 game/tests/unit/battle_settlement/roll_multiplier_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 181 | 310 | "单分量基线在位(normal 分量 = 落地值、基线 %d——充能退役批)" ↩ % base_v | int(base_row.get("normal", -1)) == base_v and base_v > 0 | (待填) | | | +| 182 | 312 | "增伤经乘区加成合计(%d → %d = ×1.5——attack_up 直乘旁路退役,覆盖面=攻击合计)" ↩ % [base_v, int(up_row.get("value", -1))] | int(up_row.get("value", -1)) == int(floor(base_v * 1.5)) | (待填) | | | +| 183 | 314 | "乏力经乘区削减合计(%d → %d = ×0.75)" ↩ % [base_v, int(sapped_row.get("value", -1))] | int(sapped_row.get("value", -1)) == int(floor(base_v * 0.75)) | (待填) | | | +| 184 | 316 | "硬化折半方向(%d → %d = ×0.5 且 < 基线——敌方防御池负值减伤,翻倍 bug 退役)" ↩ % [base_v, int(fortify_row.get("value", -1))] | int(fortify_row.get("value", -1)) == int(floor(base_v * 0.5)) and int(fortify_row.get("value", -1)) < base_v | (待填) | | | + +## S34 _smoke_damage_types(行 345–379 · 10 条 · 源 game/tests/unit/battle_settlement/roll_multiplier_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 185 | 345 | "goblin 护甲 = light(轻甲——教学位)" | enemy_system.spawn(1, 0, int(_db.hero.max_hp)) and String(enemy_system.all_enemies()[0].armor_type) == "light" | (待填) | | | +| 186 | 348 | "bugbear 护甲 = light(护甲类型重配批 2026-09-18:关卡 1 全轻甲)" | enemy_system_b.spawn(1, 2, int(_db.hero.max_hp)) and String(enemy_system_b.all_enemies()[0].armor_type) == "light" | (待填) | | | +| 187 | 351 | "ogre_chief 护甲 = light(Boss 行集——护甲类型重配批 2026-09-18:关卡 1 全轻甲)" | enemy_system_c.spawn(1, 4, int(_db.hero.max_hp), [], -1, -1, [], "", "boss") and String(enemy_system_c.all_enemies()[0].armor_type) == "light" | (待填) | | | +| 188 | 355 | "普通行:轻甲 0 / 魔法护甲 +1 / 重甲 −1(加法核)" | int(am.normal.light) == 0 and int(am.normal.ward) == 1 and int(am.normal.heavy) == -1 | (待填) | | | +| 189 | 357 | "穿刺行:轻甲 −1 / 魔法护甲 +1 / 重甲 0 中性(用户拍板)" | int(am.pierce.light) == -1 and int(am.pierce.ward) == 1 and int(am.pierce.heavy) == 0 | (待填) | | | +| 190 | 359 | "魔法行:轻甲 0 / 魔法护甲 −1 / 重甲 +1(加法核)" | int(am.magic.light) == 0 and int(am.magic.ward) == -1 and int(am.magic.heavy) == 1 | (待填) | | | +| 191 | 373 | "标错封印产生 seal_miss 归因行(×%d)" ↩ % miss_rows | miss_rows >= 1 | (待填) | | | +| 192 | 374 | "标错封印兑付该安全格资源的负向字典行(×%d——attack/defense/weaken 效果行或 heal 直伤行)" ↩ % negative_rows | negative_rows >= 1 | (待填) | | | +| 193 | 375 | "封印命中兑付该危险格资源的正向字典行(×%d——充能退役后无魔法分量,正向=格面资源)" ↩ % hit_rows | hit_rows >= 1 | (待填) | | | +| 194 | 378 | "标错惩罚轮回放断言:同种子步骤流逐行相同(类型化负向兑付确定性)" | JSON.stringify(run1.rows) == JSON.stringify(run2.rows) | (待填) | | | + +## S35 _smoke_attack_chain_fresh_pool(行 445–460 · 3 条 · 源 game/tests/unit/battle_settlement/roll_multiplier_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 195 | 445 | "fresh 池回归战斗启动(Boss 行)" | settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) | (待填) | | | +| 196 | 452 | "非施放攻击拍采样充足(%d 拍——增益施放行零兑付为设计语义不计)" ↩ % ticks.size() | ticks.size() >= 2 | (待填) | | | +| 197 | 460 | "fresh 池非蓄力拍 d_tick = max(1, 掷点档位基线)(Boss 攻击链健康——Bug#2 回归断言)" | all_full | (待填) | | | + +## S36 _smoke_full_battle(行 51–79 · 7 条 · 源 game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 198 | 51 | "%s 战斗在轮次上限内终局(%d 轮,%s)" ↩ % [label, run1.rounds, run1.result] | run1.terminated | (待填) | | | +| 199 | 59 | "%s 回放断言:结算步骤流逐行相同" ↩ % label | JSON.stringify(run1.stream) == JSON.stringify(run2.stream) | (待填) | | | +| 200 | 60 | "%s 回放断言:事件流逐位相同" ↩ % label | JSON.stringify(run1.events) == JSON.stringify(run2.events) | (待填) | | | +| 201 | 61 | "%s 结算步骤流非空(%d 行)" ↩ % [label, run1.stream.size()] | run1.stream.size() > 0 | (待填) | | | +| 202 | 75 | "%s 步骤流含判定行" ↩ % label | has_verdict | (待填) | | | +| 203 | 78 | "%s 主角先手攻击行必存在(2026-09-11 拍板① + 案C 地图账殿后)×%d" ↩ % [label, hero_attacks] | hero_attacks >= 1 | (待填) | | | +| 204 | 79 | "%s 主角攻击行携带类型键与掷点读数(单分量——充能退役批)" ↩ % label | hero_breakdown_ok | (待填) | | | + +## S37 _run_battle(行 111–111 · 1 条 · 源 game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 205 | 111 | "战斗收束后行动经济相位回 Idle(notify_combat_end 生效,实测=%s)" ↩ % String(action_economy.phase) | action_economy.phase == "Idle" | (待填) | | | + +## S38 _smoke_active_refresh(行 160–215 · 17 条 · 源 game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 206 | 160 | "轮内刷新战斗启动(Boss 行 150HP——承载多轮样本)" | settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) | (待填) | | | +| 207 | 166 | "三轮停手攒 RAP=%d(round=%d rap=%d)" ↩ % [rap_banked, settlement.round_index, settlement.action_economy.rap] | settlement.round_index == 4 and settlement.action_economy.rap == rap_banked | (待填) | | | +| 208 | 167 | "第 4 轮 Active 相位" | settlement.action_economy.phase == "Active" | (待填) | | | +| 209 | 177 | "轮内第 1 次刷新受理" | ok1 | (待填) | | | +| 210 | 178 | "相位保持 Active、AP 保留不重置(ap=%d)" ↩ % settlement.action_economy.ap | settlement.action_economy.phase == "Active" and settlement.action_economy.ap == ap_before | (待填) | | | +| 211 | 179 | "RAP -刷新价(%d)" ↩ % settlement.action_economy.rap | settlement.action_economy.rap == rap_banked - int(_db.action_economy.rap_refresh_cost) | (待填) | | | +| 212 | 180 | "当场换新图(board_changed +1)" | int(ct["board_swaps"]) == swaps_before + 1 and minesweeper.current_board() != board_before | (待填) | | | +| 213 | 186 | "轮内第 2 次刷新受理(RAP=%d)" ↩ % settlement.action_economy.rap | ok2 | (待填) | | | +| 214 | 192 | "第 3 次刷新被拒(每轮兜底上限 2——RAP %d 足额仍拒)" ↩ % settlement.action_economy.rap | not ok3 | (待填) | | | +| 215 | 194 | "V5 快照刷新计数=2(受理单源——main 副本已删)〔迭代27 T3〕" | int(settlement.presentation_snapshot().get("refresh_used", -1)) == 2 | (待填) | | | +| 216 | 195 | "V5 上限达门禁一致(can_refresh 与快照计数同源)〔迭代27 T3〕" | not settlement.can_refresh() | (待填) | | | +| 217 | 207 | "轮 4 挖至 AP 耗尽自动结算(round=%d result='%s')" ↩ % [settlement.round_index, settlement.battle_result] | settlement.round_index == 5 and settlement.battle_result == "" | (待填) | | | +| 218 | 208 | "跨图 facts 完整兑现(safe_cell 结算行 %d == safe 事件 %d——快照批次隔离)" ↩ % [int(ct["safe_rows"]), int(ct["safe_events"])] | int(ct["safe_rows"]) == int(ct["safe_events"]) | (待填) | | | +| 219 | 209 | "跨图挖掘 safe 事件 ≥3(%d——断言非空转)" ↩ % int(ct["safe_events"]) | int(ct["safe_events"]) >= 3 | (待填) | | | +| 220 | 210 | "结算段内(Waiting 相位)刷新被拒(等待期换图通道已移除)" | bool(ct["waiting_refresh_rejected"]) and bool(ct["waiting_probe_hit"]) | (待填) | | | +| 221 | 214 | "轮内刷新回放断言:同种子两遍步骤流逐行相同" | JSON.stringify(rep1) == JSON.stringify(rep2) | (待填) | | | +| 222 | 215 | "轮内刷新步骤流非空(%d 行)" ↩ % rep1.size() | rep1.size() > 0 | (待填) | | | + +## S39 _smoke_encounter_roll(行 254–312 · 8 条 · 源 game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 223 | 254 | "遭遇扫描战斗启动(quiet 计数档——计数入总账,成败由后续 spawn 断言归因)" | settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) | (待填) | | | +| 224 | 257 | "D0 区间 [1,2] 两值均出现(分布 %s)" ↩ % str(counts) | counts.has(1) and counts.has(2) | (待填) | | | +| 225 | 263 | "全部计数在域内 [1,2](分布 %s)" ↩ % str(counts) | all_in_domain | (待填) | | | +| 226 | 267 | "同种子遭遇骰可回放(%s == %s)" ↩ % [str(r1), str(r2)] | r1 == r2 | (待填) | | | +| 227 | 272 | "D1 战斗启动" | settlement_d1.begin_battle({"level_index": 1, "node_depth": 1, "hero_hp": int(_db.hero.max_hp)}) | (待填) | | | +| 228 | 274 | "D1 区间 [2,3] 域内(%d 只)" ↩ % n1 | n1 >= 2 and n1 <= 3 | (待填) | | | +| 229 | 287 | "熊地精首遇带(1,battle,%d——扫表取最低带)固定 1 不加骰〔fv23〕" ↩ % bugbear_lead_band | settlement_d2.presentation_snapshot().enemies.size() == 1 \\ and int(bugbear_lead_row.get("unit_count", -1)) == 1 and not bugbear_lead_row.has("unit_count_max") | (待填) | | | +| 230 | 312 | "全深度 × 12 种子 spawn 全域内(%d 场,∈[unit_count, unit_count_max] 且 ≥1)——空遭遇不可达(Bug#1 回归)" ↩ % scan_total | scan_bad == 0 | (待填) | | | + +## S40 _smoke_settlement_segments(行 380–445 · 5 条 · 源 game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 231 | 380 | "segment_order 五段冻结在位(表值 = 拍板序恒等)" | Array(_db.settlement.segment_order) == SettlementTable.FROZEN_SEGMENT_ORDER and SettlementTable.FROZEN_SEGMENT_ORDER == ["grant", "buff_debuff", "combat", "decay", "verdict"] | (待填) | | | +| 232 | 391 | "行型封闭 10 类(删五类零残留:item/consumed_ineffective/use_rejected/window_open/window_skip)" | legacy_total == 0 | (待填) | | | +| 233 | 418 | "每轮行相位单调(授予→增益/减益→战斗→衰减→判定)" | order_ok | (待填) | | | +| 234 | 444 | "案C 段内序:地图伤害账殿后(主角先手→敌方逐个→地图账;连珠矢随先手段)" | mapdmg_ok | (待填) | | | +| 235 | 445 | "衰减/判定段每结算恰一段(先兑付后衰减——含末轮)" | decay_verdict_ok | (待填) | | | + +## S41 _smoke_skill_pool_data(行 496–682 · 28 条 · 源 game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 236 | 496 | "技能表十六行(普攻/突刺/蓄力/重击/硬化 + 撕咬/毒爆/蛛网束缚 + 大地脉动 + 震地怒击/遏毒/岩崩 + 毒吐/盾击——前三批 + Boss 半血解锁技三行〔迭代14④ T5〕+ 普通敌错峰两行〔迭代 24 N2〕+ 触发条件批示范两行〔孤注守势/趁隙偷袭〕)" | _db.skill.rows.size() == 16 | (待填) | | | +| 237 | 498 | "普攻行:倍率恒 1 · 冷却恒 0(域校验锁死——池恒非空的结构前提)" | not basic.is_empty() and String(basic.type) == "basic" and int(basic.cooldown_beats) == 0 and (not basic.has("damage_mult") or is_equal_approx(float(basic.damage_mult), 1.0)) | (待填) | | | +| 238 | 502 | "突刺行(伤害 ×1.5 · 冷却 2——提案 §2.5 建议池新行)" | not lunge.is_empty() and is_equal_approx(float(lunge.damage_mult), 1.5) and int(lunge.cooldown_beats) == 2 | (待填) | | | +| 239 | 504 | "蓄力行 = 纯攻击增益技(0 伤害、buff_ref → empower——与重击无组合语义)" | not charge_row.is_empty() and String(charge_row.buff_ref) == "empower" | (待填) | | | +| 240 | 506 | "撕咬行(伤害 ×1.5 · 冷却 1——巨蜘蛛池载体,与突刺同倍率错开冷却轴)" | not bite.is_empty() and is_equal_approx(float(bite.damage_mult), 1.5) and int(bite.cooldown_beats) == 1 | (待填) | | | +| 241 | 508 | "毒爆行(伤害 ×2.5 · 冷却 4 · 必中附加 ×0.75——蛛化卓尔精灵专属池独占技,新效应词汇批)" | not venom.is_empty() and is_equal_approx(float(venom.damage_mult), 2.5) and int(venom.cooldown_beats) == 4 and is_equal_approx(float(venom.rider_mult), 0.75) | (待填) | | | +| 242 | 516 | "毒爆独占性(仅蛛化卓尔精灵池引用——Boss 专属池独占技纪律)" | venom_sole_ok | (待填) | | | +| 243 | 519 | "蛛网束缚行(妨害型 · buff_ref → off_guard 失衡 · duration 2 = 1 有效拍 · 冷却 4——新效应词汇批首发)" | not snare.is_empty() and String(snare.type) == "debuff" and String(snare.buff_ref) == "off_guard" and int(snare.buff_duration) == 2 and int(snare.cooldown_beats) == 4 | (待填) | | | +| 244 | 527 | "蛛网束缚独占性(仅蛛化卓尔精灵池引用——实装面收窄,其余敌种不动)" | snare_sole_ok | (待填) | | | +| 245 | 529 | "失衡状态行(hero · ac 轴——妨害技落点,ConfigDB side 守卫正例)" | not off_guard_row.is_empty() and String(off_guard_row.target_side) == "hero" and String(off_guard_row.unit) == "ac" | (待填) | | | +| 246 | 532 | "毒吐行(伤害 ×1.6 · 冷却 2 · 无 rider——低 atk 下 rider 恒被 damage_floor 兜成 1 白占字段,迭代 24 N2)" | not spit.is_empty() and String(spit.display_name) == "毒吐" and String(spit.type) == "damage" \\ and is_equal_approx(float(spit.damage_mult), 1.6) and int(spit.cooldown_beats) == 2 and not spit.has("rider_mult") | (待填) | | | +| 247 | 536 | "盾击行(伤害 ×1.7 · 冷却 3 · 无 rider——迭代 24 N2)" | not bash.is_empty() and String(bash.display_name) == "盾击" and String(bash.type) == "damage" \\ and is_equal_approx(float(bash.damage_mult), 1.7) and int(bash.cooldown_beats) == 3 and not bash.has("rider_mult") | (待填) | | | +| 248 | 550 | "毒吐投放面(仅 depth_band > 巨蜘蛛首遇带(%d)的巨蜘蛛族行——首遇带池不动,迭代 24 N2/Q6)〔fv24〕" ↩ % spider_lead_band | spit_sole_ok | (待填) | | | +| 249 | 551 | "盾击投放面(仅 (3,·) 灰矮人族——钩爪恐魔零触碰,迭代 24 N2)" | bash_sole_ok | (待填) | | | +| 250 | 559 | "巨蜘蛛首遇带行 pool 恒 {attack:6, bite:2, charge:1}(教学位池零新招——毒吐首投在更深的带)〔fv24〕" | spider_lead_pool == {"attack": 6, "bite": 2, "charge": 1} | (待填) | | | +| 251 | 605 | "二十八行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1/1〔触发条件批+孤注守势 w1〕 · 熊地精 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/2〔触发条件批+趁隙偷袭 w2〕 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2——半血解锁收编技能行 trigger 闩型——迭代14④ T5/触发条件批迁移〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕;迭代 35 覆盖批八行为顶档池克隆,逐行同检)" | pools_ok | (待填) | | | +| 252 | 615 | "每池恰一行普攻(空池不可能的加载校验同款)" | basic_count_ok | (待填) | | | +| 253 | 632 | "逐池增益 ≤1 · 妨害 ≤1(池构成纪律——迭代 24 N2 毒吐/盾击投放后复查)" | comp_ok | (待填) | | | +| 254 | 643 | "每池至少一行无条件特殊技(触发条件批池构成纪律——逐池复查)〔触发器批 V7〕" | uncond_ok | (待填) | | | +| 255 | 648 | "前置:卓尔 Boss 战受理(2,9——解锁技 venom_curb 门槛 50)" | sett_u.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) | (待填) | | | +| 256 | 651 | "前置:Boss 压到阈值 65(=round(130×0.5))" | int(boss_u.hp) == 65 | (待填) | | | +| 257 | 657 | "解锁归因行恰一次(×%d——闩去重,二次检查零行)" ↩ % unlock_rows.size() | unlock_rows.size() == 1 | (待填) | | | +| 258 | 659 | "解锁行文本 =「蛛化卓尔精灵 解锁了 遏毒」(高光键 skill_unlock——呈现层一次性技能名 toast 路由键)" | String(unlock_rows[0].get("display_name", "")) == "蛛化卓尔精灵 解锁了 遏毒" | (待填) | | | +| 259 | 660 | "解锁行型 = highlight 既有行型(零行型新增——行协议封闭)" | String(unlock_rows[0].get("type", "")) == "highlight" | (待填) | | | +| 260 | 674 | "全深度实例 spawn 后可摇集合恒非空(普攻恒冷却 0——容器级断言,L1/L2 双关卡)" | rollable_ok | (待填) | | | +| 261 | 678 | "蓄力状态行 empower(enemy +100 percent——敌方输出 ×2 走既有 enemy_output 池)" | not empower.is_empty() and String(empower.target_side) == "enemy" and int(empower.magnitude) == 100 | (待填) | | | +| 262 | 679 | "硬化状态行 fortify(enemy −50 percent——敌方防御池负值 = 主角攻击折半;+100 符号 bug 2026-09-18 纠正,provisional 归 状态层重设计批)" | not fortify.is_empty() and String(fortify.target_side) == "enemy" and int(fortify.magnitude) == -50 | (待填) | | | +| 263 | 680 | "effect_pool_map 收编 empower/fortify(敌方防御池 enemy_defense 入表)" | String(_db.settlement.core.effect_pool_map.get("empower", "")) == "enemy_output" \\ and String(_db.settlement.core.effect_pool_map.get("fortify", "")) == "enemy_defense" | (待填) | | | + +## S42 _smoke_skill_pool_replay_independence(行 715–721 · 2 条 · 源 game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 264 | 715 | "多实例种子可达(seed=%d——D3 遭遇 ≥2 实例)" ↩ % seed_val | seed_val > 0 | (待填) | | | +| 265 | 721 | "同种子回放:各实例摇定动作序列逐位相同(×%d 轮 × %d 实例——子流独立复放面随并流退役)" ↩ % [rounds, int(run1.count)] | not run1.is_empty() and JSON.stringify(run1.seqs) == JSON.stringify(run2.seqs) | (待填) | | | + +## S43 _smoke_skill_pool_ev_anchor(行 809–809 · 1 条 · 源 game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 266 | 809 | "EV 抽样对锚 %s(实测 %.4f ∈ [%.2f, %.2f] 含闸长期速率,×%d 拍)" ↩ % [eid, measured, float(band[0]), float(band[1]), n_beats] | in_band | (待填) | | | + +## S44 _smoke_skill_pool_death_void(行 829–848 · 5 条 · 源 game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 267 | 829 | "死亡打断种子可达(seed=%d——D2 首拍摇中特殊技)" ↩ % void_seed | void_seed > 0 | (待填) | | | +| 268 | 835 | "死亡打断战斗启动(hero_atk 999——先手必杀 40HP 熊地精)" | settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}) | (待填) | | | +| 269 | 838 | "开轮已摇定本拍动作(待发 = %s)" ↩ % pending | pending != "" | (待填) | | | +| 270 | 844 | "先手击杀 → 摇定技能作废(第 1 轮零敌方行动行——死亡即不动手)" | enemy_rows == 0 | (待填) | | | +| 271 | 848 | "作废不进账(全池冷却 0 · 保底计数 0——执行上报未发生)" | cd_sum == 0 and int(u.basic_streak) == 0 | (待填) | | | + +## S45 _smoke_skill_pool_validation_reject(行 859–895 · 13 条 · 源 game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 272 | 859 | "加载校验拒绝:池条目 skill_id 悬空致命" | _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "dangling_skill") | (待填) | | | +| 273 | 861 | "加载校验拒绝:池普攻行数 ≠ 1(空池不可能前提)" | _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition") | (待填) | | | +| 274 | 866 | "加载校验拒绝:池增益技 > 1(每池增益 ≤1 纪律)" | _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition") | (待填) | | | +| 275 | 868 | "加载校验拒绝:普攻配倍率 ≠ 1(域校验锁死)" | not skill_table.validate().is_empty() | (待填) | | | +| 276 | 870 | "加载校验拒绝:普攻配冷却 ≠ 0(域校验锁死)" | not skill_table.validate().is_empty() | (待填) | | | +| 277 | 872 | "加载校验拒绝:damage_mult 越域 [1.1,16]" | not skill_table.validate().is_empty() | (待填) | | | +| 278 | 875 | "加载校验拒绝:池权重 ≤ 0" | not et2.validate().is_empty() | (待填) | | | +| 279 | 877 | "加载校验拒绝:空池(禁)" | not et2.validate().is_empty() | (待填) | | | +| 280 | 883 | "加载校验拒绝:池妨害技 > 1(每池妨害 ≤1 纪律)" | _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition") | (待填) | | | +| 281 | 885 | "加载校验拒绝:妨害技缺 buff_ref(效果引用)" | not skill_table.validate().is_empty() | (待填) | | | +| 282 | 887 | "加载校验拒绝:rider_mult 越域 (0,1]" | not skill_table.validate().is_empty() | (待填) | | | +| 283 | 889 | "加载校验拒绝:非伤害技带必中附加分量" | not skill_table.validate().is_empty() | (待填) | | | +| 284 | 895 | "加载校验拒绝:妨害技引用敌方侧状态(施放类 side 守卫)" | _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table, _db.status_effects), "cast_side") | (待填) | | | + +## S46 _smoke_debuff_rider(行 973–975 · 3 条 · 源 game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 285 | 973 | "毒爆必中附加分量种子可达(seed=%d,毒爆伤害行 ×%d)" ↩ % [venom_seed, venom_rows_seen] | venom_seed > 0 and venom_rows_seen >= 1 | (待填) | | | +| 286 | 974 | "毒爆伤害行逐拍精确复算:终值 = max(1, 掷点档位主分量) + 必中附加(rider 只随 rider_mult 行出现)" | venom_check_ok | (待填) | | | +| 287 | 975 | "蛛网束缚落主角:施放后掷点阈值 = 基准 AC + 失衡幅度(off_guard ac 轴端到端,seed=%d)" ↩ % snare_seed | snare_seed > 0 | (待填) | | | + +## S47 _smoke_counter_buckets(行 998–1037 · 7 条 · 源 game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 288 | 998 | "V1 第1战真实 request_dig 受理(begin_battle 后 ACTIVE 相位)〔无状态化 V1〕" | dig1 | (待填) | | | +| 289 | 999 | "V1 第1战付费挖格经 ctx 就地写 adventure 桶=1(无 export 搬运时点)〔无状态化 V1〕" | int(led.get("adv_twin_digs", 0)) == 1 | (待填) | | | +| 290 | 1005 | "V1 第2战 begin_battle 经 ctx 读到上一战持久值=1(读路径走宿主字典引用,非副本种子回灌)〔无状态化 V1〕" | EffectEngine.counter(o2.fx_host().counters_ctx, "adv_twin_digs") == 1 | (待填) | | | +| 291 | 1007 | "V1 第2战挖后 adventure 桶跨战累积=2(共享账判空早退缺陷消解的回归钉面——旧副本态恒 1)〔无状态化 V1〕" | int(led.get("adv_twin_digs", 0)) == 2 | (待填) | | | +| 292 | 1024 | "V6 第1战标错落 battle 桶 insurance_used=1(_apply_counter_hit 静态写口经 ctx)〔无状态化 V6〕" | EffectEngine.counter(oA.fx_host().counters_ctx, "insurance_used") == 1 | (待填) | | | +| 293 | 1030 | "卡④ wr2①:完美保险标错行 type=extended 且 effect_id=insurance_used(effect_id 必载)〔明细收敛 V2〕" | not v6_ins.is_empty() and String(v6_ins.get("type", "")) == "extended" and String(v6_ins.get("source_class", "")) == "seal_miss" and bool(v6_ins.get("forgiven", 0)) | (待填) | | | +| 294 | 1037 | "V6 battle 桶随宿主销毁:次战新编排器 fx_counters_battle 天然空,读 insurance_used=0〔无状态化 V6〕" | EffectEngine.counter(oB.fx_host().counters_ctx, "insurance_used") == 0 | (待填) | | | + +## S48 _smoke_extended_rows(行 1064–1083 · 2 条 · 源 game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 295 | 1064 | "卡④ wr2②:专长揭示兑付行 type=extended 且 effect_id=fx_reveal 且来源格真实〔明细收敛 V2〕" | not paid.is_empty() and String(paid.get("type", "")) == "extended" and Vector2i(paid["origin_cell"][0], paid["origin_cell"][1]).x >= 0 | (待填) | | | +| 296 | 1081 | "卡④ wr2③:揭示落空行 type=extended 且 effect_id=fx_reveal 且 origin_cell=越界哨值〔明细收敛 V2〕" | not missed.is_empty() and String(missed.get("type", "")) == "extended" and Vector2i(missed["origin_cell"][0], missed["origin_cell"][1]).x == -1 | (待填) | | | + +## S49 _smoke_seal_ap_exhausted(行 54–80 · 5 条 · 源 game/tests/unit/battle_settlement/seal_polarity_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 297 | 54 | "回归战斗启动(Boss 行——D0 20HP 级会被盲挖粉尘秒杀,无法承载封印回归场景)" | settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) | (待填) | | | +| 298 | 68 | "%d 次挖掘后恰剩 1 AP(phase=%s ap=%d)" ↩ % [ap_total - 1, str(settlement.action_economy.phase), settlement.action_economy.ap] | settlement.action_economy.phase == "Active" and settlement.action_economy.ap == 1 | (待填) | | | +| 299 | 73 | "仍存在可挖目标(封印目标来源)" | not plan2.is_empty() | (待填) | | | +| 300 | 79 | "末点 AP 封印被受理" | not out.is_empty() and bool(out[0].accepted) | (待填) | | | +| 301 | 80 | "封印动作耗尽 AP 触发轮末结算并进入第 2 轮(rounds=%s)" ↩ % str(rounds) | rounds.size() >= 2 | (待填) | | | + +## S50 _smoke_clear_reward_chain(行 329–341 · 5 条 · 源 game/tests/unit/battle_settlement/seal_polarity_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 302 | 329 | "① 完美封印清图达成(%d 轮)" ↩ % int(run.rounds) | bool(run.cleared) | (待填) | | | +| 303 | 336 | "① 清图奖励双份真实兑付(clear_reward 伤害行 ×%d——clear_blast 身份两份各打一发)" ↩ % dmg.size() | dmg.size() == 2 | (待填) | | | +| 304 | 338 | "① 第二份按身份类型走真实兑付(首份『清图奖励』/ 第二份『完美第二份』)" | String(dmg[0].display_name).contains("清图奖励") and String(dmg[1].display_name).contains("完美第二份") | (待填) | | | +| 305 | 339 | "④ 高光行 value = 实际有效份数 2(不记名义值;行数 %d)" ↩ % hl.size() | hl.size() == 1 and int(hl[0].value) == 2 | (待填) | | | +| 306 | 341 | "高光标记 = perfect_sweep" | String(hl[0].get("highlight", "")) == "perfect_sweep" | (待填) | | | + +## S51 _smoke_clear_reward_zero_paths(行 359–399 · 11 条 · 源 game/tests/unit/battle_settlement/seal_polarity_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 307 | 359 | "零兑付构造战斗启动" | s.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80, "node_type": "boss"}) | (待填) | | | +| 308 | 363 | "清图槽身份 = blast 型(clear_default 唯一活行 clear_blast——第二份同身份兑付)" | snap != null and String(snap.cell_type) == "blast" | (待填) | | | +| 309 | 366 | "② 即时类首份零兑付(无存活敌)→ first_ok=false" | not first_ok | (待填) | | | +| 310 | 367 | "② 零兑付产出 reward_ineffective 行(batch_key=clear_blast)" | _has_source_row(rows, "reward_ineffective", "clear_reward", "clear_blast") | (待填) | | | +| 311 | 374 | "高光守卫构造战斗启动" | s_b.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80, "node_type": "boss"}) | (待填) | | | +| 312 | 381 | "② grant_clear_reward 守卫路径重走首份 → 未生效行仍在案" | _has_source_row(rows_b, "reward_ineffective", "clear_reward", "clear_blast") | (待填) | | | +| 313 | 382 | "② 首份零兑付 → 高光行不发(不记名义值 2)" | _rows_of_type(rows_b, "highlight").is_empty() | (待填) | | | +| 314 | 389 | "第二份被拒构造战斗启动" | s2.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80, "node_type": "boss"}) | (待填) | | | +| 315 | 396 | "③ 首份 aoe 真实兑付(击杀唯一存活敌)" | _has_source_row(rows2, "damage", "clear_reward") | (待填) | | | +| 316 | 397 | "③ 第二份被拒产出 reward_ineffective 行(零兑付)" | _has_source_row(rows2, "reward_ineffective", "clear_reward", "clear_blast") | (待填) | | | +| 317 | 399 | "④ 高光行 value = 实际有效份数 1(不记名义值 2)" | hl2.size() == 1 and int(hl2[0].value) == 1 | (待填) | | | + +## S52 _smoke_refresh_provenance_guard(行 410–421 · 5 条 · 源 game/tests/unit/battle_settlement/seal_polarity_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 318 | 410 | "轮内刷新受理(三轮停手攒 RAP 后 Active 相位发起)" | bool(run.refreshed) | (待填) | | | +| 319 | 411 | "刷新图清图达成(%d 轮)" ↩ % int(run.rounds) | bool(run.cleared) | (待填) | | | +| 320 | 419 | "刷新图清图 → 无 clear_reward 行(守卫在位——实测 %d 行)" ↩ % cr | cr == 0 | (待填) | | | +| 321 | 420 | "刷新图清图 → 无高光行(守卫在位——实测 %d 行)" ↩ % hl | hl == 0 | (待填) | | | +| 322 | 421 | "对照基线:常规换图清图有 clear_reward 行(×%d——守卫非全局静默)" ↩ % _regular_clear_reward_rows | _regular_clear_reward_rows > 0 | (待填) | | | + +## S53 _smoke_polarity_routing(行 442–549 · 12 条 · 源 game/tests/unit/battle_settlement/seal_polarity_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 323 | 442 | "极性路由构造种子可达(seed=%d——并流后行为面重锚)" ↩ % polarity_seed | polarity_seed > 0 | (待填) | | | +| 324 | 443 | "构造触达:标中真雷 + 标空安全格均入队" | bool(probe["seal_hit_done"]) and bool(probe["seal_miss_done"]) | (待填) | | | +| 325 | 444 | "构造触达:挖到危险格 ≥1(%d)" ↩ % int(probe["mine_events"]) | int(probe["mine_events"]) >= 1 | (待填) | | | +| 326 | 445 | "构造触达:挖到安全格 ≥1(%d)" ↩ % int(probe["safe_events"]) | int(probe["safe_events"]) >= 1 | (待填) | | | +| 327 | 462 | "挖安全格 → 只出正向兑付(效果行 ∈ 正向字典 + 每格 ≥1 行——%d 格)" ↩ % probe["safe_cells"].size() | safe_all_pos | (待填) | | | +| 328 | 479 | "挖危险格 → 只出负向兑付(效果行 ∈ 负向字典 + 每格 ≥1 行——%d 格)" ↩ % probe["mine_cells"].size() | mine_all_neg | (待填) | | | +| 329 | 492 | "封印命中 → 该危险格资源的正向兑付行(origin 指回危险格,效果 ∈ 正向字典;action 型按行值符号辨极性)" | hit_ok | (待填) | | | +| 330 | 498 | "封印溢价配置在位(seal_hit_premium=125 provisional——形态 a 兑付溢价)" | prem_pct == 125 | (待填) | | | +| 331 | 526 | "标中兑付溢价(×%d 行逐型=同型正向 ×%d/100——等号压线注记:δ=1.25 时条件二 E[标中]=1.25·E_dig 恰等成立)" ↩ % [prem_rows, prem_pct] | prem_rows >= 1 and prem_ok | (待填) | | | +| 332 | 532 | "标空惩罚零溢价(负向直伤行=固定表值原值——溢价只挂标中正向)" | miss_flat_ok | (待填) | | | +| 333 | 543 | "标空封印 → 封印未中归因行 + 该安全格资源负向兑付(同格同批)" | not miss_rows.is_empty() and miss_neg_ok | (待填) | | | +| 334 | 549 | "封印判定兑付仅封印判定批两封印在案(撤封印抵消过滤零泄漏——%d 格)" ↩ % seal_srcs.size() | int(seal_srcs.size()) <= 2 | (待填) | | | + +## S54 _smoke_seal_irreversible_quadrants(行 571–663 · 21 条 · 源 game/tests/unit/battle_settlement/seal_polarity_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 335 | 571 | "四情况 A:开局受理" | settle_a.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) | (待填) | | | +| 336 | 574 | "四情况 A:找到安全格封印目标" | not t_a.is_empty() | (待填) | | | +| 337 | 578 | "四情况 A:封印受理" | ms_a.settle_batch([{"action": "seal_place", "cell": t_a.seal_cell}])[0].accepted | (待填) | | | +| 338 | 580 | "四情况 A:seal_pull 出目录拒绝(撤封印退役)" | not pull_out.is_empty() and not bool(pull_out[0].get("accepted", true)) and String(pull_out[0].get("sub_reason", "")) == "out_of_catalog" | (待填) | | | +| 339 | 582 | "四情况 A:重复封印仍被拒(seal_present)" | not dup_place.is_empty() and not bool(dup_place[0].get("accepted", true)) and String(dup_place[0].get("sub_reason", "")) == "seal_present" | (待填) | | | +| 340 | 584 | "四情况 A:封印格直接挖开被受理(不再须先撤封印)" | not dig_a.is_empty() and bool(dig_a[0].get("accepted", false)) | (待填) | | | +| 341 | 585 | "四情况 A:封印随挖掘隐式退役(has_seal = false)" | not ms_a.has_seal(t_a.seal_cell) | (待填) | | | +| 342 | 600 | "四情况 A:标空判定零兑付(×%d——封印已擦除不入轮末基线)" ↩ % miss_a | miss_a == 0 | (待填) | | | +| 343 | 601 | "四情况 A:安全格正向兑付作废(×%d——零奖励)" ↩ % grant_a | grant_a == 0 | (待填) | | | +| 344 | 602 | "四情况 A:兑付作废归因行恰一次(×%d——零惩罚零奖励留痕)" ↩ % void_a | void_a == 1 | (待填) | | | +| 345 | 609 | "四情况 B:开局受理" | settle_b.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) | (待填) | | | +| 346 | 612 | "四情况 B:找到危险格封印目标" | not t_b.is_empty() | (待填) | | | +| 347 | 616 | "四情况 B:封印受理" | ms_b.settle_batch([{"action": "seal_place", "cell": t_b.seal_cell}])[0].accepted | (待填) | | | +| 348 | 618 | "四情况 B:封印危险格直接挖开被受理" | not dig_b.is_empty() and bool(dig_b[0].get("accepted", false)) | (待填) | | | +| 349 | 619 | "四情况 B:危险格已揭示(挖雷本体照常)" | ms_b.is_revealed(t_b.seal_cell) and ms_b.current_board().is_mine(t_b.seal_cell) | (待填) | | | +| 350 | 631 | "四情况 B:挖雷惩罚照常兑付(×%d——只受挖雷一层惩罚)" ↩ % mine_hit_b | mine_hit_b >= 1 | (待填) | | | +| 351 | 632 | "四情况 B:标中不发(×%d——封印已擦除不判)" ↩ % hit_b | hit_b == 0 | (待填) | | | +| 352 | 639 | "四情况 C:开局受理" | settle_c.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) | (待填) | | | +| 353 | 643 | "四情况 C:安全格+危险格封印目标齐备" | not t_safe.is_empty() and not t_mine.is_empty() | (待填) | | | +| 354 | 662 | "四情况 C:标空惩罚照常(归因×%d 负向兑付×%d)" ↩ % [miss_c, miss_neg_c] | miss_c >= 1 and miss_neg_c >= 1 | (待填) | | | +| 355 | 663 | "四情况 C:标中奖励照常(×%d)" ↩ % hit_c | hit_c >= 1 | (待填) | | | + +## S55 _smoke_negative_dictionary(行 675–755 · 8 条 · 源 game/tests/unit/battle_settlement/seal_polarity_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 356 | 675 | "sapped 行在位(hero · −25 percent · dur 1 · max 16 · stack——weaken 型负向)" | not sapped.is_empty() and String(sapped.get("target_side", "")) == "hero" \\ and int(sapped.get("magnitude", 0)) == -25 and int(sapped.get("duration_settlements", 0)) == 1 \\ and int(sapped.get("max_stack", 0)) == 16 and String(sapped.get("stack_policy", "")) == "stack" | (待填) | | | +| 357 | 679 | "effect_pool_map 收编 sapped → hero_offense(attack_up 镜像池)" | String(_db.settlement.core.effect_pool_map.get("sapped", "")) == "hero_offense" | (待填) | | | +| 358 | 716 | "扫描 6 板内出现五型齐备板(%s)" ↩ % str(by_type.keys()) | by_type.size() == 5 | (待填) | | | +| 359 | 726 | "%s 型负向 → %s 效果行(mine_hit 来源指回该格)" ↩ % [ctype, eff] | _has_source_row(rows, "effect", "mine_hit") and _has_effect_id(rows, eff) | (待填) | | | +| 360 | 738 | "heal 型负向 → 主角直伤 %d(%d → %d,不经增益状态)" ↩ % [backlash_power, hp_before, hp_after] | hp_after == hp_before - backlash_power | (待填) | | | +| 361 | 748 | "blast 型负向 → 警觉(敌方护甲等级 +1 状态在位)" | alert_ok | (待填) | | | +| 362 | 753 | "action 型负向 → 下轮行动点 −1(排队)" | settlement.action_economy.pending_ap_delta() == d0 - 1 | (待填) | | | +| 363 | 755 | "action 型正负抵消 → 排队净值归零" | settlement.action_economy.pending_ap_delta() == d0 | (待填) | | | + +## S56 _smoke_resource_surface(行 785–804 · 5 条 · 源 game/tests/unit/battle_settlement/seal_polarity_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 364 | 785 | "chain/dig 揭示事件 resource 恒 true(雷安同池——违例 %d)" ↩ % digchain_bad | digchain_bad == 0 | (待填) | | | +| 365 | 786 | "initial 揭示事件(封锁格)resource 恒 false(×%d 事件)" ↩ % initial_count | initial_bad == 0 and initial_count >= 1 | (待填) | | | +| 366 | 787 | "危险格揭示携带资源(mine ×resource=true %d——危险格不再恒无)" ↩ % mine_with_resource | mine_with_resource >= 1 | (待填) | | | +| 367 | 803 | "封锁格(R₀)身份查询 = null(不入摇号清单)" | settlement.query_reward_identity(r0_first) == null | (待填) | | | +| 368 | 804 | "未开危险格身份恒可查({reward_id, cell_type, tier}——秘宝 桥接前提)" | mine_unrevealed != null and settlement.query_reward_identity(mine_unrevealed) != null | (待填) | | | + +## S57 _init(行 18–18 · 1 条 · 源 game/tests/unit/battle_settlement/skill_unlock_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 369 | 18 | "前置:表加载零 error" | load_ok and db.diagnostics_count("error") == 0 | (待填) | | | + +## S58 _boss(行 30–30 · 1 条 · 源 game/tests/unit/battle_settlement/skill_unlock_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 370 | 30 | "前置:spawn (%d,%d) 受理" ↩ % [L, D] | ok | (待填) | | | + +## S59 _test_gate_predicate(行 49–69 · 9 条 · 源 game/tests/unit/battle_settlement/skill_unlock_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 371 | 49 | "前置:卓尔 max_hp=130(表值——难度乘数空表恒 1.0)" | mhp == 130 | (待填) | | | +| 372 | 52 | "门谓词:hp=66 > 阈值 65 → 零解锁" | ec.check_trigger_unlocks(u).is_empty() | (待填) | | | +| 373 | 53 | "双面过滤:未解锁 venom_curb 不入可摇集合" | not _has_sid(ec.rollable_skills(u), "venom_curb") | (待填) | | | +| 374 | 54 | "双面过滤:未解锁 venom_curb 不入保底特殊技集合" | not _has_sid(ec.special_skills(u), "venom_curb") | (待填) | | | +| 375 | 57 | "前置:补一刀至阈值 65 受理" | hit65 and int(u.hp) == 65 | (待填) | | | +| 376 | 59 | "门谓词:hp=65 ≤ 阈值 65 → 恰解锁 venom_curb 一次" | unlocked.size() == 1 and String(unlocked[0]) == "venom_curb" | (待填) | | | +| 377 | 61 | "解锁即可摇:venom_curb 入可摇集合(冷却初值 0)" | _has_sid(ec.rollable_skills(u), "venom_curb") | (待填) | | | +| 378 | 62 | "解锁即可摇:venom_curb 入保底特殊技集合" | _has_sid(ec.special_skills(u), "venom_curb") | (待填) | | | +| 379 | 69 | "保底不逼锁定:锁定期保底集合 = heavy+venom_burst+web_snare(venom_curb 过滤不出)" | specials.size() == 3 and _has_sid(specials, "heavy") and _has_sid(specials, "web_snare") and _has_sid(specials, "venom_burst") and not _has_sid(specials, "venom_curb") | (待填) | | | + +## S60 _test_latch_irreversible(行 77–80 · 3 条 · 源 game/tests/unit/battle_settlement/skill_unlock_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 380 | 77 | "前置:半血触发解锁" | ec.check_trigger_unlocks(u).size() == 1 | (待填) | | | +| 381 | 79 | "闩不可逆:满血复查零新解锁(单向闩不解锁回退)" | ec.check_trigger_unlocks(u).is_empty() | (待填) | | | +| 382 | 80 | "闩不可逆:可摇集合仍含 venom_curb" | _has_sid(ec.rollable_skills(u), "venom_curb") | (待填) | | | + +## S61 _test_plain_rows_zero_drift(行 86–105 · 6 条 · 源 game/tests/unit/battle_settlement/skill_unlock_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 383 | 86 | "零扰动前置:巨蜘蛛首遇带 spawn(池三行全无 trigger——快照键策略钉死载体)" | ec.spawn(2, 1, int(db.hero.max_hp), [], -1, -1, [], "", "", "battle") | (待填) | | | +| 384 | 92 | "零扰动:无字段行池快照零 unlock 键(16/19 行逐位同前)" | not any_unlock_key | (待填) | | | +| 385 | 93 | "零扰动:满血复查零解锁" | ec.check_trigger_unlocks(u).is_empty() | (待填) | | | +| 386 | 96 | "零扰动:可摇集合 = 全池(普攻冷却恒 0,过滤恒空转)" | rollable.size() == pool_count | (待填) | | | +| 387 | 99 | "确定性前置:第二实例蜘蛛 spawn" | ec2.spawn(2, 1, int(db.hero.max_hp), [], -1, -1, [], "", "", "battle") | (待填) | | | +| 388 | 105 | "确定性:两实例同输入可摇集合逐位同(容器零随机纪律)" | a == b | (待填) | | | + +## S62 _smoke_variant_placement(行 60–132 · 9 条 · 源 game/tests/unit/battle_settlement/variant_placement_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 389 | 60 | "L%d 布置段战斗启动失败" ↩ % L | false | (待填) | | | +| 390 | 63 | "L%d 布置段板非空" ↩ % L | b != null | (待填) | | | +| 391 | 84 | "L%d 变异 id 全在候选池(门控一致性:%s)" ↩ % [L, str(pool_ids)] | not bad_id | (待填) | | | +| 392 | 85 | "L%d 每图至多一种变异类型(单格单 id + 全图同 id)" ↩ % L | single_type | (待填) | | | +| 393 | 93 | "L%d 变异格全在初始展开区外且不吃危险格(布置域结构保障——closed_only)" ↩ % L | domain_ok | (待填) | | | +| 394 | 100 | "L%d 变异格数 %d ≤ 公式上限 %d(可挖池百分比 + 关卡递增)" ↩ % [L, vcells.size(), expect_cap] | vcells.size() <= expect_cap | (待填) | | | +| 395 | 103 | "L%d 公式量>0 时非零布置(极小池 round 至 0 = 合法零布置——2026-09-21 基数修正批)" ↩ % L | expect_cap == 0 or vcells.size() > 0 | (待填) | | | +| 396 | 125 | "L%d 全部连通片 ≤ %d 格(全连通生长、2×2/1×4/1×3+1 等形)" ↩ % [L, pack_max] | pack_ok | (待填) | | | +| 397 | 132 | "L%d 同种子变异布置逐格相同(variant_place 流确定性)" ↩ % L | b2 != null and _variant_dump(b2) == _variant_dump(b) | (待填) | | | + +## S63 _smoke_global_variant(行 154–276 · 33 条 · 源 game/tests/unit/battle_settlement/variant_placement_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 398 | 154 | "互锁负样本③:resource_face + scope=region 被拒(resource_face ⇒ global)" | _errs_has_cross_field(vt.validate()) | (待填) | | | +| 399 | 158 | "互锁负样本④:scope=global + effect=hide_number 被拒(global ⇒ resource_face)" | _errs_has_cross_field(vt.validate()) | (待填) | | | +| 400 | 162 | "互锁负样本⑤:scope=global + trigger=on_open 被拒(global ⇒ always 整图恒在)" | _errs_has_cross_field(vt.validate()) | (待填) | | | +| 401 | 166 | "互锁负样本⑥:scope=global + placement=closed_only 被拒(global ⇒ any 无格子布置域)" | _errs_has_cross_field(vt.validate()) | (待填) | | | +| 402 | 175 | "互锁负样本⑦:resource_face effect_value=51 越域 [0,50] 被拒(不适用 flat_damage [1,9] 加法核)" | domain_hit | (待填) | | | +| 403 | 180 | "门控三维:L2 underdark boss 池 = region 两行(贫矿脉 2026-10-16 屏蔽删行后无 global 行)" | pool_boss_ids == ["magma", "ash"] | (待填) | | | +| 404 | 184 | "门控三维:battle 节点静默跳过 global 行(普通图零报错零警告)" | pool_battle_ids == ["magma", "ash"] | (待填) | | | +| 405 | 185 | "门控三维:缺省双参调用 = global 行跳过(既有调用口径零扰动)" | _db.variant_pool(2, "underdark").is_empty() == false and _db.variant_pool(2, "underdark").size() == 2 | (待填) | | | +| 406 | 186 | "L1 Boss 免疫:unlock_level=2 门控在 boss 节点同样生效(教学保护带 B)" | _db.variant_pool(1, "underdark", "boss").is_empty() | (待填) | | | +| 407 | 187 | "全局决议:battle 节点恒无全局型" | _db.global_variant_row(2, "underdark", "battle").is_empty() | (待填) | | | +| 408 | 188 | "全局决议:L2 boss 全局池空 → 空行(贫矿脉屏蔽后兜底回落承载)" | _db.global_variant_row(2, "underdark", "boss").is_empty() | (待填) | | | +| 409 | 189 | "全局决议:L1 boss 全局池空 → 空行(兜底回落承载)" | _db.global_variant_row(1, "underdark", "boss").is_empty() and _db.global_variant_row(1, "dungeon", "boss").is_empty() | (待填) | | | +| 410 | 190 | "elite 前向词位:boss 同池恒空(贫矿脉屏蔽后——词表位不造行)" | _db.global_variant_row(2, "underdark", "elite").is_empty() | (待填) | | | +| 411 | 196 | "L2 Boss 战斗启动(node_depth=9 Boss 终点——兜底回落断言载体)" | boss_ok | (待填) | | | +| 412 | 202 | "屏蔽后回落:Boss 图无整图全局型(board_variants 空——全局池空合法态)" | bb.board_variant_ids().is_empty() | (待填) | | | +| 413 | 207 | "屏蔽后回落:Boss 图 region 变异恰一行且在候选池内(实测 %s)" ↩ % str(boss_vids.keys()) | boss_vids.size() == 1 and (boss_vids.has("magma") or boss_vids.has("ash")) | (待填) | | | +| 414 | 208 | "屏蔽后回落:Boss 图恢复 region 变异格布置(原替换制跳过 region 的逻辑随全局池空失效)" | not bb.variants.is_empty() | (待填) | | | +| 415 | 209 | "屏蔽后回落:variant_place 恢复取号(原全局取用零取号态消失)" | crng.variant_calls > 0 | (待填) | | | +| 416 | 211 | "快照投影:board_variants 空态一致(横幅/角标唯一读取面——无全局型即零预告)" | _arr_str_eq(snap.get("board_variants", []), bb.board_variant_ids()) | (待填) | | | +| 417 | 221 | "确定性:同种子 Boss 图两遍 board_variants/变异格/取号逐位相同(region 布置路径)" | _arr_str_eq(bb2.board_variant_ids(), boss1_ids) and bb2.variants == boss1_variants and crng2.variant_calls == boss1_calls | (待填) | | | +| 418 | 226 | "偏斜基线:正向池现行合计 77(挖掘风险批口径)" | base_sum == 77 | (待填) | | | +| 419 | 229 | "off 态零扰动:board_skew=0 行集与基线逐位相同(不进投影域)" | off_rows.size() == base_rows.size() and _rows_weights_equal(off_rows, base_rows) | (待填) | | | +| 420 | 232 | "合计守恒:偏斜后正向池合计 = 77(整数最大余数重归一)" | eco._pool_weight(on_rows) == 77 | (待填) | | | +| 421 | 234 | "白名单豁免:weaken/gp/action 三行偏斜后逐位不动" | wmap.get("weaken", -1) == 8 and wmap.get("ap_up", -1) == 6 and wmap.get("gp_coin", -1) == 2 | (待填) | | | +| 422 | 235 | "生存族下调:heal 4→3 / defense 11→7(v=30 构造例)" | wmap.get("heal_minor", -1) == 3 and wmap.get("ac_up", -1) == 7 | (待填) | | | +| 423 | 236 | "进攻族上浮:attack 14→15 / blast 16→18 / pierce 8→9 / magic 8→9(v=30 构造例,等比上浮守恒)" | wmap.get("attack_up", -1) == 15 and wmap.get("spark_aoe", -1) == 18 and wmap.get("pierce_strike", -1) == 9 and wmap.get("magic_bolt", -1) == 9 | (待填) | | | +| 424 | 240 | "合成序:图级偏斜先(乘法面)件级 luck_bias 后(加法 +2×四稀有类)——合计 77+8" | eco._pool_weight(combo_rows) == 77 + 4 * 2 | (待填) | | | +| 425 | 241 | "合成序构造例:skew(heal 3)+2=5 / gp 2+2=4 / pierce 9+2=11 / magic 9+2=11(图级定型后玩家构筑叠加)" | cw.get("heal_minor", -1) == 5 and cw.get("gp_coin", -1) == 4 and cw.get("pierce_strike", -1) == 11 and cw.get("magic_bolt", -1) == 11 | (待填) | | | +| 426 | 244 | "域上界 v=50:守恒成立且逐行权重恒正(域 [0,50] 安全)" | eco._pool_weight(eco._biased_cell_rows(0)) == 77 and int(v50.get("heal_minor", -1)) > 0 | (待填) | | | +| 427 | 256 | "wr10①判定面:空订阅=%d / 装载后命中 %d 行 → luck_bias_for(4)=%d(期望 0/1/2;命中 0 即判据空转)" ↩ % [empty_bias, hit_rows, loaded_bias] | empty_bias == 0 and hit_rows == 1 and loaded_bias == 2 | (待填) | | | +| 428 | 258 | "wr10②接线面:组装点注入的查询口有效且与宿主查询口同源" | woc._reward_economy.luck_bias_query.is_valid() and woc._reward_economy.luck_bias_query == wfx.luck_bias_for | (待填) | | | +| 429 | 264 | "wr10③权重生效面:注入回调取回 %d → 偏运池合计 77+4*2(实测 %d)" ↩ % [wbias, woc._reward_economy._pool_weight(woc._reward_economy._biased_cell_rows(wbias))] | wbias == 2 and woc._reward_economy._pool_weight(woc._reward_economy._biased_cell_rows(wbias)) == 77 + 4 * 2 | (待填) | | | +| 430 | 276 | "确定性:同种子普通图布置/摇号两遍逐位相同(off 全链路)" | n1 == _board_resource_dump(normal2) and not n1.is_empty() | (待填) | | | + +## S64 _smoke_type_gate(行 25–29 · 2 条 · 源 game/tests/unit/contracts/source_guard_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 431 | 25 | "V7 类型闸 warning 配置在位(untyped_declaration=1 显形)〔迭代27 T5〕" | pj.contains("gdscript/warnings/untyped_declaration=1") | (待填) | | | +| 432 | 28 | "V7 类型审计器闸机在位(static_scan S9 → tools/audit_type_hints.py)〔迭代27 T5〕" | scan_src.contains("audit_type_hints.py") | (待填) | | | + +## S65 _smoke_hygiene_gates(行 56–77 · 6 条 · 源 game/tests/unit/contracts/source_guard_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 433 | 56 | "V8 字符串 Callable 产品码零命中(实测应 0——方法引用换装闸)〔迭代27 T6〕" | callables == 0 | (待填) | | | +| 434 | 58 | "V8 FeatsEngine 整文件退役(class_name 注销=文件删除)〔迭代27 T6〕" | not FileAccess.file_exists("res://core/feats_engine.gd") | (待填) | | | +| 435 | 61 | "V8 死代码抽查·_attune_row 零残留〔迭代27 T6〕" | not bo.contains("func _attune_row") | (待填) | | | +| 436 | 64 | "V8 死代码抽查·attunement_counters 零残留〔迭代27 T6〕" | not sm.contains("func attunement_counters") | (待填) | | | +| 437 | 67 | "V8 GP 影子账残留零命中(main 源码面——文件已删+委托缝已拆)〔迭代27 T3〕" | not mv.contains("gp_shadow_ledger") and not mv.contains("_gp_ledger") | (待填) | | | +| 438 | 76 | "V1d 层序落位单口(_apply_layer_contract 之外 move_child 调用点=%d 应=0)〔装配生命批 V1〕" ↩ % outside_calls | outside_calls == 0 | (待填) | | | + +## S66 _init(行 22–22 · 1 条 · 源 game/tests/unit/contracts/system_contracts_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 439 | 22 | "前置:表加载零 error" | load_ok and db.diagnostics_count("error") == 0 | (待填) | | | + +## S67 _test_damage_controller_vocabularies(行 43–57 · 4 条 · 源 game/tests/unit/contracts/system_contracts_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 440 | 43 | "五配方常量非空(开单面唯一分派键)" | nonempty | (待填) | | | +| 441 | 47 | "五配方常量互异(词表封闭无别名)" | unique_kinds.size() == kinds.size() | (待填) | | | +| 442 | 55 | "三目标规则常量互异(结账时刻现场解析词表)" | unique_targets.size() == targets.size() | (待填) | | | +| 443 | 56 | "双结账政策常量互异(A 即结 / B 攒批回切位)" | String(DamageControllerScript.POLICY_IMMEDIATE) != String(DamageControllerScript.POLICY_SEGMENT) | (待填) | | | + +## S68 _test_rng_tag_closure(行 64–67 · 2 条 · 源 game/tests/unit/contracts/system_contracts_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 444 | 64 | "settlement.armor_roll_stream ∈ RngService 封闭标签词表(EC12)" | RngServiceScript.PURPOSE_TAGS.has(stream_label) | (待填) | | | +| 445 | 66 | "RngService 用途标签词表非空且互异(封闭语义由流改标签)" | RngServiceScript.PURPOSE_TAGS.size() > 0 | (待填) | | | + +## S69 _test_ticket_nine_columns(行 83–85 · 3 条 · 源 game/tests/unit/contracts/system_contracts_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 446 | 83 | "单据九栏 + 可选两栏缺省齐备(开单面只写差异栏前提)" | all_present | (待填) | | | +| 447 | 84 | "缺省无类型轴(attack_type 空 = 恒不查克制矩阵)" | String(t.attack_type) == "" | (待填) | | | +| 448 | 85 | "缺省无前置依赖(requires = -1)" | int(t.requires) == -1 | (待填) | | | + +## S70 _test_status_boolean_acceptance(行 107–121 · 5 条 · 源 game/tests/unit/contracts/system_contracts_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 449 | 107 | "前置:状态表非空" | not any_row.is_empty() | (待填) | | | +| 450 | 112 | "域闭授予拒绝(EC1——战斗域外 apply_effect = false)" | effects.apply_effect(probe_id, src) == false | (待填) | | | +| 451 | 113 | "未知 effect_id 拒绝(EC2)" | effects.apply_effect("no_such_effect_id__", src) == false | (待填) | | | +| 452 | 115 | "开域后受理已知 id(布尔受理口径 true 通道)" | effects.apply_effect(probe_id, src) == true | (待填) | | | +| 453 | 121 | "受理后投影 effective_value = 层数×授予时快照(抗热重载 EC9 面)" | projected | (待填) | | | + +## S71 _smoke_adventure_api(行 61–82 · 11 条 · 源 game/tests/unit/core/adventure_feats_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 454 | 61 | "冒险启动受理(主种子注入恰一次——state=running;种子 42=首局固定种子生成图——精确路径断言需要确定图(卡② 代差))" | adr.start_adventure(42) | (待填) | | | +| 455 | 62 | "进行中重复 start_adventure 拒绝(重开须待结算完毕)" | not adr.start_adventure(43) | (待填) | | | +| 456 | 63 | "停点前移次序契约:level_started 先于入口候选呈现(反序选路口会被呈现层 runner 空防御丢弃)" | sig_seq.size() == 2 and String(sig_seq[0]) == "level_started" and String(sig_seq[1]) == "choices_presented" | (待填) | | | +| 457 | 64 | "入口停点:选路态 + 入口单候选 + 首战未开(呈现零 RNG 消耗——pick 前零战斗域)" | adr.runner().awaiting_choice() and (obs["entry_cands"] as Array).size() == 1 \\ and adr.runner().current_battle() == null | (待填) | | | +| 458 | 67 | "pick 入口候选受理(同一 _enter_node——与旧自动进入逐位等价)" | adr.runner().pick_next_node(int(adr.runner().next_candidate_ids()[0])) | (待填) | | | +| 459 | 68 | "pick 后 node_entered 到达 + 首战在飞(接口①开始关卡——停点闭合直达战斗节点)" | sig_seq.size() == 3 and String(sig_seq[2]) == "node_entered" and adr.runner().current_battle() != null | (待填) | | | +| 460 | 69 | "战斗中 abandon 拒绝(战斗域单事务在飞)" | not adr.abandon() | (待填) | | | +| 461 | 78 | "首战胜后选路态(非战斗期——current_battle 置空)" | adr.runner().current_battle() == null and adr.runner().awaiting_choice() | (待填) | | | +| 462 | 79 | "非战斗期 abandon 受理(选路态——同失败型结算)" | adr.abandon() | (待填) | | | +| 463 | 81 | "放弃结算:result=abandoned · cleared=false(已获面不追回——钥石累计 %d)" ↩ % int(sum.get("keystone_total", -1)) | String(sum.get("result", "")) == "abandoned" and not bool(sum.get("cleared", true)) | (待填) | | | +| 464 | 82 | "结算后重复 abandon 拒绝(结算受理只算一次——state=idle)" | not adr.abandon() | (待填) | | | + +## S72 _smoke_adventure(行 118–443 · 57 条 · 源 game/tests/unit/core/adventure_feats_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 465 | 118 | "序列关数 = 行集关数(三关正式序列生效)" | int(_db.level.rows.size()) == 3 | (待填) | | | +| 466 | 135 | "生成图每个战斗/Boss 节点经解析链命中敌行集与棋盘行(扫描 %d 节点,缺失 %d)〔fv24〕" ↩ % [cov_scan, cov_miss] | cov_scan > 0 and cov_miss == 0 | (待填) | | | +| 467 | 140 | "满配冒险成功(42 / atk+900 / hp+400——cleared=true;v2 怪阶梯后旧 200/100 档不再满配)" | String(st["result"]) == "completed" and bool(sum.get("cleared", false)) | (待填) | | | +| 468 | 141 | "完成几关 = 3(levels_cleared)" | int(sum.get("levels_cleared", -1)) == 3 | (待填) | | | +| 469 | 145 | "钥石合计 = ΣBoss 首杀行值(%d——逐杀掉落新口径;断点未注入恒首杀)" ↩ % int(sum.get("keystone_total", -1)) | int(sum.get("keystone_total", -1)) == ks_exp | (待填) | | | +| 470 | 147 | "逐杀掉落明细恰 3 笔(L1/L2/L3——additive 摘要键 keystone_drops)" | drops.size() == 3 | (待填) | | | +| 471 | 148 | "摘要携带主种子(42)" | int(sum.get("master_seed", -1)) == 42 | (待填) | | | +| 472 | 159 | "关卡完成三选一恰呈现 2 次、恰兑付 2 次(L1/L2 后——末关不发;resolves 含节点回执另计)" | picks.size() == 2 and resolves.size() == 2 + node_picks | (待填) | | | +| 473 | 170 | "三关生成图各含 ≥1 奖励节点(节点三选一候选位在图)〔v4〕" | reward_levels == 3 | (待填) | | | +| 474 | 174 | "奖励分支点选→三选一候选可取且停点在途(候选 %s 件)" ↩ % str((rw.get("cands", []) as Array).size()) | bool(rw.get("pending", false)) and (rw.get("cands", []) as Array).size() >= 1 | (待填) | | | +| 475 | 187 | "奖励三选一点选兑付受理(件=%s)" ↩ % String(rw_pc.get("kind", "")) | rw_pick_ok | (待填) | | | +| 476 | 193 | "进战载荷两样=关卡序号+节点深度(实测 depth=%s 句柄=%s)〔v6〕" ↩ % [str(enc_probe.get("node_depth", -1)), str(node_probe.get("id", -1))] | not enc_probe.is_empty() and int(enc_probe.get("node_depth", -1)) == int(node_probe.get("depth", -1)) and int(enc_probe.get("level_index", -1)) == 1 and int(node_probe.get("id", -1)) != int(node_probe.get("depth", -1)) | (待填) | | | +| 477 | 202 | "奖励节点兑付回执与经停逐笔相等(context=reward_node——同账计数 %d/%d)" ↩ % [node_resolves, node_picks] | node_resolves == node_picks | (待填) | | | +| 478 | 249 | "三选一候选 2–3 枚且零重复(令牌洗牌不放回——kind+tier 复合身份)" | cand_ok | (待填) | | | +| 479 | 250 | "关卡完成部分回血封顶(floor(观测上限×比例) 封顶——购血观测值精确断言,核代码增补)" | heal_ok | (待填) | | | +| 480 | 251 | "钥石逐杀入账(首杀档位 + 累计逐位)" | ks_gain_ok | (待填) | | | +| 481 | 252 | "点选恰兑付选中一件(值=令牌档位表值;未选项零生效)" | resolve_ok | (待填) | | | +| 482 | 263 | "各战上限入参 ∈ [基础, 基础+3×件值+购血余量](覆盖值通道——双渠道共享上限 3;模拟档贪心购血合法叠加〔数值修复批 v3.4〕;首战=基础 %d)" ↩ % exp_base | seq_ok | (待填) | | | +| 483 | 266 | "同种子两局逐位可复现(summary/上限序列/行进序列/三选一载荷——reward_pick 封闭流)" | st2["summary"] == sum and st2["encounter_max_hp_seq"] == seq and st2["visited_ids"] == st["visited_ids"] and st2["reward_events"] == st["reward_events"] | (待填) | | | +| 484 | 276 | "零配 L1 败北种子可达(seed=%s)" ↩ % str(lose.get("s", -1)) | not lose.is_empty() | (待填) | | | +| 485 | 279 | "L1 败北摘要:未打穿 · 完成 0 关 · 钥石 0 · 止步第 1 层" | not bool(sl["cleared"]) and int(sl["levels_cleared"]) == 0 \\ and int(sl["keystone_total"]) == 0 and int(sl["stopped_at"]) == 1 | (待填) | | | +| 486 | 283 | "L1 败北败退明细:层号/节点编号/节点类型在案(数值验证取数面 2026-09-22)" | int(dl.get("level_index", -1)) == 1 and int(dl.get("node_id", -1)) >= 0 \\ and String(dl.get("node_type", "")) in ["battle", "boss"] | (待填) | | | +| 487 | 292 | "半配(atk+50)L2 败北种子可达(seed=%s)" ↩ % str(half.get("s", -1)) | not half.is_empty() | (待填) | | | +| 488 | 295 | "L2 败北摘要:L1 已过(钥石=L1 首杀掉落)· 止步第 2 层" | not bool(sh["cleared"]) and int(sh["levels_cleared"]) == 1 \\ and int(sh["keystone_total"]) == int(_db.meta_boss_keystone_row(1).get("first_kill", 0)) \\ and int(sh["stopped_at"]) == 2 | (待填) | | | +| 489 | 300 | "L2 败北败退明细:层号=2 · 节点在案(数值验证取数面 2026-09-22)" | int(dh.get("level_index", -1)) == 2 and int(dh.get("node_id", -1)) >= 0 \\ and String(dh.get("node_type", "")) in ["battle", "boss"] | (待填) | | | +| 490 | 306 | "重复拒绝场景:冒险启动(seed 7——生成图确定同上)" | adr6.start_adventure(7) | (待填) | | | +| 491 | 314 | "重复拒绝场景就位:选路态放弃 → 已结算" | adr6.abandon() | (待填) | | | +| 492 | 317 | "重复关卡结束宣布拒绝(账本与摘要零变化——契约 7)" | adr6.summary == sum6_before | (待填) | | | +| 493 | 327 | "每战完成入账恰一次(gain %d = battles %d——GP 袋件入账单列)" ↩ % [gains, int(st["battles"])] | gains == int(st["battles"]) | (待填) | | | +| 494 | 328 | "钱包 clear 恰一次且为末事件(关卡收束不清——GP 冒险收束清零归冒险结构)" | clears == 1 and wallet.size() > 0 and String(wallet[wallet.size() - 1]["event"]) == "clear" | (待填) | | | +| 495 | 330 | "三关商店房各到达一次(每关入口→终点路径恰穿一店的驱动投影)且首达余额 > 0(%d——GP 跨关延续)" ↩ % (shop_seq[0] if shop_seq.size() > 0 else -1) | shop_seq.size() == 3 and int(shop_seq[0]) > 0 | (待填) | | | +| 496 | 341 | "同账场景种子可达(seed=%s——L2 商店房就位)" ↩ % str(same.get("s", -1)) | not same.is_empty() | (待填) | | | +| 497 | 354 | "进店余额 ≥ hp_up L2 首价(%d = 基价 %d + 关卡加成 %d——配置推导)" ↩ % [hp_price + l2_price_bonus, hp_price, l2_price_bonus] | gp0 >= hp_price + l2_price_bonus | (待填) | | | +| 498 | 355 | "购买 hp_up 受理(出账 + 加算区)" | r8.buy_product("hp_up") | (待填) | | | +| 499 | 357 | "统一冒险加算区含商店值(max_hp=%d ≥ 商店 3——L1 三选一件值另计)" ↩ % acc_shop | acc_shop >= 3 | (待填) | | | +| 500 | 360 | "L2 关卡完成三选一停点在案(直调兑付前——零自动推进)〔拆件批 V2〕" | not a8.stop()._pending.is_empty() | (待填) | | | +| 501 | 361 | "未兑付推进拒绝(选择停点零自动推进契约)" | not a8.continue_to_next_level() | (待填) | | | +| 502 | 364 | "三选一点选兑付受理(候选取首件)" | a8.stop().pick_curio(String(l2_pick.get("kind", "")), String(l2_pick.get("tier", "")), String(l2_pick.get("feat_id", ""))) | (待填) | | | +| 503 | 367 | "三选一属性件与商店购买同账累加(max_hp=%d = %d + 三选一%s)" ↩ % [a8.journal().bonus_total("max_hp"), acc_shop, picked2] | a8.journal().bonus_total("max_hp") == expect_acc | (待填) | | | +| 504 | 369 | "关卡完成部分回血封顶(hp %d ≤ 生效上限 %d——血回满语义退役)" ↩ % [a8.stats().hero_hp, max_eff8] | a8.stats().hero_hp <= max_eff8 | (待填) | | | +| 505 | 370 | "兑付后推进受理(下一关开始)" | a8.continue_to_next_level() | (待填) | | | +| 506 | 373 | "恢复点数学:floor(100×30%)=30 · 封顶缺口 5 · 满血回 0 合法" | int(a9.apply_rest(30, 70, 100)) == 30 and int(a9.apply_rest(30, 95, 100)) == 5 \\ and int(a9.apply_rest(30, 100, 100)) == 0 | (待填) | | | +| 507 | 377 | "同一公式两个落点逐位一致(LevelRunner.rest_heal_amount 内置默认)" | int(lr9.rest_heal_amount(30, 70, 100)) == 30 and int(lr9.rest_heal_amount(30, 95, 100)) == 5 | (待填) | | | +| 508 | 381 | "放弃场景:冒险启动(seed 15——生成图确定同上)" | a10.start_adventure(15) | (待填) | | | +| 509 | 382 | "放弃场景:入口停点 pick 进首战(停点前移批 2026-09-23 驱动口径)" | a10.runner().pick_next_node(int(a10.runner().next_candidate_ids()[0])) | (待填) | | | +| 510 | 383 | "战斗中 abandon 拒绝(战斗域单事务在飞)" | not a10.abandon() | (待填) | | | +| 511 | 390 | "非战斗期(选路态)abandon 受理" | a10.runner().current_battle() == null and a10.abandon() | (待填) | | | +| 512 | 394 | "剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 wide_scout_band)" | a11.start_adventure(11, {"treasure_unlocked": {"wide_scout_band": true}}) | (待填) | | | +| 513 | 403 | "池耗即枯竭(attack/ap 令牌耗尽不出候选——%s)" ↩ % str(draw) | not draw_kinds.has("attack") and not draw_kinds.has("ap") | (待填) | | | +| 514 | 404 | "池耗后候选恒 ≥2(稀有档余量——防「三选项全不可选」死角)" | (draw as Array).size() >= 2 | (待填) | | | +| 515 | 413 | "池收缩——40 次重抽零 aux 候选(停点渠道 aux 卡退役,迭代 11 第①批 T7)" | not treasure_seen | (待填) | | | +| 516 | 422 | "满血关卡完成回血 0 合法(封顶口径——heal_gained=0 非负溢出)" | int(cap["payload"].get("heal_gained", -1)) == 0 | (待填) | | | +| 517 | 425 | "节点共享池场景:冒险启动(seed 13)" | a13.start_adventure(13, {"treasure_unlocked": {"wide_scout_band": true}}) | (待填) | | | +| 518 | 436 | "奖励节点共享池:令牌耗尽同样不出候选(同库同池——%s)" ↩ % str(ncands13) | not has_attack13 | (待填) | | | +| 519 | 440 | "奖励节点 GP 件入账(钱包「奖励入账」收口——+%d)" ↩ % int(_db.adventure.curio_value("gp", "common")) | a13.stop().apply_node_curio("gp", "common") and a13.wallet().gp == gp13 + int(_db.adventure.curio_value("gp", "common")) | (待填) | | | +| 520 | 441 | "节点停点态兑付后清零(恰一次语义同构)" | a13.stop()._node_pending.is_empty() | (待填) | | | +| 521 | 443 | "放弃摘要:abandoned · 未打穿(已获大奖励不追回——同失败型结算)" | String(s10.get("result", "")) == "abandoned" and not bool(s10.get("cleared", true)) | (待填) | | | + +## S73 _smoke_feats(行 454–766 · 68 条 · 源 game/tests/unit/core/adventure_feats_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 522 | 454 | "feat_table 已加载(30 件=基础 27+迁入 2+卡② 完美保险 1——迭代 31 卡② 换代)" | _db.feats != null and (_db.feats.rows as Array).size() == 30 | (待填) | | | +| 523 | 455 | "feat_table.validate 零违例(四级 rarity/cap 域/trigger 词表/行键白名单全过)" | _db.feats.validate().is_empty() | (待填) | | | +| 524 | 457 | "专长带场景:冒险启动(seed 71)" | a_r0.start_adventure(71) | (待填) | | | +| 525 | 465 | "专长带入池:30 件四级直映射(普通 12/优秀 9/稀有 9/传说 0)——牌库 102+30=132〔专长批 V2+卡② 完美保险迁入〕" | feat_tokens == 30 and int(tier_counts.get("common", 0)) == 12 and int(tier_counts.get("excellent", 0)) == 9 and int(tier_counts.get("rare", 0)) == 9 and a_r0._curio.deck.size() == 132 | (待填) | | | +| 526 | 467 | "旧 27 件已退役:arcane_ember/magma_body 表内零残留〔专长批 V2〕" | _db.feats.row_by_id("arcane_ember").is_empty() and _db.feats.row_by_id("magma_body").is_empty() | (待填) | | | +| 527 | 468 | "迁出收尾:treasure_items 无 venom_edge/seal_burst 行(同调目录自然收窄)〔专长批 V13〕" | _db.treasure_items.row_by_id("venom_edge").is_empty() and _db.treasure_items.row_by_id("seal_burst").is_empty() | (待填) | | | +| 528 | 475 | "层级纪律:牌库 kind 全集=CURIO_KINDS+relic(秘宝 item_id 零入池)〔专长批 V18〕" | kinds_ok | (待填) | | | +| 529 | 481 | "棋盘事实不可变:新 30 行零棋盘改写类订阅(rewrite_board 已除名消费面)〔专长批 V19〕" | rewrite_rows == 0 | (待填) | | | +| 530 | 488 | "对外命名纪律:30 行 display_name/desc 零「雷/旗」字面(封印/陷阱风味系)〔专长批 V21 初筛〕" | bad_words == 0 | (待填) | | | +| 531 | 489 | "数值纪律:陷阱爆发倍率>1 · 血蜜回复<典型陷阱伤 · 石肤上限 3 在册(结构锚形态内 provisional)〔专长批 V22 机检半〕" | float((_db.feats.row_by_id("trap_burst").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("mult", 0)) > 1.0 and int((_db.feats.row_by_id("blood_honey").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("value", 99)) < 5 and int((_db.feats.row_by_id("stone_skin").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("cap", 0)) == 3 | (待填) | | | +| 532 | 507 | "新 schema 构造表 validate 零违例(四级 rarity/cap 字段/词表全过——流派标签已退役零 archetype)" | rt.validate().is_empty() | (待填) | | | +| 533 | 511 | "专长带场景:冒险启动(seed 72——注入 5 行临时表)" | a_r.start_adventure(72) | (待填) | | | +| 534 | 516 | "专长带入池:5 行 relic 每件 1 张(四级 rarity 直映射)——牌库 102+5=107" | deck_relic == 5 and a_r._curio.deck.size() == 107 | (待填) | | | +| 535 | 527 | "关卡完成渠道只出专长(60 次重抽 ≥2 种不同 relic——身份键 feat_id 同档可多件)" | feat_seen.size() >= 2 | (待填) | | | +| 536 | 528 | "节点渠道零专长(60 次重抽零 relic——专长在非 Boss 渠道只以专长凭证形态出现)" | not node_relic_seen | (待填) | | | +| 537 | 536 | "A5 专长凭证兑付受理(节点渠道——拿到即开)" | a_t.stop().apply_node_curio("feat_ticket", "excellent") | (待填) | | | +| 538 | 542 | "A5 拿到即开——停点候选已替换为专长三选一(全 relic)" | tkt_all_relic | (待填) | | | +| 539 | 543 | "A5 零摘牌——专长凭证兑付不动令牌库(不进持有集)" | a_t.deck().pool_stock("attack") == int(stock_before["attack"]) and a_t.deck().pool_stock("gp") == int(stock_before["gp"]) | (待填) | | | +| 540 | 544 | "A5 无专长凭证残留持有集" | not (a_t.feats_held() as Array).has("feat_ticket") | (待填) | | | +| 541 | 547 | "A5 专长面兑付受理(专长凭证开出候选可正常入持有集)" | (a_t.feats_held() as Array).size() == feats_n + 1 | (待填) | | | +| 542 | 552 | "A6 保底触发(pity=5 → 第 6 次展示必出——概率无关)" | a_t.deck().feat_ticket_consider(rg_t) | (待填) | | | +| 543 | 553 | "A6 触发后清零" | a_t.stop()._deck.ticket_pity == 0 | (待填) | | | +| 544 | 556 | "A6 限购满不再触发(保底冻结)" | not a_t.deck().feat_ticket_consider(rg_t) | (待填) | | | +| 545 | 564 | "A6 概率面存在(chance=20% 200 展示内必出现——零触发=概率面失守)" | offered | (待填) | | | +| 546 | 566 | "relic 兑付:入持有集 + 牌库摘牌(选中踢池——池库存 %d→%d)" ↩ % [deck_relic, deck_relic - 1] | a_r.stop().pick_curio("relic", "rare", "x3") and (a_r.feats_held() as Array).has("x3") and a_r.deck().pool_stock("relic") == deck_relic - 1 | (待填) | | | +| 547 | 590 | "双管镐:第 %d 次付费挖格返 1 AP(每第 2 次——mod_eq 预评估口径)" ↩ % (i + 1) | refund | (待填) | | | +| 548 | 592 | "双管镐反例:第 %d 次不返(未满 2 次)" ↩ % (i + 1) | not refund | (待填) | | | +| 549 | 596 | "地质学家:首开新类型集 1 点〔专长批 V3〕" | orch._hits_of(geo_hits, "counter_add").size() == 1 | (待填) | | | +| 550 | 600 | "地质学家:满 4 种(预评估现值 3+本格)→揭示 1 格·计数清零〔专长批 V3〕" | orch._hits_of(geo_hits2, "reveal_tiles").size() == 1 and EffectEngine.counter(orch.fx_host().counters_ctx, "geo_types") == 0 | (待填) | | | +| 551 | 602 | "地质学家反例:同类型重复首开不叠点〔专长批 V3〕" | orch._hits_of(geo_hits3, "counter_add").is_empty() | (待填) | | | +| 552 | 606 | "安全帽:开安全格攒层 ×2〔专长批 V3〕" | EffectEngine.counter(orch.fx_host().counters_ctx, "helm_layers") == 2 | (待填) | | | +| 553 | 612 | "安全帽:踩雷耗层减伤=层值 2(一次清零随施加面 set 行)〔专长批 V3〕" | hh_reduce == 2 | (待填) | | | +| 554 | 616 | "跳格采掘:空白 0 格攒 1 点〔专长批 V3〕" | orch._hits_of(sk_zero, "counter_add").size() == 1 | (待填) | | | +| 555 | 620 | "跳格采掘:满 3 点→揭示 1 格〔专长批 V3〕" | orch._hits_of(sk_full, "reveal_tiles").size() == 1 | (待填) | | | +| 556 | 621 | "跳格采掘反例:普通开格不计点〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {"is_blank_zero": false}), "counter_add").is_empty() | (待填) | | | +| 557 | 628 | "爆破会计:本轮挖格花费≥5→+1 GP〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "round_end", {}), "grant_gp").size() == 1 | (待填) | | | +| 558 | 630 | "边陲矿工:边缘格产出 +1·中心格反例零产出〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {"is_edge_cell": true}), "grant_material").size() == 1 and orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "dig_reveal", {"is_edge_cell": false}), "grant_material").is_empty() | (待填) | | | +| 559 | 638 | "封印回响:标中计数≥2→轮末返 1 AP(下轮 queue)〔专长批 V3〕" | orch._hits_of(se_hits, "grant_ap").size() == 1 and String((orch._hits_of(se_hits, "grant_ap")[0] as Dictionary).get("mode", "")) == "queue" | (待填) | | | +| 560 | 647 | "封印匠:标中每第 3 次→中立移除恰一次(频率闸死)〔专长批 V3〕" | sm_remove == 1 | (待填) | | | +| 561 | 649 | "封印桩:标中本轮 AC +N(round 域——round_start 清零)〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}), "ac_bonus").size() == 1 and String((orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}), "ac_bonus")[0] as Dictionary).get("mode", "")) == "round" | (待填) | | | +| 562 | 653 | "示警烟火:标中揭示 3×3 资源类型(不揭安危位置)〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}), "preview_area").size() == 1 | (待填) | | | +| 563 | 662 | "疼痛记忆:攻击附加层值伤害=层值 2·用后清零(set 行随施加面)〔专长批 V3〕" | pm_bonus == 2 | (待填) | | | +| 564 | 669 | "保险丝反例:单次 <5 不攒点不触发〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "damage_taken", {"kind": "terrain", "amount": 3}), "shield").is_empty() | (待填) | | | +| 565 | 673 | "保险丝:首点不出盾(2 点才出——预评估口径)〔专长批 V3〕" | fw_shield == 0 | (待填) | | | +| 566 | 675 | "见血开窍:踩雷揭示 1 格〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {}), "reveal_tiles").size() == 1 | (待填) | | | +| 567 | 682 | "陷阱本能:踩雷本轮攻击 +2(attack_bonus 通道——面板加算)〔专长批 V3〕" | ti_val == 2 | (待填) | | | +| 568 | 687 | "连环陷阱:同轮第 2 颗起每颗额外 +1 层(疼痛记忆同账)〔专长批 V3〕" | EffectEngine.counter(orch.fx_host().counters_ctx, "pain_layers") == ct_before + 2 | (待填) | | | +| 569 | 690 | "陷阱爆发:全体敌雷伤×倍率直伤(blast_scaled——施加面值=damage×mult)〔专长批 V3〕" | orch._hits_of(tb_hits, "blast_scaled").size() == 1 and float((orch._hits_of(tb_hits, "blast_scaled")[0] as Dictionary).get("mult", 0)) > 1.0 | (待填) | | | +| 570 | 694 | "血蜜:踩雷回复 N 生命〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {}), "heal").size() == 1 | (待填) | | | +| 571 | 697 | "石肤:受伤 AC+1(本场域 cap 3——达上限不再涨由累积器封顶)〔专长批 V3〕" | orch._hits_of(ss_hits, "ac_bonus").size() == 1 and int((orch._hits_of(ss_hits, "ac_bonus")[0] as Dictionary).get("cap", 0)) == 3 | (待填) | | | +| 572 | 701 | "背水:生命≤50% 减伤·过半血反例零减伤〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().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.fx_host().counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 2, "hp_ratio": 80}), "damage_reduce").is_empty() | (待填) | | | +| 573 | 706 | "破封一击:挖开带封印危险格 +1 层·无封印反例零触发〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {"dug_under_seal": true}), "counter_add").size() == 1 and orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {"dug_under_seal": false}), "counter_add").is_empty() | (待填) | | | +| 574 | 714 | "弃图兑换:停手剩余 AP≥3 入账〔专长批 V3〕" | orch._hits_of(me_hits, "counter_add").size() == 1 | (待填) | | | +| 575 | 718 | "弃图兑换:折为攻击伤害=剩余 AP 全额·用后清零〔专长批 V3〕" | me_atk == 4 | (待填) | | | +| 576 | 719 | "弃图兑换反例:剩余 AP<3 不触发〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "dig_stop", {"ap_left": 2}), "counter_add").is_empty() | (待填) | | | +| 577 | 722 | "一滴不剩:上轮 AP 耗尽→+1 AP〔专长批 V3〕" | orch._hits_of(ld_hits, "grant_ap").size() == 1 | (待填) | | | +| 578 | 723 | "一滴不剩反例:上轮有余不发〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "round_start", {"round": 3, "ap_exhausted_last": false}), "grant_ap").is_empty() | (待填) | | | +| 579 | 729 | "收盘:本轮开格≥8→+1 GP〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "round_end", {}), "grant_gp").size() == 1 | (待填) | | | +| 580 | 732 | "斩首学者:击杀揭示 1 格〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "kill", {"target": "敌", "overflow": 0}), "reveal_tiles").size() == 1 | (待填) | | | +| 581 | 735 | "处刑台:敌血量≤30% 挂单位标记加伤·阈值外反例零命中〔专长批 V3〕" | orch._hits_of(ex_hits, "target_attack_bonus").size() == 1 and orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "enemy_hp_threshold", {"unit": "e1", "pct": 60, "is_boss": false}), "target_attack_bonus").is_empty() | (待填) | | | +| 582 | 739 | "战地包扎:战斗胜利回复 N 生命(battle_won 新挂点)〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "battle_won", {"round": 2}), "heal").size() == 1 | (待填) | | | +| 583 | 742 | "血税:双向同局账本(踩陷阱−2max_hp/封印命中+1atk——bonus_delta 战末汇入)〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {}), "bonus_delta").size() == 1 and String((orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}), "bonus_delta")[0] as Dictionary).get("axis", "")) == "atk" | (待填) | | | +| 584 | 747 | "淬毒刃纹(迁入):克制正面(magic_vs_heavy 矩阵值>0)追加·中性面反例零触发〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "clamp_check", {"matchup": "magic_vs_heavy"}), "modify_ticket").size() == 1 and orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "clamp_check", {"matchup": "normal_vs_light"}), "modify_ticket").is_empty() | (待填) | | | +| 585 | 751 | "封印爆裂(迁入):标中全体敌群伤〔专长批 V3〕" | orch._hits_of(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}), "open_ticket").size() == 1 | (待填) | | | +| 586 | 755 | "零持有订阅=零命中(relic 关着零扰动——伤害管线既有行为逐位不变)" | orch0.fx_host().fx_emit("attack_commit", {"atk": 10, "target": "敌", "matchup": "", "dig_streak": 0, "nest_count": 0}).is_empty() and orch0.fx_host().fx_emit("dig_reveal", {"cell": Vector2i(1, 1), "round": 1, "is_first_dig_of_battle": true, "terrain_kind": []}).is_empty() and orch0.fx_host().fx_emit("mine_hit", {"damage": 5}).is_empty() and orch0.fx_host().fx_emit("battle_won", {"round": 1}).is_empty() | (待填) | | | +| 587 | 763 | "特性段空态隐藏(视图空态纪律同特性段)" | not panel._feat_section.visible | (待填) | | | +| 588 | 765 | "特性段持有呈现(逐件行常驻可读——「局内奖励选了什么看不到」缺口承载)" | panel._feat_section.visible and panel._feat_list.get_child_count() == 2 | (待填) | | | +| 589 | 766 | "特性卡文案表值化:desc 行字段直读(零硬编码映射)" | String(load("res://presentation/battle_narrator.gd").feat_card_text({"feat_id": "pain_memory", "display_name": "疼痛记忆", "desc": "触陷阱攒 1 层"})).contains("触陷阱攒 1 层") | (待填) | | | + +## S74 _smoke_layout_cell_formula(行 65–68 · 2 条 · 源 game/tests/unit/core/board_gen_reward_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 590 | 65 | "L%d %dx%d 板网格高 %d ≤ 可用高 %d − 预留 24(格边 %dpx——下缘不贴边不裁切)" ↩ % [L, b.width, b.height, grid_h, int(avail.y), int(cell)] | grid_h <= int(avail.y) - 24 | (待填) | | | +| 591 | 67 | "L%d 格边在 26–46 档内(%dpx)" ↩ % [L, int(cell)] | cell >= 26.0 and cell <= 46.0 | (待填) | | | + +## S75 _smoke_reward_identity_unit(行 83–116 · 10 条 · 源 game/tests/unit/core/board_gen_reward_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 592 | 83 | "roll_board 受理" | reward_economy.roll_board(eligible) | (待填) | | | +| 593 | 84 | "重复 roll_board 被拒(恰一次——double-roll 守卫)" | not reward_economy.roll_board(eligible) | (待填) | | | +| 594 | 95 | "query_identity 投影 = {reward_id, cell_type, tier} 且五型封闭(样本 %s)" ↩ % str(types_seen.keys()) | types_ok | (待填) | | | +| 595 | 97 | "清单外坐标查询 = null(封锁格无身份同口径)" | reward_economy.query_identity(Vector2i(99, 99)) == null | (待填) | | | +| 596 | 101 | "同格重复 grant 幂等(同一快照——受理恰一次)" | JSON.stringify(snap1) == JSON.stringify(snap2) | (待填) | | | +| 597 | 102 | "清单外 grant 拒绝" | reward_economy.grant(Vector2i(99, 99)) == null | (待填) | | | +| 598 | 106 | "清图批尾身份 = clear_blast(clear_default 唯一活行)" | String(cs1.cell_type) == "blast" and String(cs1.reward_id) == "clear_blast" | (待填) | | | +| 599 | 111 | "清图奖励 power ∈ 表区间 [%d, %d](roll 值 %d——配置推导)" ↩ % [cb_range[0], cb_range[1], int(cs1.power)] | int(cs1.power) >= int(cb_range[0]) and int(cs1.power) <= int(cb_range[1]) | (待填) | | | +| 600 | 113 | "grant_clear 同批次重调幂等" | JSON.stringify(cs1) == JSON.stringify(cs2) | (待填) | | | +| 601 | 116 | "映射退役后 grant 拒绝(生命周期闭合)" | reward_economy.grant(eligible[0].cell) == null | (待填) | | | + +## S76 _smoke_board_generator(行 135–210 · 24 条 · 源 game/tests/unit/core/board_gen_reward_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 602 | 135 | "BoardGen 行解析 (%d,0)" ↩ % L | not row.is_empty() | (待填) | | | +| 603 | 141 | "BoardGen 精确命中 (2,3)(覆盖批键域补齐)" | int(exact_row.get("node_depth", -1)) == 3 | (待填) | | | +| 604 | 151 | "R1 ceil 能力不失(夹具摘除 (2,3)→向上取整到 4·偏难口径)" | int(ceil_row.get("node_depth", -1)) == 4 | (待填) | | | +| 605 | 153 | "夹具表原样还原(行数与解析回到覆盖批现值)" | _db.board_gen.rows.size() == rows_before and int(_db.resolve_board_gen(2, 3).get("node_depth", -1)) == 3 | (待填) | | | +| 606 | 156 | "L%d 生成产物非空" ↩ % L | b1 != null | (待填) | | | +| 607 | 159 | "L%d clearable(安全格 8 邻连通)" ↩ % L | board_generator.is_clearable(b1) | (待填) | | | +| 608 | 160 | "L%d legality 全过" ↩ % L | board_generator.legality(b1, row) | (待填) | | | +| 609 | 167 | "L%d inferability %.3f %s target %.2f(accept_mode=%s)" ↩ % [L, inf, "≥" if inf >= float(row.inferability_target) else "<", float(row.inferability_target), mode] | inf >= float(row.inferability_target) or mode == "accept_best" | (待填) | | | +| 610 | 169 | "L%d 生成器产物零变体格(变异布置归战斗初始化第 4 步——生成器管线不布置,2026-09-19 用户拍板棋盘设置顺序)" ↩ % L | b1.variants.is_empty() | (待填) | | | +| 611 | 170 | "L%d 指标集输出降级标记与目标雷数(三分出口表)" ↩ % L | b1.metrics.has("degraded_product") and b1.metrics.has("target_mines") | (待填) | | | +| 612 | 171 | "L%d 降级产物标记 = (accept_mode==accept_best)(%s→%s)" ↩ % [L, mode, str(b1.degraded_product)] | b1.degraded_product == (mode == "accept_best") | (待填) | | | +| 613 | 173 | "L%d 非法产物标记 = (accept_mode==accept_worst)(%s→%s)" ↩ % [L, mode, str(b1.illegal_product)] | b1.illegal_product == (mode == "accept_worst") | (待填) | | | +| 614 | 175 | "L%d 降级/非法产物标记互斥(两出口不可同真)" ↩ % L | not b1.degraded_product or not b1.illegal_product | (待填) | | | +| 615 | 177 | "L%d 产物断言:实际雷数 %d ≤ 目标雷数 N=%d(先布后删只减不增)" ↩ % [L, int(b1.metrics.actual_mines), int(b1.metrics.target_mines)] | int(b1.metrics.actual_mines) <= int(b1.metrics.target_mines) | (待填) | | | +| 616 | 179 | "L%d attempts_used ∈ [1,%d]" ↩ % [L, int(row.max_gen_attempts)] | b1.metrics.get("attempts_used", 99) >= 1 and b1.metrics.get("attempts_used", 99) <= int(row.max_gen_attempts) | (待填) | | | +| 617 | 181 | "L%d 非法产物标记 = false" ↩ % L | not b1.illegal_product | (待填) | | | +| 618 | 185 | "L%d 同种子两次生成逐格相同" ↩ % L | _board_dump(b2) == _board_dump(b3) | (待填) | | | +| 619 | 187 | "L%d 异种子产物不同(退化护栏)" ↩ % L | _board_dump(b2) != _board_dump(b4) | (待填) | | | +| 620 | 193 | "L%d r0_max_cap 正式行投值在位(%d ≥ 出生保障面 9——板面 30%% 锚)" ↩ % [L, cap_val] | cap_val >= BoardData.SPAWN_AREA_CELLS | (待填) | | | +| 621 | 195 | "L%d 结构断言 R₀=%d ≤ cap=%d(R₀≤cap 重掷闸承载)" ↩ % [L, int(b1.metrics.r0_count), cap_val] | int(b1.metrics.r0_count) <= cap_val | (待填) | | | +| 622 | 197 | "L%d r0_cap_rejected 尝试事实指标在位(本图 %d 次触发)" ↩ % [L, int(b1.metrics.get("r0_cap_rejected", -1))] | int(b1.metrics.get("r0_cap_rejected", -1)) >= 0 | (待填) | | | +| 623 | 199 | "L%d ring_removed=%d ≤ 剃雷域候选危险格数((3+2r)²−9——全域剃雷退役)" ↩ % [L, int(b1.metrics.get("ring_removed", 99))] | int(b1.metrics.get("ring_removed", 99)) <= (2 + int(row.get("shave_ring_radius", 1))) * (2 + int(row.get("shave_ring_radius", 1))) * 4 - 5 | (待填) | | | +| 624 | 207 | "L1 cap 压线 9:出图 R₀=%d %s(三分出口诚实降级——超阈即 accept_worst 非法标记,玩家永不卡死)" ↩ % [int(b9.metrics.r0_count), "≤9" if int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS else ">9→illegal_product"] | b9 != null and (int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS or b9.illegal_product) | (待填) | | | +| 625 | 209 | "L1 cap 压线出图指标集完整(accept_mode=%s / 闸触发 %d 次)" ↩ % [String(b9.metrics.get("accept_mode", "")), int(b9.metrics.get("r0_cap_rejected", 0))] | int(b9.metrics.get("r0_cap_rejected", 0)) >= 0 and String(b9.metrics.get("accept_mode", "")) != "" | (待填) | | | + +## S77 _smoke_power_interval(行 240–298 · 5 条 · 源 game/tests/unit/core/board_gen_reward_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 626 | 240 | "区间行配对齐全且 power = floor 中点(配置推导 %d 行:%s)" ↩ % [interval_rows.size(), str(interval_rows.keys())] | anchors_ok and interval_rows.size() >= 1 | (待填) | | | +| 627 | 282 | "24 板全区间行快照 power ∈ [power_min, power_max](含清图槽)" | in_domain | (待填) | | | +| 628 | 283 | "定值行快照 power 恒等表值——区间批零外溢" | fixed_ok | (待填) | | | +| 629 | 297 | "区间行上下阶实际流动(≥2 样本的区间行须 ≥2 个不同 roll 值)" | variance_ok | (待填) | | | +| 630 | 298 | "定值行(lo=hi)roll 恒等表值" | fixed_rows_ok | (待填) | | | + +## S78 _init(行 17–17 · 1 条 · 源 game/tests/unit/core/board_generator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 631 | 17 | "数值表加载" | false | (待填) | | | + +## S79 _test_generate_shape_and_mines(行 42–55 · 5 条 · 源 game/tests/unit/core/board_generator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 632 | 42 | "生成返回棋盘" | false | (待填) | | | +| 633 | 47 | "棋盘尺寸与 board_gen 表一致(%dx%d)" ↩ % [want_w, want_h] | b.width == want_w and b.height == want_h | (待填) | | | +| 634 | 49 | "雷数非零且不超过总格数" | b.mines.size() > 0 and b.mines.size() < b.width * b.height | (待填) | | | +| 635 | 54 | "全部危险格在界内" | all_in_bounds | (待填) | | | +| 636 | 55 | "生成期指标集已落盘(metrics 非空)" | not b.metrics.is_empty() | (待填) | | | + +## S80 _test_target_mine_count_single_source(行 75–83 · 3 条 · 源 game/tests/unit/core/board_generator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 637 | 75 | "target_mine_count 与原式同参同值(%d 档对拍全等)〔wd14〕" ↩ % checked | checked > 0 and bad == 0 | (待填) | | | +| 638 | 78 | "生成返回棋盘(雷数单点段)" | false | (待填) | | | +| 639 | 81 | "产物断言读数 = target_mine_count 同源(metrics.target_mines)〔wd14〕" | int(b.metrics.get("target_mines", -1)) == BoardGeneratorScript.target_mine_count({ "w": int(row1.board_width), "h": int(row1.board_height), "density": float(row1.mine_density)}) | (待填) | | | + +## S81 _test_digits_equal_neighbor_mine_count(行 89–103 · 2 条 · 源 game/tests/unit/core/board_generator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 640 | 89 | "生成返回棋盘(数字段)" | false | (待填) | | | +| 641 | 103 | "每个安全格的数字恒等于八邻雷数(全盘逐格复算,错 %d 格)" ↩ % bad | bad == 0 | (待填) | | | + +## S82 _test_same_seed_bit_identical(行 110–113 · 3 条 · 源 game/tests/unit/core/board_generator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 642 | 110 | "生成返回棋盘(确定性段)" | false | (待填) | | | +| 643 | 112 | "同种子生成的雷集逐位一致" | str(a.mines) == str(b.mines) | (待填) | | | +| 644 | 113 | "同种子生成的数字集逐位一致" | str(a.digits) == str(b.digits) | (待填) | | | + +## S83 _test_clearable_and_inferability_selfconsistent(行 119–126 · 3 条 · 源 game/tests/unit/core/board_generator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 645 | 119 | "生成返回棋盘(可推理段)" | false | (待填) | | | +| 646 | 122 | "可推理性度量返回非空指标集" | not m.is_empty() | (待填) | | | +| 647 | 125 | "清图判定与安全格总数同在(安全格 %d 个,可清=%s)" ↩ % [safe_total, str(clearable)] | safe_total > 0 | (待填) | | | + +## S84 _test_all_links_valid(行 59–59 · 1 条 · 源 game/tests/unit/core/config_db_cross_validator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 648 | 59 | "全对齿的技能池零错(正向面——实际报错 %s)" ↩ % str(_cats(errs)) | errs.is_empty() | (待填) | | | + +## S85 _test_dangling_skill_id(行 70–72 · 2 条 · 源 game/tests/unit/core/config_db_cross_validator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 649 | 70 | "池内悬空 skill_id 被报(跨表护栏不静默放过)" | not errs.is_empty() | (待填) | | | +| 650 | 71 | "悬空项的错误类别可定位(category 非空)" | not errs.is_empty() and String((errs[0] as Dictionary).get("category", "")) != "" | (待填) | | | + +## S86 _test_cast_side_mismatch(行 84–84 · 1 条 · 源 game/tests/unit/core/config_db_cross_validator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 651 | 84 | "buff 挂主角侧状态被报(施放类 side 守卫)" | _cats(errs).has("cast_side") | (待填) | | | + +## S87 _test_band_hit(行 58–62 · 2 条 · 源 game/tests/unit/core/config_db_resolver_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 652 | 58 | "带命中取偏难一侧:请求深度 4 命中带 5(逐字不回带 0)〔fv1〕" | hit.size() == 1 and String((hit[0] as Dictionary).get("enemy_id", "")) == "hobgoblin" | (待填) | | | +| 653 | 61 | "精确落带:请求深度 5 命中带 5〔fv1〕" | exact.size() == 1 and String((exact[0] as Dictionary).get("enemy_id", "")) == "hobgoblin" | (待填) | | | + +## S88 _test_rowset_order_preserved(行 67–72 · 2 条 · 源 game/tests/unit/core/config_db_resolver_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 654 | 67 | "行集返回同带全部行(2 行)〔fv2〕" | rows.size() == 2 | (待填) | | | +| 655 | 71 | "行集按表文件行序返回(goblin 先于 bugbear——行序即契约)〔fv2〕" | ids == ["goblin", "bugbear"] | (待填) | | | + +## S89 _test_top_band_clamp(行 78–87 · 3 条 · 源 game/tests/unit/core/config_db_resolver_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 656 | 78 | "越顶带 clamp 取顶带行集(battle 顶带 5)〔fv3〕" | rows.size() == 1 and String((rows[0] as Dictionary).get("enemy_id", "")) == "hobgoblin" | (待填) | | | +| 657 | 84 | "越顶带发 warning resolve_depth_clamp〔fv3〕" | clamp_hit | (待填) | | | +| 658 | 87 | "同键重复请求警告去重(四元组注册表)〔fv3〕" | _diags.size() == warn_before | (待填) | | | + +## S90 _test_row_kind_routing(行 93–95 · 1 条 · 源 game/tests/unit/core/config_db_resolver_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 659 | 93 | "行类路由:同请求深度 battle 与 boss 命中不同键域〔fv4〕" | battle_rows.size() == 2 and boss_rows.size() == 1 and String((boss_rows[0] as Dictionary).get("enemy_id", "")) == "ogre_chief" | (待填) | | | + +## S91 _test_request_oob(行 101–107 · 3 条 · 源 game/tests/unit/core/config_db_resolver_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 660 | 101 | "关卡序号 <1 → fatal request_oob〔fv5〕" | _has_fatal_oob() | (待填) | | | +| 661 | 104 | "节点深度 <0 → fatal request_oob〔fv5〕" | _has_fatal_oob() | (待填) | | | +| 662 | 107 | "row_kind 非法 → fatal request_oob〔fv5〕" | _has_fatal_oob() | (待填) | | | + +## S92 _test_difficulty_clamp(行 119–122 · 3 条 · 源 game/tests/unit/core/config_db_resolver_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 663 | 119 | "难度乘数越域被 clamp:hp 99 → 上限 10" | absf(float(m.get("hp_mult", 0.0)) - 10.0) < 0.001 | (待填) | | | +| 664 | 120 | "难度乘数越域被 clamp:atk 0.01 → 下限 0.1" | absf(float(m.get("atk_mult", 0.0)) - 0.1) < 0.001 | (待填) | | | +| 665 | 121 | "未登记坐标返回空修饰(零值不伪装成 ×1)" | _resolver.resolve_difficulty_modifiers(3, 3).is_empty() | (待填) | | | + +## S93 _test_field_in_table(行 106–114 · 4 条 · 源 game/tests/unit/core/level_map_type_vis_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 666 | 106 | "N 落表:level.tres 读数 2(实际 %d)〔fv6〕" ↩ % n_in_table | n_in_table == 2 | (待填) | | | +| 667 | 110 | "N=1 合法:validate 零错〔fv6〕" | t.validate().is_empty() | (待填) | | | +| 668 | 112 | "N 越域 0:validate 判 domain 错〔fv6〕" | _has_n_domain_err(t.validate()) | (待填) | | | +| 669 | 114 | "N 越域 -1:validate 判 domain 错〔fv6〕" | _has_n_domain_err(t.validate()) | (待填) | | | + +## S94 _test_layer_distance(行 131–133 · 3 条 · 源 game/tests/unit/core/level_map_type_vis_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 670 | 131 | "层距 N 内可见:锚层 1 取节点 4(层距 2)〔fv1〕" | vis.has(4) | (待填) | | | +| 671 | 132 | "层距 N+1 不可见:锚层 1 排节点 5(层距 3)〔fv1〕" | not vis.has(5) | (待填) | | | +| 672 | 133 | "层距 N+2 不可见:锚层 1 排节点 6(层距 4)〔fv1〕" | not vis.has(6) | (待填) | | | + +## S95 _test_candidate_exempt(行 142–143 · 2 条 · 源 game/tests/unit/core/level_map_type_vis_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 673 | 142 | "候选恒显:跨层跳边候选(层距 N+1)仍在可见集〔fv4〕" | vis.has(3) | (待填) | | | +| 674 | 143 | "候选豁免不改层规则:同锚点非候选节点 2(层距 4)不可见〔fv4〕" | not vis.has(2) | (待填) | | | + +## S96 _test_boss_exempt(行 152–155 · 4 条 · 源 game/tests/unit/core/level_map_type_vis_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 675 | 152 | "入口停点锚层 0:层距 N 内(节点 2)可见〔fv1〕" | vis.has(2) | (待填) | | | +| 676 | 153 | "入口停点锚层 0:层距 N+2(节点 4)不可见〔fv1〕" | not vis.has(4) | (待填) | | | +| 677 | 154 | "Boss 恒显:层距 6 的末位 Boss 仍在可见集〔fv3〕" | vis.has(7) | (待填) | | | +| 678 | 155 | "Boss 豁免不扩散:同层距的非 Boss 节点 6 不在可见集〔fv3〕" | not vis.has(6) | (待填) | | | + +## S97 _test_single_circle(行 165–167 · 3 条 · 源 game/tests/unit/core/level_map_type_vis_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 679 | 165 | "单圈回雾:已过远端节点 0(层距 5)不因 visited 豁免〔fv7〕" | not vis.has(0) | (待填) | | | +| 680 | 166 | "单圈回雾:已过中距节点 2(层距 4)不可见〔fv7〕" | not vis.has(2) | (待填) | | | +| 681 | 167 | "单圈只按锚层取窗:已过近端节点 4(层距 2)仍可见〔fv7〕" | vis.has(4) | (待填) | | | + +## S98 _test_single_chain_entry(行 176–179 · 3 条 · 源 game/tests/unit/core/level_map_type_vis_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 682 | 176 | "单链退化形态:入口锚层 0,句柄 2 可见〔fv1〕" | vis.has(2) | (待填) | | | +| 683 | 177 | "单链退化形态:层距 N+1(句柄 3)不可见〔fv1〕" | not vis.has(3) | (待填) | | | +| 684 | 178 | "单链编号空间 = 图句柄 id:Boss 以句柄 6 入集(链上层距 4 的战斗节点 4 入雾)〔fv1〕" | vis.has(6) and not vis.has(4) | (待填) | | | + +## S99 _test_n_from_table(行 188–196 · 3 条 · 源 game/tests/unit/core/level_map_type_vis_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 685 | 188 | "N 读表:N=1 时节点 4(层距 2)出窗〔fv6〕" | not r.type_visible_ids().has(4) | (待填) | | | +| 686 | 190 | "N 读表:N=3 时节点 5(层距 3)入窗〔fv6〕" | r.type_visible_ids().has(5) | (待填) | | | +| 687 | 196 | "N 读表:N ≥ 全图层高 → 全部非豁免节点可见〔fv6〕" | all_in | (待填) | | | + +## S100 _test_resume_rebuild(行 234–235 · 1 条 · 源 game/tests/unit/core/level_map_type_vis_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 688 | 234 | "续玩重建同值:resume 路径与同停点直入 type_visible_ids() 逐位一致〔fv7〕" | resumed.type_visible_ids() == direct.type_visible_ids() | (待填) | | | + +## S101 _smoke_table_migration(行 262–269 · 4 条 · 源 game/tests/unit/core/level_shop_economy_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 689 | 262 | "shop.relic_base_price 表值 == 迁移前字面量(15.0)〔wd11〕" | _db.shop.relic_base_price == 15.0 | (待填) | | | +| 690 | 263 | "shop.spend_discount 表值 == 迁移前字面量(0.8)〔wd11〕" | _db.shop.spend_discount == 0.8 | (待填) | | | +| 691 | 264 | "hero.display_name 表值 == 迁移前字面量(探险者)〔wd16〕" | _db.hero.display_name == "探险者" | (待填) | | | +| 692 | 268 | "spawn 后英雄单位 display_name 读表同值(探险者)〔wd16〕" | spawned and String(units.hero_unit().get("display_name", "")) == String(_db.hero.display_name) | (待填) | | | + +## S102 _smoke_generated_coverage(行 283–320 · 6 条 · 源 game/tests/unit/core/level_shop_economy_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 693 | 283 | "L%d 参数档缺行(覆盖断言无源可扫)〔v11〕" ↩ % L | false | (待填) | | | +| 694 | 290 | "L%d 种子 %d 生成图为空(覆盖扫描面空转)〔v11〕" ↩ % [L, seed_val] | false | (待填) | | | +| 695 | 317 | "生成图覆盖扫描面非空(三关参数档×种子 %d–%d 共 %d 个节点读数)〔v11〕" ↩ % [SWEEP_SEED_FROM, SWEEP_SEED_TO - 1, scanned] | scanned > 0 | (待填) | | | +| 696 | 318 | "生成图每个 battle/boss 节点深度的敌侧解析落带 ≥ 请求深度(band_of 非 -1·顶 clamp 消解禁)〔v11〕" | enemy_ok | (待填) | | | +| 697 | 319 | "生成图每个节点深度在 board_gen 精确命中(非空且 node_depth 等值)〔v11〕" | board_ok | (待填) | | | +| 698 | 320 | "多终点 Boss 各终点解析带键两两互异(L2/L3×20 种子)〔v11〕" | boss_band_ok | (待填) | | | + +## S103 _smoke_level(行 331–387 · 17 条 · 源 game/tests/unit/core/level_shop_economy_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 699 | 331 | "L1 现行起关图取数非空(种子 %d × 参数档 → 节点 %d 颗)" ↩ % [ACTIVE_GRAPH_SEED, nodes1.size()] | not nodes1.is_empty() | (待填) | | | +| 700 | 334 | "关卡·满配(atk+200/hp+100)完成(实测:%s @节点 %d)" ↩ % [win.result, win.defeat_node_index] | win.result == "victory" | (待填) | | | +| 701 | 342 | "L1 关卡完成路径逐跳为图出边(入口起步·末节点无出边——句柄轴行进自洽)" | _path_in_graph(nodes1, win.visited_ids) | (待填) | | | +| 702 | 343 | "L1 关卡完成路径战斗/Boss 节点数 = 行进 battles 读数(路径 %d 节点·战斗 %d 颗·battles %d)" ↩ % [win.visited_ids.size(), path_battles, int(win.battles)] | path_battles == int(win.battles) and path_battles >= 2 | (待填) | | | +| 703 | 353 | "L1 终点 Boss 深度 = 参数档层高 %d 且关卡完成路径末节点落该深度(实测终点深度 %d·末句柄 %d)" ↩ % [int(params1.get("layers", -1)), boss_depth1, last_id] | boss_depth1 == int(params1.get("layers", -1)) and int(by_id1.get(last_id, {}).get("depth", -1)) == boss_depth1 | (待填) | | | +| 704 | 355 | "L1 终点恰一个(大岔口自 L2 起的图侧投影)" | _terminal_ids(nodes1).size() == 1 and _ids_of_type(nodes1, "boss").size() == 1 | (待填) | | | +| 705 | 356 | "L1 生成图含岔口(出度 ≥2 节点 %d 处)" ↩ % _fork_ids(nodes1).size() | _fork_ids(nodes1).size() >= 1 | (待填) | | | +| 706 | 357 | "进 Boss HP 携带在域内(%d)" ↩ % win.hp_at_boss | win.hp_at_boss >= 1 and win.hp_at_boss <= int(_db.hero.max_hp) * 3 | (待填) | | | +| 707 | 363 | "恢复点公式无源(现行图入口后一位无可直达的恢复候选)" | false | (待填) | | | +| 708 | 366 | "恢复点公式·缺口充分档:回复 = floor(生效max_hp×%d%%) = %d(生效 max %d,实测 %d)" ↩ % [pct1, expect_pct, int(rest_wide["max"]), int(rest_wide["healed"])] | int(rest_wide["healed"]) == expect_pct and expect_pct > 0 | (待填) | | | +| 709 | 370 | "恢复点公式缺口封顶档无源(同图二次驱动未达恢复候选)" | false | (待填) | | | +| 710 | 372 | "恢复点公式·缺口不足档:回复封顶到缺口 2(实测 %d,比例项 %d)" ↩ % [int(rest_narrow["healed"]), expect_pct] | int(rest_narrow["healed"]) == mini(expect_pct, 2) and expect_pct > 2 | (待填) | | | +| 711 | 376 | "关卡·裸装(atk≈1/hp≈10)败北路径可达(实测:%s)" ↩ % lose.result | lose.result == "defeat" | (待填) | | | +| 712 | 377 | "败北节点记录在域内(节点 %d,图节点数 %d)" ↩ % [lose.defeat_node_index, nodes1.size()] | lose.defeat_node_index >= 0 and lose.defeat_node_index < nodes1.size() | (待填) | | | +| 713 | 382 | "关卡回放断言:同种子两遍结算步骤流逐行相同" | JSON.stringify(rep1.rows) == JSON.stringify(rep2.rows) | (待填) | | | +| 714 | 383 | "关卡步骤流非空(%d 行)" ↩ % rep1.rows.size() | rep1.rows.size() > 0 | (待填) | | | +| 715 | 385 | "模拟后 hero 表恢复基线(atk=%d hp=%d ac=%d——对配置快照)" ↩ % [int(_db.hero.atk), int(_db.hero.max_hp), int(_db.hero.armor_class)] | int(_db.hero.atk) == int(hero_base.atk) and int(_db.hero.max_hp) == int(hero_base.hp) \\ and int(_db.hero.armor_class) == int(hero_base.ac) | (待填) | | | + +## S104 _smoke_level_graph(行 442–761 · 70 条 · 源 game/tests/unit/core/level_shop_economy_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 716 | 442 | "LevelTable 行级校验零错误(图合法性移交 validate_graph)" | _db.level.validate().is_empty() | (待填) | | | +| 717 | 446 | "L2 现行起关图取数非空(种子 %d × 参数档 → 节点 %d 颗)" ↩ % [ACTIVE_GRAPH_SEED, nodes2.size()] | not nodes2.is_empty() | (待填) | | | +| 718 | 449 | "L2 节点数 %d ≥ 层高 %d+1 且 = 句柄去重读数 %d(全员句柄唯一)" ↩ % [nodes2.size(), int(params2.get("layers", -1)), _nodes_by_id(nodes2).size()] | nodes2.size() >= int(params2.get("layers", -1)) + 1 and _nodes_by_id(nodes2).size() == nodes2.size() | (待填) | | | +| 719 | 451 | "L2 五型计数合计 = 节点数(battle %d + boss %d + reward %d + rest %d + shop %d = %d)" ↩ % [tcount["battle"], tcount["boss"], tcount["reward"], tcount["rest"], tcount["shop"], nodes2.size()] | int(tcount["battle"]) + int(tcount["boss"]) + int(tcount["reward"]) + int(tcount["rest"]) + int(tcount["shop"]) == nodes2.size() | (待填) | | | +| 720 | 453 | "L2 战斗节点数 %d ≥ 参数档战斗保底 %d 且 Boss 节点数 %d = 参数档出口数 %d" ↩ % [tcount["battle"], int(params2.get("battle_floor", -1)), tcount["boss"], int(params2.get("exit_count", -1))] | int(tcount["battle"]) >= int(params2.get("battle_floor", -1)) and int(tcount["boss"]) == int(params2.get("exit_count", -1)) | (待填) | | | +| 721 | 455 | "L2 恢复/奖励/商店节点各 ≥1(实测 rest %d · reward %d · shop %d——计数由逐节点扫描同一读数)" ↩ % [tcount["rest"], tcount["reward"], tcount["shop"]] | int(tcount["rest"]) >= 1 and int(tcount["reward"]) >= 1 and int(tcount["shop"]) >= 1 | (待填) | | | +| 722 | 457 | "L2 岔口(出度 ≥2 的节点)= %d 处(≥1)" ↩ % branch | branch >= 1 | (待填) | | | +| 723 | 467 | "图校验正例:两岔合流小图零错误〔v1〕" | LT.validate_graph(good).is_empty() | (待填) | | | +| 724 | 470 | "图校验拒绝:句柄重复(图内唯一句柄)〔v1〕" | _has_err_category(LT.validate_graph(dup), "dup_key") | (待填) | | | +| 725 | 473 | "图校验正例:多入口合法(入口唯一判据随模板池退役)〔v1〕" | LT.validate_graph(dual).is_empty() | (待填) | | | +| 726 | 476 | "图校验拒绝:Boss 带出边(终点不可外出)〔v1〕" | _has_err_category(LT.validate_graph(boss_out), "structural") | (待填) | | | +| 727 | 479 | "图校验拒绝:回边 1→0(目标深度须严格大于源)〔v1〕" | _has_err_category(LT.validate_graph(cyc), "structural") | (待填) | | | +| 728 | 482 | "图校验拒绝:出边指向不存在句柄(悬空致命)〔v1〕" | _has_err_category(LT.validate_graph(dang), "dangling_next") | (待填) | | | +| 729 | 488 | "图校验拒绝:非 Boss 终点(终点恒为 Boss)〔v1〕" | _has_err_category(LT.validate_graph(orphan), "structural") | (待填) | | | +| 730 | 491 | "L2·满配 min 路径完成(实测:%s)" ↩ % win_min.result | win_min.result == "victory" | (待填) | | | +| 731 | 496 | "L2 min 路径逐跳为图出边·末节点为 Boss(实测路径 %s·末句柄型别 %s)" ↩ % [str(win_min.visited_ids), n2_last_type] | _path_in_graph(nodes2, win_min.visited_ids) and n2_last_type == "boss" | (待填) | | | +| 732 | 498 | "L2 min 路径终点深度 = 参数档层高 %d(行进读数 %d——终点落最长路径步数)" ↩ % [int(params2.get("layers", -1)), int(win_min.boss_depth)] | int(win_min.boss_depth) == int(params2.get("layers", -1)) | (待填) | | | +| 733 | 500 | "L2 min 路径穿商店恰 1 家(路径句柄 %s 型别计数 = 1——必经恰一店的行进面)" ↩ % str(win_min.visited_ids) | _path_shop_count(nodes2, win_min.visited_ids) == 1 | (待填) | | | +| 734 | 502 | "选路口发生 %d 次(每节点兑付后——零自动推进)" ↩ % win_min.choices | win_min.choices >= 3 | (待填) | | | +| 735 | 504 | "L2·满配 max 路径(候选取最大句柄)完成(实测:%s·路径 %s)" ↩ % [win_max.result, str(win_max.visited_ids)] | win_max.result == "victory" | (待填) | | | +| 736 | 509 | "L2 max 路径逐跳为图出边·末节点为 Boss·穿商店恰 1 家" | _path_in_graph(nodes2, win_max.visited_ids) and n2_last_max_type == "boss" and _path_shop_count(nodes2, win_max.visited_ids) == 1 | (待填) | | | +| 737 | 512 | "L2 max 与 min 路径分岔(方向轴生效——两路终点句柄 %d / %d 不同)" ↩ % [n2_last, n2_last_max] | n2_last != n2_last_max and win_max.visited_ids != win_min.visited_ids | (待填) | | | +| 738 | 519 | "手动驱动:L2 启动(入口战斗直接开打——入口无前置兑付;图 = 参数档 × 种子 %d)" ↩ % ACTIVE_GRAPH_SEED | runner.start_level(2, ACTIVE_GRAPH_SEED) | (待填) | | | +| 739 | 526 | "首战胜利后:settlement 释放 + 进选路态(战斗胜利不再自动推进——接口漂移清偿)" | String(res_box.get("result", "")) == "victory" and runner.current_battle() == null and runner.awaiting_choice() | (待填) | | | +| 740 | 529 | "runner 消费面图 = 生成面图(节点数 %d/%d 且句柄集逐位相同)" ↩ % [rnodes.size(), nodes2.size()] | rnodes.size() == nodes2.size() and _ids_of_type(rnodes, "battle").size() + _ids_of_type(rnodes, "boss").size() + _ids_of_type(rnodes, "reward").size() \\ + _ids_of_type(rnodes, "rest").size() + _ids_of_type(rnodes, "shop").size() == nodes2.size() and _nodes_by_id(rnodes) == _nodes_by_id(nodes2) | (待填) | | | +| 741 | 537 | "入口选路口候选非空且全在图内(候选 %s·类型 %s)" ↩ % [str(cand), str(cand_types)] | not cand.is_empty() and not cand_types.has("<不在图>") | (待填) | | | +| 742 | 538 | "候选下一跳类型两两互异(岔口去路类型不重复契约——实测 %s)" ↩ % str(cand_types) | cand_types.size() == _uniq(cand_types).size() | (待填) | | | +| 743 | 545 | "候选外句柄 %d 拒绝且仍待选(拒绝语义)" ↩ % outside_id | outside_id >= 0 and not runner.pick_next_node(outside_id) and runner.awaiting_choice() | (待填) | | | +| 744 | 555 | "入口岔口含奖励位与战斗位(句柄 %d/%d——候选型别可定位)" ↩ % [reward_cand, battle_cand] | reward_cand >= 0 and battle_cand >= 0 | (待填) | | | +| 745 | 556 | "选奖励候选 %d 受理" ↩ % reward_cand | runner.pick_next_node(reward_cand) | (待填) | | | +| 746 | 557 | "到达奖励点:直通兑付(确认前不出选路口)" | String(runner.current_node().get("type", "")) == "reward" and not runner.awaiting_choice() | (待填) | | | +| 747 | 559 | "兑付确认后才出现选路口(continue_after_node 泛化语义)候选 = 该点图出边 %s" ↩ % str(reward_outs) | runner.continue_after_node() and runner.awaiting_choice() and _sorted_ids(runner.next_candidate_ids()) == _sorted_ids(reward_outs) | (待填) | | | +| 748 | 566 | "选下一跳战斗候选 %d 开战" ↩ % next_battle | next_battle >= 0 and runner.pick_next_node(next_battle) and runner.current_battle() != null | (待填) | | | +| 749 | 591 | "首遇位零踩雷:完美档 12 种子(节点 0 熊地精)恒零踩雷" | green_ok | (待填) | | | +| 750 | 592 | "首遇战胜局 ≥7/12(实际 %d——阶段拔高后锚行退出绿档,多数胜口径)" ↩ % green_wins | green_wins >= 7 | (待填) | | | +| 751 | 593 | "首遇战胜后 HP 下探读数 %d(>0——绿档记录)" ↩ % green_min_hp | green_min_hp > 0 | (待填) | | | +| 752 | 597 | "跨表覆盖扫描面非空:L2 现行图战斗/Boss 节点 %d 颗" ↩ % int(cov2["nodes"]) | int(cov2["nodes"]) >= 1 | (待填) | | | +| 753 | 598 | "L2 现行图每战斗/Boss 节点深度敌侧解析落带 ≥ 请求深度(违例 %d 处)" ↩ % int(cov2["enemy_bad"]) | int(cov2["enemy_bad"]) == 0 | (待填) | | | +| 754 | 599 | "L2 现行图每节点深度 board_gen 精确命中(非空且 node_depth 等值·违例 %d 处)" ↩ % int(cov2["board_bad"]) | int(cov2["board_bad"]) == 0 | (待填) | | | +| 755 | 607 | "巨蜘蛛首遇带(2,battle,%d——扫表取最低带)行集 = {giant_spider} 恰一行且 unit_count=2 无 unit_count_max(固定 2 只——首遇教学位不加骰)〔fv22〕" ↩ % l2_spider_band | l2_spider_band >= 0 and l2_spider_ids.has("giant_spider") and l2_spider_ids.size() == 1 and l2_spider_set.size() == 1 \\ and int(l2_spider_set[0].get("unit_count", -1)) == 2 and not l2_spider_set[0].has("unit_count_max") | (待填) | | | +| 756 | 617 | "L2 全部终点深度 %s 的 Boss 行集均 = {drider} 恰一行且首行魔法甲(护甲类型重配批:关卡 2 唯一非轻甲;P4 乙 Boss 行集多行合法——零内容期仍单行)〔fv24〕" ↩ % str(l2_terms) | l2_boss_ok | (待填) | | | +| 757 | 624 | "首批混编行集 (2,battle,5) = 双行且行序 [bugbear 重击手 → giant_spider 巢群](R6 行序契约——文件序 = 声明序 = 生成序,首行默认目标 = 前排坦克;迭代 18 卡⑤投放)〔fv18〕" | mix_shape_ok | (待填) | | | +| 758 | 628 | "首批混编行集 (2,battle,5) 静态最坏骰和 = Σ(unit_count_max,固定行缺省=unit_count) = %d ≤ 4(行集实例总数守卫同口径——重击手固定 1 + 巢群 [2,3])〔fv19〕" ↩ % mix_sum_max | mix_sum_max <= 4 | (待填) | | | +| 759 | 629 | "首批混编新行 (2,battle,5,giant_spider) 行值 = 复制巢群行(P4 甲零数值新增——2-3 只/hp 26/atk [1,2]/light/ac 2;既有 19 行现值零变更)" | int(l2_mix_set[1].get("unit_count", 0)) == 2 and int(l2_mix_set[1].get("unit_count_max", 0)) == 3 \\ and int(l2_mix_set[1].get("hp", 0)) == 26 and int(l2_mix_set[1].get("armor_class", -1)) == 2 | (待填) | | | +| 760 | 636 | "L3 现行起关图取数非空(种子 %d × 参数档 → 节点 %d 颗)" ↩ % [ACTIVE_GRAPH_SEED, nodes3.size()] | not nodes3.is_empty() | (待填) | | | +| 761 | 639 | "L3 节点数 %d ≥ 层高 %d+1 且 = 句柄去重读数 %d(全员句柄唯一)" ↩ % [nodes3.size(), int(params3.get("layers", -1)), _nodes_by_id(nodes3).size()] | nodes3.size() >= int(params3.get("layers", -1)) + 1 and _nodes_by_id(nodes3).size() == nodes3.size() | (待填) | | | +| 762 | 641 | "L3 五型计数合计 = 节点数(battle %d + boss %d + reward %d + rest %d + shop %d = %d)" ↩ % [tcount3["battle"], tcount3["boss"], tcount3["reward"], tcount3["rest"], tcount3["shop"], nodes3.size()] | int(tcount3["battle"]) + int(tcount3["boss"]) + int(tcount3["reward"]) + int(tcount3["rest"]) + int(tcount3["shop"]) == nodes3.size() | (待填) | | | +| 763 | 643 | "L3 战斗节点数 %d ≥ 参数档战斗保底 %d 且 Boss 节点数 %d = 参数档出口数 %d" ↩ % [tcount3["battle"], int(params3.get("battle_floor", -1)), tcount3["boss"], int(params3.get("exit_count", -1))] | int(tcount3["battle"]) >= int(params3.get("battle_floor", -1)) and int(tcount3["boss"]) == int(params3.get("exit_count", -1)) | (待填) | | | +| 764 | 645 | "L3 恢复/奖励/商店节点各 ≥1(实测 rest %d · reward %d · shop %d——计数由逐节点扫描同一读数)" ↩ % [tcount3["rest"], tcount3["reward"], tcount3["shop"]] | int(tcount3["rest"]) >= 1 and int(tcount3["reward"]) >= 1 and int(tcount3["shop"]) >= 1 | (待填) | | | +| 765 | 647 | "L3 岔口(出度 ≥2 的节点)= %d 处(≥1)" ↩ % branch3 | branch3 >= 1 | (待填) | | | +| 766 | 648 | "L3 入口数 %d = 参数档 entry_count · 终点数 %d = exit_count ≥2(多终点大岔口自 L2 起)" ↩ % [_entry_ids(nodes3).size(), _terminal_ids(nodes3).size()] | _entry_ids(nodes3).size() == int(params3.get("entry_count", -1)) and _terminal_ids(nodes3).size() == int(params3.get("exit_count", -1)) and _terminal_ids(nodes3).size() >= 2 | (待填) | | | +| 767 | 652 | "跨表覆盖扫描面非空:L3 现行图战斗/Boss 节点 %d 颗" ↩ % int(cov3["nodes"]) | int(cov3["nodes"]) >= 1 | (待填) | | | +| 768 | 653 | "L3 现行图每战斗/Boss 节点深度敌侧解析落带 ≥ 请求深度(违例 %d 处)" ↩ % int(cov3["enemy_bad"]) | int(cov3["enemy_bad"]) == 0 | (待填) | | | +| 769 | 654 | "L3 现行图每节点深度 board_gen 精确命中(非空且 node_depth 等值·违例 %d 处)" ↩ % int(cov3["board_bad"]) | int(cov3["board_bad"]) == 0 | (待填) | | | +| 770 | 661 | "灰矮人首遇带(3,battle,%d——扫表取最低带)行集 = {duergar} 恰一行、首行重甲、固定 1 只不加骰〔fv23〕" ↩ % l3_lead_band | l3_lead_band >= 0 and l3_lead_ids.has("duergar") and l3_lead_ids.size() == 1 \\ and String(l3_lead_set[0].get("armor_type", "")) == "heavy" \\ and int(l3_lead_set[0].get("unit_count", -1)) == 1 and not l3_lead_set[0].has("unit_count_max") | (待填) | | | +| 771 | 672 | "L3 全部终点深度 %s 的 Boss 行集均 = {duergar_king} 恰一行且首行重甲(重甲收束「轻甲→魔法甲→重甲」教学梯度)〔fv24〕" ↩ % str(l3_terms) | l3_boss_ok | (待填) | | | +| 772 | 695 | "L3 首遇位绿档:成长代理档口径(atk_pct=200/hp_pct=100)完美档 12 种子首遇战(节点 0 灰矮人)全胜且零踩雷" | green3_ok | (待填) | | | +| 773 | 696 | "L3 首遇战胜后 HP 下探读数 %d(>0——绿档记录)" ↩ % green3_min_hp | green3_min_hp > 0 | (待填) | | | +| 774 | 706 | "混编行集 spawn 受理((1,battle,5) 双行 地精1+熊地精2——定值无区间路径,行集构成纪律放开面正向)" | mix_sys.spawn(1, 5, int(_db.hero.max_hp)) | (待填) | | | +| 775 | 708 | "混编实例总数 = 各行数量求和(1+2=3——行集实例总数 ≤4 守卫内)" | mix_units.size() == 3 | (待填) | | | +| 776 | 709 | "混编生成序 = 文件行序(行序即契约——地精在前熊地精在后,handle_id 跨行连续派发)" | String(mix_units[0].get("enemy_id")) == "goblin" and String(mix_units[1].get("enemy_id")) == "bugbear" and String(mix_units[2].get("enemy_id")) == "bugbear" | (待填) | | | +| 777 | 710 | "混编各行行值独立投影(display_name/hp/atk 按各自行值——同场不同种不同值)" | String(mix_units[0].get("display_name")) == "地精" and int(mix_units[1].get("hp")) == 20 and int(mix_units[1].get("atk")) == 3 | (待填) | | | +| 778 | 714 | "行集实例总数守卫:两行各定值 3(总 6 > 4)spawn 被拒(行集构成纪律③——R3 fail-fast 运行面)" | not over_sys.spawn(1, 6, int(_db.hero.max_hp)) | (待填) | | | +| 779 | 719 | "混编断言段注入行全量还原(表回 28 行——迭代 35 生成图深度扩面覆盖批后的基线行数;首批混编投放基线 20 行+覆盖 8 行)" | int(_db.enemy.rows.size()) == 28 | (待填) | | | +| 780 | 725 | "可见性冒烟:L2 入口停点就位(defer_entry——无当前节点,锚层 0)" | vis_ok and vis_runner.awaiting_choice() and vis_runner.current_node_id() == -1 | (待填) | | | +| 781 | 731 | "可见性冒烟:入口停点候选集 ⊆ 类型可见集(下一跳恒可见)〔fv4〕" | vis_cand_in | (待填) | | | +| 782 | 736 | "可见性冒烟:L2 全部终点 Boss 句柄 %s 恒在可见集(终点不入雾)〔fv3〕" ↩ % str(_ids_of_type(nodes2, "boss")) | vis_boss_ok | (待填) | | | +| 783 | 738 | "可见性冒烟:N 读数据表 = %d(provisional 2——代码零硬编码)〔fv6〕" ↩ % vis_n | vis_n == 2 | (待填) | | | +| 784 | 760 | "可见性冒烟:入口锚层 0 时层距 ≤ %d 的节点全在可见集(扫描面 %d 颗·违例 0)〔fv1〕" ↩ % [vis_n, near_count] | near_count >= 1 and near_ok | (待填) | | | +| 785 | 761 | "可见性冒烟:入口锚层 0 时层距 > %d 的非终点节点全入雾(扫描面 %d 颗·违例 0)〔fv1〕" ↩ % [vis_n, fog_count] | fog_count >= 1 and fog_ok | (待填) | | | + +## S105 _smoke_gp_wallet(行 798–834 · 9 条 · 源 game/tests/unit/core/level_shop_economy_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 786 | 798 | "GP 到账:L1 启动(普通战档)" | runner.start_level(1, 42) | (待填) | | | +| 787 | 808 | "L1 首战胜利(battle_finished gp_dropped 载荷在案)" | fin.has("p") and String(fin["p"].get("result", "")) == "victory" | (待填) | | | +| 788 | 811 | "L1 普通战 gp_dropped − escrow ∈ [2+0, 4+0](掷定+关级加值 %d,escrow %+d)" ↩ % [gp_dropped - escrow_sum, escrow_sum] | gp_dropped - escrow_sum >= 2 and gp_dropped - escrow_sum <= 4 | (待填) | | | +| 789 | 816 | "钱包恰入账一次(gain 事件 ×%d)" ↩ % gains.size() | gains.size() == 1 | (待填) | | | +| 790 | 818 | "入账金额 = gp_dropped 载荷(%d)" ↩ % gp_dropped | int(gains[0]["amount"]) == gp_dropped | (待填) | | | +| 791 | 819 | "钱包读数 = 入账后投影(gp=%d,破财负值场景经 clamp ≥0)" ↩ % runner.gp | runner.gp == maxi(0, gp_dropped) and runner.gp == int(gains[0]["gp"]) | (待填) | | | +| 792 | 826 | "L2 Boss 战启动((2,9)——Boss 档掷定 [6,10] + 关级加值 1)" | boss.begin_battle({"level_index": 2, "node_depth": 9, "node_type": "boss", "hero_hp": int(_db.hero.max_hp)}) | (待填) | | | +| 793 | 831 | "L2 Boss 战终局(%s——gp_dropped 于战斗末掷定上抛)" ↩ % String(boss.battle_result) | fin_b.has("p") | (待填) | | | +| 794 | 834 | "L2 Boss 战 gp_dropped − escrow ∈ [6+1, 10+1](掷定+关级加值 %d,escrow %+d)" ↩ % [gp_b - escrow_b, escrow_b] | gp_b - escrow_b >= 7 and gp_b - escrow_b <= 11 | (待填) | | | + +## S106 _smoke_gp_escrow(行 872–936 · 12 条 · 源 game/tests/unit/core/level_shop_economy_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 795 | 872 | "GP 袋(gp 型安全格)可挖掘局种子可达(seed=%s)" ↩ % str(scan.get("seed", -1)) | not scan.is_empty() | (待填) | | | +| 796 | 885 | "GP局战斗终局(先手 999 收束)" | settlement2.battle_result == "victory" | (待填) | | | +| 797 | 890 | "GP 袋兑付行在案(GP 袋 +3 → 战斗内缓冲,origin 指回来源格)" | gain_ok | (待填) | | | +| 798 | 891 | "escrow 累计缓冲 ≥3(实测 %d——战斗域中间态,不入钱包不入增益容器)" ↩ % int(settlement2.reward_grant().escrow) | int(settlement2.reward_grant().escrow) >= 3 | (待填) | | | +| 799 | 893 | "格面兑付计入 gp_dropped(%d − escrow %d ∈ [2,4]——战斗末一次到账,随上抛即弃)" ↩ % [gp_d, int(settlement2.reward_grant().escrow)] | gp_d - int(settlement2.reward_grant().escrow) >= 2 and gp_d - int(settlement2.reward_grant().escrow) <= 4 | (待填) | | | +| 800 | 899 | "破财直验战斗启动" | settle_l.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999, "node_type": "boss"}) | (待填) | | | +| 801 | 903 | "破财 ×3 → escrow −6(负向缓冲记账,零钱包触达)" | int(settle_l.reward_grant().escrow) == -6 | (待填) | | | +| 802 | 908 | "破财压制 gp_dropped 负值上抛(%d + 6 = %d ∈ Boss 档掷定 [%d,%d] + 关级加值 %d——掷定不受破财污染)" ↩ % [ gp_l, gp_l + 6, int(boss_drop.get("roll_min", 0)), int(boss_drop.get("roll_max", 0)), boss_bonus1] | gp_l + 6 >= int(boss_drop.get("roll_min", 0)) + boss_bonus1 and gp_l + 6 <= int(boss_drop.get("roll_max", 0)) + boss_bonus1 | (待填) | | | +| 803 | 918 | "clamp 场景:L1 启动(冒险钱包开局 0)" | runner.start_level(1, 4242) | (待填) | | | +| 804 | 930 | "破财压制入账载荷为负(gp_dropped=%d)" ↩ % gp3 | gp3 < 0 | (待填) | | | +| 805 | 935 | "破财入账恰一次且投影读数 0(amount=%d,clamp 后 gp=0)" ↩ % gp3 | gain3.size() == 1 and int(gain3[0]["amount"]) == gp3 and int(gain3[0]["gp"]) == 0 | (待填) | | | +| 806 | 936 | "钱包全程不透支(gp=%d,事件投影最小读数 %d ≥ 0)" ↩ % [runner.gp, int(ev_box["min_gp"])] | runner.gp == 0 and int(ev_box["min_gp"]) >= 0 | (待填) | | | + +## S107 _smoke_shop_room(行 1002–1088 · 25 条 · 源 game/tests/unit/core/level_shop_economy_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 807 | 1002 | "商店房到达种子可达(seed=%s——进店 GP ≥ 16)" ↩ % str(shop_run.get("seed", -1)) | not shop_run.is_empty() | (待填) | | | +| 808 | 1015 | "L2 行进到商店房:当前节点型别 = shop 且出边恰一条指终点 Boss(句柄 %d·出边 %s)" ↩ % [shop_id, str(shop_outs)] | String(runner.current_node().get("type", "")) == "shop" and shop_outs.size() == 1 \\ and String(s_byid.get(int(shop_outs[0]), {}).get("type", "")) == "boss" \\ and (s_byid.get(int(shop_outs[0]), {}).get("next", []) as Array).is_empty() | (待填) | | | +| 809 | 1019 | "L2 到店路径穿商店恰 1 家(已到句柄 %s 型别计数——必经恰一店的行进面)" ↩ % str(runner.visited_ids()) | _path_shop_count(s_nodes, runner.visited_ids()) == 1 | (待填) | | | +| 810 | 1021 | "L2 商店节点(句柄 %d)入边恰一条(店位归终点的直接前驱——行进面复核)" ↩ % shop_id | shop_preds == 1 | (待填) | | | +| 811 | 1023 | "到店商店节点无出边(夹具停摆——去终点方向无法从图解析)" | false | (待填) | | | +| 812 | 1025 | "到店开店态(覆盖层数据源就位——离开前不出选路口,零自动推进)" | not runner.awaiting_choice() and runner.next_candidate_ids().is_empty() | (待填) | | | +| 813 | 1028 | "三商品全陈列(atk_up/hp_up/armor_up——无刷新无摇号)" | prods.size() == 3 | (待填) | | | +| 814 | 1030 | "L2 首购现价 = 基价 + 关级加值 2(atk_up 6 / hp_up 4 / armor_up 6)" | int(by_id["atk_up"]["price"]) == 6 and int(by_id["hp_up"]["price"]) == 4 and int(by_id["armor_up"]["price"]) == 6 | (待填) | | | +| 815 | 1033 | "购买 atk_up 受理(现价 6)" | runner.buy_product("atk_up") | (待填) | | | +| 816 | 1034 | "钱包扣现价 6(%d → %d)" ↩ % [gp0, runner.gp] | runner.gp == gp0 - 6 | (待填) | | | +| 817 | 1036 | "统一冒险加算区 atk +1(加算区住加算日记)" | runner.journal().bonus_total("atk") == 1 and runner.journal().bonus_total("max_hp") == 0 and runner.journal().bonus_total("armor_class") == 0 | (待填) | | | +| 818 | 1038 | "atk_up 二购现价 = 4+2+2=8(步长 2 × 已购 1 档,现价现算)" | int(by_id["atk_up"]["price"]) == 8 and int(by_id["atk_up"]["purchased"]) == 1 | (待填) | | | +| 819 | 1045 | "购买 hp_up 受理(现价 4)" | runner.buy_product("hp_up") | (待填) | | | +| 820 | 1046 | "钱包扣 4(%d)" ↩ % runner.gp | runner.gp == gp0 - 6 - 4 | (待填) | | | +| 821 | 1047 | "生命上限 +3(%d → %d——加算区 max_hp 轴)" ↩ % [mhp_before, runner.effective_max_hp()] | runner.effective_max_hp() == mhp_before + 3 | (待填) | | | +| 822 | 1048 | "当前生命同步 +3 封顶新上限(%d → %d)" ↩ % [hp_before, runner.hero_hp] | runner.hero_hp == mini(hp_before + 3, runner.effective_max_hp()) | (待填) | | | +| 823 | 1050 | "购买 armor_up 受理(现价 6)" | runner.buy_product("armor_up") | (待填) | | | +| 824 | 1051 | "AC = 10+1(armor_class 加算区轴,无百分比通道)" | runner.effective_ac() == int(_db.hero.armor_class) + 1 | (待填) | | | +| 825 | 1066 | "余额不足购买被拒(gp=%d < 现价 %d——零扣费零档位变动)" ↩ % [gp_dry, hp_dry] | not runner.buy_product("hp_up") and runner.gp == gp_dry and runner.purchased_tier("hp_up") == tier_dry | (待填) | | | +| 826 | 1068 | "离开商店 → 回选路口(候选 = 店出边 %s——零自动推进收口)" ↩ % str(shop_outs) | runner.continue_after_node() and runner.awaiting_choice() and _sorted_ids(runner.next_candidate_ids()) == _sorted_ids(shop_outs) | (待填) | | | +| 827 | 1071 | "主角攻击生效值 = 2+1(基础表值 + 加算区)" | runner.effective_atk() == int(_db.hero.atk) + 1 | (待填) | | | +| 828 | 1072 | "选终点 Boss(句柄 %d)开战" ↩ % int(shop_outs[0]) | runner.pick_next_node(int(shop_outs[0])) and runner.current_battle() != null | (待填) | | | +| 829 | 1076 | "战斗开始参数 = 基础表值 + 加算区(atk %d = 2+1 / max_hp %d = %d+%d×3)" ↩ % [int(snap["hero"].get("atk", -1)), int(snap["hero"].get("max_hp", -1)), int(_db.hero.max_hp), hp_buys] | int(snap["hero"].get("atk", -1)) == int(_db.hero.atk) + 1 and int(snap["hero"].get("max_hp", -1)) == int(_db.hero.max_hp) + 3 * hp_buys | (待填) | | | +| 830 | 1087 | "冒险收束(Boss 战终局:%s)" ↩ % String(fin_end.get("p", {}).get("result", "")) | fin_end.has("p") and String(fin_end["p"].get("result", "")) in ["victory", "defeat"] | (待填) | | | +| 831 | 1088 | "冒险收束钱包清零(局内货币生命周期终点——不入存档不入摘要)" | runner.gp == 0 | (待填) | | | + +## S108 _smoke_shop_graph(行 1110–1167 · 9 条 · 源 game/tests/unit/core/level_shop_economy_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 832 | 1110 | "L%d 参数档缺行(商店编排断言无源可扫)" ↩ % L | false | (待填) | | | +| 833 | 1115 | "L%d 种子 %d 生成图为空(编排断言扫描面空转)" ↩ % [L, seed_val] | false | (待填) | | | +| 834 | 1141 | "编排断言扫描面非空(三关参数档 × 种子 %d–%d=图 %d 张·终点 %d·路径 %d)〔v5〕" ↩ % [SWEEP_SEED_FROM, SWEEP_SEED_TO - 1, graph_total, term_total, path_total] | graph_total == 3 * (SWEEP_SEED_TO - SWEEP_SEED_FROM) and term_total >= 3 and path_total >= 3 | (待填) | | | +| 835 | 1143 | "性质① 每个终点的直接前驱恰一家商店(入边一条且源型别=shop·违例 %d/%d 终点)〔v5〕" ↩ % [pred_bad, term_total] | pred_bad == 0 | (待填) | | | +| 836 | 1144 | "性质② 每图商店节点数 = 终点数(违例 %d/%d 图)〔v5〕" ↩ % [count_bad, graph_total] | count_bad == 0 | (待填) | | | +| 837 | 1145 | "性质③ 每条入口→终点路径恰穿一家商店(违例 %d/%d 条路径)〔v5〕" ↩ % [path_bad, path_total] | path_bad == 0 | (待填) | | | +| 838 | 1151 | "L%d 现行图终点 %d 个 = 商店 %d 个·入口→终点路径 %d 条全穿恰一店" ↩ % [L, cur_terms.size(), cur_shops.size(), _all_entry_terminal_paths(cur_nodes).size()] | cur_terms.size() == cur_shops.size() and not cur_terms.is_empty() and _all_entry_terminal_paths(cur_nodes).size() >= cur_terms.size() and _paths_all_one_shop(cur_nodes) | (待填) | | | +| 839 | 1163 | "L%d 现行图终点深度 %s 逐点 Boss 行集解析命中且落带 ≥ 终点深度" ↩ % [L, str(tdepths)] | hit_ok | (待填) | | | +| 840 | 1166 | "L1 现行图跨表覆盖(战斗/Boss 节点 %d 颗非空·敌侧违例 %d·板侧违例 %d)" ↩ % [int(l1_cov["nodes"]), int(l1_cov["enemy_bad"]), int(l1_cov["board_bad"])] | int(l1_cov["nodes"]) >= 1 and int(l1_cov["enemy_bad"]) == 0 and int(l1_cov["board_bad"]) == 0 | (待填) | | | + +## S109 _smoke_multi_terminal_settlement(行 1175–1222 · 5 条 · 源 game/tests/unit/core/level_shop_economy_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 841 | 1175 | "L2 现行图为多终点(Boss 终点 %s 两颗以上——大岔口承载面)" ↩ % str(boss_ids) | boss_ids.size() >= 2 | (待填) | | | +| 842 | 1187 | "L2 min/max 两路各打进各自终点 Boss(末句柄 %d / %d 互异且都在图 Boss 集 %s 内;两路 result=%s/%s)" ↩ % [min_last, max_last, str(boss_ids), String(run_min.get("result", "")), String(run_max.get("result", ""))] | String(run_min.get("result", "")) == "victory" and String(run_max.get("result", "")) == "victory" and boss_ids.has(min_last) and boss_ids.has(max_last) and min_last != max_last | (待填) | | | +| 843 | 1191 | "min 路未到 max 终点 %d、max 路未到 min 终点 %d(区域隔离——走 A 区不误兑付 B 区 Boss)" ↩ % [max_last, min_last] | not min_vis.has(max_last) and not max_vis.has(min_last) | (待填) | | | +| 844 | 1198 | "L2 全程驱动启动受理" | runner.start_level(2, 42) | (待填) | | | +| 845 | 1220 | "任一 Boss 胜即本关收束恰一次(count=%d·载荷 result=%s·node_id=%s 在图 Boss 集 %s 内)" ↩ % [int(fin["count"]), String(payload.get("result", "")), str(payload.get("node_id", -1)), str(boss_ids)] | int(fin["count"]) == 1 and String(payload.get("result", "")) == "victory" and boss_ids.has(int(payload.get("node_id", -1))) | (待填) | | | + +## S110 _smoke_shop_pool(行 1257–1391 · 29 条 · 源 game/tests/unit/core/level_shop_economy_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 846 | 1257 | "池模式商店房场景可达(seed=%s)" ↩ % str(scen.get("s", -1)) | not scen.is_empty() | (待填) | | | +| 847 | 1265 | "进店抽 3(货架 %d 卡——同构交互)" ↩ % first_shelf_a.size() | first_shelf_a.size() == 3 | (待填) | | | +| 848 | 1271 | "刷新首价=基价(%d)" ↩ % cost0 | cost0 == int(_db.adventure.shop_refresh_cost_base) | (待填) | | | +| 849 | 1272 | "基础免费 0 次(Q2——天赋增益位后续添加)" | r8.shop_refresh_free_left() == 0 | (待填) | | | +| 850 | 1274 | "GP 刷新受理(gp=%d cost=%d)" ↩ % [gp0, cost0] | r8.shop_refresh() | (待填) | | | +| 851 | 1275 | "GP 刷新扣费(%d→%d)" ↩ % [gp0, int(r8.gp)] | int(r8.gp) == gp0 - cost0 | (待填) | | | +| 852 | 1276 | "刷新爬价 ×curve(%d)" ↩ % int(r8.shop_refresh_cost()) | int(r8.shop_refresh_cost()) == int(ceil(float(cost0) * float(_db.adventure.shop_refresh_cost_curve))) | (待填) | | | +| 853 | 1284 | "同种子同货架逐位(确定性重放承载——断点货架续玩语义)" | same_shelf | (待填) | | | +| 854 | 1294 | "relic 现价 = ceil(15.0×b^(关序−1))(读表与迁移前字面量逐位一致,%d)〔wd11〕" ↩ % relic_price_now | relic_price_now == relic_price_pre | (待填) | | | +| 855 | 1300 | "货架 kind 在封闭词表或保底 relic 卡(%s)" ↩ % ckind | is_relic or shelf_kinds.has(ckind) | (待填) | | | +| 856 | 1307 | "首购现价 = ceil(基价×b^(关序−1))(%s: %d = %d 推导)" ↩ % [ckind, int(c.get("price", -1)), expect_p] | int(c.get("price", -1)) == expect_p | (待填) | | | +| 857 | 1314 | "可购卡在架(GP %d——种子扫描保证首购空间)" ↩ % int(r8.gp) | not buy_c.is_empty() | (待填) | | | +| 858 | 1322 | "购买受理(pool 口——%s\|%s)" ↩ % [kind, tier] | r8.buy_pool_item(kind) | (待填) | | | +| 859 | 1323 | "钱包扣现价(%d → %d)" ↩ % [gp0, int(r8.gp)] | int(r8.gp) == gp0 - int(buy_c.get("price", 0)) | (待填) | | | +| 860 | 1324 | "加算区 +令牌档位件值(池件=奖励件本体——%s\|%s %+d)" ↩ % [kind, tier, int(_db.adventure.curio_value(kind, tier))] | a8.journal().bonus_total(String(val_map[kind])) == val0 + int(_db.adventure.curio_value(kind, tier)) | (待填) | | | +| 861 | 1325 | "共享池账本 -1(跨渠道单点——taken %d → %d)" ↩ % [taken0, int(a8._curio.taken.get(kind, 0))] | int(a8._curio.taken.get(kind, 0)) == taken0 + 1 | (待填) | | | +| 862 | 1337 | "A 链爬价 = ceil(base×b^(关序−1)×A^1)(进店第 2 购——%d)" ↩ % expect_p2 | int(second.get("price", -1)) == expect_p2 | (待填) | | | +| 863 | 1338 | "同 visit 第二购受理(A 链软闸在价不在限)" | r8.buy_pool_item(String(second.get("kind_id", ""))) | (待填) | | | +| 864 | 1340 | "A 链爬价场景未构造(抬 GP 后货架仍无可购第二卡)——场景绑定失败" | false | (待填) | | | +| 865 | 1348 | "切边件零入店(40 次重抽零 gp/aux——负交易切边+三轴 doctrine)" | not cut_seen | (待填) | | | +| 866 | 1359 | "库存尽剔牌库(attack 令牌耗尽——池消耗即上限跨渠道共库 reward→shop 向)" | not attack_seen | (待填) | | | +| 867 | 1361 | "池空消费拒绝(handler 零副作用——买光即枯竭硬闸)" | not a8.stop().consume_pool_purchase("attack", "common") and a8.deck().pool_stock("attack") == stock_before | (待填) | | | +| 868 | 1364 | "商店消费口受理(停点件兑付单点同口——真单点库)〔拆件批 V3〕" | a8.stop().consume_pool_purchase("attack", "common") | (待填) | | | +| 869 | 1370 | "商店购买进奖励牌库摘牌(买一件=奖励面少一件——shop→reward 向共库)" | not reward_attack_seen | (待填) | | | +| 870 | 1375 | "离开商店 → 回选路口(货架态清零落档——零自动推进收口)" | r8.continue_after_node() and r8.awaiting_choice() | (待填) | | | +| 871 | 1377 | "断点扩键 shop_shelf 在案(离开店=空架合法态——P2 甲扩列)" | bp.has("shop_shelf") and (bp["shop_shelf"] as Array).is_empty() | (待填) | | | +| 872 | 1378 | "断点扩键 shop_visit_buys 在案(离开店清零——A 链输入不入档语义)" | bp.has("shop_visit_buys") and int(bp["shop_visit_buys"]) == 0 | (待填) | | | +| 873 | 1383 | "终局池读数六键出讫(run_adventure pool 面——四池种+T5 专长凭证+T6 heal 行,读数只锚一次载体)" | ps.size() == 6 | (待填) | | | +| 874 | 1391 | "终局池余量在域 [0,配额](%s)" ↩ % str(ps) | ps_ok | (待填) | | | + +## S111 _test_positive(行 46–56 · 3 条 · 源 game/tests/unit/core/level_table_validate_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 875 | 46 | "四检正例:多入口多终点图零错误〔v1〕" | _errs(multi).is_empty() | (待填) | | | +| 876 | 53 | "四检正例:含岔口单终点图零错误〔v1〕" | _errs(forked).is_empty() | (待填) | | | +| 877 | 56 | "四检正例:单链退化图零错误(不设必有岔口)〔v1〕" | _errs(chain).is_empty() | (待填) | | | + +## S112 _test_negative(行 63–89 · 11 条 · 源 game/tests/unit/core/level_table_validate_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 878 | 63 | "负例:终点非 Boss 命中「终点恒为 Boss」〔v1〕" | _has(_errs(non_boss_end), "structural", "终点 1 非 Boss") | (待填) | | | +| 879 | 66 | "负例:Boss 带出边被拒〔v1〕" | _has(_errs(boss_out), "structural", "Boss 节点 1 带出边") | (待填) | | | +| 880 | 68 | "负例:回边(目标深度不严格大于源)命中前进方向判据〔v1〕" | _has(_errs(back_edge), "structural", "违反前进方向") | (待填) | | | +| 881 | 71 | "负例:环(2→1 回边)被前进方向拦下〔v1〕" | _has(_errs(cycle), "structural", "违反前进方向") | (待填) | | | +| 882 | 77 | "判据②正证:两独立链(双入口双终点)不误伤·全员可达〔v1〕" | _errs(two_components).is_empty() | (待填) | | | +| 883 | 79 | "判据②兜底面:自环先被前进方向拦下(④ 与 ② 互斥的实证)〔v1〕" | _has(_errs(self_loop), "structural", "违反前进方向") | (待填) | | | +| 884 | 82 | "负例:悬空出边报 dangling_next〔v1〕" | _has(_errs(dangling), "dangling_next") | (待填) | | | +| 885 | 84 | "负例:句柄重复报 dup_key〔v1〕" | _has(_errs(dup), "dup_key") | (待填) | | | +| 886 | 86 | "负例:battle 节点缺深度被良构基底拒〔v1〕" | _has(_errs(no_depth), "structural", "缺深度") | (待填) | | | +| 887 | 87 | "负例:空节点集被拒〔v1〕" | _has(_errs([]), "structural", "图节点集为空") | (待填) | | | +| 888 | 89 | "负例:类型出域报 enum〔v1〕" | _has(_errs(bad_type), "enum") | (待填) | | | + +## S113 _test_scope_not_extended(行 97–102 · 3 条 · 源 game/tests/unit/core/level_table_validate_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 889 | 97 | "范围反证:无商店图不在加载校验内报错(商店位由冒烟断言把关)〔v1〕" | _errs(no_shop).is_empty() | (待填) | | | +| 890 | 99 | "范围反证:双入口合法(入口唯一判据已随模板池退役,个数归产出侧)〔v1〕" | _errs(two_entries).is_empty() | (待填) | | | +| 891 | 102 | "范围反证:节点缺 next 字段仍被良构基底拒〔v1〕" | _has(_errs(missing_key), "structural", "缺字段 next") | (待填) | | | + +## S114 _meta_pricing_and_buy(行 138–151 · 4 条 · 源 game/tests/unit/core/meta_progression_forge_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 892 | 138 | "树购买受理(Lv 0→1、余额 %d−%d)" ↩ % [bal0, price0] | mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1 and sv.keystone_balance() == bal0 - price0 | (待填) | | | +| 893 | 141 | "购买持久(reload 后级与余额不变)" | sv_r.tree_level("sp_hp") == 1 and sv_r.keystone_balance() == bal0 - price0 | (待填) | | | +| 894 | 148 | "余额不足 false 零副作用(0 余额购买拒绝——三域零变化〔天赋树批 V3〕)" | not mp_dry.tree_buy("sp_hp") and sv_dry.keystone_balance() == 0 and sv_dry.tree_nodes() == tn_before and sv_dry.fork_sides() == fs_before | (待填) | | | +| 895 | 151 | "未知节点 false 零副作用(词表外出域)" | not mp_dry.tree_buy("no_such_node") | (待填) | | | + +## S115 _meta_settle_and_workshop(行 168–341 · 42 条 · 源 game/tests/unit/core/meta_progression_forge_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 896 | 168 | "新档首通 L1:boss_drops[0] {first:true, amount=first_kill}(验收5——明细随摘要归档)" | bool(drop1.get("first", false)) and int(drop1.get("amount", 0)) == int(row1.get("first_kill", -1)) and int(drop1.get("level", 0)) == 1 | (待填) | | | +| 897 | 171 | "明细 total = keystone_total(逐杀掉落全源——无二次派生加账)" | int(d1.get("total", 0)) == int(row1.get("first_kill", 0)) | (待填) | | | +| 898 | 173 | "余额 = 累计入账(逐杀掉落合计——无重复加账)" | sv.keystone_balance() == int(row1.get("first_kill", 0)) | (待填) | | | +| 899 | 181 | "再通 L1:{first:false, amount=repeat_kill}(首杀标记置位后转小额)" | not bool(drop2.get("first", true)) and int(drop2.get("amount", 0)) == int(row1.get("repeat_kill", -1)) | (待填) | | | +| 900 | 187 | "重复杀第三轮恒定不递减(验收5——无衰减曲线)" | not bool(d3["boss_drops"][0].get("first", true)) and int(d3["boss_drops"][0].get("amount", 0)) == int(row1.get("repeat_kill", -1)) | (待填) | | | +| 901 | 195 | "两关完成逐关归因:L1 重复档 + L2 首杀档(first_kill 金额)" | not bool(d4["boss_drops"][0].get("first", true)) and bool(d4["boss_drops"][1].get("first", false)) and int(d4["boss_drops"][1].get("amount", 0)) == int(row2.get("first_kill", -1)) | (待填) | | | +| 902 | 203 | "败北零关卡完成摘要:零入账零标记(钥石只增不减——验收1;败北局 clear_counts 不变)" | dz["boss_drops"].is_empty() and sv.keystone_balance() == bal_before and sv.clear_count(1) == cc1_before | (待填) | | | +| 903 | 210 | "条件件误购拒绝(购买件专属——契约 8 分配面)〔秘宝批 V3 反例〕" | not mp_u.buy_treasure_unlock("sky_eye") and not sv_u.item_unlocked("sky_eye") and not mp_u.buy_treasure_unlock("ranged_pick") | (待填) | | | +| 904 | 213 | "余额不足购买拒绝且零副作用(余额 10 < 价 25)〔秘宝批 V3〕" | not mp_u.buy_treasure_unlock("twin_loader") and sv_u.keystone_balance() == 10 | (待填) | | | +| 905 | 215 | "钥石购买解锁:余额足→解锁态落档+余额扣减正确(价 10)〔秘宝批 V3〕" | mp_u.buy_treasure_unlock("morning_order") and sv_u.item_unlocked("morning_order") and sv_u.keystone_balance() == 0 | (待填) | | | +| 906 | 218 | "已解锁重复购买拒绝〔秘宝批 V3〕" | not mp_u.buy_treasure_unlock("morning_order") | (待填) | | | +| 907 | 220 | "条件未达不解锁(远程镐=冒险成功 1 次)〔秘宝批 V4〕" | mp_u.refresh_treasure_unlocks().is_empty() and not sv_u.item_unlocked("ranged_pick") | (待填) | | | +| 908 | 223 | "远程镐=adventure_cleared≥1 达标恰解锁一次+重复刷新幂等(C6:冒险成功=打通整局冒险)〔秘宝批 V4〕" | (mp_u.refresh_treasure_unlocks() as Array).has("ranged_pick") and sv_u.item_unlocked("ranged_pick") and mp_u.refresh_treasure_unlocks().is_empty() | (待填) | | | +| 909 | 227 | "刷新执照=boss_kill_count≥3(阈值读 unlock_requires 行值不硬编)〔秘宝批 V4〕" | (mp_u.refresh_treasure_unlocks() as Array).has("refresh_license") | (待填) | | | +| 910 | 230 | "天眼单条件不足不解锁(AND 语义——cleared 3 达 boss 4 未达)〔秘宝批 V4〕" | not sv_u.item_unlocked("sky_eye") | (待填) | | | +| 911 | 233 | "天眼双条件 AND(冒险成功≥3 且 Boss≥4)达标解锁〔秘宝批 V4〕" | (mp_u.refresh_treasure_unlocks() as Array).has("sky_eye") | (待填) | | | +| 912 | 236 | "信标网络=真视类工具累计使用≥10(行为计数新域——use_treasure 受理成功累计)〔秘宝批 V4〕" | (mp_u.refresh_treasure_unlocks() as Array).has("beacon_network") | (待填) | | | +| 913 | 241 | "解锁态持久投影一致(reload 面同源读数;5 件=购买 1+条件 4)〔秘宝批 V4〕" | r_n == 5 | (待填) | | | +| 914 | 244 | "摘要最新在前 + 元素含掉落明细副本(复盘可归因——验收5)" | rs.size() >= 5 and String(rs[0].get("result", "")) != "" and rs[0].has("boss_drops") and (rs[1] as Dictionary).has("keystone_drops") and int(rs[0].get("master_seed", -1)) == 15 | (待填) | | | +| 915 | 253 | "摘要 cap 10 且最新在前(12 连 settle 截断后 seed 11 居首)" | sv_c.recent_summaries().size() == 10 and int(sv_c.recent_summaries()[0].get("master_seed", -1)) == 11 | (待填) | | | +| 916 | 258 | "白板档同调位求值 = 0(0 起步裁决)且 Y=0(未购无刷新能力——P1 甲初始 0 同构)" | Attunement.slots(_db, sv_a) == 0 and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 0 | (待填) | | | +| 917 | 260 | "满位禁选(Slots 0 时装配拒绝;兼未解锁拒绝前置——直置解锁后下方装配)〔秘宝批 V5〕" | mp_a.toggle_attunement("morning_order") == false | (待填) | | | +| 918 | 265 | "Y 升级购买入账(Lv1 → 每冒险 +1 次刷新)" | mp_a.buy_attune_refresh() and sv_a.attune_refresh_level() == 1 and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 1 | (待填) | | | +| 919 | 270 | "完成 1 关 → Slots 恒 0(旧「按击杀 Boss 次数自动涨」条件已删——槽位唯一来源=树点亮)且 Boss 击杀总数=1(计数域保留)〔天赋树批 V14〕" | Attunement.slots(_db, sv_a) == 0 and sv_a.boss_kill_count() == 1 | (待填) | | | +| 920 | 273 | "树上点亮同调位 1 → Slots=1(槽位收编树节点——Q2 裁甲)" | Attunement.slots(_db, sv_a) == 1 | (待填) | | | +| 921 | 275 | "稀有件入普通格拒绝(件档 rank 3 > 装入位序 1——契约 2 档位格校验)〔秘宝批 V5〕" | not mp_a.toggle_attunement("sky_eye") | (待填) | | | +| 922 | 277 | "装配持久写入 + 满位禁选(Slots 1 装第二件拒绝)〔秘宝批 V5 换主语〕" | mp_a.toggle_attunement("morning_order") and mp_a.toggle_attunement("twin_loader") == false | (待填) | | | +| 923 | 279 | "卸下恒可再装配(自卸自装——营地换装=冒险开始前合法面,装配不可变只锁运行中)〔秘宝批换主语〕" | mp_a.toggle_attunement("morning_order") and mp_a.toggle_attunement("morning_order") | (待填) | | | +| 924 | 284 | "装配关系读回持久(reload 后投影一致——写新件 id)〔秘宝批 V15〕" | (mp_a2.attune_camp_status()["equipped"] as Array).has("morning_order") | (待填) | | | +| 925 | 288 | "充能重置场景:装配冒险启动(seed 12)〔秘宝批 V8 换主语〕" | a12.start_adventure(12, {"attunement": ["incense_stick", "sky_eye"]}) | (待填) | | | +| 926 | 290 | "充能按装备 active_charged 件种行(新目录表值:线香 battle 级 1 次/天眼 level 级 1 次——周期重置点归 V8 逐形态面;读数=行派生)" | a12._loadout.charges_left("incense_stick", a12.runner().treasure_inventory) == 1 and a12._loadout.charges_left("sky_eye", a12.runner().treasure_inventory) == 1 | (待填) | | | +| 927 | 296 | "刷新双计数器——X 优先消耗(x1y2 → x0y2)" | a12.stop().refresh_curio() and a12.refresh_bank()._refresh_x == 0 and a12.refresh_bank()._refresh_y == 2 | (待填) | | | +| 928 | 298 | "刷新双计数器——X 耗尽转 Y 池、池竭拒绝(x0y2 → y0 → 第四次 false 零副作用)" | a12.stop().refresh_curio() and a12.stop().refresh_curio() and a12.refresh_bank()._refresh_y == 0 and a12.stop().refresh_curio() == false | (待填) | | | +| 929 | 302 | "刷新 X 每碰三选一重置(不结转;新目录无 bonus_refresh 行源——X 机制保留恒 0 空转,存量加仓走 _refresh_stock=刷新练习 V8)〔秘宝批换代〕" | a12.refresh_bank()._refresh_x == 0 and a12.refresh_bank().free_left() == 0 | (待填) | | | +| 930 | 313 | "开局传参夹具前置:Fork 侧 a 激活 rule 键非空 + Y=Lv2 行表值 2(防断言空转)" | rules_e.has("posture_first_strike") and mp_e.attune_refresh_charges() == 2 | (待填) | | | +| 931 | 317 | "主菜单开新冒险参数面:同调装配 = 存档装配集(营地装了什么开局就带什么)" | (p_new.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array) | (待填) | | | +| 932 | 319 | "主菜单开新冒险参数面:Y 刷新池 = 当前等级行表值(营地升几档开局就给几次)" | int(p_new.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges() | (待填) | | | +| 933 | 321 | "主菜单开新冒险参数面:树 rule 键集 = Fork 激活侧 effects(点哪侧带哪侧)" | (p_new.get("tree_rules", []) as Array) == rules_e | (待填) | | | +| 934 | 323 | "主菜单开新冒险参数面:入口特有键(treasure_unlocked/shop_mode)随 extra 原样并入" | bool((p_new.get("treasure_unlocked", {}) as Dictionary).get("wide_scout_band", false)) and String(p_new.get("shop_mode", "")) == "pool" | (待填) | | | +| 935 | 327 | "主菜单继续上局参数面:三键与开新冒险同源同值(续玩不吃白板开局)" | (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 | (待填) | | | +| 936 | 338 | "继续上局受理面:resume_adventure 受理三键参数面(尾裁微批b 受理闭环——受理不再吃白板)" | adr_res.resume_adventure(bp_min, p_resume) | (待填) | | | +| 937 | 340 | "继续上局受理面:tree_rules 随参数面进 runner._tree_rules(消费面 set_tree_rules 下传现成)" | (adr_res._tree_rules as Array) == rules_e | (待填) | | | + +## S116 _meta_injection(行 353–383 · 10 条 · 源 game/tests/unit/core/meta_progression_forge_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 938 | 353 | "生命淬炼 2 级 → hero_override().max_hp == Σ 节点 effects×等级(验收4 换代〔天赋树批 V12〕)" | int(mp.hero_override().get("max_hp", -1)) == 2 * int(_db.tree_node("sp_hp").get("effects", {}).get("max_hp", 0)) | (待填) | | | +| 939 | 355 | "攻/AC 树零来源(契约 9——树表零 atk/armor_class 行,override 二轴恒 0+预留位 0)" | int(mp.hero_override().get("atk", -1)) == 0 and int(mp.hero_override().get("armor_class", -1)) == 0 | (待填) | | | +| 940 | 357 | "命中线在账未注入(契约 6——override 无 hit 字段〔天赋树批 V13〕)" | not mp.hero_override().has("hit") | (待填) | | | +| 941 | 360 | "注入参数冒险启动(start_adventure params 通道)" | adr.start_adventure(920251, {"hero_override": mp.hero_override()}) | (待填) | | | +| 942 | 364 | "首战 hero_atk = 表值 + 永久强化 flat(begin_battle 同表达式——下一局能用到强化)" | int(r.effective_atk()) == int(_db.hero.atk) + int(ov.get("atk", 0)) | (待填) | | | +| 943 | 366 | "首战 hero_max_hp 与开局生命账本 = 表值 + 注入档" | int(r.effective_max_hp()) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0)) and int(adr.stats().hero_hp) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0)) | (待填) | | | +| 944 | 371 | "白板冒险启动(无 params 实参——B1 空参数路径)" | adr_w.start_adventure(920251) | (待填) | | | +| 945 | 374 | "零强化参数冒险启动(新档 hero_override 全零)" | adr_z.start_adventure(920251, {"hero_override": load("res://core/meta_progression.gd").new(_db, sv_white).hero_override()}) | (待填) | | | +| 946 | 378 | "白板成立:空参数 = 零强化参数 = 纯表值(atk/max_hp/ac 三轴逐位一致——验收 2/3)" | int(rw.effective_atk()) == int(_db.hero.atk) and int(rw.effective_max_hp()) == int(_db.hero.max_hp) and int(rz.effective_atk()) == int(rw.effective_atk()) and int(rz.effective_max_hp()) == int(rw.effective_max_hp()) and int(rz.effective_ac()) == int(rw.effective_ac()) | (待填) | | | +| 947 | 382 | "ac 轴:白板恒表值、未换强化时注入零位移" | int(rw.effective_ac()) == int(_db.hero.armor_class) and int(r.effective_ac()) == int(_db.hero.armor_class) | (待填) | | | + +## S117 _smoke_forge_axes(行 392–540 · 33 条 · 源 game/tests/unit/core/meta_progression_forge_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 948 | 392 | "hero.tres 无 attack_type/armor_type 字段(契约 2——静态表两轴删除)〔锻造工坊批 V5〕" | not ("attack_type" in _db.hero) and not ("armor_type" in _db.hero) | (待填) | | | +| 949 | 395 | "锻造配置默认值 attack_type=normal / armor_type=light(常驻持有)〔锻造工坊批 V5〕" | _db.forge_default_attack_type() == "normal" and _db.forge_default_armor_type() == "light" | (待填) | | | +| 950 | 399 | "spawn 受理(三参形态——两轴缺省回退面)" | uc.spawn(1, 0, int(_db.hero.max_hp)) | (待填) | | | +| 951 | 400 | "主角单位快照两轴 = 锻造配置默认值 normal/light(零注入回退——行为连续)〔锻造工坊批 V5〕" | String(uc.hero_unit().get("attack_type", "")) == "normal" and String(uc.hero_unit().get("armor_type", "")) == "light" | (待填) | | | +| 952 | 410 | "长剑锻造首购:扣 20 钥石、等级 0→1(Q1——第一级=获得装备=首个 +1)〔锻造工坊批 V2〕" | mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 1 and sv2.keystone_balance() == 80 | (待填) | | | +| 953 | 414 | "购买持久(reload 后等级与余额不变——即时写盘)〔锻造工坊批 V2〕" | sv2r.forge_route_level("sword") == 1 and sv2r.keystone_balance() == 80 | (待填) | | | +| 954 | 417 | "长剑二级购:扣 30、等级→2;轻甲独立零扰动(六路线各练各的)〔锻造工坊批 V2〕" | mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 2 and sv2.keystone_balance() == 50 and sv2.forge_route_level("light") == 0 | (待填) | | | +| 955 | 424 | "余额不足购买拒绝且零副作用(0 余额——三域零变化)〔锻造工坊批 V2〕" | not mp2d.forge_buy_level("sword") and sv2d.keystone_balance() == 0 and sv2d.forge_routes_snapshot() == fr_dry | (待填) | | | +| 956 | 429 | "已满级拒购/未解锁拒购(cap 闸+解锁闸——零副作用)〔锻造工坊批 V2〕" | not mp2.forge_buy_level("sword") and not mp2d.forge_buy_level("spear") | (待填) | | | +| 957 | 432 | "通道一:长剑练满→长枪/法杖派生解锁;轻甲未练满→重甲/魔法护甲仍锁〔锻造工坊批 V3〕" | mp2.forge_route_unlocked("spear") and mp2.forge_route_unlocked("staff") and not mp2.forge_route_unlocked("heavy") and not mp2.forge_route_unlocked("ward") | (待填) | | | +| 958 | 435 | "派生解锁后长枪可购(首购 20——余额 50−20)〔锻造工坊批 V3〕" | mp2.forge_buy_level("spear") and sv2.forge_route_level("spear") == 1 and sv2.keystone_balance() == 30 | (待填) | | | +| 959 | 442 | "通道二:魔法护甲一次性解锁价 200 直购开(轻甲未练满也可买)〔锻造工坊批 V3〕" | not mp3.forge_route_unlocked("ward") and mp3.forge_buy_unlock("ward") and sv3.keystone_balance() == 300 and mp3.forge_route_unlocked("ward") | (待填) | | | +| 960 | 447 | "直购后练满不重复收(已解锁=拒绝零收费——两通道互不影响)〔锻造工坊批 V3〕" | mp3.forge_route_unlocked("ward") and not mp3.forge_buy_unlock("ward") and sv3.keystone_balance() == 300 | (待填) | | | +| 961 | 452 | "不设串联门槛(法杖直购开——长枪零状态不阻)〔锻造工坊批 V3〕" | mp2d.forge_buy_unlock("staff") and mp2d.forge_route_unlocked("staff") and sv2d.forge_route_level("spear") == 0 and not mp2d.forge_route_unlocked("spear") | (待填) | | | +| 962 | 457 | "两币分工:购买只扣钥石(余额差=当级价 20——GP 零触碰、无材料计数)〔锻造工坊批 V4〕" | mp3.forge_buy_level("ward") and sv3.keystone_balance() == ks4 - 20 and sv3.forge_route_level("ward") == 1 | (待填) | | | +| 963 | 462 | "选中切换免费(余额零动——写存档域即盘)〔锻造工坊批 V6〕" | mp3.forge_select_armor("ward") and sv3.forge_armor() == "ward" and sv3.keystone_balance() == ks6 | (待填) | | | +| 964 | 465 | "切换后旧路线练度保留(轻甲 5 级不丢——回家待命只不生效)〔锻造工坊批 V6〕" | sv3.forge_route_level("light") == 5 | (待填) | | | +| 965 | 468 | "未解锁路线拒选零副作用〔锻造工坊批 V6〕" | not mp2d.forge_select_weapon("spear") and sv2d.forge_weapon() == "sword" | (待填) | | | +| 966 | 471 | "类型轴当前生效值随选中走(ward;未解锁档回退默认 normal/light)〔锻造工坊批 V5/V6〕" | mp3.hero_armor_type() == "ward" and mp2d.hero_attack_type() == "normal" and mp2d.hero_armor_type() == "light" | (待填) | | | +| 967 | 480 | "build_adventure_params 注入默认值 normal/light(树钥匙未点亮——新档初始持有态)〔锻造工坊批 V5〕" | String(bp_params.get("attack_type", "")) == "normal" and String(bp_params.get("armor_type", "")) == "light" | (待填) | | | +| 968 | 484 | "长枪直购解锁+首购+选中(注入面夹具——200+20 钥石)" | mp4.forge_buy_unlock("spear") and mp4.forge_buy_level("spear") and mp4.forge_select_weapon("spear") | (待填) | | | +| 969 | 487 | "build_adventure_params 读存档选中覆盖(选中长枪→pierce)〔锻造工坊批 V5〕" | String(bp_sel.get("attack_type", "")) == "pierce" and String(bp_sel.get("armor_type", "")) == "light" | (待填) | | | +| 970 | 491 | "hero_override=锻造路线加值(选中长枪 Lv1→atk +1;甲 0 级→AC +0;树零分量;无 hit 键)〔锻造工坊批 V7〕" | int(mp4.hero_override().get("atk", -1)) == 1 and int(mp4.hero_override().get("max_hp", -1)) == 0 and int(mp4.hero_override().get("armor_class", -1)) == 0 and not mp4.hero_override().has("hit") | (待填) | | | +| 971 | 497 | "换当前武器→攻击加值随切换(长剑 3 级待命不生效——只认选中)〔锻造工坊批 V7〕" | int(mp4.hero_override().get("atk", -1)) == 1 | (待填) | | | +| 972 | 499 | "切回长剑→加值=3 级量(旧路线练度保留且生效)〔锻造工坊批 V7〕" | mp4.forge_select_weapon("sword") and int(mp4.hero_override().get("atk", -1)) == 3 | (待填) | | | +| 973 | 505 | "重甲直购解锁+首购+选中(三层 AC 夹具)" | mp5.forge_buy_unlock("heavy") and mp5.forge_buy_level("heavy") and mp5.forge_select_armor("heavy") | (待填) | | | +| 974 | 509 | "注入参数冒险启动(两轴+override 同面)" | adr5.start_adventure(77, p5) | (待填) | | | +| 975 | 511 | "入战 AC 三层:甲型基础 12(重甲)+路线加值 1+局内 0〔锻造工坊批 V7〕" | int(r5.effective_ac()) == 12 + 1 | (待填) | | | +| 976 | 513 | "参数面快照入 runner(attack_type=normal/armor_type=heavy)〔锻造工坊批 V6〕" | adr5.stats().attack_type == "normal" and adr5.stats().armor_type == "heavy" | (待填) | | | +| 977 | 516 | "冒险开始后锁死:改存档选中不影响参数面快照与生效值(契约 8)〔锻造工坊批 V6〕" | mp5.forge_select_armor("light") and adr5.stats().armor_type == "heavy" and int(r5.effective_ac()) == 12 + 1 | (待填) | | | +| 978 | 525 | "enc 两轴直达 spawn 英雄快照(hero_attack 行携带 pierce 键——注入链末端)〔锻造工坊批 V5〕" | pierce_rows >= 1 | (待填) | | | +| 979 | 529 | "spawn 显式两轴注入(magic/heavy 直入英雄快照)〔锻造工坊批 V5〕" | uc2.spawn(1, 0, int(_db.hero.max_hp), [], -1, -1, [], "magic", "heavy") and String(uc2.hero_unit().get("attack_type", "")) == "magic" and String(uc2.hero_unit().get("armor_type", "")) == "heavy" | (待填) | | | +| 980 | 539 | "树零攻防来源复核(12 行 effects 键集无 atk/armor_class——契约 1)〔锻造工坊批 V10〕" | not tree_atk_ac | (待填) | | | + +## S118 _smoke_tree(行 548–621 · 25 条 · 源 game/tests/unit/core/meta_progression_forge_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 981 | 548 | "meta_tree 已加载(12 节点=脊柱 2〔生命/命中淬炼一线一节点 cap4〕+锻造 2〔钥匙+彩蛋——彩蛋含在锻造 2 内非第 13 行〕+钥石 2〔秘宝匣/刷新升级〕+同调 6〔socket 3+Fork 3 双态〕——v0.8 终态)〔天赋树批 V1〕" | _db.meta_tree != null and (_db.meta_tree.rows as Array).size() == 12 | (待填) | | | +| 982 | 550 | "MetaTreeTable.validate 零违例(脉/类型/币词表出域即拒/Fork 三组每组恰一且持 a/b 双效果键/彩蛋 ≤3/effects 键封闭/零 archetype)" | _db.meta_tree.validate().is_empty() | (待填) | | | +| 983 | 558 | "easter_ley effects 键集 ⊆ {max_hp, hit}(攻/护分量已摘除——契约 9)〔天赋树批 V8 落点〕" | ley_ok | (待填) | | | +| 984 | 566 | "schema 反例:vein 出域被拒" | not t2.validate().is_empty() | (待填) | | | +| 985 | 571 | "schema 反例:fork_group 出域(fg_mastery 已退役组)被拒" | not t2.validate().is_empty() | (待填) | | | +| 986 | 576 | "schema 反例:effects.archetype 加载致命(🔴 层级纪律护栏面)〔天赋树批 V4〕" | not t2.validate().is_empty() | (待填) | | | +| 987 | 581 | "schema 反例:requires 悬空被拒" | not t2.validate().is_empty() | (待填) | | | +| 988 | 586 | "树购买受理(脊柱生命 L1——树全钥石单币)" | mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1 | (待填) | | | +| 989 | 588 | "行门槛:第 2 级不可购(总等级 1 < 4——一线一节点主语,买第 N 级需全树总投入 ≥4×(N−1))〔天赋树批 V2〕" | not mp.tree_can_buy("sp_hp") | (待填) | | | +| 990 | 590 | "命中 L1/锻造钥匙(free 无前置=初始零购零币可点亮)/同调位 1(根节点无前置)受理(总等级 1→4)" | mp.tree_buy("sp_hit") and mp.tree_buy("forge_workshop") and mp.tree_buy("att_socket1") | (待填) | | | +| 991 | 592 | "锻造工坊钥匙零币(free)" | int(_db.tree_node("forge_workshop").get("price", -1)) == 0 | (待填) | | | +| 992 | 593 | "行门槛恰达开放(总等级 4 ≥ 4×1)" | mp.tree_can_buy("sp_hp") | (待填) | | | +| 993 | 594 | "第 2 级购买受理(门槛恰达当刻开放)" | mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 2 | (待填) | | | +| 994 | 598 | "Fork 购买=解锁+默认 A 态(一节点双态——单行承载)" | mp.tree_buy_fork("fork_posture") and sv.tree_level("fork_posture") == 1 and sv.fork_side("fg_posture") == "a" | (待填) | | | +| 995 | 600 | "已解锁 Fork 重复购买拒绝(cap 1)" | not mp.tree_can_buy("fork_posture") | (待填) | | | +| 996 | 601 | "同态重复切换零操作(false)" | not mp.tree_toggle_fork("fg_posture", "a") | (待填) | | | +| 997 | 602 | "未解锁 Fork 切换拒绝(解锁唯一入口=购买面)" | not mp.tree_toggle_fork("fg_sense", "b") | (待填) | | | +| 998 | 604 | "零币切态 b(购买解锁后 A/B 可切)" | mp.tree_toggle_fork("fg_posture", "b") and sv.fork_side("fg_posture") == "b" | (待填) | | | +| 999 | 606 | "切态零消费(钥石余额差分不动)" | int(sv.keystone_balance()) == bal_pre_toggle | (待填) | | | +| 1000 | 607 | "规则键投影=激活态效果键(B 态稳健拓荒——tree_rule_keys 随切态换键集)" | (mp.tree_rule_keys() as Array).has("posture_bulwark") and not (mp.tree_rule_keys() as Array).has("posture_first_strike") | (待填) | | | +| 1001 | 609 | "切回 A 态键集随换(六键词表在册——V11 校验面归单元段)" | mp.tree_toggle_fork("fg_posture", "a") and (mp.tree_rule_keys() as Array).has("posture_first_strike") | (待填) | | | +| 1002 | 612 | "彩蛋不经购买(手动购亮=红——觉醒唯一入口=特化判定)" | not mp.tree_can_buy("easter_ley") | (待填) | | | +| 1003 | 613 | "觉醒前置未满足不亮(两线未拉满)" | sv.tree_level("easter_ley") == 0 | (待填) | | | +| 1004 | 618 | "两线拉满+同调位全解锁 → 地脉共鸣自动点亮(零币特化判定)" | sv.tree_level("easter_ley") == 1 | (待填) | | | +| 1005 | 621 | "觉醒零消费(钥石余额不动)" | int(sv.keystone_balance()) == bal_now | (待填) | | | + +## S119 _smoke_tree_rules(行 633–758 · 21 条 · 源 game/tests/unit/core/meta_progression_forge_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1006 | 633 | "先手压制:每关首战首轮窗命中(battle_index=1 且 round=1 → grant_ap queue)〔天赋树批 V10〕" | (fx.dispatch(ctx_p, "round_start", {"battle_index": 1, "round_index": 1}, Callable()) as Array).size() == 1 | (待填) | | | +| 1007 | 635 | "先手压制:首轮窗外地不触发(第二轮/第二关首战首轮——「每战首轮」旧语义换代件)" | (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() | (待填) | | | +| 1008 | 638 | "稳健拓荒:首轮窗内受击出盾、窗外不出(shield damage_taken 同窗条件)" | (fx.dispatch(ctx_p, "damage_taken", {"battle_index": 1, "round_index": 1}, Callable()) as Array).size() == 1 and (fx.dispatch(ctx_p, "damage_taken", {"battle_index": 2, "round_index": 1}, Callable()) as Array).is_empty() | (待填) | | | +| 1009 | 654 | "锋:踩雷减半命中+限次记账(counter 0<2 闸开——damage_mult+counter_add 双行同预评估态)" | got_mult and got_add | (待填) | | | +| 1010 | 657 | "锋:限次耗尽全额(counter 2 不<2 闸关——每关限次 provisional 2)〔无状态化 V5〕" | (fz.dispatch(ctx_fz, "mine_hit", {}, Callable()) as Array).is_empty() | (待填) | | | +| 1011 | 663 | "锋:限次跨战持久(level 桶共享引用——替代旧副本搬运链)〔无状态化 V4〕" | (fz2.dispatch(ctx_fz2, "mine_hit", {}, Callable()) as Array).is_empty() | (待填) | | | +| 1012 | 685 | "锋:惊巢反制 mine_hit 减半分支受理(wa12 前提——halve_pct=50 效果行落 step_row)〔wa12〕" | nc_hit != "" | (待填) | | | +| 1013 | 687 | "惊巢反制效果行==「惊巢反制——危险格挖掘惩罚减半(6→3)」零「挖雷」(u13 换代)〔wa12〕" | nc_hit == "惊巢反制——危险格挖掘惩罚减半(6→3)" and not nc_hit.contains("挖雷") | (待填) | | | +| 1014 | 691 | "守侧 rig:战斗编排启动(tree_rules=sense_guard 携带)" | orch.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, "feats": [], "tree_rules": ["sense_guard"], "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}) | (待填) | | | +| 1015 | 703 | "守侧 rig:安全格定位(非危险格=标错负样本)" | safe.x >= 0 | (待填) | | | +| 1016 | 704 | "守侧 rig:首轮后 queue 干净(开局 posture queue 已兑现)" | int(orch.action_economy.pending_ap_delta()) == 0 | (待填) | | | +| 1017 | 708 | "守①:当回合标错且封印在场 → 下轮 AP+1(queue 排队——本轮 AP 逐位零变化)" | int(orch.action_economy.pending_ap_delta()) == 1 | (待填) | | | +| 1018 | 713 | "守③④:旧封印重判零触发+同格最多一次(_seal_miss_seen 去重闸=「当回合标错动作」判别器)" | int(orch.action_economy.pending_ap_delta()) == 1 | (待填) | | | +| 1019 | 717 | "守②负样本:标中/被挖封印零触发(标错事件只在 miss 分支发射——发射面过滤承载)" | int(orch.action_economy.pending_ap_delta()) == 1 | (待填) | | | +| 1020 | 721 | "守⑤ rig:次战编排启动(新战新队列实例)" | orch2.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, "feats": [], "tree_rules": ["sense_guard"], "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}) | (待填) | | | +| 1021 | 727 | "守⑤:末轮判出不返(queue 只在 notify_round_start 兑现、战亡即弃——无跨战泄漏)" | int(orch2.action_economy.pending_ap_delta()) == 0 | (待填) | | | +| 1022 | 733 | "散未激活:商品标价原价(无 economy_spend 键)" | int(lr._spend_discounted(100)) == 100 | (待填) | | | +| 1023 | 736 | "散激活:商品购买价 ×80%(provisional 折率——floor 偏向玩家、下限 1;刷新价不折=另一计价点零触碰)" | int(lr._spend_discounted(100)) == 80 and int(lr._spend_discounted(1)) == 1 | (待填) | | | +| 1024 | 740 | "聚 rig:基线编排启动(无增益侧)" | orch3.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, "feats": [], "tree_rules": [], "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}) | (待填) | | | +| 1025 | 749 | "聚 rig:增益编排启动(tree_rules=economy_hoard)" | orch4.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, "feats": [], "tree_rules": ["economy_hoard"], "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}) | (待填) | | | +| 1026 | 757 | "聚:战斗掉落乘算 ×1.5(provisional——同种子配对 %d→%d,入账前=结算框显示实得)" | gp_hoard == int(round(float(gp_base) * 1.5)) and gp_hoard >= gp_base | (待填) | | | + +## S120 _init(行 22–30 · 2 条 · 源 game/tests/unit/core/minesweeper_rules_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1027 | 22 | "数值表加载" | false | (待填) | | | +| 1028 | 30 | "生成返回棋盘(前置)" | false | (待填) | | | + +## S121 _test_install_invariants(行 48–56 · 4 条 · 源 game/tests/unit/core/minesweeper_rules_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1029 | 48 | "装板后 current_board 即所装棋盘" | _rules.current_board() == _board | (待填) | | | +| 1030 | 52 | "装板带初始扩张揭示(%d 格,非零且未铺满安全格 %d)" ↩ % [opened, safe_total] | opened > 0 and opened < safe_total | (待填) | | | +| 1031 | 54 | "安全格余量 = 安全格总数 − 已揭示数(%d − %d)" ↩ % [safe_total, opened] | _rules.safe_remaining() == safe_total - opened | (待填) | | | +| 1032 | 56 | "装板后仍有未揭示格" | _rules.has_unrevealed() | (待填) | | | + +## S122 _test_dig_face_rules(行 73–80 · 2 条 · 源 game/tests/unit/core/minesweeper_rules_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1033 | 73 | "挖已揭示格被拒(board_face=revealed,禁重复挖)——载荷 %s" ↩ % str(o.get("sub_reason", "")) | String(o.get("outcome", "")) == "rejected" and String(o.get("sub_reason", "")) == "target_revealed" | (待填) | | | +| 1034 | 79 | "挖非相邻格被拒(邻接规则)——载荷 %s" ↩ % str(o2.get("sub_reason", "")) | String(o2.get("outcome", "")) == "rejected" | (待填) | | | + +## S123 _test_reveal_advances(行 86–93 · 4 条 · 源 game/tests/unit/core/minesweeper_rules_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1035 | 86 | "存在未揭示安全格(揭示推进前置)" | false | (待填) | | | +| 1036 | 90 | "专长揭示受理(fx_reveal 不受邻接约束)" | ok | (待填) | | | +| 1037 | 91 | "fx_reveal 后该格已揭示" | _rules.is_revealed(c) | (待填) | | | +| 1038 | 92 | "fx_reveal 后安全格余量递减(%d → %d)" ↩ % [before, _rules.safe_remaining()] | _rules.safe_remaining() < before | (待填) | | | + +## S124 _test_seal_place(行 99–105 · 3 条 · 源 game/tests/unit/core/minesweeper_rules_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1039 | 99 | "存在未揭示安全格(封印前置)" | false | (待填) | | | +| 1040 | 104 | "封印后该格在封印态" | _rules.has_seal(c) | (待填) | | | +| 1041 | 105 | "封印数计数 = 1" | _rules.seal_count() == 1 | (待填) | | | + +## S125 _test_reject_out_of_catalog(行 111–112 · 1 条 · 源 game/tests/unit/core/minesweeper_rules_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1042 | 111 | "目录外意图被拒(不静默吞掉)——实际载荷 %s" ↩ % str(o).substr(0, 80) | out.size() == 1 and String(o.get("outcome", o.get("result", ""))) == "rejected" | (待填) | | | + +## S126 _test_output_ok(行 88–89 · 2 条 · 源 game/tests/unit/core/node_map_generator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1043 | 88 | "三档×20 种子产出图过产出约束五条+结构铁律(自证零错误)〔fv1〕" | all_ok | (待填) | | | +| 1044 | 89 | "入口数/终点数 = 参数档 entry_count/exit_count〔fv1〕" | entry_exit_ok | (待填) | | | + +## S127 _test_reproducible(行 99–119 · 3 条 · 源 game/tests/unit/core/node_map_generator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1045 | 99 | "同(种子,参数档)两次产图逐位同图〔fv3〕" | same | (待填) | | | +| 1046 | 109 | "counter 无关(不同号位态同种子同图)〔fv3〕" | _sig(gen_a.generate(_params(2), 42)) == _sig(gen_b.generate(_params(2), 42)) and _sig(gen_a.generate(_params(3), 7)) == _sig(gen_b.generate(_params(3), 7)) | (待填) | | | +| 1047 | 119 | "同参数档不同种子产出异图(10 种子去重数 ≥ 8)〔fv3〕" | distinct >= 8 | (待填) | | | + +## S128 _test_fork_rules(行 163–164 · 2 条 · 源 game/tests/unit/core/node_map_generator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1048 | 163 | "任意岔口各去路下一节点类型互不相同〔fv5〕" | next_hop_ok | (待填) | | | +| 1049 | 164 | "任意岔口各支路类型构成互异且各有独有类型〔fv5〕" | comp_ok | (待填) | | | + +## S129 _test_shop_and_attribution(行 228–230 · 3 条 · 源 game/tests/unit/core/node_map_generator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1050 | 228 | "任意入口→终点路径恰过一家店〔fv6〕" | paths_ok | (待填) | | | +| 1051 | 229 | "每终点前一位恰一店+商店总数=终点数(归属锁定)〔fv6〕" | lock_ok | (待填) | | | +| 1052 | 230 | "多终点落在互异节点深度(L2/L3)〔fv6〕" | depth_ok | (待填) | | | + +## S130 _test_battle_floor(行 257–257 · 1 条 · 源 game/tests/unit/core/node_map_generator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1053 | 257 | "任意合法产出战斗节点数 ≥ battle_floor〔fv7〕" | ok | (待填) | | | + +## S131 _test_exit_structure(行 285–287 · 3 条 · 源 game/tests/unit/core/node_map_generator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1054 | 285 | "三关参数档生成图各含 ≥1 岔口节点(第一关即含)〔fv9〕" | fork_all | (待填) | | | +| 1055 | 286 | "L1 生成图终点恰 1(出口恒 1)〔fv9〕" | l1_single | (待填) | | | +| 1056 | 287 | "L2/L3 含分区域大岔口(一节点可达 ≥2 终点且去路数=出口数)〔fv9〕" | big_fork_ok | (待填) | | | + +## S132 _test_rng_single_point(行 313–318 · 3 条 · 源 game/tests/unit/core/node_map_generator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1057 | 313 | "pure_stream 不推全局计数器(产图前后 counter 同值)〔fv10〕" | svc.counter() == before | (待填) | | | +| 1058 | 316 | "pure_stream 同 (seed,scope) 同流〔fv10〕" | a.randf() == b.randf() and a.randi() == b.randi() | (待填) | | | +| 1059 | 317 | "pure_stream 不推计数器且产图非空〔fv10〕" | svc.counter() == before and (g["nodes"] as Array).size() > 0 | (待填) | | | + +## S133 _test_negative_fixtures(行 328–412 · 13 条 · 源 game/tests/unit/core/node_map_generator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1060 | 328 | "句柄重复夹具报 dup_key〔fv1〕" | _has_cat(_gen.validate_output(dup), "dup_key") | (待填) | | | +| 1061 | 332 | "悬空出边夹具报 dangling_next〔fv1〕" | _has_cat(_gen.validate_output(dang), "dangling_next") | (待填) | | | +| 1062 | 336 | "类型出域夹具报 enum〔fv1〕" | _has_cat(_gen.validate_output(badty), "enum") | (待填) | | | +| 1063 | 343 | "战斗节点低于 battle_floor 的合法形状夹具命中「低于保底」判据〔fv7〕" | _has_msg(_gen.validate_output(thin_battle, {"battle_floor": 4}), "低于保底") | (待填) | | | +| 1064 | 345 | "同夹具在保底 1 下自证零错误(判据不误伤)〔fv7〕" | _gen.validate_output(thin_battle, {"battle_floor": 1}).is_empty() | (待填) | | | +| 1065 | 350 | "非 Boss 终点夹具命中「终点(无出边)非 Boss」判据〔fv1〕" | _has_msg(_gen.validate_output(noBoss), "终点(无出边)非 Boss") | (待填) | | | +| 1066 | 361 | "合法合流图(第二入口直插主链,深度取最长路 3)自证零错误〔fv1〕" | _gen.validate_output(conf_legal).is_empty() | (待填) | | | +| 1067 | 370 | "声明深度高于最长路径步数被拒(深度=自入口最长路径步数)〔fv1〕" | _has_msg(_gen.validate_output(conf_high), "自入口最长路径步数") | (待填) | | | +| 1068 | 377 | "回边夹具报前进方向违例(structural)〔fv1〕" | _has_cat(_gen.validate_output(loop), "structural") | (待填) | | | +| 1069 | 387 | "多终点同深度夹具报 structural〔fv6〕" | _has_cat(_gen.validate_output(twin), "structural") | (待填) | | | +| 1070 | 397 | "双终点异深+区域类型互异合法夹具自证零错误〔fv6〕" | _gen.validate_output(twin_ok).is_empty() | (待填) | | | +| 1071 | 404 | "终点前驱非店夹具报 structural(必经恰一店/商店数)〔fv6〕" | _has_cat(cats, "structural") | (待填) | | | +| 1072 | 412 | "岔口同类型下一跳夹具报 structural〔fv5〕" | _has_cat(_gen.validate_output(sameHop), "structural") | (待填) | | | + +## S134 _test_growth(行 57–58 · 2 条 · 源 game/tests/unit/core/node_map_growth_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1073 | 57 | "生长递增:三档最长路径步数逐关变大〔fv8〕" | steps_ok | (待填) | | | +| 1074 | 58 | "生长递增:节点总数逐关变大且增幅变大〔fv8〕" | count_ok | (待填) | | | + +## S135 _test_fail_fast_static(行 64–74 · 3 条 · 源 game/tests/unit/core/node_map_growth_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1075 | 64 | "生产三档参数档可行性判定零错误〔fv4〕" | NodeMapGenerator.check_params(db.level_gen_params_row(1)).is_empty() and NodeMapGenerator.check_params(db.level_gen_params_row(3)).is_empty() | (待填) | | | +| 1076 | 70 | "层深 6 放三出口无解 → check_params 拦截〔fv4〕" | not NodeMapGenerator.check_params(unsolvable).is_empty() | (待填) | | | +| 1077 | 74 | "战斗保底超可放数无解 → check_params 拦截〔fv4〕" | not NodeMapGenerator.check_params(overfloor).is_empty() | (待填) | | | + +## S136 _test_fatal_subprocess(行 82–91 · 2 条 · 源 game/tests/unit/core/node_map_growth_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1078 | 82 | "未找到引擎二进制,致命子进程通道未跑(如实标注)〔fv4〕" | false | (待填) | | | +| 1079 | 91 | "无解参数档置致命封印并由组合根停机(退出码恰 1,无残图交付)〔fv4〕" | code == 1 | (待填) | | | + +## S137 _smoke_save(行 81–329 · 60 条 · 源 game/tests/unit/core/save_manager_domain_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1080 | 81 | "apply 原语入账(钥石 7+3、关卡完成次数 1/1/0)" | w.keystone_balance() == 10 and w.clear_count(1) == 1 and w.clear_count(2) == 1 and w.clear_count(3) == 0 | (待填) | | | +| 1081 | 83 | "首杀标记随关卡完成置位(1/2 有、3 无)" | w.boss_first_kill_marked(1) and w.boss_first_kill_marked(2) and not w.boss_first_kill_marked(3) | (待填) | | | +| 1082 | 85 | "生涯终身计数随 apply 自增恰一(中止局:总局数 1、冒险成功局数 0——迭代 18 卡④)" | w.adventure_total() == 1 and w.adventure_cleared() == 0 | (待填) | | | +| 1083 | 87 | "写盘成功" | w.write_to_disk() | (待填) | | | +| 1084 | 89 | "读回成功" | r.load_from_disk() | (待填) | | | +| 1085 | 90 | "钥石余额往返相等(10)" | r.keystone_balance() == 10 | (待填) | | | +| 1086 | 91 | "树域往返相等(tree_nodes/fork_sides——迭代 21 换代承载)" | r.tree_level("sp_hp") == 2 and r.tree_level("sp_hit") == 1 and r.fork_side("fg_posture") == "b" | (待填) | | | +| 1087 | 93 | "锻造工坊域往返相等(levels/unlocked/weapon/armor——迭代 23 T5)〔锻造工坊批 V8〕" | r.forge_route_level("spear") == 3 and r.forge_route_level("sword") == 0 and r.forge_unlocked_purchases() == ["spear"] and r.forge_weapon() == "spear" and r.forge_armor() == "ward" | (待填) | | | +| 1088 | 97 | "道具解锁往返相等" | r.item_unlocked("probe_compass") and not r.item_unlocked("rope") | (待填) | | | +| 1089 | 98 | "关卡完成次数表往返相等" | r.clear_count(1) == 1 and r.clear_count(2) == 1 and r.clear_count(3) == 0 | (待填) | | | +| 1090 | 99 | "首杀标记往返相等" | r.boss_first_kill_marked(1) and r.boss_first_kill_marked(2) and not r.boss_first_kill_marked(3) | (待填) | | | +| 1091 | 101 | "生涯终身计数往返相等(1/0)" | r.adventure_total() == 1 and r.adventure_cleared() == 0 | (待填) | | | +| 1092 | 103 | "冒险摘要往返相等(1 条,seed 99)" | r.recent_summaries().size() == 1 and int(s0.get("master_seed", -1)) == 99 and int(s0.get("keystone_total", -1)) == 3 | (待填) | | | +| 1093 | 108 | "新档合法态(load true + 全零默认)" | f2.load_from_disk() and f2.keystone_balance() == 0 and f2.tree_level("sp_hp") == 0 and not f2.item_unlocked("anything") and f2.clear_count(1) == 0 and f2.recent_summaries().is_empty() | (待填) | | | +| 1094 | 111 | "新档生涯终身计数缺省全零(迭代 18 卡④两新域——白板存档完整可玩)" | f2.adventure_total() == 0 and f2.adventure_cleared() == 0 | (待填) | | | +| 1095 | 117 | "余额 5 花 8 拒绝且余额不动" | not f3.spend_keystone(8) and f3.keystone_balance() == 5 | (待填) | | | +| 1096 | 118 | "余额内消费成功(5→2)" | f3.spend_keystone(3) and f3.keystone_balance() == 2 | (待填) | | | +| 1097 | 126 | "高版本拒读(load false)" | not f5.load_from_disk() | (待填) | | | +| 1098 | 127 | "拒读后原文件字节不动" | FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text | (待填) | | | +| 1099 | 128 | "拒读态写盘拒绝且文件仍字节不动" | not f5.write_to_disk() and FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text | (待填) | | | +| 1100 | 137 | "损坏档兜底(默认数据继续)" | f6.load_from_disk() and f6.keystone_balance() == 0 and f6.recent_summaries().is_empty() | (待填) | | | +| 1101 | 139 | "坏档备份 .corrupt.bak 存在" | FileAccess.file_exists(paths[4] + ".corrupt.bak") | (待填) | | | +| 1102 | 140 | "备份内容 = 原坏档字节" | FileAccess.open(paths[4] + ".corrupt.bak", FileAccess.READ).get_as_text() == junk | (待填) | | | +| 1103 | 151 | "v0 旧档经迁移链入账(keystone_old 12 → 余额 12)" | f7.load_from_disk() and f7.keystone_balance() == 12 | (待填) | | | +| 1104 | 153 | "迁移链 0→1(测试钩)→7(生产) 写盘 version == 7(v7=锻造工坊域,迭代 23 T5——v6=生涯终身计数域迭代 18 卡④)" | f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 7 | (待填) | | | +| 1105 | 165 | "域界干净——meta 域无局内资源键(发现 %s)" ↩ % str(leaked) | leaked.is_empty() | (待填) | | | +| 1106 | 173 | "摘要 cap 10(12 份 → 10 条)" | arr.size() == 10 | (待填) | | | +| 1107 | 174 | "最新在前(头 seed 11 / 尾 seed 2)" | int(arr[0].get("master_seed", -1)) == 11 and int(arr[9].get("master_seed", -1)) == 2 | (待填) | | | +| 1108 | 182 | "断点:新档无断点(合法空态——「继续上局」隐藏)" | not fbp.has_breakpoint() | (待填) | | | +| 1109 | 193 | "断点:写入+写盘成功" | fbp.has_breakpoint() and fbp.write_to_disk() | (待填) | | | +| 1110 | 195 | "断点:读回在档" | rb.load_from_disk() and rb.has_breakpoint() | (待填) | | | +| 1111 | 197 | "断点七字段往返:关卡序号/节点位/生命" | int(bp.get("level_index", -1)) == 2 and int(bp.get("node_id", -1)) == 5 and int(bp.get("hero_hp", -1)) == 13 | (待填) | | | +| 1112 | 201 | "断点七字段往返:属性加成区" | 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 | (待填) | | | +| 1113 | 204 | "断点七字段往返:GP 钱包" | int(bp.get("gp", -1)) == 17 | (待填) | | | +| 1114 | 206 | "断点七字段往返:商店已购档" | int(bp_shop.get("hp_small", -1)) == 2 and int(bp_shop.get("atk_small", -1)) == 1 | (待填) | | | +| 1115 | 208 | "断点七字段往返:生成图代差标记键" | int(bp.get("graph_gen_v2", 0)) == 1 | (待填) | | | +| 1116 | 209 | "断点附随件往返:主种子/计数器/钥石累计" | int(bp.get("master_seed", -1)) == 424242 and int(bp.get("rng_counter", -1)) == 38 and int(bp.get("keystone", -1)) == 6 | (待填) | | | +| 1117 | 213 | "断点附随件往返:已到节点序" | bp_visited.size() == 3 and int(bp_visited[0]) == 0 and int(bp_visited[2]) == 5 | (待填) | | | +| 1118 | 216 | "断点附随件往返:道具库存" | bp_treasure.size() == 1 and String(bp_treasure[0].get("item_id", "")) == "aux_scout" | (待填) | | | +| 1119 | 219 | "断点读面值拷贝(改读面不渗档)" | int((rb.breakpoint_snapshot() as Dictionary).get("hero_hp", -1)) == 13 | (待填) | | | +| 1120 | 222 | "断点:清除+写盘(三结局收束口径)" | not rb.has_breakpoint() and rb.write_to_disk() | (待填) | | | +| 1121 | 224 | "断点:清除后读回无断点" | rc.load_from_disk() and not rc.has_breakpoint() | (待填) | | | +| 1122 | 229 | "v1 旧档迁移 v7:断点域补空(合法空态,无断点域不炸)" | fo.load_from_disk() and fo.keystone_balance() == 4 and not fo.has_breakpoint() | (待填) | | | +| 1123 | 231 | "v1 旧档迁移后写盘 version == 7(v1→v7 链式——锻造工坊域 2026-10-12)" | fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 7 | (待填) | | | +| 1124 | 243 | "v2 旧档迁移 v3:断点域与 meta 域原样保留(缺新附随件=合法空态)" | fv2.load_from_disk() and fv2.has_breakpoint() and fv2.keystone_balance() == 7 | (待填) | | | +| 1125 | 245 | "v2 旧断点字段零丢失(向后兼容——breakpoint 内容不因 schema 升版重写)" | int((fv2.breakpoint_snapshot() as Dictionary).get("master_seed", 0)) == 777 | (待填) | | | +| 1126 | 248 | "v2 旧断点续玩受理(缺 bonus 附随件走常规重建路径——g 裁 semver 口径)" | adr_v2.resume_adventure(fv2.breakpoint_snapshot(), {"hero_override": {}}) | (待填) | | | +| 1127 | 257 | "v5 旧档升 v7:计数域缺省补零且既有余额原样(迁移只增字段不改值——v5→v6→v7 链式)" | f13.load_from_disk() and f13.adventure_total() == 0 and f13.adventure_cleared() == 0 and f13.keystone_balance() == 9 | (待填) | | | +| 1128 | 260 | "升档幂等:v7 档写盘再载零漂移(重复迁移零副作用——锻造工坊域同批补齐)" | f13.write_to_disk() and f13.load_from_disk() and int(JSON.parse_string(FileAccess.open(paths[11], FileAccess.READ).get_as_text()).get("version", -1)) == 7 and f13.adventure_total() == 0 and f13.adventure_cleared() == 0 | (待填) | | | +| 1129 | 268 | "收束自增恰一·冒险成功局(三关全通 cleared=true:总局数+1 且冒险成功局数+1)" | f13b.adventure_total() == 1 and f13b.adventure_cleared() == 1 | (待填) | | | +| 1130 | 272 | "收束自增恰一·中止局(放弃局:仅总局数+1,冒险成功局数不动)" | f13b.adventure_total() == 2 and f13b.adventure_cleared() == 1 | (待填) | | | +| 1131 | 283 | "换代前结构存档:树域废弃重置(旧节点 id/fork 侧/forge_levels 旧域残留清退)、钥石余额零折算零变动" | f15.load_from_disk() and f15.keystone_balance() == 33 and f15.tree_nodes().is_empty() and f15.fork_sides().is_empty() | (待填) | | | +| 1132 | 288 | "废弃重置后后续购买正常受理(无残留脏节点)" | mp16.tree_buy("sp_hp") and f15.tree_level("sp_hp") == 1 | (待填) | | | +| 1133 | 297 | "v6 旧档升 v7:既有域逐位不动(钥石/树/生涯计数零折算——迁移只增不改)〔锻造工坊批 V8〕" | f16.load_from_disk() and f16.keystone_balance() == 21 and f16.tree_level("sp_hp") == 1 and f16.adventure_total() == 4 and f16.adventure_cleared() == 1 | (待填) | | | +| 1134 | 300 | "v6 旧档缺键补齐=初始持有态(六路线 0 级/直购清单空/选中=sword+light)〔锻造工坊批 V8〕" | f16.forge_route_level("sword") == 0 and f16.forge_route_level("spear") == 0 and f16.forge_route_level("light") == 0 and f16.forge_route_level("ward") == 0 and f16.forge_unlocked_purchases().is_empty() and f16.forge_weapon() == "sword" and f16.forge_armor() == "light" | (待填) | | | +| 1135 | 307 | "forge_routes 域键集恰四项(无特效域——满级特效本期不做,契约 7)〔锻造工坊批 V8/V11〕" | fr_keys == ["armor", "levels", "unlocked", "weapon"] | (待填) | | | +| 1136 | 311 | "levels 六路线键集恰全(sword/spear/staff/light/heavy/ward)〔锻造工坊批 V8〕" | lv_keys == ["heavy", "light", "spear", "staff", "sword", "ward"] | (待填) | | | +| 1137 | 316 | "forge_routes_snapshot 值拷贝出户(改快照不渗档)〔锻造工坊批 V8〕" | f16.forge_route_level("sword") == 0 | (待填) | | | +| 1138 | 324 | "收束链自增·中止局(settle_adventure 真实挂点:总局数+1、冒险成功局数不动)" | f13b.adventure_total() == 3 and f13b.adventure_cleared() == 1 | (待填) | | | +| 1139 | 328 | "收束链自增·冒险成功局(三关全通:总局数+1 且冒险成功局数+1——全链恰一)" | f13b.adventure_total() == 4 and f13b.adventure_cleared() == 2 | (待填) | | | + +## S138 _smoke_save_slots(行 359–499 · 31 条 · 源 game/tests/unit/core/save_manager_domain_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1140 | 359 | "sv5 进档受理·槽 %d(激活+读盘)〔卡① sv5〕" ↩ % i | t.activate_slot(i) | (待填) | | | +| 1141 | 362 | "sv3 激活态写盘受理·槽 %d〔卡① sv1〕" ↩ % i | t.write_to_disk() | (待填) | | | +| 1142 | 366 | "sv1 三槽往返一致·槽 %d(异值按位读回)〔卡① sv1〕" ↩ % i | t2.activate_slot(i) and t2.keystone_balance() == 100 * i and t2.adventure_total() == 1 and t2.adventure_cleared() == (1 if i == 1 else 0) | (待填) | | | +| 1143 | 372 | "sv3 写盘受理·槽 1 加值后〔卡① sv1〕" | t.write_to_disk() | (待填) | | | +| 1144 | 373 | "sv1 位间隔离:改槽 1 槽 2 字节不动〔卡① sv1〕" | FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_before | (待填) | | | +| 1145 | 377 | "sv5 切激活至槽 3(删激活槽夹具前置)〔卡① sv5〕" | t.activate_slot(3) | (待填) | | | +| 1146 | 378 | "sv6 删档受理·激活槽 3〔卡① sv6〕" | t.delete_slot(3) | (待填) | | | +| 1147 | 379 | "sv6 删后文件不在盘+无 .tmp 残留〔卡① sv6〕" | not FileAccess.file_exists(slot_tpl % 3) and not FileAccess.file_exists(slot_tpl % 3 + ".tmp") | (待填) | | | +| 1148 | 381 | "sv6 删激活槽激活指针连清除(该槽不再可写)〔卡① sv6〕" | t.active_slot() == 0 and not t.has_write_target() | (待填) | | | +| 1149 | 385 | "sv6 删非激活槽受理·槽 2〔卡① sv6〕" | t.delete_slot(2) | (待填) | | | +| 1150 | 386 | "sv6 删非激活槽:文件不在盘+激活指针保持〔卡① sv6〕" | not FileAccess.file_exists(slot_tpl % 2) and t.active_slot() == 1 | (待填) | | | +| 1151 | 388 | "sv6 删槽 2 槽 1 字节不动(位间互隔离)〔卡① sv6〕" | FileAccess.get_file_as_bytes(slot_tpl % 1) == slot1_before | (待填) | | | +| 1152 | 397 | "sv4 新档受理·槽 3(创建即激活)〔卡① sv4〕" | t.create_slot(3) | (待填) | | | +| 1153 | 398 | "sv4 新档=全零可玩合法白板态(旧内容 999/5 零带入——钥石/总局数/断点全零)〔卡① sv4〕" | t.active_slot() == 3 and t.keystone_balance() == 0 and not t.has_breakpoint() and t.adventure_total() == 0 | (待填) | | | +| 1154 | 402 | "sv4 新档落盘=白板内容(盘面核验非 999 旧值)〔卡① sv4〕" | int((created.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 0 | (待填) | | | +| 1155 | 411 | "sv5 进档槽 1 读回(107)〔卡① sv10〕" | hv.activate_slot(1) and hv.keystone_balance() == 107 | (待填) | | | +| 1156 | 414 | "sv9 高版本槽进档拒绝(拒读禁写链按槽)〔卡① sv9〕" | not hv.activate_slot(2) | (待填) | | | +| 1157 | 415 | "sv10 激活失败恢复切换前三态(路径/激活号/数据归槽 1)〔卡① sv10〕" | hv.active_slot() == 1 and hv.keystone_balance() == 107 | (待填) | | | +| 1158 | 418 | "sv10 恢复后写盘落激活槽 1(高版本槽 2 零误写)〔卡① sv10〕" | hv.write_to_disk() and FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_hv | (待填) | | | +| 1159 | 429 | "sv9 迁移链按槽成立·槽 %d(v5→v7 各槽独立+写盘 version=7)〔卡① sv9〕" ↩ % i | mi.activate_slot(i) and mi.keystone_balance() == i * 11 and mi.write_to_disk() | (待填) | | | +| 1160 | 444 | "sv7 窥视三项逐槽正确·槽 %d〔卡① sv7〕" ↩ % i | bool(ps.get("readable", false)) and int(ps.get("keystone", -1)) == 200 * i and int(ps.get("cleared", -1)) == i and int(ps.get("total", -1)) == 5 * i | (待填) | | | +| 1161 | 447 | "sv6 删槽 3 备空槽窥视〔卡① sv6〕" | pv.delete_slot(3) | (待填) | | | +| 1162 | 449 | "sv7 空槽窥视=零值合法态〔卡① sv7〕" | bool(pe.get("readable", false)) and int(pe.get("keystone", -1)) == 0 and int(pe.get("cleared", -1)) == 0 and int(pe.get("total", -1)) == 0 | (待填) | | | +| 1163 | 458 | "sv8 坏槽窥视=显式损坏标记〔卡① sv8〕" | not bool(pb.get("readable", true)) | (待填) | | | +| 1164 | 459 | "sv8 坏槽窥视零副作用(字节+mtime 双比对不变+零 .corrupt.bak 改名)〔卡① sv8〕" | FileAccess.get_file_as_bytes(slot_tpl % 1) == bad_bytes and FileAccess.get_modified_time(slot_tpl % 1) == bad_mtime and not FileAccess.file_exists(slot_tpl % 1 + ".corrupt.bak") | (待填) | | | +| 1165 | 469 | "sv8 高版本槽窥视=不可读标记〔卡① sv8〕" | not bool(ph.get("readable", true)) | (待填) | | | +| 1166 | 470 | "sv8 高版本窥视零副作用(字节+mtime 不变+零改名)〔卡① sv8〕" | FileAccess.get_file_as_bytes(slot_tpl % 2) == hv_bytes and FileAccess.get_modified_time(slot_tpl % 2) == hv_mtime | (待填) | | | +| 1167 | 483 | "sv12 旧档字节迁入槽 1(逐域相等——余额 1234)〔卡① sv12〕" | int((migrated.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 1234 | (待填) | | | +| 1168 | 485 | "sv12 迁移后源改名留痕(.migrated.bak 在盘、原路径不在)〔卡① sv12〕" | not FileAccess.file_exists(legacy_src) and FileAccess.file_exists(legacy_src + ".migrated.bak") | (待填) | | | +| 1169 | 494 | "sv12 幂等:槽 1 已存在不重迁(预置 777 零覆盖、源保留)〔卡① sv12〕" | int((slot1_now.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 1234 and FileAccess.file_exists(legacy_src) | (待填) | | | +| 1170 | 497 | "sv12 迁移他槽零触碰(槽 2/3 字节不动)〔卡① sv12〕" | FileAccess.get_file_as_bytes(slot_tpl % 2) == s2_before and FileAccess.get_file_as_bytes(slot_tpl % 3) == s3_before | (待填) | | | + +## S139 _init(行 14–15 · 2 条 · 源 game/tests/unit/data_config/config_db_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1171 | 14 | "全部表加载 + 校验通过(含跨表护栏零 error 诊断)〔fv6〕" | load_ok and db.diagnostics_count("error") == 0 | (待填) | | | +| 1172 | 15 | "is_ready 通道随加载置位" | db.is_ready | (待填) | | | + +## S140 _test_progress_resolution(行 55–83 · 7 条 · 源 game/tests/unit/data_config/config_db_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1173 | 55 | "进度解析精确命中返回原行(board_gen 首行)" | int(got.get("level_index", -99)) == int(probe_row.level_index) and int(got.get("node_depth", -99)) == int(probe_row.node_depth) and String(got.get("board_type", "")) == String(probe_row.get("board_type", "")) | (待填) | | | +| 1174 | 63 | "enemy 行集同坐标解析非空(双表精确行契约——battle 行类)" | not en_got.is_empty() | (待填) | | | +| 1175 | 68 | "深度越最大行 clamp 取该关最大深度行" | int(deep.get("level_index", -99)) == min_l and int(deep.get("node_depth", -99)) == int(min_depths[-1]) | (待填) | | | +| 1176 | 70 | "clamp 解析发警告诊断(resolve_depth_clamp)" | db.diagnostics_count("warning") >= 1 | (待填) | | | +| 1177 | 75 | "警告类别 = resolve_depth_clamp" | found_clamp | (待填) | | | +| 1178 | 78 | "同键重复请求警告去重(四元组注册表)" | db.diagnostics_count("warning") == warn_before | (待填) | | | +| 1179 | 83 | "关卡越最大行 clamp 取最大关卡行" | int(high.get("level_index", -99)) == max_l | (待填) | | | + +## S141 _test_variant_pool_gate(行 108–118 · 6 条 · 源 game/tests/unit/data_config/config_db_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1180 | 108 | "变异池 unlock 前不含未解锁行(level=unlock−1)" | not _rows_contain(before, gated_row) | (待填) | | | +| 1181 | 109 | "变异池 unlock 当级含该行" | _rows_contain(after, gated_row) | (待填) | | | +| 1182 | 113 | "∅ board_types 行通配(任一 board_type 命中)" | _rows_contain(any_pool, wildcard_row) | (待填) | | | +| 1183 | 116 | "global 行 boss 节点入池(board_types ∅ 通配)" | _rows_contain(db.variant_pool(u3, "nonexistent_board_type_tag", "boss"), global_row) | (待填) | | | +| 1184 | 117 | "global 行缺省双参调用跳过(既有调用口径零扰动)" | not _rows_contain(db.variant_pool(u3, "nonexistent_board_type_tag"), global_row) | (待填) | | | +| 1185 | 118 | "global 行 battle 节点静默跳过(门控兜底零报错)" | not _rows_contain(db.variant_pool(u3, "nonexistent_board_type_tag", "battle"), global_row) | (待填) | | | + +## S142 _test_shop_accessors(行 133–152 · 8 条 · 源 game/tests/unit/data_config/config_db_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1186 | 133 | "ShopTable products 行族非空(表已已入库前提)" | false | (待填) | | | +| 1187 | 135 | "ShopTable products 行族非空" | true | (待填) | | | +| 1188 | 138 | "shop_product_row 按 product_id 精确命中首行" | got == first | (待填) | | | +| 1189 | 139 | "shop_product_row 未知 id 返回空字典(容错通道)" | db.shop_product_row("no_such_product_id__").is_empty() | (待填) | | | +| 1190 | 143 | "shop_gp_drop_row 按 tier 命中" | not db.shop_gp_drop_row(tier0).is_empty() | (待填) | | | +| 1191 | 147 | "shop_level_gp_bonus 按关卡序号命中行值" | db.shop_level_gp_bonus(blv) == int(bonus_rows[0].bonus) | (待填) | | | +| 1192 | 148 | "shop_level_gp_bonus 未登记关卡返回 0(兜底通道)" | db.shop_level_gp_bonus(-1) == 0 | (待填) | | | +| 1193 | 152 | "shop_price_level_bonus 按关卡序号命中行值" | db.shop_price_level_bonus(plv) == int(price_rows[0].bonus) | (待填) | | | + +## S143 _test_meta_accessors(行 162–166 · 2 条 · 源 game/tests/unit/data_config/config_db_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1194 | 162 | "meta_boss_keystone_row 按关卡序号命中" | db.meta_boss_keystone_row(klv) == key_rows[0] | (待填) | | | +| 1195 | 166 | "meta_conversion_row 按 attr 命中" | db.meta_conversion_row(cattr) == conv_rows[0] | (待填) | | | + +## S144 _test_manifest_dir_sync(行 188–188 · 1 条 · 源 game/tests/unit/data_config/config_db_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1196 | 188 | "TABLE_MANIFEST 与 data/tables 目录逐文件同步(无缺登/无悬登)" | synced | (待填) | | | + +## S145 _test_rowset_discipline(行 208–273 · 8 条 · 源 game/tests/unit/data_config/config_db_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1197 | 208 | "混编行集加载通过(同带键两行不同敌种相邻——行集恰一敌种校验退役的正向面)" | errs.is_empty() | (待填) | | | +| 1198 | 214 | "Boss 带混编行集加载通过(P4 乙——Boss 多行合法,无硬闸)" | errs.is_empty() | (待填) | | | +| 1199 | 225 | "行序相邻校验:同带键行被隔断报 rowset_rule 且定位带键〔fv9〕〔fv20〕" | gap_hit | (待填) | | | +| 1200 | 235 | "同带键同敌种二行被 dup_key 新键拦(纪律①——enemy_id 唯一)〔fv8〕" | dup_hit | (待填) | | | +| 1201 | 246 | "单行数量域 [1,3] 域闸不回归(UNIT_COUNT_MAX 语义 = 单种数)" | dom_hit | (待填) | | | +| 1202 | 256 | "非法 row_kind 被拒(封闭枚举 {battle, boss})〔fv7〕" | kind_hit | (待填) | | | +| 1203 | 265 | "缺 (battle, band 0) 锚行报 anchor〔fv10〕" | no_anchor_hit | (待填) | | | +| 1204 | 273 | "缺 Boss 行集报 anchor(每关 Boss 行集 ≥1)〔fv10〕" | no_boss_hit | (待填) | | | + +## S146 _init(行 17–17 · 1 条 · 源 game/tests/unit/data_config/forge_table_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1205 | 17 | "生产 forge.tres 随全表加载 + validate 零违例〔锻造工坊批 V1〕" | load_ok and db.diagnostics_count("error") == 0 | (待填) | | | + +## S147 _test_production_rows(行 30–81 · 13 条 · 源 game/tests/unit/data_config/forge_table_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1206 | 30 | "六路线行族规模恰 6(三武器+三甲)〔锻造工坊批 V1〕" | rows.size() == 6 | (待填) | | | +| 1207 | 35 | "路线 id 全集 = {sword, spear, staff, light, heavy, ward}〔锻造工坊批 V1〕" | ids == ["heavy", "light", "spear", "staff", "sword", "ward"] | (待填) | | | +| 1208 | 46 | "六行字段键集恰 = ROW_KEYS 闭集(无特效参数位)〔锻造工坊批 V11〕" | keys_closed | (待填) | | | +| 1209 | 53 | "初始持有恰 sword+light〔锻造工坊批 V1〕" | initial == ["light", "sword"] | (待填) | | | +| 1210 | 55 | "默认攻击类型轴 = normal(常驻持有)〔锻造工坊批 V1〕" | db.forge_default_attack_type() == "normal" | (待填) | | | +| 1211 | 56 | "默认甲型轴 = light(常驻持有)〔锻造工坊批 V1〕" | db.forge_default_armor_type() == "light" | (待填) | | | +| 1212 | 58 | "长枪/法杖 ← 长剑练满(并联双通道①)〔锻造工坊批 V1〕" | String(db.forge_route("spear").get("prereq_route", "")) == "sword" and String(db.forge_route("staff").get("prereq_route", "")) == "sword" | (待填) | | | +| 1213 | 61 | "重甲/魔法护甲 ← 轻甲练满(并联双通道①)〔锻造工坊批 V1〕" | String(db.forge_route("heavy").get("prereq_route", "")) == "light" and String(db.forge_route("ward").get("prereq_route", "")) == "light" | (待填) | | | +| 1214 | 64 | "四待解锁路线一次性解锁价 = 200〔锻造工坊批 V1〕" | int(db.forge_route("spear").get("unlock_price", 0)) == 200 and int(db.forge_route("staff").get("unlock_price", 0)) == 200 and int(db.forge_route("heavy").get("unlock_price", 0)) == 200 and int(db.forge_route("ward").get("unlock_price", 0)) == 200 | (待填) | | | +| 1215 | 70 | "甲型基础 AC = 轻 10 / 重 12 / 魔法护甲 9〔锻造工坊批 V1〕" | int(db.forge_route("light").get("base_ac", 0)) == 10 and int(db.forge_route("heavy").get("base_ac", 0)) == 12 and int(db.forge_route("ward").get("base_ac", 0)) == 9 | (待填) | | | +| 1216 | 75 | "forge_armor_route_by_type(ward) = 魔法护甲锻造〔锻造工坊批 V1〕" | String(db.forge_armor_route_by_type("ward").get("route_id", "")) == "ward" | (待填) | | | +| 1217 | 77 | "forge_weapon_route_by_type(pierce) = 长枪锻造〔锻造工坊批 V1〕" | String(db.forge_weapon_route_by_type("pierce").get("route_id", "")) == "spear" | (待填) | | | +| 1218 | 80 | "未知 route_id / 未知类型轴 = 空字典〔锻造工坊批 V1〕" | db.forge_route("blade").is_empty() and db.forge_armor_route_by_type("plate").is_empty() | (待填) | | | + +## S148 _test_price_ladder(行 91–94 · 2 条 · 源 game/tests/unit/data_config/forge_table_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1219 | 91 | "长剑各级价格 = 20/30/45/67/101(基础 20×150%^已购级数)〔锻造工坊批 V2〕" | prices == [20, 30, 45, 67, 101] | (待填) | | | +| 1220 | 93 | "已满级(owned=5)再购价照算 151(cap 闸归写口,价格函数纯几何)〔锻造工坊批 V2〕" | ForgeTableScript.level_price(row, 5) == 151 | (待填) | | | + +## S149 _test_negative_cases(行 133–189 · 2 条 · 源 game/tests/unit/data_config/forge_table_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1221 | 133 | "负向夹具(合法六行)validate 零违例〔锻造工坊批 V1〕" | t_ok.validate().is_empty() | (待填) | | | +| 1222 | 189 | "锻造 schema 负向:%s〔锻造工坊批 V1〕" ↩ % label | _has_cat(errs, expect_cat) | (待填) | | | + +## S150 _test_effect_kinds_closed(行 197–200 · 2 条 · 源 game/tests/unit/data_config/forge_table_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1223 | 197 | "效果引擎效果类型闭集 32 种(锻造工坊批零新增口径随迭代 31 卡② 扩签 forgive_seal_miss 换代)〔锻造工坊批 V11〕" | EffectEngineScript.EFFECT_KINDS.size() == 32 | (待填) | | | +| 1224 | 199 | "效果引擎事件词表在册 20 种(本批零扩签参照面)〔锻造工坊批 V11〕" | EffectEngineScript.EVENTS.size() == 20 | (待填) | | | + +## S151 _init(行 16–17 · 1 条 · 源 game/tests/unit/data_config/level_gen_params_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1225 | 16 | "生产 level_gen_params.tres 随全表加载 + validate 零违例〔fv2〕" | load_ok and db.diagnostics_count("error") == 0 | (待填) | | | + +## S152 _test_production(行 47–55 · 4 条 · 源 game/tests/unit/data_config/level_gen_params_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1226 | 47 | "访问器读回 L1 行五旋钮齐备〔fv2〕" | int(r1.get("layers", 0)) == 6 and is_equal_approx(float(r1.get("branch_density", 0.0)), 0.3) and int(r1.get("exit_count", 0)) == 1 and int(r1.get("entry_count", 0)) == 1 | (待填) | | | +| 1227 | 50 | "访问器读回 L2/L3 行层深 9/13 递增〔fv2〕" | int(r2.get("layers", 0)) == 9 and int(r3.get("layers", 0)) == 13 | (待填) | | | +| 1228 | 52 | "三行出口 = 1/2/3、战斗保底 = 4/6/8 与 §8.1 表对上〔fv2〕" | int(r1.get("battle_floor", 0)) == 4 and int(r2.get("exit_count", 0)) == 2 and int(r3.get("exit_count", 0)) == 3 and int(r3.get("battle_floor", 0)) == 8 | (待填) | | | +| 1229 | 55 | "生产表 validate 零错误〔fv2〕" | db.level_gen_params.validate().is_empty() | (待填) | | | + +## S153 _test_accessor(行 62–62 · 1 条 · 源 game/tests/unit/data_config/level_gen_params_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1230 | 62 | "row_by_level_index 缺行返回空字典〔fv2〕" | t.row_by_level_index(9).is_empty() | (待填) | | | + +## S154 _test_negative(行 72–105 · 11 条 · 源 game/tests/unit/data_config/level_gen_params_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1231 | 72 | "合法三行域校验零错误(夹具自证)〔fv2〕" | _table(base).validate().is_empty() | (待填) | | | +| 1232 | 75 | "L1 出口=2 被拒(出口 L1 恒 1 已锁)〔fv2〕" | not _table(bad_l1_exit).validate().is_empty() | (待填) | | | +| 1233 | 78 | "配比合计 0.5 被拒(四类合计 1 已锁)〔fv2〕" | not _table(bad_ratio).validate().is_empty() | (待填) | | | +| 1234 | 81 | "层深 5<6 被拒(层深下限已锁)〔fv2〕" | not _table(bad_layers).validate().is_empty() | (待填) | | | +| 1235 | 85 | "层深增幅倒挂被拒(递增且增幅变大)〔fv2〕" | not _table(bad_growth).validate().is_empty() | (待填) | | | +| 1236 | 88 | "出口 4 越域 [1,3] 被拒〔fv2〕" | not _table(bad_exit).validate().is_empty() | (待填) | | | +| 1237 | 92 | "岔口去路数 (2,4) 被拒(恒 2/3 已锁)〔fv2〕" | not _table([bad_fork_row, base[1], base[2]]).validate().is_empty() | (待填) | | | +| 1238 | 97 | "字段键集非 ROW_KEYS 闭集被拒〔fv2〕" | not _table([bad_keys_row, base[1], base[2]]).validate().is_empty() | (待填) | | | +| 1239 | 100 | "键重复行被拒〔fv2〕" | not _table([base[0], base[0]]).validate().is_empty() | (待填) | | | +| 1240 | 102 | "战斗保底 -1 被拒(≥0)〔fv2〕" | not _table([_row(1, 6, 0.3, [0.5, 0.2, 0.2, 0.1], 1, 1, -1), base[1], base[2]]).validate().is_empty() | (待填) | | | +| 1241 | 105 | "空表域校验零错误(合法态)〔fv2〕" | _table([]).validate().is_empty() | (待填) | | | + +## S155 _test_derive_determinism(行 36–40 · 2 条 · 源 game/tests/unit/data_config/rng_service_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1242 | 36 | "同种子两实例 derive_at(0..7) 逐位一致" | same | (待填) | | | +| 1243 | 40 | "derive_at 为纯函数不推计数器" | a2.counter() == before | (待填) | | | + +## S156 _test_counter_ledger(行 51–57 · 2 条 · 源 game/tests/unit/data_config/rng_service_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1244 | 51 | "open_stream 逐次推进全局计数器(+1/次)" | c1 == c0 + 1 and c2 == c0 + 2 | (待填) | | | +| 1245 | 57 | "同 scope 异用途标签派生种子一致(标签不参与派生)" | s1 == s2 | (待填) | | | + +## S157 _test_stream_draw_determinism(行 71–71 · 1 条 · 源 game/tests/unit/data_config/rng_service_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1246 | 71 | "同种子两实例同标签流取数 12 次逐位一致" | same | (待填) | | | + +## S158 _test_negative_seed_normalization(行 79–79 · 1 条 · 源 game/tests/unit/data_config/rng_service_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1247 | 79 | "负种子 abs 归一化后派生与正种子一致" | pos.derive_at(0) == neg.derive_at(0) | (待填) | | | + +## S159 _smoke_initialize(行 28–53 · 13 条 · 源 game/tests/unit/data_config/rng_stream_boot_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1248 | 28 | "全部表加载 + 校验通过(含跨表护栏)" | load_ok | (待填) | | | +| 1249 | 37 | "统一流向量 m=42 scope=0(=旧 board_gen scope0 连续性锚)" | rng.derive_at(0) == 8672514112661456564 | (待填) | | | +| 1250 | 38 | "统一流向量 m=42 scope=1" | rng.derive_at(1) == 8834291164406037215 | (待填) | | | +| 1251 | 39 | "统一流向量 m=42 scope=2" | rng.derive_at(2) == 7714373262647006282 | (待填) | | | +| 1252 | 40 | "统一流向量 m=42 scope=3" | rng.derive_at(3) == 1280102845971471347 | (待填) | | | +| 1253 | 41 | "统一流向量 m=42 scope=4" | rng.derive_at(4) == 82585501446482399 | (待填) | | | +| 1254 | 42 | "统一流向量 m=42 scope=5" | rng.derive_at(5) == 8078924421474470922 | (待填) | | | +| 1255 | 43 | "统一流向量 m=42 scope=6" | rng.derive_at(6) == 6638052740430622424 | (待填) | | | +| 1256 | 44 | "统一流向量 m=42 scope=7" | rng.derive_at(7) == 8264196488417721294 | (待填) | | | +| 1257 | 45 | "derive_at 纯函数恒等" | rng.derive_at(0) == rng.derive_at(0) | (待填) | | | +| 1258 | 49 | "open_stream 全局计数器单调 +1 ×2" | rng.counter() == 2 | (待填) | | | +| 1259 | 50 | "首个取号 = scope 0(顺序确定)" | s0.seed == rng.derive_at(0) and s1.seed == rng.derive_at(1) | (待填) | | | +| 1260 | 53 | "new_adventure 重置全局计数器" | rng.counter() == 1 and s2.seed == rng.derive_at(0) | (待填) | | | + +## S160 _smoke_adventure_table(行 92–203 · 18 条 · 源 game/tests/unit/data_config/table_structure_guard_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1261 | 92 | "db.adventure 已加载(adventure.tres 入 manifest)" | _db.adventure != null | (待填) | | | +| 1262 | 95 | "AdventureTable.validate() 零违例(全键域检+档位配额结构锚在案)" | _db.adventure.validate().is_empty() | (待填) | | | +| 1263 | 96 | "clear_heal_pct ∈ [0,100](%d——关卡完成部分回血比例)" ↩ % int(_db.adventure.clear_heal_pct) | int(_db.adventure.clear_heal_pct) >= 0 and int(_db.adventure.clear_heal_pct) <= 100 | (待填) | | | +| 1264 | 99 | "档位配额结构锚(四级 70/25/7/0——迭代 31 卡② 净删两件投放):%s = %d 张" ↩ % [tier, int(_db.adventure.TIER_QUOTAS[tier])] | int(_db.adventure.tier_total(tier)) == int(_db.adventure.TIER_QUOTAS[tier]) | (待填) | | | +| 1265 | 101 | "令牌池总量 = 102 张(四级 普通 70/优秀 25/稀有 7/传说 0——迭代 31 卡② 净删两件投放 104→102)〔凭证裁削批 V5〕" | int(_db.adventure.deck_total()) == 102 | (待填) | | | +| 1266 | 102 | "数值对(四级换代承袭):小GP 20/大GP 50 · 小增伤 +1/大 +2 · 小生命 +5/大 +10 · 小护甲 +1/大 +2" | int(_db.adventure.curio_value("gp", "common")) == 20 and int(_db.adventure.curio_value("gp", "excellent")) == 50 and int(_db.adventure.curio_value("attack", "common")) == 1 and int(_db.adventure.curio_value("attack", "excellent")) == 2 and int(_db.adventure.curio_value("max_hp", "common")) == 5 and int(_db.adventure.curio_value("max_hp", "excellent")) == 10 and int(_db.adventure.curio_value("armor", "common")) == 1 and int(_db.adventure.curio_value("armor", "excellent")) == 2 | (待填) | | | +| 1267 | 107 | "恢复件 +6 当即回血(优秀档承袭)· AP +1(稀有档承袭)〔专长批 V14 换代〕" | int(_db.adventure.curio_value("heal", "excellent")) == 6 and int(_db.adventure.curio_value("ap", "rare")) == 1 | (待填) | | | +| 1268 | 109 | "cap 占位在域(案②提前退役——99=实质无上限,实数归调参批)〔专长批 V8 形态面〕" | int(_db.adventure.curio_cap("gp")) == 99 and int(_db.adventure.curio_cap("attack")) == 99 | (待填) | | | +| 1269 | 111 | "同调位读数 null 档早退分支求值 0(迭代 21 T4 槽位收编——谓词表已退役)〔天赋树批 V14·核代码增补〕" | Attunement.slots(_db, null) == 0 | (待填) | | | +| 1270 | 113 | "同调位读数源=树同调位节点点亮数(非空档——socket1 点亮 1 件计 1)〔天赋树批 V14·核代码增补〕" | Attunement.slots(_db, _TreeSaveMock.new()) == 1 | (待填) | | | +| 1271 | 120 | "新目录 12 件全量落表(旧 12 行删除;装配词表=活行 12 件零重复)〔秘宝批 V1〕" | trows.size() == 12 and tid_set.size() == 12 | (待填) | | | +| 1272 | 131 | "命名锁定:12 件 item_id 与 display_name 逐件=正式名(C2 代号即名)〔秘宝批 V2 初筛〕" | names_ok | (待填) | | | +| 1273 | 159 | "逐行解锁方式与契约 8 分配一致(钥石 8 件带价格、条件 4 件带谓词行互斥)+三档 7/3/2〔秘宝批 V1〕" | alloc_ok and tier_map["common"] == 7 and tier_map["excellent"] == 3 and tier_map["rare"] == 2 | (待填) | | | +| 1274 | 162 | "装配→战斗贯通:enc.attunement=[morning_order] 起战 attune_has=true(注入面+只读出口)〔秘宝批换主语〕" | orch_l.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "attunement": ["morning_order"]}) and orch_l.attune_has("morning_order") | (待填) | | | +| 1275 | 166 | "空装配基线:不带 attunement 起战 attune_has=false(off 态零扰动)〔秘宝批换主语〕" | orch_e.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) and not orch_e.attune_has("morning_order") | (待填) | | | +| 1276 | 181 | "新 12 行效果词零棋盘改写类(危险格/安全格分布同池冻结——真视/开格动作外无写面)〔秘宝批 V9〕" | v9_ok | (待填) | | | +| 1277 | 188 | "效果三方向覆盖在册(信息/行动资源/操作能力=plan·抉择刷新——12 件目录结构对齐设计权威)〔秘宝批 V10 覆盖半〕" | tag_set.has("info") and tag_set.has("action") and tag_set.has("plan") | (待填) | | | +| 1278 | 202 | "装配面空目录合法态:白板档 12 件全未解锁且 mode 分派正确(空目录=合法可玩态)〔秘宝批 V18〕" | v18_ok and locked_n == 12 | (待填) | | | + +## S161 _smoke_enemy_bands(行 229–261 · 4 条 · 源 game/tests/unit/data_config/table_structure_guard_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1279 | 229 | "敌人表 28 行且键序 = 覆盖表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕" | _db.enemy.rows.size() == 28 and got_keys == expect_keys | (待填) | | | +| 1280 | 236 | "hobgoblin 行在 (1,battle,3) 留任随迁(缺口行不删不补——补齐归敌种内容批)〔fv12〕〔fv25〕" | int(hob.get("level_index", -1)) == 1 and String(hob.get("row_kind", "")) == "battle" \\ and int(hob.get("depth_band", -1)) == 3 | (待填) | | | +| 1281 | 239 | "hobgoblin 缺口登记随行(表文件注释在位——缺口不静默)〔fv25〕" | FileAccess.get_file_as_string("res://data/tables/enemy.tres").contains("hobgoblin 行为缺口行") | (待填) | | | +| 1282 | 260 | "三关 Boss 行集带键 = 覆盖表(模板终点 4/9/9 在册+生成图终点 6/8/9/11/12/13 扩带)%s〔fv13〕" ↩ % boss_detail | boss_ok | (待填) | | | + +## S162 _smoke_ac_swap_guard(行 272–380 · 13 条 · 源 game/tests/unit/data_config/table_structure_guard_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1283 | 272 | "状态表 defense_up 行已退役(无双轨)" | _db.status_effect_row("defense_up").is_empty() | (待填) | | | +| 1284 | 274 | "ac_up 行在位(hero · +1 ac · 只续时不叠层——2026-09-20 主角增益续时批)" | not ac_row.is_empty() and String(ac_row.get("target_side", "")) == "hero" \\ and String(ac_row.get("unit", "")) == "ac" and int(ac_row.get("magnitude", 0)) == 1 \\ and String(ac_row.get("stack_policy", "")) == "refresh" | (待填) | | | +| 1285 | 282 | "奖励表 defense 正向行 effect_id = ac_up(显示名盾牌加护,权重/tier 不变)" | String(ac_reward.get("effect_id", "")) == "ac_up" and String(ac_reward.get("display_name", "")) == "盾牌加护" | (待填) | | | +| 1286 | 284 | "effect_pool_map 无 defense_up/ac_up 键(减伤不入池——AC 不经乘区)" | not _db.settlement.core.effect_pool_map.has("defense_up") and not _db.settlement.core.effect_pool_map.has("ac_up") | (待填) | | | +| 1287 | 299 | "hero_defense 池 percent 行叠满不可触池底(墙最深%s > clamp_lo %d——单效果归零结构性不可达)" ↩ % [ wall_detail if wall_detail != "" else "(池内现无负向行)", int(_db.settlement.core.pool_clamp_lo)] | wall_ok | (待填) | | | +| 1288 | 305 | "hero_defense 池映射数==0(绊线钉——红=已加映射,去核上方护栏是否已同步激活)" | hd_maps == 0 | (待填) | | | +| 1289 | 307 | "主角 armor_class 在域内(%d——暂定基准随表走)" ↩ % int(_db.hero.armor_class) | int(_db.hero.armor_class) >= 0 and int(_db.hero.armor_class) <= 30 | (待填) | | | +| 1290 | 324 | "敌方 AC 行值在已裁带宽内(杂鱼 0~5·重击手/重装手 8~9·Boss 8~12——迭代 5 返工批 v1 重投,越带行%s)" ↩ % [ ac_detail if ac_detail != "" else "无"] | ac_band | (待填) | | | +| 1291 | 332 | "Boss AC 关卡台阶严格递增(%d < %d < %d)" ↩ % [int(boss_ac.get(1, 0)), int(boss_ac.get(2, 0)), int(boss_ac.get(3, 0))] | int(boss_ac.get(1, 0)) < int(boss_ac.get(2, 0)) and int(boss_ac.get(2, 0)) < int(boss_ac.get(3, 0)) | (待填) | | | +| 1292 | 348 | "熊地精首遇带(1,battle,%d)降档锚定(hp 22 · ac 2 · 池 attack6/charge2/heavy2 零改动——迭代 24 N1;首遇带数据驱动定位)〔fv21〕" ↩ % l12_band | l12_band >= 0 and not l12_row.is_empty() and int(l12_row.get("hp", -1)) == 22 \\ and int(l12_row.get("armor_class", -1)) == 2 \\ and l12_pool == {"attack": 6, "charge": 2, "heavy": 2} | (待填) | | | +| 1293 | 370 | "第一关相邻深度带总量比 ≤ 2.5(12/10/22/35/72——迭代 24 N1 曲线护栏,带键升序;越带%s)" ↩ % [ curve_detail if curve_detail != "" else "无"] | curve_ok | (待填) | | | +| 1294 | 375 | "掷点参数拍板值在位(d20 / nat20 暴击 ×200%% / 下限 1 / attack_bonus 0 / armor_roll 流)" | int(_db.settlement.damage_roll.roll_faces) == 20 and int(_db.settlement.damage_roll.crit_roll) == 20 \\ and int(_db.settlement.damage_roll.crit_multiplier) == 200 and int(_db.settlement.damage_roll.damage_floor) == 1 \\ and int(_db.settlement.damage_roll.attack_bonus) == 0 and String(_db.settlement.damage_roll.armor_roll_stream) == "armor_roll" | (待填) | | | +| 1295 | 379 | "档位表拍板值在位(缺口 1-3 → ×0.75 · 4-7 → ×0.5 · 8+ → ×0.25——无零档)" | bounds == [3, 7] and discounts == [75, 50, 25] | (待填) | | | + +## S163 _smoke_variation_framework(行 387–415 · 9 条 · 源 game/tests/unit/data_config/table_structure_guard_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1296 | 387 | "变异清单 6 行(荆棘丛/藤蔓缠绕/岩浆/烬覆 + 落石/矿尘——首批类型定稿批 + 第三关内容批;贫矿脉 2026-10-16 用户令屏蔽删行)" | _db.variation.rows.size() == 6 | (待填) | | | +| 1297 | 389 | "变异表校验零错误(6 行全过域与跨字段互锁——含 scope=global 三互锁负样本)" | _db.variation.validate().is_empty() | (待填) | | | +| 1298 | 394 | "L1 dungeon 候选池 = 荆棘丛+藤蔓缠绕(unlock + board_types 双闸)" | pool1_ids == ["thorn", "vine"] | (待填) | | | +| 1299 | 398 | "L2 underdark 候选池 = 岩浆+烬覆(第二关解锁)" | pool2_ids == ["magma", "ash"] | (待填) | | | +| 1300 | 399 | "unlock 门控:L1 不进 underdark 行(岩浆/烬覆第二关解锁)" | _db.variant_pool(1, "underdark").is_empty() | (待填) | | | +| 1301 | 403 | "board_types 门控:L2 dungeon 板池 = 荆棘丛+藤蔓缠绕(解锁累积;岩浆/烬覆被 board_types ∅ 交集排除)" | pool2d_ids == ["thorn", "vine"] | (待填) | | | +| 1302 | 405 | "清单外 variant_id 返回空(读取通道在位)" | _db.variation.row_by_id("nonexistent").is_empty() | (待填) | | | +| 1303 | 411 | "跨字段互锁负样本①:flat_damage + placement=any 被拒(只出现在未打开的格子)" | _errs_has_cross_field(vt.validate()) | (待填) | | | +| 1304 | 415 | "跨字段互锁负样本②:hide_number + trigger≠on_number_shown 被拒" | _errs_has_cross_field(vt.validate()) | (待填) | | | + +## S164 _smoke_difficulty_table(行 422–444 · 8 条 · 源 game/tests/unit/data_config/table_structure_guard_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1305 | 422 | "DifficultyTable MVP 空表加载(结构位)" | _db.difficulty != null and _db.difficulty.rows.is_empty() | (待填) | | | +| 1306 | 425 | "spawn 受理" | enemy_system.spawn(1, 0, int(_db.hero.max_hp)) | (待填) | | | +| 1307 | 428 | "空表零修饰(hp=%d atk=%d = 行值 %d/%d)" ↩ % [int(unit.hp), int(unit.atk), int(d0.hp), int(d0.atk)] | int(unit.hp) == int(d0.hp) and int(unit.atk) == int(d0.atk) | (待填) | | | +| 1308 | 433 | "修饰后 spawn 受理" | enemy_system_b.spawn(1, 0, int(_db.hero.max_hp)) | (待填) | | | +| 1309 | 435 | "hp 修饰 ×1.5(%d→%d,max_hp 同步)" ↩ % [int(d0.hp), int(g2.hp)] | int(g2.hp) == int(round(float(d0.hp) * 1.5)) and int(g2.max_hp) == int(g2.hp) | (待填) | | | +| 1310 | 437 | "atk 修饰 ×2.0(%d→%d)" ↩ % [int(d0.atk), int(g2.atk)] | int(g2.atk) == int(d0.atk) * 2 | (待填) | | | +| 1311 | 442 | "越域修饰 spawn 受理" | enemy_system_c.spawn(1, 0, int(_db.hero.max_hp)) | (待填) | | | +| 1312 | 443 | "越域乘数 clamp 至 10(%d→%d)" ↩ % [int(d0.hp), int(enemy_system_c.all_enemies()[0].hp)] | int(enemy_system_c.all_enemies()[0].hp) == int(d0.hp) * 10 | (待填) | | | + +## S165 _smoke_nine_types_gp_stream(行 452–494 · 6 条 · 源 game/tests/unit/data_config/table_structure_guard_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1313 | 452 | "格面资源十型封闭(第九型 gp 九型批 2026-09-20 / 第十型 treasure_item 秘宝内容批 2026-09-29——词表 v1.2)" | RewardTable.CELL_TYPES.size() == 10 and RewardTable.CELL_TYPES.has("gp") and RewardTable.CELL_TYPES.has("treasure_item") | (待填) | | | +| 1314 | 467 | "奖励字典 18 行(九型×正负 18 格——aux 挖格投放四行已退役 2026-10-01 用户拍板甲:战斗/挖格不掉道具,秘宝唯一来路=三选一共享池〔节点/关卡完成〕;清图槽独立批尾不计)" | cell_rows == 18 | (待填) | | | +| 1315 | 474 | "危险格负向直伤 %s power=%d(卡⑧投值)" ↩ % [rid, int(mine_power[rid])] | int(row.get("power", -1)) == int(mine_power[rid]) | (待填) | | | +| 1316 | 487 | "正向 cell 行权重 ≥1 全在池 / 负向行权重恒 0 不入池(正向合计 %d——配置推导)" ↩ % pos_weight | pos_all_in_pool and neg_all_zero | (待填) | | | +| 1317 | 489 | "gp 型两行(GP 袋 gp_gain 正向 weight 2 / 破财 gp_loss 负向 weight 0 不入池)" | gp_pos == 2 and gp_neg == 0 | (待填) | | | +| 1318 | 493 | "derive_at 纯函数不推计数器(统一流全局计数器恒 0——并流后纯函数语义不变,gp_drop 专用向量随分盐作废、统一向量中央断言)" | rng.counter() == 0 | (待填) | | | + +## S166 _smoke_meta(行 502–515 · 4 条 · 源 game/tests/unit/data_config/table_structure_guard_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1319 | 502 | "MetaTable 非空 + validate 零错(三行族词表封闭/唯一/连续/域界/first≥repeat/ratio 全过)" | meta_tbl != null and meta_tbl.validate().is_empty() | (待填) | | | +| 1320 | 504 | "MetaTable 行族规模 3/3/2(Boss/换算恰三/Y 升级两档——forge_products 迭代 21 退役、workshop_items 迭代 22 退役)" | meta_tbl.boss_keystone.size() == 3 and meta_tbl.conversion.size() == 3 and meta_tbl.attune_refresh_upgrade.size() == 2 | (待填) | | | +| 1321 | 510 | "conversion attr 词表封闭恰全(三属性轴——meta_* 访问器可达)" | attrs_ok | (待填) | | | +| 1322 | 515 | "boss_keystone L1–L3 行连续可达且 first_kill ≥ repeat_kill 全行(首杀全量/重复小额)" | boss_ok | (待填) | | | + +## S167 _init(行 13–14 · 2 条 · 源 game/tests/unit/example_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1323 | 13 | "headless SceneTree 协议可用(quit(code) 通道)" | _test_headless_protocol() | (待填) | | | +| 1324 | 14 | "命名规范可执行:test_[scenario]_[expected]" | _test_naming_convention_followed() | (待填) | | | + +## S168 _test_numeric_lines_carry_values(行 31–36 · 3 条 · 源 game/tests/unit/presentation/battle_narrator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1325 | 31 | "休整叙述带入回复量与当前血量(实际「%s」)" ↩ % rest | rest.contains("12") and rest.contains("30") and rest.contains("40") | (待填) | | | +| 1326 | 34 | "商店叙述带入 GP 数(实际「%s」)" ↩ % shop | shop.contains("77") | (待填) | | | +| 1327 | 35 | "奖励行与休整行互异(不是同一句套所有场景)" | NarratorScript.reward_line() != rest | (待填) | | | + +## S169 _test_end_text_differs_by_result(行 42–48 · 4 条 · 源 game/tests/unit/presentation/battle_narrator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1328 | 42 | "关卡收束态恒为「关卡完成!」(实际「%s」)" ↩ % level | level == "关卡完成!" | (待填) | | | +| 1329 | 45 | "单场胜负文案互异(胜「%s」/负「%s」)" ↩ % [win, lose] | win != lose | (待填) | | | +| 1330 | 46 | "胜利文案非空(实际「%s」)" ↩ % win | win != "" | (待填) | | | +| 1331 | 48 | "未知结果有兜底文案不吐空串(实际「%s」)" ↩ % unknown | unknown != "" | (待填) | | | + +## S170 _test_phase_text_nonempty(行 53–55 · 2 条 · 源 game/tests/unit/presentation/battle_narrator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1332 | 53 | "相位叙述非空(实际「%s」)" ↩ % t | t != "" | (待填) | | | +| 1333 | 55 | "Boss 在场时叙述带入 Boss 名(实际「%s」)" ↩ % boss | boss.contains("灰矮人石君王") | (待填) | | | + +## S171 _test_boss_name_uses_boss_rowset(行 82–83 · 2 条 · 源 game/tests/unit/presentation/battle_narrator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1334 | 82 | "Boss 名取自 Boss 行集首行(期望「%s」实际「%s」)〔fv24〕" ↩ % [boss_expect, got] | got == boss_expect | (待填) | | | +| 1335 | 83 | "Boss 名与同深度战斗行集名不同(期望「%s」≠「%s」——行类路由可判别)〔fv24〕" ↩ % [boss_expect, battle_name] | boss_expect != battle_name | (待填) | | | + +## S172 _test_level_clear_wording(行 90–101 · 4 条 · 源 game/tests/unit/presentation/battle_narrator_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1336 | 90 | "关卡完成回血行带入回血量与生命区间(实际「%s」)〔口径换代 V1〕" ↩ % healed | healed.contains("关卡完成回血 +5(生命 10 → 15)") | (待填) | | | +| 1337 | 93 | "满血零回血合法态文案(实际「%s」)〔口径换代 V1〕" ↩ % full_hp | full_hp.contains("生命已满——关卡完成回血 0(合法态)") | (待填) | | | +| 1338 | 96 | "奖励节点头部行渠道句点明关卡完成三选一(实际「%s」)〔口径换代 V2〕" ↩ % node_header | node_header.contains("与关卡完成专长三选一同库分流") | (待填) | | | +| 1339 | 100 | "冒险摘要完成关数行(实际「%s」)〔口径换代 V3〕" ↩ % summary | summary.contains("完成 1 关 · 止步第 2 层") and not summary.contains("通关") | (待填) | | | + +## S173 _run(行 27–102 · 13 条 · 源 game/tests/unit/presentation/forge_workbench_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1340 | 27 | "ConfigDb 加载(工作间测试夹具前提)" | false | (待填) | | | +| 1341 | 44 | "锁定态:工作间不可见(forge_workshop 钥匙未点亮)〔锻造工坊批 V13〕" | not ov._forge_workbench.visible and ov._forge_lock_l.visible | (待填) | | | +| 1342 | 46 | "锁定态:两区零行板(占位纪律——零购买路径)〔锻造工坊批 V13〕" | ov._forge_zone_weapon.get_child_count() == 0 and ov._forge_zone_armor.get_child_count() == 0 | (待填) | | | +| 1343 | 52 | "解锁态:工作间可见(钥匙点亮展开)〔锻造工坊批 V13〕" | ov._forge_workbench.visible | (待填) | | | +| 1344 | 53 | "两区各三路线(武器锻造区 3 + 护甲锻造区 3)〔锻造工坊批 V13〕" | ov._forge_zone_weapon.get_child_count() == 3 and ov._forge_zone_armor.get_child_count() == 3 | (待填) | | | +| 1345 | 56 | "六路线行册恰全(sword/spear/staff/light/heavy/ward)〔锻造工坊批 V13〕" | rows.has("sword") and rows.has("spear") and rows.has("staff") and rows.has("light") and rows.has("heavy") and rows.has("ward") | (待填) | | | +| 1346 | 63 | "长剑行:购买钮「锻造 20」(0 级位几何价)+选中钮「当前」禁用〔锻造工坊批 V13〕" | rsw.get("buy_btn") != null and String(rsw.buy_btn.text) == "锻造 20" and not rsw.buy_btn.disabled and rsw.get("select_btn") != null and String(rsw.select_btn.text) == "当前" and rsw.select_btn.disabled | (待填) | | | +| 1347 | 66 | "轻甲行:购买钮「锻造 20」+选中钮「当前」禁用〔锻造工坊批 V13〕" | rli.get("buy_btn") != null and String(rli.buy_btn.text) == "锻造 20" and String(rli.select_btn.text) == "当前" and rli.select_btn.disabled | (待填) | | | +| 1348 | 73 | "长枪行:解锁钮「解锁 200」+无购买钮+无选中钮〔锻造工坊批 V13〕" | rsp.get("unlock_btn") != null and String(rsp.unlock_btn.text) == "解锁 200" and not rsp.unlock_btn.disabled and rsp.get("buy_btn") == null and rsp.get("select_btn") == null | (待填) | | | +| 1349 | 76 | "魔法护甲行:解锁钮「解锁 200」+状态含并联双通道文案(练满轻甲锻造 或 200 钥石直购)〔锻造工坊批 V13〕" | rwa.get("unlock_btn") != null and String(rwa.status_l.text).contains("练满轻甲锻造") and String(rwa.status_l.text).contains("200") | (待填) | | | +| 1350 | 84 | "余额 0:长剑购买钮禁用+长枪解锁钮禁用(余额联动常态防线)〔锻造工坊批 V13〕" | rows.sword.buy_btn.disabled and rows.spear.unlock_btn.disabled | (待填) | | | +| 1351 | 92 | "长剑满级:无购买钮+状态「已满级」+长枪派生解锁出解锁钮消失(练满通道)〔锻造工坊批 V13〕" | rows.sword.get("buy_btn") == null and String(rows.sword.status_l.text) == "已满级" and rows.spear.get("unlock_btn") == null and rows.spear.get("buy_btn") != null | (待填) | | | +| 1352 | 101 | "三意图信号外发(buy=spear/unlock=heavy/select=spear——UI 只发意图)〔锻造工坊批 V13〕" | buys == ["spear"] and unlocks == ["heavy"] and selects == ["spear"] | (待填) | | | + +## S174 _test_accepted_text(行 20–20 · 1 条 · 源 game/tests/unit/presentation/hud_presenter_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1353 | 20 | "受理态文案固定为「执行完成。」(实际「%s」)" ↩ % t | t == "执行完成。" | (待填) | | | + +## S175 _test_reject_text_by_reason(行 36–38 · 2 条 · 源 game/tests/unit/presentation/hud_presenter_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1354 | 36 | "棋盘面各拒绝原因文案逐类正确(错 %d 项 %s)" ↩ % [bad.size(), str(bad)] | bad.is_empty() | (待填) | | | +| 1355 | 38 | "资源面拒绝文案为「AP 不足。」(实际「%s」)" ↩ % ap | ap == "AP 不足。" | (待填) | | | + +## S176 _test_unknown_reason_fallback(行 43–47 · 2 条 · 源 game/tests/unit/presentation/hud_presenter_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1356 | 43 | "未知拒绝原因兜底为「动作被拒。」而非空串(实际「%s」)" ↩ % t | t == "动作被拒。" | (待填) | | | +| 1357 | 47 | "棋盘面未知子因兜底带前缀可诊断(实际「%s」)" ↩ % sub | sub.begins_with("棋盘面拒绝: ") | (待填) | | | + +## S177 _test_fog_hides_type(行 51–56 · 3 条 · 源 game/tests/unit/presentation/level_map_info_fog_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1358 | 51 | "可见集内节点:图标/类型文字均绘制〔fv2〕" | bool(shown["draw_icon"]) and bool(shown["draw_label"]) | (待填) | | | +| 1359 | 54 | "雾下节点:图标与类型文字均不绘制(显隐语义,非灰化)〔fv2〕" | not bool(fogged["draw_icon"]) and not bool(fogged["draw_label"]) | (待填) | | | +| 1360 | 56 | "雾下非 Boss 节点无 Boss 描边〔fv2〕" | not bool(fogged["boss_border"]) | (待填) | | | + +## S178 _test_boss_exempt_and_multi_boss(行 65–73 · 4 条 · 源 game/tests/unit/presentation/level_map_info_fog_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1361 | 65 | "多终点:两个 Boss 节点均带 Boss 描边〔fv3〕" | bool(boss_a["boss_border"]) and bool(boss_b["boss_border"]) | (待填) | | | +| 1362 | 67 | "Boss 不入雾:可见集与图标/文字三通道均在〔fv3〕" | bool(boss_a["draw_icon"]) and bool(boss_a["draw_label"]) and bool(boss_b["draw_icon"]) | (待填) | | | +| 1363 | 70 | "Boss 豁免独立于可见集:不在 type_vis 的 Boss 仍出示类型〔fv3〕" | bool(far_boss["draw_icon"]) and bool(far_boss["draw_label"]) | (待填) | | | +| 1364 | 73 | "Boss 豁免不扩散:雾下非 Boss 仍隐类型〔fv3〕" | not bool(fogged_battle["draw_icon"]) | (待填) | | | + +## S179 _test_details_never_shown(行 87–89 · 2 条 · 源 game/tests/unit/presentation/level_map_info_fog_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1365 | 87 | "决策面不消费内容键:携带 enemy_id/reward_id 与不带者输出逐键相等〔fv5〕" | same | (待填) | | | +| 1366 | 88 | "label_text 不含内容值(实际「%s」)〔fv5〕" ↩ % String(b["label_text"]) | not String(b["label_text"]).contains("goblin_secret") and not String(b["label_text"]).contains("reward_secret") | (待填) | | | + +## S180 _test_label_three_states(行 96–101 · 3 条 · 源 game/tests/unit/presentation/level_map_info_fog_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1367 | 96 | "当前位文案=「当前位置」〔fv2〕" | String(ScreenScript.node_display(node, 3, [3], true, false)["label_text"]) == "当前位置" | (待填) | | | +| 1368 | 98 | "候选文案=「战斗 · 可选」〔fv2〕" | String(ScreenScript.node_display(node, 3, [3], false, true)["label_text"]) == "战斗 · 可选" | (待填) | | | +| 1369 | 100 | "其余文案=类型名「战斗」〔fv2〕" | String(ScreenScript.node_display(node, 3, [3], false, false)["label_text"]) == "战斗" | (待填) | | | + +## S181 _test_big_graph_fit(行 165–191 · 6 条 · 源 game/tests/unit/presentation/level_map_info_fog_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1370 | 165 | "大图夹具规模:≥11 层 × ≥6 槽(实测最大层 %d/最大槽 %d)〔fv8〕" ↩ % [maxl, maxslot] | maxl >= 10 and maxslot >= 5 | (待填) | | | +| 1371 | 171 | "大图拟合:画布 min.x 覆盖最深节点(%.1f ≥ %.1f)〔fv8〕" ↩ % [min_size.x, need_w] | min_size.x >= need_w - 0.01 | (待填) | | | +| 1372 | 173 | "大图拟合:画布 min.y 覆盖最宽层底节点(%.1f ≥ %.1f)〔fv8〕" ↩ % [min_size.y, need_h] | min_size.y >= need_h - 0.01 | (待填) | | | +| 1373 | 181 | "大图布局:全节点方块落在拟合画布内(origin=(%.1f,%.1f) size=(%.1f,%.1f))〔fv8〕" ↩ % [origin.x, origin.y, min_size.x, min_size.y] | all_in | (待填) | | | +| 1374 | 188 | "大图呈现:两个终点 Boss 均有描边标记〔fv8〕" | disp.has(boss_ids[0]) and disp.has(boss_ids[1]) and bool(disp[boss_ids[0]]["boss_border"]) and bool(disp[boss_ids[1]]["boss_border"]) | (待填) | | | +| 1375 | 191 | "大图呈现:入口停点远处战斗节点(句柄 32,层 6)类型入雾〔fv8〕" | not bool(disp[32]["draw_icon"]) | (待填) | | | + +## S182 _test_layout_cache(行 209–226 · 2 条 · 源 game/tests/unit/presentation/level_map_info_fog_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1376 | 209 | "布局缓存:同 nodes 两次查询返回同一实例(第二次命中缓存)〔levelmap批 V1〕" | is_same(p1, p2) | (待填) | | | +| 1377 | 224 | "布局缓存换代:新图查询返回新实例非旧缓存(失效重算生效)〔levelmap批 V2〕" | not is_same(p1, pc1) and is_same(pc1, pc2) and (pc1[chain[0]] as Vector2) != (p1[nodes[0]] as Vector2) | (待填) | | | + +## S183 _init(行 105–149 · 11 条 · 源 game/tests/unit/presentation/pacing_queue_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1378 | 105 | "排空前后核心诊断快照逐位一致(AC⑤ 演出关停零分叉)" | snap_before == snap_after | (待填) | | | +| 1379 | 107 | "结算行已产生(断言前提)" | rows_seen.size() > 0 | (待填) | | | +| 1380 | 108 | "排空后队列恒空(flush 完整性)" | main._row_queue.is_empty() | (待填) | | | +| 1381 | 117 | "末信号行原文在明细带(排空不吞行:『%s』)" ↩ % last_text | last_text != "" and main.log_view.parsed_text().contains(last_text) | (待填) | | | +| 1382 | 123 | "常态段间延迟 >0 且 ≥ 段内(%f/%f)" ↩ % [d_on, d_same] | d_on > 0.0 and d_on >= d_same | (待填) | | | +| 1383 | 125 | "减动效段间延迟归零(行不跳过——同帧直落快路)" | main.pace_delay_for("damage", "heal") == 0.0 | (待填) | | | +| 1384 | 135 | "结算标出按行 target_id 解析站桩(id 与名同传——d12)〔wd12〕" | fstage.calls.size() == 1 and fstage.calls[0] == ["goblin_b", "地精"] | (待填) | | | +| 1385 | 138 | "跨区弹道按行 target_id 解析站桩(id 与名同传——d12)〔wd12〕" | fstage.calls.size() == 2 and fstage.calls[1] == ["goblin_b", "地精"] | (待填) | | | +| 1386 | 140 | "两路解析成功后演出层各接一拍(记参面非空)〔wd12〕" | ffx.calls.size() == 2 | (待填) | | | +| 1387 | 147 | "会话更替夹具已入队(前提:%d 行)〔wr4〕" ↩ % main._row_queue.size() | main._row_queue.size() == 2 | (待填) | | | +| 1388 | 149 | "会话更替后待播队列清零(_begin_session 内机制 flush)〔wr4〕" | main._row_queue.is_empty() | (待填) | | | + +## S184 _sweep_hero_panel(行 47–50 · 2 条 · 源 game/tests/unit/presentation/player_copy_sweep_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1389 | 47 | "C 区专长段头==「专长」(u1 换代——段头文案钉值)〔wa1〕" | feat_head != null and feat_head.text == "专长" | (待填) | | | +| 1390 | 49 | "C 区属性值提升段头==「属性值提升」(u2 换代——同屏两段同标撞车消除)〔wa1〕" | curio_head != null and curio_head.text == "属性值提升" | (待填) | | | + +## S185 _sweep_narrator(行 57–64 · 7 条 · 源 game/tests/unit/presentation/player_copy_sweep_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1391 | 57 | "奖励点头部含「冒险牌库」(u3 机制事实①同库)〔wa4〕" | head.contains("冒险牌库") | (待填) | | | +| 1392 | 58 | "奖励点头部含「属性值提升与补给」(u3 机制事实②渠道分流)〔wa4〕" | head.contains("属性值提升与补给") | (待填) | | | +| 1393 | 59 | "奖励点头部含「同库分流」(u3 机制事实②措辞)〔wa4〕" | head.contains("同库分流") | (待填) | | | +| 1394 | 60 | "奖励点头部零「同权重」旧话(u3 清零)〔wa4〕" | not head.contains("同权重") | (待填) | | | +| 1395 | 61 | "奖励点头部零「同上限」旧话(u3 清零)〔wa4〕" | not head.contains("同上限") | (待填) | | | +| 1396 | 62 | "奖励点头部零「提升池」旧话(u3 清零)〔wa4〕" | not head.contains("提升池") | (待填) | | | +| 1397 | 63 | "专长卡兜底==「专长\\n???」(u11 换代——数据缺行真实兜底路径)〔wa3〕" | String(narrator.feat_card_text({})) == "专长\\n???" | (待填) | | | + +## S186 _sweep_legend(行 75–79 · 5 条 · 源 game/tests/unit/presentation/player_copy_sweep_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1398 | 75 | "图例封印行存在(wa5 夹具前提)〔wa5〕" | not seal_row.is_empty() | (待填) | | | +| 1399 | 76 | "图例封印行含「标空」(u4——与「标中」成对)〔wa5〕" | String(seal_row[2] if seal_row.size() > 2 else "").contains("标空") | (待填) | | | +| 1400 | 77 | "图例封印行含「受挖掘惩罚」(u4 雷字清零换代)〔wa5〕" | String(seal_row[2] if seal_row.size() > 2 else "").contains("受挖掘惩罚") | (待填) | | | +| 1401 | 78 | "图例封印行零「插错」(u4 旗族遗留词干清零)〔wa5〕" | not String(seal_row[2] if seal_row.size() > 2 else "").contains("插错") | (待填) | | | +| 1402 | 79 | "图例封印行零「挖雷」(u4 雷字清零)〔wa5〕" | not String(seal_row[2] if seal_row.size() > 2 else "").contains("挖雷") | (待填) | | | + +## S187 _sweep_source_names(行 92–104 · 4 条 · 源 game/tests/unit/presentation/player_copy_sweep_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1403 | 92 | "状态槽 mine_hit 效果行在座(wa6 夹具前提)〔wa6〕" | slot != null | (待填) | | | +| 1404 | 95 | "状态槽来源注记==「危险格」(u5 换代——meta tooltip 消费半)〔wa6〕" | row_view._tip_label.text.contains("来源:危险格") | (待填) | | | +| 1405 | 98 | "结算表 source_label_map[mine_hit]==「危险格」(u6 换代——ConfigDB 真装载)〔wa7〕" | String(_db.settlement.core.source_label_map.get("mine_hit", "")) == "危险格" | (待填) | | | +| 1406 | 103 | "战斗日志 mine_hit 来源行含「(危险格)」(u6 消费半——_format_row 生产路径)〔wa7〕" | row_txt.contains("(危险格)") | (待填) | | | + +## S188 _sweep_board_glyph(行 133–142 · 4 条 · 源 game/tests/unit/presentation/player_copy_sweep_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1407 | 133 | "棋盘夹具含挖掘射程内未开危险格(wa8 前提——实测 %d 步)〔wa8〕" ↩ % glow_steps | mine_cell.x >= 0 | (待填) | | | +| 1408 | 139 | "已开危险格 glyph==「危」(u7 换代)〔wa8〕" | u.glyph == "危" | (待填) | | | +| 1409 | 140 | "已开危险格 plate==true(u9 牌载哨兵包含项随动)〔wa8〕" | u.plate == true | (待填) | | | +| 1410 | 141 | "已开危险格 glyph_color==RED_MINE(u7 墨色不随字形换代漂移)〔wa8〕" | u.glyph_color == UiKit.RED_MINE | (待填) | | | + +## S189 _sweep_token_scans(行 148–158 · 2 条 · 源 game/tests/unit/presentation/player_copy_sweep_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1411 | 148 | "board_view.gd 源零 \\"雷\\" 字面量(u7/u8/u9 哨兵族统一钉住)〔wa8〕" | board_src.count("\\"雷\\"") == 0 | (待填) | | | +| 1412 | 157 | "battle_orchestrator.gd 玩家面字符串字面量零「挖雷」(u13——注释保留面不入域)〔wa12〕" | orch_hits == 0 | (待填) | | | + +## S190 _init(行 75–132 · 18 条 · 源 game/tests/unit/presentation/standee_fx_sweep_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1413 | 75 | "站桩 enemy_id 访问器读装载值〔wd12〕" | s1.enemy_id() == "goblin_a" and s2.enemy_id() == "goblin_b" | (待填) | | | +| 1414 | 76 | "同名敌按 id 精确解析(不做名子串撞车)〔wd12〕" | stage._card_by_id("goblin_b") == s2 and stage._card_by_id("goblin_a") == s1 | (待填) | | | +| 1415 | 78 | "空 id / 未登记 id 恒不命中(名兜底归调用点)〔wd12〕" | stage._card_by_id("") == null and stage._card_by_id("no_such") == null | (待填) | | | +| 1416 | 83 | "伤害行按 target_id 命中正确站桩(闪白落点)〔wd12〕" | s2._flash_count == 1 and s1._flash_count == 0 | (待填) | | | +| 1417 | 88 | "弹道落点按 target_id 取站桩中心〔wd12〕" | c_b != null and c_b == c_b_expect | (待填) | | | +| 1418 | 91 | "无 target_id 时按名兜底同旧行为(首命中)〔wd12〕" | c_fallback != null and c_fallback == c_fallback_expect | (待填) | | | +| 1419 | 95 | "蓄力技能(heavy)置 _intent_charge=true〔wd2〕" | s1._intent_charge | (待填) | | | +| 1420 | 97 | "非蓄力攻击技(goblin_slash)零置位〔wd2〕" | not s1._intent_charge | (待填) | | | +| 1421 | 99 | "增益系零置位〔wd2〕" | not s1._intent_charge | (待填) | | | +| 1422 | 101 | "普攻零表示路径复位 _intent_charge〔wd2〕" | not s1._intent_charge | (待填) | | | +| 1423 | 108 | "首开胜利画面创建庆祝句柄〔wd4〕" | first != null and first.is_valid() | (待填) | | | +| 1424 | 111 | "重开胜利画面:旧句柄已 kill、新句柄在位(至多一个活跃)〔wd4〕" | first != null and not first.is_valid() and second != null and second.is_valid() | (待填) | | | +| 1425 | 114 | "close 必杀庆祝句柄(句柄清空)〔wd4〕" | ov.get_meta(&"celebrate_tw", null) == null | (待填) | | | +| 1426 | 119 | "模拟运行期调试入口钮同置灰〔wd5〕" | dbg._dbg_btn.disabled | (待填) | | | +| 1427 | 121 | "模拟收尾调试入口钮恢复(未锁定)〔wd5〕" | not dbg._dbg_btn.disabled | (待填) | | | +| 1428 | 130 | "控制器运行中 is_running()=true(驱动回调内读数)〔wd8〕" | drv.seen == [true, true] | (待填) | | | +| 1429 | 131 | "控制器收尾 is_running()=false〔wd8〕" | not ctrl.is_running() and not panel.running | (待填) | | | +| 1430 | 132 | "两局报告全文注入面板(run 收尾链路完整)〔wd8〕" | String(panel.report) != "" | (待填) | | | + +## S191 _init(行 24–34 · 2 条 · 源 game/tests/unit/presentation/terrain_tile_sets_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1431 | 24 | "前置:表加载零 error" | load_ok and db.diagnostics_count("error") == 0 | (待填) | | | +| 1432 | 34 | "前置:board_gen 词表非空(%d 型)" ↩ % types.size() | types.size() > 0 | (待填) | | | + +## S192 _test_set_completeness(行 56–56 · 1 条 · 源 game/tests/unit/presentation/terrain_tile_sets_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1433 | 56 | "图型 %s 瓦片套完整(16 墙 + %d 地面,缺=%s)" ↩ % [String(bt), variants, str(missing)] | missing.is_empty() | (待填) | | | + +## S193 _test_hue_domains(行 72–82 · 4 条 · 源 game/tests/unit/presentation/terrain_tile_sets_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1434 | 72 | "图型 %s 抽检样本可读" ↩ % String(bt) | false | (待填) | | | +| 1435 | 77 | "图型 %s 墙顶均值色相在带 %s(实测 %.3f)" ↩ % [String(bt), str(band), float(wall["h"])] | float(wall["h"]) >= float(band[0]) and float(wall["h"]) <= float(band[1]) | (待填) | | | +| 1436 | 79 | "图型 %s 图底分离(墙顶明度 %.3f > 地面 %.3f)" ↩ % [String(bt), wall_v[bt], floor_v[bt]] | wall_v[bt] > floor_v[bt] | (待填) | | | +| 1437 | 82 | "deep_underdark 墙顶更暗于 underdark(更深蓝黑方向)" | float(wall_v["deep_underdark"]) < float(wall_v["underdark"]) | (待填) | | | + +## S194 _test_digit_ink_modes(行 137–159 · 5 条 · 源 game/tests/unit/presentation/terrain_tile_sets_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1438 | 137 | "图型 %s 数字墨族断言样本可读" ↩ % String(bt) | false | (待填) | | | +| 1439 | 144 | "图型 %s 双峰纹理落牌(亮族 p90 %.2f/均值 %.2f,深族 p90 %.2f/均值 %.2f 均未过门)" ↩ % [ String(bt), float(mode["cr_p90_bright"]), float(mode["cr_mean_bright"]), float(mode["cr_p90_dark"]), float(mode["cr_mean_dark"])] | float(mode["cr_mean_dark"]) < float(TilePaletteScript.DIGIT_INK_CR_MEAN_DARK) | (待填) | | | +| 1440 | 151 | "图型 %s 所选%s 对 p90 ≥%.1f(实测 %.2f)" ↩ % [String(bt), mode_name, float(TilePaletteScript.DIGIT_INK_CR_P90), cr_p90] | cr_p90 >= float(TilePaletteScript.DIGIT_INK_CR_P90) | (待填) | | | +| 1441 | 155 | "图型 %s 所选%s 对均值 ≥%.1f(实测 %.2f)" ↩ % [String(bt), mode_name, mean_gate, cr_mean] | cr_mean >= mean_gate | (待填) | | | +| 1442 | 158 | "图型 %s 呈现模式期望=%s(实测=%s)" ↩ % [String(bt), "色牌" if bool(expect_plate[bt]) else "直画", mode_name] | plate == bool(expect_plate[bt]) | (待填) | | | + +## S195 _smoke_type_glow(行 215–375 · 24 条 · 源 game/tests/unit/presentation/theme_projection_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1443 | 215 | "V1 微光默认开(Q4 丙案——呈现态布尔缺省 true,生产写入面零显式开)" | bool(snap.get("enabled", false)) | (待填) | | | +| 1444 | 217 | "V1 夹具全集合法九型 ⇒ 清单外登记为空(禁静默空光通道零误报)" | (snap.get("errors", []) as Array).is_empty() | (待填) | | | +| 1445 | 220 | "V1 作用集恒等:微光格集合 == _diggable ∩ 身份可查(期望 %d,实测 %d)" ↩ % [expect.size(), (snap.get("cells", {}) as Dictionary).size()] | _glow_key_set_equal(snap.get("cells", {}), expect) | (待填) | | | +| 1446 | 222 | "V1 夹具前提:英雄未开邻格 ≥5(实测 %d——novice %d 步)" ↩ % [expect.size(), steps] | expect.size() >= 5 | (待填) | | | +| 1447 | 232 | "V1 已开格/r0 出生展开区零微光(S1)%s" ↩ % ("" if opened_leak == "" else "违例:" + opened_leak) | opened_leak == "" | (待填) | | | +| 1448 | 249 | "V2 夹具前提:未开邻格派定 ≥5 型(实测 %d)" ↩ % mapped | mapped >= 5 | (待填) | | | +| 1449 | 258 | "V2 九型→五组逐格映射(attack/defense/action/gp→增益/heal→治疗/pierce→穿刺/blast→群伤/magic→魔法)%s" ↩ % ("" if map_bad == "" else "违例:" + map_bad) | map_bad == "" | (待填) | | | +| 1450 | 272 | "V2 清单外 cell_type 显式登记(errors=%s)" ↩ % str(errs) | errs.has("keystone") | (待填) | | | +| 1451 | 273 | "V2 清单外格零微光(无静默空光——%s 不在作用集)" ↩ % str(bogus_cell) | not (snap.get("cells", {}) as Dictionary).has(bogus_cell) | (待填) | | | +| 1452 | 275 | "V2 清单外格剔除后作用集 = 派定数 −1(%d→%d)" ↩ % [mapped, (snap.get("cells", {}) as Dictionary).size()] | (snap.get("cells", {}) as Dictionary).size() == mapped - 1 | (待填) | | | +| 1453 | 288 | "V1 封锁格零微光(query null ⇒ 天然惰性——%s)" ↩ % str(blocked_cell) | not (snap.get("cells", {}) as Dictionary).has(blocked_cell) | (待填) | | | +| 1454 | 290 | "V1 封锁格零登记(errors 仍仅清单外一条——%s)" ↩ % str(snap.get("errors", [])) | (snap.get("errors", []) as Array).size() == 1 | (待填) | | | +| 1455 | 292 | "V1 封锁格剔除后作用集 = 派定数 −1(实测 %d)" ↩ % (snap.get("cells", {}) as Dictionary).size() | (snap.get("cells", {}) as Dictionary).size() == mapped - 1 | (待填) | | | +| 1456 | 298 | "V1 封锁解除后作用集复位恒等(期望 %d,实测 %d)" ↩ % [expect.size(), (snap.get("cells", {}) as Dictionary).size()] | _glow_key_set_equal(snap.get("cells", {}), expect) | (待填) | | | +| 1457 | 304 | "V7 off 态开关读数=false" | not bool(snap.get("enabled", true)) | (待填) | | | +| 1458 | 305 | "V7 off 态作用集为空(基线保护面)" | (snap.get("cells", {}) as Dictionary).is_empty() | (待填) | | | +| 1459 | 308 | "V7 on 态开关读数=true(复现)" | bool(snap_on.get("enabled", false)) | (待填) | | | +| 1460 | 309 | "V7 off→on 作用集逐位复现(关前 %d 格/开后 %d 格,组号逐位一致)" ↩ % [ (snap_before_off.get("cells", {}) as Dictionary).size(), (snap_on.get("cells", {}) as Dictionary).size()] | _glow_map_equal(snap_on.get("cells", {}), snap_before_off.get("cells", {})) | (待填) | | | +| 1461 | 312 | "V7 off→on 作用集与独立重算期望集恒等(%d 格)" ↩ % (snap_on.get("cells", {}) as Dictionary).size() | _glow_key_set_equal(snap_on.get("cells", {}), expect) | (待填) | | | +| 1462 | 316 | "V7 微光开关零玩法写入(已开集/英雄位/封印数三面对照逐位一致)" | revealed_before == revealed_after and hero_before == ms.hero_pos() and seals_before == ms.seal_count() | (待填) | | | +| 1463 | 329 | "V3 换图 rebuild 后作用集按新板重算(期望 %d,实测 %d——零旧板残留)" ↩ % [expect2.size(), (snap2.get("cells", {}) as Dictionary).size()] | _glow_key_set_equal(snap2.get("cells", {}), expect2) | (待填) | | | +| 1464 | 331 | "V3 换图清清单外登记(rebuild 清零)" | (snap2.get("errors", []) as Array).is_empty() | (待填) | | | +| 1465 | 372 | "契约 1 同板同序列 on/off 两态结算逐位一致(%d 步对照——A 开态/B 关态)%s" ↩ % [seq_steps, diverge] | diverge == "" | (待填) | | | +| 1466 | 375 | "契约 1 对照前提:同序列 ≥5 步(实测 %d)" ↩ % seq_steps | seq_steps >= 5 | (待填) | | | + +## S196 _smoke_battle_log_view(行 392–423 · 6 条 · 源 game/tests/unit/presentation/theme_projection_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1467 | 392 | "敌方伤害行格式化(baseline 行不读退役 attach)" | enemy_txt.contains("撕咬") and enemy_txt.contains("〔掷 15 vs 护甲 12") | (待填) | | | +| 1468 | 394 | "退役伏击/基线拆行文案不再出现" | not enemy_txt.contains("伏击") and not enemy_txt.contains("基线") | (待填) | | | +| 1469 | 401 | "主角穿刺型暴击行金框(单分量 payload 不触发三分量拆行)" | hero_txt.contains("暴击×") and hero_txt.contains("⚔击杀") and hero_txt.contains("■") | (待填) | | | +| 1470 | 403 | "退役三分量拆行文案不再出现" | not hero_txt.contains(" + 穿刺 ") | (待填) | | | +| 1471 | 409 | "未生效行聚合计数 ×2 呈现" | agg_txt.contains("⚠") and agg_txt.contains("×2") | (待填) | | | +| 1472 | 422 | "行型 %s 格式化落地(解析文本增长)" ↩ % String(row_type) | view.parsed_text().length() > before | (待填) | | | + +## S197 _smoke_level_open_line(行 430–436 · 3 条 · 源 game/tests/unit/presentation/theme_projection_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1473 | 430 | "开场行 boosted=true 尾接「(含面板加成)」〔wc1〕" | String(NarratorScript.level_open_line("第一层", 42, 5, 30, true)).contains("(含面板加成)") | (待填) | | | +| 1474 | 432 | "开场行 boosted=false 零后缀(白板局不带)〔wc1〕" | not String(NarratorScript.level_open_line("第一层", 42, 5, 30, false)).contains("(含面板加成)") | (待填) | | | +| 1475 | 434 | "开场行种子/两值带入不受后缀位影响(数值带入回归)〔wc1〕" | String(NarratorScript.level_open_line("第一层", 42, 5, 30, true)).contains("种子 42") and String(NarratorScript.level_open_line("第一层", 42, 5, 30, true)).contains("攻击 5 / 生命 30") | (待填) | | | + +## S198 _smoke_global_theme(行 445–540 · 22 条 · 源 game/tests/unit/presentation/theme_projection_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1476 | 445 | "V1 项目主题挂载(project.godot gui/theme/custom → main_theme.tres)" | th != null | (待填) | | | +| 1477 | 454 | "V1 主题类型覆盖 %s" ↩ % t | type_set.has(t) | (待填) | | | +| 1478 | 456 | "V1 红线 Button 类型词条零接触" | not type_set.has("Button") | (待填) | | | +| 1479 | 464 | "V1 红线 focus 族词条零定义%s" ↩ % ("" if focus_hits == "" else "(违例:" + focus_hits + ")") | focus_hits == "" | (待填) | | | +| 1480 | 468 | "V1 滚动条槽/柄三态词条在位" | vsb.has_theme_stylebox("scroll") and vsb.has_theme_stylebox("grabber") and vsb.has_theme_stylebox("grabber_highlight") and vsb.has_theme_stylebox("grabber_pressed") | (待填) | | | +| 1481 | 471 | "V1 滚动条 scroll_focus 未定义(红线)" | not th.has_stylebox("scroll_focus", "VScrollBar") and not th.has_stylebox("scroll_focus", "HScrollBar") | (待填) | | | +| 1482 | 474 | "V1 滑轨槽/填充/柄词条在位" | hsl.has_theme_stylebox("slider") and hsl.has_theme_stylebox("grabber_area") and hsl.has_theme_stylebox("grabber_area_highlight") and hsl.has_theme_icon("grabber") and hsl.has_theme_icon("grabber_highlight") and hsl.has_theme_icon("grabber_disabled") | (待填) | | | +| 1483 | 480 | "V1 LineEdit 词条在位(含占位色 font_placeholder_color)" | le.has_theme_stylebox("normal") and le.has_theme_color("font_color") and le.has_theme_color("font_placeholder_color") | (待填) | | | +| 1484 | 484 | "V1 SpinBox 箭头词条在位(updown 合并槽)" | sp.has_theme_icon("updown") | (待填) | | | +| 1485 | 487 | "V1 分隔线词条在位" | hsep.has_theme_stylebox("separator") and vsep.has_theme_stylebox("separator") | (待填) | | | +| 1486 | 491 | "V1 TooltipPanel 词条在位" | tpanel.has_theme_stylebox("panel") | (待填) | | | +| 1487 | 492 | "V1 TooltipLabel 词条在位" | tlabel.has_theme_color("font_color") and tlabel.has_theme_font_size("font_size") | (待填) | | | +| 1488 | 500 | "V2 冻结值锚定(已填充段=BG_ROOT #3A2E22,槽=暖石 #B8A582)" | fill_c.is_equal_approx(Color8(58, 46, 34)) and track_c.is_equal_approx(Color8(184, 165, 130)) | (待填) | | | +| 1489 | 503 | "V2 滑轨两段对比 %.2f:1 ≥4.5:1(拖到中点一眼可辨)" ↩ % ratio | ratio >= 4.5 | (待填) | | | +| 1490 | 505 | "V2 滑轨 stylebox 类型均为 StyleBoxFlat" | false | (待填) | | | +| 1491 | 508 | "V3 CheckBox 四态图标非空" | cbx.get_theme_icon("checked") != null and cbx.get_theme_icon("unchecked") != null and cbx.get_theme_icon("checked_disabled") != null and cbx.get_theme_icon("unchecked_disabled") != null | (待填) | | | +| 1492 | 512 | "V3 CheckButton 四态图标非空(与 CheckBox 同套 checked/unchecked 名,4.7 源码实证)" | cbt.get_theme_icon("checked") != null and cbt.get_theme_icon("unchecked") != null and cbt.get_theme_icon("checked_disabled") != null and cbt.get_theme_icon("unchecked_disabled") != null | (待填) | | | +| 1493 | 516 | "V3 PopupMenu 词条存在(弹层预盖)" | pm.has_theme_stylebox("panel") and pm.has_theme_stylebox("hover") and pm.has_theme_font_size("font_size") | (待填) | | | +| 1494 | 520 | "V3 OptionButton 词条存在(预盖)" | ob.has_theme_stylebox("normal") and ob.has_theme_stylebox("hover") and ob.has_theme_stylebox("pressed") and ob.has_theme_color("font_color") | (待填) | | | +| 1495 | 534 | "V4 退役实现零残留(%s)%s" ↩ % [path, "" if bad == "" else "违例:" + bad] | bad == "" | (待填) | | | +| 1496 | 537 | "V6 主题 default_font 非空(挂主题顶掉 custom_font 也不丢中文字形)" | th.default_font != null | (待填) | | | +| 1497 | 540 | "V6 Label 实取字体非空(中文字形防御实证)" | lbl.get_theme_font("font") != null | (待填) | | | + +## S199 _smoke_button_five_states(行 553–625 · 23 条 · 源 game/tests/unit/presentation/theme_projection_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1498 | 553 | "V1 普通钮 %s 样式盒非空" ↩ % st | b.has_theme_stylebox_override(st) | (待填) | | | +| 1499 | 559 | "V1 普通钮 normal/hover/disabled 均为石纹九宫格贴图样式盒" | n_sb is StyleBoxTexture and h_sb is StyleBoxTexture and d_sb is StyleBoxTexture | (待填) | | | +| 1500 | 563 | "V1 普通钮 hover=modulate 提亮(乙案,异于常态 WHITE)" | h_mod != Color.WHITE and h_mod.r > 1.0 | (待填) | | | +| 1501 | 567 | "V1 普通钮 disabled=本族底 α0.45 幽灵化(甲案)" | is_equal_approx(d_mod.a, 0.45) and d_mod.r == 1.0 and d_mod.g == 1.0 | (待填) | | | +| 1502 | 570 | "V1 普通钮 disabled≠pressed(旧错配已修正)" | _sb_content_differs(d_sb, p_sb) | (待填) | | | +| 1503 | 572 | "V1 普通钮 focus 样式盒为扁平环(StyleBoxFlat)" | f_sb is StyleBoxFlat | (待填) | | | +| 1504 | 574 | "V1 焦点环色=INK(与 font_focus_color 同源)" | fsb.border_color == uikit_s.INK | (待填) | | | +| 1505 | 575 | "V1 焦点环宽=2px" | is_equal_approx(fsb.border_width_left, 2.0) and is_equal_approx(fsb.border_width_right, 2.0) and is_equal_approx(fsb.border_width_top, 2.0) and is_equal_approx(fsb.border_width_bottom, 2.0) | (待填) | | | +| 1506 | 578 | "V1 焦点环内缩=1px" | is_equal_approx(fsb.expand_margin_left, -1.0) and is_equal_approx(fsb.expand_margin_top, -1.0) and is_equal_approx(fsb.expand_margin_right, -1.0) and is_equal_approx(fsb.expand_margin_bottom, -1.0) | (待填) | | | +| 1507 | 581 | "V1 焦点环无底(叠加态不遮钮底)" | not fsb.draw_center and fsb.bg_color.a < 0.001 | (待填) | | | +| 1508 | 586 | "V1 主钮 %s 样式盒非空" ↩ % st | pb.has_theme_stylebox_override(st) | (待填) | | | +| 1509 | 590 | "V1 主钮 pressed/disabled=本族平涂(非石纹)" | ppn is StyleBoxFlat and pdd is StyleBoxFlat | (待填) | | | +| 1510 | 592 | "V1 主钮 pressed=烛光黄深档(现行值不动)" | (ppn as StyleBoxFlat).bg_color == Color8(214, 172, 88) | (待填) | | | +| 1511 | 594 | "V1 主钮 disabled=本族底 α0.45 幽灵化" | is_equal_approx((pdd as StyleBoxFlat).bg_color.a, 0.45) | (待填) | | | +| 1512 | 596 | "V1 主钮 disabled≠pressed" | _sb_content_differs(ppn, pdd) | (待填) | | | +| 1513 | 597 | "V1 主钮 hover=提亮档(承现行不动)" | (phv as StyleBoxFlat).bg_color == uikit_s.CANDLE.lightened(0.06) | (待填) | | | +| 1514 | 603 | "V1 危险钮 %s 样式盒非空" ↩ % st | db.has_theme_stylebox_override(st) | (待填) | | | +| 1515 | 606 | "V1 危险钮 pressed/disabled=本族平涂(非石纹,旧错配已修正)" | dpr is StyleBoxFlat and dds is StyleBoxFlat | (待填) | | | +| 1516 | 608 | "V1 危险钮 pressed=深红深档(暗于常态)" | (dpr as StyleBoxFlat).bg_color.r < 180.0 / 255.0 | (待填) | | | +| 1517 | 610 | "V1 危险钮 disabled=本族底 α0.45 幽灵化" | is_equal_approx((dds as StyleBoxFlat).bg_color.a, 0.45) | (待填) | | | +| 1518 | 612 | "V1 危险钮 disabled≠pressed" | _sb_content_differs(dpr, dds) | (待填) | | | +| 1519 | 617 | "V2 红线维持 Button 类型词条零接触" | not th.get_type_list().has("Button") | (待填) | | | +| 1520 | 624 | "V2 红线维持 focus 族词条零定义%s" ↩ % ("" if focus_hits == "" else "(违例:" + focus_hits + ")") | focus_hits == "" | (待填) | | | + +## S200 _smoke_texture_filter(行 632–674 · 9 条 · 源 game/tests/unit/presentation/theme_projection_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1521 | 632 | "V3 工程设置 default_texture_filter=0(Nearest,翻转面=立绘/瓦片/九宫格 crisp)" | v != null and int(v) == 0 | (待填) | | | +| 1522 | 636 | "V3 白名单在位:暗角 vignette 显式 LINEAR" | vg.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR | (待填) | | | +| 1523 | 640 | "V3 白名单在位:光柱 beams 显式 LINEAR" | bm.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR | (待填) | | | +| 1524 | 658 | "T4 光柱带心峰值 α≈0.05(8bit 截断量化下限 12/255,实测 %.5f)" ↩ % max_a | max_a >= 12.0 / 255.0 and max_a <= 0.05 | (待填) | | | +| 1525 | 660 | "T4 光柱带内连续 alpha 过渡像素 %d 个(旧零过渡硬边已修)" ↩ % mid_a | mid_a > 100 | (待填) | | | +| 1526 | 661 | "T4 光柱纵向衰减生效(上半亮度 %.0f > 下半 %.0f 的 2 倍——上亮下暗)" ↩ % [top_sum, bot_sum] | top_sum > bot_sum * 2.0 | (待填) | | | +| 1527 | 665 | "V3 白名单在位:地图渐变 gradient_base 显式 LINEAR" | gb.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR | (待填) | | | +| 1528 | 670 | "V3 脚影显式 LINEAR 不丢(board_world.gd)" | bw_src.contains("shadow.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR") | (待填) | | | +| 1529 | 673 | "V3 状态图标显式 LINEAR 不丢(status_slot_row.gd)" | ss_src.contains("icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR") | (待填) | | | + +## S201 _smoke_contrast_inks(行 683–699 · 9 条 · 源 game/tests/unit/presentation/theme_projection_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1530 | 683 | "V4 顺位徽标/重甲徽标 GOLD_BRIGHT 对舞台底 %.2f:1 ≥4.5:1" ↩ % r_order | r_order >= 4.5 | (待填) | | | +| 1531 | 685 | "V4 魔法护甲徽标 HERO_BLUE_BRIGHT 对舞台底 %.2f:1 ≥4.5:1" ↩ % r_ward | r_ward >= 4.5 | (待填) | | | +| 1532 | 687 | "V4 RAP 反馈行 STONE_DEEP 对数据板 %.2f:1 ≥4.5:1" ↩ % r_rap | r_rap >= 4.5 | (待填) | | | +| 1533 | 690 | "V4 顺位徽标①②③ 墨=GOLD_BRIGHT(13px 行原位)" | sb_src.contains("HORIZONTAL_ALIGNMENT_LEFT, -1, 13, UiKit.GOLD_BRIGHT") | (待填) | | | +| 1534 | 692 | "V4 魔法护甲徽标墨=HERO_BLUE_BRIGHT" | sb_src.contains("col = UiKit.HERO_BLUE_BRIGHT") | (待填) | | | +| 1535 | 693 | "V4 重甲徽标墨=GOLD_BRIGHT" | sb_src.contains("col = UiKit.GOLD_BRIGHT") | (待填) | | | +| 1536 | 694 | "V4 意图蓄力档判据=分档布尔(standee_body:179 CHARGE_SKILL_IDS 判定;旧 GOLD_DEEP 恒不可达判据清零)〔wd2〕" | sb_src.contains("_intent_charge = pid in CHARGE_SKILL_IDS") and not sb_src.contains("if _intent_col == UiKit.GOLD_DEEP:") | (待填) | | | +| 1537 | 697 | "V4 RAP 反馈行墨=STONE_DEEP(原位行锚定)" | ap_src.contains('UiKit.label("",UiKit.FS_CAPTION, false, UiKit.STONE_DEEP)') | (待填) | | | +| 1538 | 699 | "V4 RAP 反馈行旧墨 STONE_FAINT 清零(含注记面)" | not ap_src.contains("STONE_FAINT") | (待填) | | | + +## S202 _smoke_element_details(行 708–727 · 8 条 · 源 game/tests/unit/presentation/theme_projection_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1539 | 708 | "T5② BCard 图标槽白图标对比 %.2f:1 ≥3:1(硬约束)" ↩ % r_white | r_white >= 3.0 | (待填) | | | +| 1540 | 711 | "T5② 明度差降档生效(槽对卡面 %.2f:1 < 旧 %.2f:1)" ↩ % [r_new, r_old] | r_new < r_old | (待填) | | | +| 1541 | 713 | "T5② BCard 图标槽落柔和档(SLOT_BACKDROP_SOFT)" | uk_src.contains("UiKit.panel_flat(SLOT_BACKDROP_SOFT, PANEL_BORDER, 0, 1)") | (待填) | | | +| 1542 | 716 | "T5② B 区状态图标槽仍用深档(SLOT_BACKDROP,未误伤)" | ss_src.contains("UiKit.SLOT_BACKDROP") | (待填) | | | +| 1543 | 720 | "T5③ 商店卡片区垂直居中(ALIGNMENT_CENTER)" | shop_src.contains("_cards_box.alignment = BoxContainer.ALIGNMENT_CENTER") | (待填) | | | +| 1544 | 724 | "T5③ 营地三 holder 统一 min 高 336(实测 %d 处)" ↩ % camp_min | camp_min == 3 | (待填) | | | +| 1545 | 726 | "T5③ 营地四栏面板纵向 FILL 等高(实测 %d 处)" ↩ % camp_fill | camp_fill == 4 | (待填) | | | +| 1546 | 727 | "T5③ 三处锁文垂直居中在位" | camp_src.count("SIZE_SHRINK_CENTER") >= 3 | (待填) | | | + +## S203 _smoke_diag_button_retired(行 734–747 · 7 条 · 源 game/tests/unit/presentation/theme_projection_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1547 | 734 | "V6 主区无 build_diag_button 调用残留(旧实现同批清理)" | not main_src.contains("build_diag_button") | (待填) | | | +| 1548 | 736 | "V6 转储入口连接随迁(组合根接浮窗 diagnose_requested 信号)" | main_src.contains("debug_overlay.diagnose_requested.connect(_on_diagnose_pressed)") | (待填) | | | +| 1549 | 738 | "V6 转储编排承接函数在位(dump_diagnosis 卡死取证保留)" | main_src.contains("func _on_diagnose_pressed()") | (待填) | | | +| 1550 | 741 | "V6 build_diag_button 工厂已退役(ui_assembler 零残留)" | not asm_src.contains("build_diag_button") | (待填) | | | +| 1551 | 744 | "V6 调试浮窗 diagnose_requested 信号在位" | dbg_src.contains("signal diagnose_requested") | (待填) | | | +| 1552 | 745 | "V6 浮窗转储钮外发方法在位(纯视图边界)" | dbg_src.contains("_emit_diagnose_requested") | (待填) | | | +| 1553 | 746 | "V6 浮窗诊断转储钮在位(玩家画面角落钮清零后唯一入口)" | dbg_src.contains('UiKit.button("转储")') | (待填) | | | + +## S204 _status_panel_projection(行 763–793 · 10 条 · 源 game/tests/unit/presentation/theme_projection_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1554 | 763 | "状态面板开层可见(open_status 直开——P4 甲零动效路径)" | po.visible | (待填) | | | +| 1555 | 764 | "余额徽投影 = 快照 balance 读数(sv4 回归锚)〔sv4〕" | String(po._balance_badge.value_label.text) == "钥石 77" | (待填) | | | +| 1556 | 766 | "属性行全值投影 = 契约4 钉值格式逐字(生命/上限/攻击/AC/GP 五现值)〔sv1〕" | String(po._attrs_label.text) == "生命 13/29 · 攻击 7 · 护甲 AC 11 · GP 41" | (待填) | | | +| 1557 | 768 | "秘宝行投影 = 受理序 join+已用件「(已用)」尾缀〔sv2〕" | String(po._curios_label.text) == "青苔密卷 · 锈蚀怀表(已用)" | (待填) | | | +| 1558 | 770 | "专长行投影 = 行串 join〔sv3〕" | String(po._feats_label.text) == "疼痛记忆 ×2 · 双管镐 ×1" | (待填) | | | +| 1559 | 777 | "面板子树零「道具工坊/天赋树」文案(跨关概览退役零残留)〔sv5〕" | stale == "" | (待填) | | | +| 1560 | 778 | "旧段成员退役(_workshop_holder/_tree_label 零残留——契约2 数据/成员层面清零)〔sv5〕" | po.get("_workshop_holder") == null and po.get("_tree_label") == null | (待填) | | | +| 1561 | 785 | "空秘宝态句=「未携带秘宝」〔sv2〕" | String(po2._curios_label.text) == "未携带秘宝" | (待填) | | | +| 1562 | 786 | "空专长态句=「未获专长」〔sv3〕" | String(po2._feats_label.text) == "未获专长" | (待填) | | | +| 1563 | 792 | "提示行=「查看本局冒险进度(只读)」(新定位换代)〔sv8〕" | hint_txt == "查看本局冒险进度(只读)" | (待填) | | | + +## S205 _camp_recent_projection(行 818–819 · 1 条 · 源 game/tests/unit/presentation/theme_projection_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1564 | 818 | "营地最近冒险行完成关数措辞(实际「%s」)〔口径换代 V6〕" ↩ % recent_text | recent_text.contains("完成 2 关") and not recent_text.contains("通关") | (待填) | | | + +## S206 _career_overlay_projection(行 852–897 · 11 条 · 源 game/tests/unit/presentation/theme_projection_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1565 | 852 | "生涯页开层可见(open_career 直开——零动效路径)" | co.visible | (待填) | | | +| 1566 | 853 | "总局数投影 = adventure_total() 读数(%s)" ↩ % co._total_label.text | String(co._total_label.text) == "2 局" | (待填) | | | +| 1567 | 855 | "冒险成功局数投影 = adventure_cleared() 读数(%s)" ↩ % co._cleared_label.text | String(co._cleared_label.text) == "1 局(三关全通)" | (待填) | | | +| 1568 | 860 | "各关完成次数投影 = clear_count() 逐关读数(%s)" ↩ % co._clears_label.text | String(co._clears_label.text) == " · ".join(expect_clears) | (待填) | | | +| 1569 | 862 | "Boss 首杀/总杀投影 = 首杀标记计数 + boss_kill_count()(%s / %s)" ↩ % [co._boss_first_label.text, co._boss_kills_label.text] | String(co._boss_first_label.text) == "3/3 关" and String(co._boss_kills_label.text) == "4 次" | (待填) | | | +| 1570 | 864 | "钥石余额徽投影 = keystone_balance() 读数(%s)" ↩ % co._keystone_badge.value_label.text | String(co._keystone_badge.value_label.text) == "钥石 %d" % mp.keystone_balance() | (待填) | | | +| 1571 | 871 | "生涯页行标签换代(冒险成功局数/各关完成次数)〔口径换代 V4〕" | career_labels_joined.contains("冒险成功局数") and career_labels_joined.contains("各关完成次数") | (待填) | | | +| 1572 | 873 | "生涯页 label 全集零「通关」残留〔口径换代 V4〕" | not career_labels_joined.contains("通关") | (待填) | | | +| 1573 | 874 | "秘宝解锁条件标签冒险成功口径(UiKit 单点常量)〔口径换代 V5〕" | UiKit.TREASURE_REQ_LABELS["adventure_cleared"] == "冒险成功" | (待填) | | | +| 1574 | 883 | "v5 现役档升 v6 载入成功(计数域缺省补零)" | v5.load_from_disk() and v5.adventure_total() == 0 and v5.adventure_cleared() == 0 | (待填) | | | +| 1575 | 894 | "旧档载入后生涯页正常显示(v5 升 v6:总局数/冒险成功局数显示 0,既有钥石余额原样呈现)" | co_v5.visible and String(co_v5._total_label.text) == "0 局" \\ and String(co_v5._cleared_label.text) == "0 局(三关全通)" \\ and String(co_v5._keystone_badge.value_label.text) == "钥石 3" | (待填) | | | + +## S207 _test_grouping_closed(行 61–79 · 7 条 · 源 game/tests/unit/presentation/type_glow_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1576 | 61 | "九型封闭:%s → 组%d(期望组%d)" ↩ % [t, got, int(TYPE_EXPECT[t])] | got == int(TYPE_EXPECT[t]) | (待填) | | | +| 1577 | 65 | "清单外值「%s」→ GLOW_NONE(禁静默空光)" ↩ % bad | UiKitScript.glow_group_for_cell_type(bad) == UiKitScript.GLOW_NONE | (待填) | | | +| 1578 | 70 | "两带并存锚定:卡顶带 blast→TG_MAGIC(E5 回滚既存行为未动)" | band_blast == UiKitScript.TG_MAGIC | (待填) | | | +| 1579 | 71 | "两带并存锚定:格面微光 blast→群伤组(与卡顶带刻意不同)" | UiKitScript.glow_group_for_cell_type("blast") == UiKitScript.GLOW_AOE | (待填) | | | +| 1580 | 74 | "glow_color_for_group 群伤组 = TG_GLOW_AOE" | UiKitScript.glow_color_for_group(UiKitScript.GLOW_AOE) == UiKitScript.TG_GLOW_AOE | (待填) | | | +| 1581 | 76 | "glow_color_for_group 无组(GLOW_NONE)→ 全透明" | UiKitScript.glow_color_for_group(UiKitScript.GLOW_NONE) == Color(0, 0, 0, 0) | (待填) | | | +| 1582 | 78 | "glow_color_for_group 越界组号 → 全透明" | UiKitScript.glow_color_for_group(99) == Color(0, 0, 0, 0) | (待填) | | | + +## S208 _test_frozen_hue_face(行 88–102 · 5 条 · 源 game/tests/unit/presentation/type_glow_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1583 | 88 | "实现值锚定:%s = #%02X%02X%02X(色值单点 ui_kit,逐位钉死)" ↩ % [CONST_NAME[g], int(m.r8), int(m.g8), int(m.b8)] | impl_const == m | (待填) | | | +| 1584 | 93 | "冻结色相保持:%s ΔH=%.3f° ≤0.2° 且 ΔS=%.4f ≤0.01(只动明度档)" ↩ % [g, dh * 360.0, ds] | dh <= 0.2 / 360.0 and ds <= 0.01 | (待填) | | | +| 1585 | 98 | "群伤组专项:H 与冻结 #FF2D55 差 %.4f°(零移动)" ↩ % (absf(aoe_m.h - aoe_f.h) * 360.0) | absf(aoe_m.h - aoe_f.h) < 0.001 | (待填) | | | +| 1586 | 100 | "群伤组专项:S 与冻结差 %.4f ≤0.01(8bit 量化噪声级)" ↩ % absf(aoe_m.s - aoe_f.s) | absf(aoe_m.s - aoe_f.s) <= 0.01 | (待填) | | | +| 1587 | 102 | "群伤组专项:V=0.65 明度档( provisional 域内)" | absf(aoe_m.v - 166.0 / 255.0) < 0.002 | (待填) | | | + +## S209 _test_render_params(行 107–113 · 3 条 · 源 game/tests/unit/presentation/type_glow_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1588 | 107 | "泛光强度 α = 170/255(v2 呈现参数冻结)" | is_equal_approx(UiKitScript.TYPE_GLOW_ALPHA, 170.0 / 255.0) | (待填) | | | +| 1589 | 109 | "带宽 = cell÷8(TYPE_GLOW_BAND_FRAC=0.125)" | is_equal_approx(UiKitScript.TYPE_GLOW_BAND_FRAC, 0.125) | (待填) | | | +| 1590 | 111 | "组号常量域连续(GLOW_NONE=0…GLOW_MAGIC=5)" | UiKitScript.GLOW_NONE == 0 and UiKitScript.GLOW_BUFF == 1 and UiKitScript.GLOW_HEAL == 2 and UiKitScript.GLOW_PIERCE == 3 and UiKitScript.GLOW_AOE == 4 and UiKitScript.GLOW_MAGIC == 5 | (待填) | | | + +## S210 _test_gamma_ladder(行 128–139 · 3 条 · 源 game/tests/unit/presentation/type_glow_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1591 | 128 | "阶梯单调:%s 合成亮度 %.4f < 上一档 %.4f(伽马合成空间)" ↩ % [g, lum, prev_lum] | lum < prev_lum | (待填) | | | +| 1592 | 134 | "相邻组模型 CR:%s/%s = %.3f ≥1.20(色弱互辨预判)" ↩ % [order[i], order[i + 1], cr] | cr >= 1.20 | (待填) | | | +| 1593 | 138 | "群伤/魔法线性亮度序:群伤 %.4f < 魔法 %.4f(首版 CIE L* 排反事故的守门)" ↩ % [aoe_lin, magic_lin] | aoe_lin < magic_lin | (待填) | | | + +## S211 _test_probe_api_shape(行 146–153 · 5 条 · 源 game/tests/unit/presentation/type_glow_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1594 | 146 | "probe 键集 = {enabled, cells, errors}" | snap.has("enabled") and snap.has("cells") and snap.has("errors") and snap.size() == 3 | (待填) | | | +| 1595 | 148 | "probe 默认态:enabled=true(Q4 丙案生产默认开)" | bool(snap.get("enabled", false)) | (待填) | | | +| 1596 | 149 | "probe 默认态:作用集空(无板未派生)" | (snap.get("cells", {}) as Dictionary).is_empty() | (待填) | | | +| 1597 | 150 | "probe 默认态:清单外登记空" | (snap.get("errors", []) as Array).is_empty() | (待填) | | | +| 1598 | 152 | "set_type_glow_enabled(false) → probe enabled=false(无板不崩)" | not bool(view.type_glow_probe().get("enabled", true)) | (待填) | | | + +## S212 _init(行 16–28 · 6 条 · 源 game/tests/unit/presentation/ui_font_asset_test.gd · 去向:待分类回填) + +| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet | +|---|---|---|---|---|---|---| +| 1599 | 16 | "随包字体守卫装载非空(res://assets/fonts/ui_font_fusion_pixel_zh_hans.ttf)" | f != null | (待填) | | | +| 1600 | 18 | "中文字形在集(攻/雷/旗/封)" | f.has_char("攻".unicode_at(0)) and f.has_char("雷".unicode_at(0)) \\ and f.has_char("旗".unicode_at(0)) and f.has_char("封".unicode_at(0)) | (待填) | | | +| 1601 | 20 | "中文字形在集(危——已开危险格字形换代前置,迭代36 u12/wa9)" | f.has_char("危".unicode_at(0)) | (待填) | | | +| 1602 | 21 | "ASCII/数字字形在集(A/0/%)" | f.has_char("A".unicode_at(0)) and f.has_char("0".unicode_at(0)) and f.has_char("%".unicode_at(0)) | (待填) | | | +| 1603 | 25 | "中文采样文本宽度 > 0(C6 headless 判据)" | w_cn > 0.0 and w_label > 0.0 | (待填) | | | +| 1604 | 28 | "字体缺失时后续断言跳过(缺位=回归,此行仅占位计数)" | false | (待填) | | | + +--- +全源共 212 章、1604 条(静态调用点口径)。 diff --git a/reports/工程改动报告-单元闸挂死守护-2026-10-28.md b/reports/工程改动报告-单元闸挂死守护-2026-10-28.md deleted file mode 100644 index 7109bd33..00000000 --- a/reports/工程改动报告-单元闸挂死守护-2026-10-28.md +++ /dev/null @@ -1,32 +0,0 @@ -# 工程改动报告——单元闸挂死守护(run_all.gd 每套件阻塞面超时) - -**承接**:待办清单 2026-10-25 条目「闸与探针批上下文挂死防护——probe_gate 进程树回收与 run_all 阻塞面超时」件②(件① battle_log_view 已随迭代 38 卡⑥ 出账,probe_gate.sh 整树回收件 2026-10-28 已落;本批完成后该条目整条删除,git 历史可考)。 -**车道**:工程改动直改批(改动四类,不写卡不编迭代),触及代码面走 worktree 批——分支 `runall-hangguard-20261028`,改动提交 a2710f22,并 master 后合并提交 4afe6c47。 -**实证**:迭代 40 批末阻塞面挂死实拍三例(测试 `_init` 在 `quit()` 前中断,进程挂至外层超时 124),与件②同根。 - -## 改动—验收对照表 - -| 需求点 | 改动(文件:行为) | 验收测试 | 层级 | 真实入口 | 判据绑定面 | 结果 | -|---|---|---|---|---|---|---| -| 每套件截止时间守护(默认 120s,环境变量 `RUN_ALL_SUITE_TIMEOUT_SEC` 可覆盖,非法值回落默认) | `run_all.gd` `_run_suite` 阻塞调用换 `OS.create_process` 非阻塞+200ms 轮询+截止判定;常量 `SUITE_TIMEOUT_*` 与 `_suite_timeout_sec()` | 机制验证:临时挂死夹具(`_init` 死循环不 quit)登记 manifest 后 `--only` 选跑,`RUN_ALL_SUITE_TIMEOUT_SEC=8` → 输出 `[run_all] FAIL …dev_hang_fixture… — 超时挂死(8s),进程树已整树回收(pid=…)`+`[run_all] 1 个套件失败`,进程退出码 1 | 闸基建 | `run_all.gd` `--script` 直跑(选跑态) | FAIL 行文本+批退出码 | 绿(夹具与 manifest 行验证后即删,未提交) | -| 超时整树回收零孤儿 | `taskkill /F /T /PID`(杀 cmd wrapper 连引擎孙进程)+等死确认 10s 宽限(确认不了报红留 PID)+临界窗口 `.code` 同删 | 杀树后 PowerShell 按命令行清点该 worktree 路径 Godot 进程=0 | 闸基建 | 同上+外部进程清点 | 清点输出 | 绿 | -| 退出码通道等价(文件通道值=原 cmd 回传值,判定形态不变) | 命令串追加 `& echo !ERRORLEVEL! > 文件`(`cmd /V:ON`);读毕即删+起跑前清陈值;缺文件/非数字 fail-closed 判红 | 负面套件 expect=1 照常判 PASS;全量 65 套件 `all green (65 suites)` 退出码 0 | 闸基建 | `run_all.gd` 全量直跑(与收口闸 02 同源) | PASS 行 exit 值+all green 行 | 绿 | -| README 超时记载换代 | `game/tests/README.md` 工具速查表 run_all 行补守护说明;Running Tests 段失真行「本地闸必须配外部 wall-clock 超时(run_all_gates.sh 已内置)」删除(该脚本实测无 timeout 包装),换代为内建守护说明 | 收口闸 06 gdd_lint/07 doc_link/08 doc_term/08c 流水线闸/08d 引用可核全绿 | 文档 | 闸机扫描 | 闸退出码 | 绿 | -| 非行为改动:文件头注重写 | 只描述现行行为;原头注「本地闸 02 闸包装外部超时:timeout 120s」与闸机现实不符,属失真句换代 | — | — | — | — | 非行为改动 | - -验证集清单(`game/tests/验证集清单.md`)零变化:本批不增删不改判任何套件断言,改动全部在运行器进程机制层。 - -## 机制验证的 API 实证(开工探针四件,30 秒级临时件,验证后即删) - -1. 现行 `OS.execute(…, out, true, false)` 实测同步等待(ping 约 3s 计时吻合)、返回退出码——第五参在 Godot 4.7.2 上不是阻塞开关,阻塞面挂死根因坐实; -2. `OS.create_process` 返回真实 Windows PID,`is_process_running` 存活期 true、taskkill 后 false; -3. `cmd /V:ON /C "… & echo !ERRORLEVEL! > 文件"` 退出码落盘通道成立(错误命令落 1); -4. `taskkill /F /T` 对 cmd→ping 树整树连坐闭环(存活计数 1→杀→0,cmd 自身 running=false)——镜像 probe_gate 已落件的同款机制。 - -## 闸况 - -全量域闸回执 2955c049(domain=full,dirty_game=False,总判定验收通过):影响面选闸判测量面(diff 全在 `game/tests/`)→ 00 冒烟/03/04 探针/05 渲染闸/11 回放确定性 SKIP;01 系统测试、02 单元与集成测试、06–08d 文档域闸、10 熵增闸全绿;09 静态扫描退出码 1=存量红(`level_map_screen.gd|quality/max-class-variables` 22 对基线登记 21 的恶化留账,还债归迭代 41 在案;指纹与入库基线一致,本批零新增)。合并后两件秒级事:`--editor --quit` 重刷类名缓存退出码 0+冒烟启动 17 项 0 失败三闸全过。 - -## 移交与观察 - -`game/tests/README.md`「套件 manifest 现况」行记 64 条,manifest 实数 65 条(62 单元+3 集成)——漂移先于本批存在(主区与任务分支同值),本批未动套件数,已登记待办清单新条目(tests/README manifest 计数行漂移订正)归文档批。 diff --git a/reports/工程改动报告-存量断言精简AB档-2026-10-28.md b/reports/工程改动报告-存量断言精简AB档-2026-10-28.md deleted file mode 100644 index 4c4472a5..00000000 --- a/reports/工程改动报告-存量断言精简AB档-2026-10-28.md +++ /dev/null @@ -1,76 +0,0 @@ -# 工程改动报告——存量断言精简(A+B 档首批删并) - -**承接**:待办清单 2026-10-28 条目「存量断言精简——验证内容不变下的断言收敛」执行形态的第一波(盘点先行→逐批裁决→分波执行→件核)。 -**车道**:工程改动触及代码面(`game/tests/*.gd`)走 worktree 批——分支 `assert-slim-20261028`,区 `../g-plan2-assert-slim-20261028`。 -**盘点方法**:机器初筛(同文件标签近似 223 对、cond 全同 42 组 109 条、跨文件同标签、护栏类 41 条)+七路只读子代理逐域语义裁决(battle_settlement/core 三路/data_config+contracts/presentation/integration+system_test)+主会话逐条抽验复核。子代理报称候选约 150 点,掺有运行时展开数与「不同查询函数间的算术蕴含」,按本批判据线核减至 19 点。 - -## 一、本批判据线(删除属显式判据改判,先立判据) - -只删满足 **同一读数面 + 同一输入 + 谓词字面等价或字面上是承接者的真子集** 的站点。两类不算冗余、本批不删: - -1. **不同查询函数之间的算术蕴含**——删了就失去对被调函数实现本身的守卫(例:`has_unrevealed()` 与 `revealed_cells().size()2 else ""` 取空串 ⟹ :76 必红 | -| 14 | #168 | roll_multiplier:131 | nat20 种子可达 | ① | #169 `:132` 行留档 roll=20+crit | `crit_seed>0` 与 `crit_rows.roll==crit_roll` 同一扫描同真同假 | -| 15 | #324 | seal_polarity:443 | 构造触达:标中标空入队 | ① | #323 `:442` 极性路由种子可达 | `_polarity_probe_accepts` 合取项(:249-252),可达即三腿同真 | -| 16 | #325 | seal_polarity:444 | 构造触达:挖危险格 ≥1 | ① | 同上 | 同上(:253) | -| 17 | #326 | seal_polarity:445 | 构造触达:挖安全格 ≥1 | ① | 同上 | 同上(:253) | -| 18 | —(口径外) | smoke_boot.gd:69 | run_scope 非空 | ① | `:70` run_scope 在树 | `!= null` ⊂ `!= null ∧ is_inside_tree()`;GDScript `and` 短路,无空引用崩溃风险 | -| 19 | —(口径外) | smoke_boot.gd:72 | _start_overlay 已装配 | ① | `:73` 启动态菜单可见 | `!= null` ⊂ `!= null ∧ visible` | - -**两条改判(标签变更,判据不变)** - -- seal_polarity:442 标签追加「缺腿:%s」,把第 15/16/17 行的三腿诊断信息折进保留条;三腿取值仍读同一 `probe`,与原三条的语义强度逐字相同。 -- roll_multiplier:132 标签追加「种子可达 seed=%d」,把第 14 行的 seed 读数折进保留条。 - -**随行清理**:theme_projection:614-625 的 `var th` 与 focus 扫描循环随其承载的两条断言一并删除(该循环只为已删断言供数据);type_glow 的 `aoe_f` 局部量随 H/S 两条删除后无消费者,一并删;两处注释改写为指认真正承载该事实的保留条。 - -## 三、件核(验证项目覆盖不变) - -| 核对项 | 结果 | -|---|---| -| 冻结清单条数 | 1604 → **1587**(−17;smoke_boot 两站点本不在清单源列,条数不受其影响) | -| `extract_checks.py --diff` | PASS——标签/cond/计数三对齐,零差异 | -| 被删 19 条标签全仓残留 grep(`tests/`+`design/`+`reports/`) | 零悬挂引用(唯一命中的「run_scope 非空」是 system_test 自己的 V2e 在册条,非本批对象) | -| 承接指认 | 15 条保留行的「处置」列逐条回填去向(旧序号+蕴含依据+批次名),新清单在册 | -| 判据等价复验 | 由代码域闸承担(见闸况)——本批不新增行为,删后被承接行为以保留断言复验 | -| 取证方式 | 未使用变异法与撤销类取证(2026-10-24/28 用户裁);删除凭据=上表逐条申报+承接者指认 | - -## 四、闸况 - -全量域闸(域=full,`dirty_game=false`,`all_green=true`,总判定「闸况与基线一致,无新增红」)回执 commit `4fbacc43`:01 系统测试、02 单元与集成测试、06/06b/07/08/08c/08d 文档域闸、10 熵增闸全绿;09 静态扫描退出码 1=存量红(`game/presentation/views/level_map_screen.gd|quality/max-class-variables` 22 对基线登记 21 的恶化留账,还债归迭代 41 在案;指纹 `8467b4588a20` 与入库基线一致,本批零新增);00 冒烟、03/04 探针、05 渲染闸、11 回放确定性由影响面选闸判 SKIP(本批 diff 全在 `game/tests/` 测量面)。 - -**本批复验缺口一条(合回后已补)**:第 18/19 两行改的是冒烟宿主自身 `smoke_boot.gd`,而 00 冒烟测试恰被影响面选闸跳过——该两站点在批末跑批中没有执行证据,只有静态蕴含论证(GDScript `and` 短路,保留条 `!= null and …` 在空值上判 false 不崩)。合回 master 后按 §3.9 code 批两件秒级事跑 `python tools/run_smoke.py`=三闸全过、`[smoke] 摘要:15 项检查,0 项失败`(原 17 项检查删两条后为 15 项计数),缺口就此闭合。第 1–17 行所在套件的复验由 01/02 全绿承担。 - -跑批踩坑一条备案:闸日志首跑写在仓库根,触 10 熵增闸「根目录文件越界」判红——我的临时件写错位置,非批次改动引发;日志挪到仓库外重跑后 10 归绿,代码面零改动,回执按同一 commit 重生成。 - -## 五、移交与观察(后续批次账) - -1. **类别② 在本仓基本不成立**:全仓钉内部路径/私有字段/中间态的断言约 52 条,被入口**严格承载 0 条**,约 51 条因覆盖入口打不到的边界/反例载荷必须保留(`fx.dispatch` 逐事件、`pending_ap_delta` 兑现时机、`_intent_charge`);且 §8.4 本身要求判据绑提交面。后续批次的可精简空间只在类别①。 -2. **前置护栏不可删**:`load_ok and diagnostics_count("error")==0` 全域 8 处属每进程 fail-fast,删致 `if load_ok:` 静默跳过→假绿,属负收益。 -3. **静默通道在冻结口径之外**:`Kit.check_quiet` 静态站点实测 **407 个**,提取器 `CALL_RE = check\(` 匹配不到该形态(test-conventions:38 已备案该工具局限)——清单条数与〔尾缀〕grep 均覆盖不到。因此「循环 quiet 与聚合条双钉」两处真冗余(node_map_growth 47/51 vs #1073/74、node_map_generator 163 vs #fv9 聚合条)删的是口径外站点,不减 1604 这个数,且聚合侧带〔fv8〕〔fv9〕验收尾缀,本批不动;若要收该账,先做提取器改版(工程批)。 -4. **恒真断言另立一账**(属判据缺陷,非重复,本批未动):feat_vouchers:51 `deck_refresh_before == 0 or true`、treasure_effects:263 `... or true`、level_shop_economy:593 与 :696 `green_min_hp > 0`(初值 99999、仅 hp>0 分支取 min,恒真);system_contracts:67 标签称「互异」而 cond 只查非空,属名实不符需改判据。建议按 §8.4 走「判据绑不上即停卡呈报」通道单独裁决。 -5. **未采的删并形态**:C 档「手写族→表驱动循环合并」(level_gen_params 逐类负向 10→1、rng_stream_boot 8 向量→1、player_copy 禁词族、standee_fx 三入参、ui_font 字形集、forge_workbench 双行)静态可再省约 25~35 点,代价=失败定位改用违例明细串(仓内已有 `forge_table_test:181-189`、`table_structure_guard:260/299` 范式);D 档「是否同一验证项目」待owner 认账项(theme:215/217 vs type_glow:148/150、level_shop_economy 现行图跨表覆盖簇 vs 〔v11〕扩面段的尾缀义务)。 -6. **example_test.gd** 两条硬编码占位断言在盘且随清单在册(旧 #S167),不进生产 manifest(run_all.gd:79-80 明示)——整件退役与否属样板件裁决,不入本账。 -7. 本批不动 `game/tests/README.md`(断言双口径行仍成立)与任何 `design/` 文件。 diff --git a/tools/doc_link_audit.py b/tools/doc_link_audit.py index d9a67e4e..9701464f 100644 --- a/tools/doc_link_audit.py +++ b/tools/doc_link_audit.py @@ -23,8 +23,8 @@ r"""doc_link_audit.py — design 文档链接健康检查 python tools/doc_link_audit.py ... # 只扫指定文件 python tools/doc_link_audit.py --json # 机器可读 -口径:扫 design/gdd/ 与 design/art/(2026-10-28 美术族成文整备批收编)——reports/ -(提案过程件)不在扫描域,但作为链接目标被校验。排除 ``` 围栏代码块与 ; 开头注释行。 +口径:只扫 design/gdd/——design/art、reports/ +(历史素材/提案)不在扫描域,但作为链接目标被校验。排除 ``` 围栏代码块与 ; 开头注释行。 """ import argparse import json @@ -32,7 +32,7 @@ import re import sys from pathlib import Path -DEFAULT_DIRS = ["design/gdd", "design/art"] +DEFAULT_DIRS = ["design/gdd"] PROTO_PREFIX = "game" ALLOWED_EXTS = ("md", "yaml", "yml", "tres", "gd", "py", "json", "csv") diff --git a/tools/gdd_lint.py b/tools/gdd_lint.py index cc5de573..3822258f 100644 --- a/tools/gdd_lint.py +++ b/tools/gdd_lint.py @@ -2,7 +2,7 @@ r"""gdd_lint.py — GDD lean 格式门禁 用法: - python tools/gdd_lint.py # 检查 design/gdd/ 根层 GDD + design/art/ + roadmap.md(默认范围) + python tools/gdd_lint.py # 检查 design/gdd/ 根层 GDD + roadmap.md(默认范围) python tools/gdd_lint.py --changelog-only # 只跑 R4 变更记录节禁令,扫描面扩到全 design/ 树 python tools/gdd_lint.py ... # 检查指定文件 python tools/gdd_lint.py --json # 机器可读输出(供 gate/CI 使用) @@ -45,13 +45,6 @@ RULE_EXEMPT = { "术语表.md": {"R5"}, } -# 美术域收编(2026-10-28 美术族成文整备批):design/art/ 为高密度规格/清单/提示词操作单体裁, -# 体裁类规则(R1 段长/R5 流程图/R7 括号跨度/R8 规则表/R9 节点行数)不适用、豁免防误报; -# R11 的锚表是 GDD 行协议/格面资源专属计数,对美术域同样不适用。 -# 跨体裁有效面照常执法:R3 修订注记、R4 变更记录节、R6 表格列数。 -ART_DOMAIN_EXEMPT = {"R1", "R5", "R7", "R8", "R9", "R11"} -ART_DIR = "design/art" - DEFAULT_DIR = "design/gdd" DEFAULT_EXCLUDE = {"game-concept.md", "game-pillars.md", "systems-index.md"} # R4 变更记录节标题(2026-10-19 用户裁「沿革只进 git」——原「必须有版本历史节」反转为此禁令, @@ -178,11 +171,7 @@ def lint_file(path): # R1 段落长度 fname = Path(path).name ex = RULE_EXEMPT.get(fname, set()) # 规则粒度分域豁免(见 RULE_EXEMPT 注) - if str(path).replace("\\", "/").startswith(ART_DIR.replace("\\", "/") + "/"): - ex = ex | ART_DOMAIN_EXEMPT for p in paras: - if "R1" in ex: - break if len(p) > MAX_PARA: issues.append(f"R1 散文段 {len(p)} 字(>{MAX_PARA}): {p[:50]}…") @@ -260,7 +249,6 @@ def lint_file(path): # R11 计数词漂移对照(锚表 CURRENT_COUNTS;轨迹措辞行豁免——变更记录节本身归 R4 禁设) in_code11 = False - r11_active = "R11" not in ex for raw in text.splitlines(): stripped = raw.strip() if stripped.startswith("```"): @@ -268,8 +256,6 @@ def lint_file(path): continue if in_code11 or stripped.startswith(";") or R11_TRACE_RE.search(raw): continue - if not r11_active: - continue # yaml/yml 的 # 行内注释是登记轨迹,不入正文扫描 line = raw.split("#", 1)[0] if str(path).endswith((".yaml", ".yml")) else raw for pat, fix in CURRENT_COUNTS: @@ -310,10 +296,6 @@ def main(): if p.name not in DEFAULT_EXCLUDE and not re.search(r"review|report|handoff", p.name, re.I) ) - targets += sorted( - p for p in Path(ART_DIR).glob("*.md") - if not re.search(r"review|report|handoff", p.name, re.I) - ) if not targets: print(f"未找到待检文件({DEFAULT_DIR}/)", file=sys.stderr) sys.exit(2) diff --git a/tools/pipeline_gate.py b/tools/pipeline_gate.py index 57224f7b..fe707606 100644 --- a/tools/pipeline_gate.py +++ b/tools/pipeline_gate.py @@ -52,8 +52,6 @@ LEDGER_RULE_DATE = (2026, 10, 21) LEDGER_HEADING_RE = re.compile(r"^#{2,4}\s*.*(验收对照表|改动[—\-]验收)") LEDGER_REQUIRED = ("需求点", "改动", "验收测试", "真实入口") LEDGER_PROOF_MARKS = ("撤销", "证明") -BINDING_RULE_DATE = (2026, 10, 28) # req-card §8:换代为判据绑定面列,撤销/变异证明列同日禁列 -BANNED_PROOF_MARKS = ("撤销证明", "变异证明") AUDIT_MARK = "验收审计" DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}") @@ -453,7 +451,7 @@ def card_date(path): return tuple(int(x) for x in found[-1].split("-")) -def check_ledger(text, path, when, issues): +def check_ledger(text, path, issues): """C3 改动—验收对照表结构检查(生效 2026-10-21)。 只查:对照表节在不在、表头有没有五个必需列、有没有数据行、必填格空不空。 @@ -493,29 +491,13 @@ def check_ledger(text, path, when, issues): "缺「%s」列" % name)) return idx[name] = hit - banned = next((cell for cell in header - if any(mark in cell for mark in BANNED_PROOF_MARKS)), None) - binding_hit = next((k for k, cell in enumerate(header) - if "判据绑定面" in cell), None) - if when >= BINDING_RULE_DATE: - if banned is not None: - issues.append((str(path), header_line, "验收对照表禁列", - "「%s」列已随 2026-10-24/28 两裁禁列(req-card §8.4," - "验收断言绑真实入口+判据绑定面)" % banned)) - return - if binding_hit is None: - issues.append((str(path), header_line, "验收对照表缺列", - "缺「判据绑定面」列(2026-10-28 判据口径换代,req-card §8)")) - return - idx["绑定面"] = binding_hit - else: - proof_hit = next((k for k, cell in enumerate(header) - if any(mark in cell for mark in LEDGER_PROOF_MARKS)), None) - if proof_hit is None: - issues.append((str(path), header_line, "验收对照表缺列", - "缺「撤销证明」列")) - return - idx["证明"] = proof_hit + proof_hit = next((k for k, cell in enumerate(header) + if any(mark in cell for mark in LEDGER_PROOF_MARKS)), None) + if proof_hit is None: + issues.append((str(path), header_line, "验收对照表缺列", + "缺「撤销证明」列")) + return + idx["证明"] = proof_hit rows = 0 for i in range(header_line, len(lines)): @@ -549,8 +531,7 @@ def check_cards(root, issues): C1 判据:「关联文档同步面」表内,目标格指向 design/ 或根规则文件、而义务格仍写 「随批」者红(2026-10-17 req-card §6 改口径后,这类点只能列 reports/ 反哺点清单)。 C2 判据:全文既无「设计事实源」也无「唯一事实源」者红(两种写法都与库内实态对齐)。 - C3 判据:有「改动—验收对照表」节;表头含需求点/改动/验收测试/真实入口+证明列 - (2026-10-28 起换代为「判据绑定面」列,并禁「撤销证明/变异证明」列); + C3 判据:有「改动—验收对照表」节;表头含需求点/改动/验收测试/真实入口/证明列; 至少一行;必填格非空;全文含「验收审计」声明。语义覆盖不在此判。 """ dirn = root / CARD_DIR @@ -569,7 +550,7 @@ def check_cards(root, issues): "须含「设计事实源」或「唯一事实源」声明句(req-card §2 三选一," "GDD 节+版本号/样张+art-bible 版本/工程形态证据指认)")) if when >= LEDGER_RULE_DATE: - check_ledger(text, path, when, issues) + check_ledger(text, path, issues) if AUDIT_MARK not in text: issues.append((str(path), 1, "独立验收审计未声明", "须在卡内写明独立验收审计入口与报告文件(2026-10-21 起," diff --git a/tools/undo_proof.py b/tools/undo_proof.py new file mode 100644 index 00000000..071fd430 --- /dev/null +++ b/tools/undo_proof.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""撤销证明自动化工具:一条命令完成「改前绿 → 真撤销复红 → 还原复绿」三连并出回执。 + +撤销证明的判据(test-conventions §8.4):只撤销该改动、跑能覆盖它的最窄测试、对应断言 +必须变红、还原后复绿;撤销后仍绿=断言没钉在改动上,判缺漏。本工具把这三连机械化—— +合法性属于「真撤销」(回退本批真实改动),全项目禁做变异类测试(改断言极性、置空状态、 +改名键、删调用行等人为制造红=伪造证据)。 + +用法: + python tools/undo_proof.py --cmd "<最窄测试命令>" --revert-commit <该批提交> + python tools/undo_proof.py --cmd "<最窄测试命令>" --revert-files --base + 可选 --out 落回执;--timeout <秒> 限制单次测试命令时长(缺省 3600)。 + + --revert-commit:按该笔提交触过的文件集回退到其父提交版本(改动须已提交在分支上)。 + --revert-files+--base:把指定文件回退到 (默认 HEAD)版本——适合改动尚未单独 + 成笔、混在当前尖里的场景。 + 测试命令在仓库根以 shell 执行;单套件直跑形态见 game/tests/README.md(测试工具手册)。 + +流程与退出码: + ① 改前基线:测试命令必须绿(红=改前就红,无证明意义,中止)。 + ② 真撤销:指定文件回退到基准版本 → 重跑命令,必须红(红=断言钉在该改动上)。 + ③ 还原:文件恢复到 HEAD → 重跑命令,必须绿。 + ④ 任何一步异常都先还原再退出;三步全过=退出码 0 并打印回执,否则退出码 1。 +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +REPO_DEFAULT = Path(__file__).resolve().parent.parent +REPO = REPO_DEFAULT + + +def run(args: list[str], check: bool = True) -> str: + r = subprocess.run(["git", "-C", str(REPO)] + args, + capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=60) + if check and r.returncode != 0: + sys.stderr.write("git %s 失败:%s\n" % (" ".join(args), (r.stderr or "").strip())) + sys.exit(1) + return (r.stdout or "").strip() + + +def run_cmd(cmd: str, timeout: int) -> int: + r = subprocess.run(cmd, shell=True, cwd=str(REPO), capture_output=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout) + if r.returncode != 0: + tail = "\n".join((r.stdout or "").splitlines()[-15:] or ["(无输出)"]) + sys.stderr.write(" | 测试命令失败(exit=%d),输出尾部:\n%s\n" % (r.returncode, tail)) + return r.returncode + + +def _in_rev(rev: str, f: str) -> bool: + r = subprocess.run(["git", "-C", str(REPO), "cat-file", "-e", "%s:%s" % (rev, f)], + capture_output=True, timeout=30) + return r.returncode == 0 + + +def apply_rev(rev: str, files: list[str]) -> None: + # 逐文件:基准版本存在 → 恢复到该版本;基准没有(该批新增的文件)→ 从工作区删除。 + for f in files: + if _in_rev(rev, f): + run(["checkout", rev, "--", f]) + else: + subprocess.run(["git", "-C", str(REPO), "rm", "-q", "-f", "--", f], + capture_output=True, text=True, timeout=30) + + +def main() -> None: + ap = argparse.ArgumentParser(description="自动三连出撤销证明红绿回执") + ap.add_argument("--cmd", required=True, help="最窄测试命令(shell 形,仓库根执行)") + ap.add_argument("--revert-commit", default=None, help="该批提交:按其触过的文件集回退到父版本") + ap.add_argument("--revert-files", default=None, help="逗号分隔的文件清单(与 --base 配用)") + ap.add_argument("--base", default="HEAD", help="回退基准版本(默认 HEAD)") + ap.add_argument("--timeout", type=int, default=3600, help="单次测试命令时长上限(秒)") + ap.add_argument("--repo", default=None, help="仓库/worktree 路径(缺省=本脚本所在仓库)") + ap.add_argument("--out", default=None, help="回执 JSON 落盘路径") + args = ap.parse_args() + + global REPO + REPO = Path(args.repo).resolve() if args.repo else REPO_DEFAULT + + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + if bool(args.revert_commit) == bool(args.revert_files): + ap.error("--revert-commit 与 --revert-files 二选一") + + if args.revert_commit: + files = [x.strip() for x in run( + ["-c", "core.quotepath=false", "show", "--name-only", "--format=", + args.revert_commit]).splitlines() if x.strip()] + base = "%s^" % args.revert_commit + if not files: + sys.stderr.write("[撤销证明] 提交 %s 未触碰任何文件,无可撤销\n" % args.revert_commit) + sys.exit(2) + else: + files = [x.strip() for x in args.revert_files.split(",") if x.strip()] + base = args.base + + dirty = run(["-c", "core.quotepath=false", "status", "--porcelain", "--"] + files) + if dirty: + sys.stderr.write("[撤销证明] 指定文件带未提交改动,撤销与还原的基准不清,先提交:\n%s\n" % dirty) + sys.exit(2) + + steps: list[dict[str, object]] = [] + result = "pass" + + def step(name: str, expect: str, code: int) -> bool: + ok = (code == 0) if expect == "绿" else (code != 0) + steps.append({"step": name, "expect": expect, "exit": code, "ok": ok}) + print("[撤销证明] %s:期望%s,实测 exit=%d → %s" + % (name, expect, code, "符合" if ok else "不符合")) + return ok + + print("[撤销证明] 对象文件 %d 个;基准 %s;命令:%s" % (len(files), base, args.cmd)) + try: + if not step("① 改前基线", "绿", run_cmd(args.cmd, args.timeout)): + result = "fail(改前就红——先修绿再做撤销证明)" + else: + apply_rev(base, files) + try: + if not step("② 真撤销复红", "红", run_cmd(args.cmd, args.timeout)): + result = "fail(撤销后仍绿=断言没钉在该改动上,判缺漏)" + else: + apply_rev("HEAD", files) + if not step("③ 还原复绿", "绿", run_cmd(args.cmd, args.timeout)): + result = "fail(还原后未复绿——还原面有残留,查工作区)" + finally: + apply_rev("HEAD", files) + except subprocess.TimeoutExpired: + result = "fail(测试命令超时)" + finally: + # 兜底还原:无论走到哪一步,退出前把对象文件恢复到 HEAD 态 + apply_rev("HEAD", files) + + residue = run(["-c", "core.quotepath=false", "status", "--porcelain", "--"] + files) + if residue: + result = "fail(退出前工作区仍有对象文件残留改动,手工核对)" + + print("─" * 56) + print("[撤销证明] 结论:%s" % result) + if args.out: + with open(args.out, "w", encoding="utf-8") as f: + json.dump({"cmd": args.cmd, "base": base, "files": files, + "steps": steps, "result": result}, f, ensure_ascii=False, indent=1) + print("[撤销证明] 回执已落 %s" % args.out) + sys.exit(0 if result == "pass" else 1) + + +if __name__ == "__main__": + main() diff --git a/待办清单.md b/待办清单.md index f8834e34..71d3026b 100644 --- a/待办清单.md +++ b/待办清单.md @@ -2,30 +2,14 @@ 本清单是流水线的唯一登记处:要做的事先在此登记一条,随流水线段推进改状态,做完整条删除;各系统文档的「待办」节只留一行指路存根,不再自建条目。条目格式(标题=登记元数据含可选优先级,正文=实况/缺口/改动面/状态四属性)与状态机以 [doc-conventions §12.1](design/engineering/doc-conventions.md) 为权威。 -#### 验证与清单体系整改——四方向分阶段推进(2026-10-28 登记,来源=会话提及) +#### 存量断言内部直查全量归位——判据达标专项批(2026-10-28 登记,来源=会话提及) -**实况**:用户裁「验证及清单不合理」,经调研定四方向全做、分阶段推进:A 粒度整改(清单从断言级升到能力/行为级,逐断言账本退役)→ B 精简裁断(在新粒度上规则驱动删并)→ C 流程风险分级(P0/P1/P2 验收强度、审计抽样)→ D 文档减肥(待办条目限长、历史归 git);测试有效性度量暂不处理。阶段 0 盘点已毕:验证集清单零机器闸依赖(run_all_gates/hooks/五技能均不引用,仅 test-conventions §8.3 维护锚+2 份过程报告引用),退役无闸改动;但测试名尾缀仅覆盖 28/47 个有断言文件(19 个零尾缀,含 seal_polarity 69 条、variant_placement 42 条、roll_multiplier 35 条),故能力清单只能按「能力→代表用例」建,不要求逐断言尾缀。对照表列改动触 pipeline_gate+req-card/iter 两技能;待办正文压缩不触机检(无长度判据)。盘点明细见 reports/盘点-验证与清单体系-2026-10-28.md。**阶段 1 设计已闭合(2026-10-28 用户四裁点)**:能力清单 v1 落 `game/tests/README.md`——按系统归属与玩家旅程排序,首期核心四域(存档与随机、冒险循环、战斗结算、奖励与经济)29 行+跨域条件闸 1 行,共 30 行,每行真实入口+2–3 个代表用例(`文件:函数`),维护规则为每批收口核对本批受影响行并刷新「最后验证批次」;`design/engineering/test-conventions.md` §8.3 维护锚改写为「验证集清单整册冻结、能力清单承接覆盖索引、阶段 2 精简后删除」;`game/tests/验证集清单.md` 头注同步冻结声明,冻结快照(基点 `b9260138`/1587 条)留作阶段 2 对账基准;`tools/extract_checks.py` 保留为审计工具,不再承担常态同步。**阶段 2 已执行闭合(2026-10-28 C 档合回)**:存量断言精简 C 档 6 组手写断言族并为 7 个表驱动站点(33→7),冻结清单对账 1587→1561,验证集清单随阶段 2 完成退役(覆盖索引归能力清单,维护锚 test-conventions §8.3),删并申报与件核见 reports/工程改动报告-存量断言精简C档-2026-10-28.md;阶段 3–4 待各自设计推进:风险分级与审计抽样规则、条目限长是否入 pipeline_gate 均未定。 - -**改动面**:文档+游戏代码+工具代码 - -**状态**:文档设计已闭合 - -#### 恒真与名实不符断言五条——判据缺陷修正(2026-10-29 登记,来源=会话提及) - -**实况**:存量断言精简 A+B 批余账④转正:五条断言属判据缺陷(恒真占位或名实不符),不是冗余合并可解的账,按 test-conventions §8.4 真实入口口径逐条修正,不计入精简条数。逐条:①`game/tests/unit/core/feat_vouchers_test.gd:51` cond 为 `deck_refresh_before == 0 or true`——恒真;②`game/tests/unit/core/treasure_effects_test.gd:263` cond 尾缀 `or true`——恒真;③`game/tests/unit/core/level_shop_economy_test.gd:593` `green_min_hp > 0`——初值 99999、仅 hp>0 分支取 min,恒真;④同文件 `:696` `green3_min_hp > 0`——同型恒真;⑤`game/tests/unit/contracts/system_contracts_test.gd:66-67` 标签称「非空且互异」而 cond 只查 `size() > 0`——互异半缺失,名实不符。修正方向:能绑判据的改绑真实可观测面(互异改值集去重计数、HP 读数改与门槛比对),造不出可观测面的按 §8.4 出口停卡呈报。 +**实况**:目标=unit/integration 存量断言级内部直查 89 处(grep 口径,2026-10-28 本会话实测)按 test-conventions §8.2 新判据(145c1865 落文)全部改钉公开可观测面。证据=§8.2 受控例外条文+本会话 grep 计数;样板=treasure_effects_test(e62e90c8 试点批,12 处改钉零产品码全绿)。执行形态=工程改动 worktree 批(纯测试维护零行为改动,不写卡不编迭代):盘点先行(逐套件定四类改法——属性直换/公开面等价式/真实路径重塑/公开面对比——并标出需产品码只读访问器的呈报项)→ 按域分波每波闸 → 收尾件核(断言级直查 grep=0)。边界:只清断言级(`Kit.check` 行内 `._` 命中),setup 直调写口构造状态不动(§5.4 惯例);断言标签逐字不动(验证集清单锚);禁放宽/删断言;需访问器项逐处呈用户裁(产品码边界);非 quiet 断言改判同步验证集清单对账。时机=排在迭代 41 之前,与波 0 gdstyle 基线刷新互不影响(tests 不在扫描面)。 **改动面**:游戏代码 **状态**:待执行 -#### 断言提取器改版——覆盖 Kit.check_quiet 静默通道(2026-10-29 登记,来源=会话提及) - -**实况**:`tools/extract_checks.py:67` 的 `CALL_RE = check\(` 只认即时通道,静默收集通道 `Kit.check_quiet(cond, desc)` 静态站点实测 407 个在提取口径外(证据=`grep -rn "check_quiet(" game/tests --include="*.gd"` 实数;test-conventions §5.2 已备案该工具局限)。能力清单以 `文件:函数` 为锚,不受影响;但要把断言面全账纳入机器对账(重建账本或全量断言面审计),须先改版提取器:识别 `check_quiet(` 并按反参序(cond 在前)提取标签与 cond。改版后须以全量套件跑一遍 `--diff` 自证提取面覆盖、且 `Kit.check(` 与 `check_quiet(` 不双计。 - -**改动面**:工具代码 - -**状态**:待执行 - #### gdstyle 存量债全量清偿——216 键按病根分波整改(2026-10-26 登记,来源=会话提及,优先级=P0) **实况**:S13 闸实测存量债 216 键/60 文件(2026-10-26,master f3b148ce,gdstyle v0.4.0 默认阈值;基线登记 234 键=实存 216+陈旧 18)。全量清单、波次方案与纪律见清点报告(本条目裁决源)。2026-10-26 用户裁:撤销「一条总账」与「按病根拆多条」两案,并为本条单账(存量债全部清除,紧急优先级);原「gdstyle 基线陈旧键待人工清理」条目并入波 0(基线对账刷新),原「结构审查未立项小件候审」条目并入波 7(卡已写;iter38 施工停卡后经用户裁于 2026-10-26 修订定稿 v1.3,待编排)。**2026-10-26 设计段闭合(逐波裁定全数落定)**:①段组织=逐波开设计段,纯代码波(波 0/2/3/4/5/6/8)写卡前确认零设计改动+按当时 master 重测读数即可写卡;②波 2 各文件内部拆、波 6 视图内部拆私有函数、波 8 config_db 纯代码收敛(三处零设计改动);③波 1 拆件定稿=轴甲两件——battle_orchestrator 拆出 BattleFxHost(效果引擎宿主域)+RewardGrant(奖励授予域),宿主保留状态机/facts 缓冲/订阅/段驱动/行发射基建/呈现投影,attune 族原位保留(迭代 39 副卡语义领地),emit 经 bind 注入 Callable,行数恶化债(c4c69701 挂账)本波还债归绿,残留债「改善非清零」口径预计 8–10 键;定稿落文 architecture.md v1.18(功能层两新件行+「战斗编排域拆件定稿」节);**同日独立审查折入**(grilling+godot-master 双子代理并行、双判回炉补裁级——骨架成立、接线未闭合):阻断 8 条去重 6 组+建议 5 条全数折入 v1.18——跨件单向接线裁死(RewardGrant 持 fx 件引用/fx 兑付走注入 Callable,禁 RefCounted 环)、settlement/ 段类随动(直持三引用封转发壳)、12 随迁键卡内收敛义务(禁波 0 refresh 吸收)、两新件消费依赖列补全+引用束注入、treasure_interact_controller 三触点分家(现值回调双取值面)、_fx_revealing 再入闸归 fx 件(裁定全文见清点报告 §六第 6 条);④全部波不改游戏规则与玩家可见行为,design/gdd/systems/ 零修订预期;波 5 已实查落定纯代码(open_ticket 12 参=单据声明形态)。波 7 的 wr5/wr6 验收载体重裁为写卡段用户裁点(卡 v1.2 在册,写卡补呈报)。裁定全文见清点报告 §六。**2026-10-28 迭代 40 合回出账(波 1+波 7 闭合,合并提交 514457d1)**:卡①(波 1)45 键清 44、宿主类变量键豁免第三条顺带落 architecture v1.22 S13 豁免登记,行数恶化债还清(实测 1036≤登记 1302;契约 2 残余 36 行系合并适配承接 master 迭代 39 内容带入,用户裁登记残余债归后续批);卡②(波 7)六件定点修+运行期违约通道负面套件(run_time_fatal_test)落地合回。余波 2/4/6/8 归迭代 41 两卡(在册)。 @@ -263,7 +247,7 @@ #### 棋盘格边长三方取值不一致——待扩板定档处置(2026-10-06 登记,来源=批次移交) -**实况**:扩板已落地(四档板型 10/12/13/14 在跑),2026-10-26 文档面已按单点维护归一:①格边长口径的唯一出处收敛到 [战斗屏规格](design/gdd/ux/battle.md)「棋盘格尺寸公式」——公式含格间距与垂直预留,可读下限 30px,1280 档算例 43/36/33/30 标注为随布局变化的非契约值;②[美术圣经](design/art/art-bible.md) §8.1 改为指路 battle.md、只锁资产侧两条比例,§8.2 甲类整数倍纪律已剔除砖面 tile(瓦片随格边长整格铺满,允许非整数倍);③[HUD框架](design/gdd/systems/表现与支撑/HUD框架.md) 只留装板可行性不等式,删去 36/45 定值档与旧算例;④demo 原型 44px 那一路随 `src/` 退役消失。剩余为代码面两处与口径不同步:`game/probe/layout_probe.gd:15` 的格子可读门槛 `MIN_CELL := 36.0` 仍取 12×12 的旧锚点值,比 battle.md 的可读下限 30px 严——现探针只进 10×10 的 L1 故仍全绿,一旦覆盖 13×13(33px)与 14×14(30px)即会误报红;`game/tests/unit/core/board_gen_reward_test.gd:53` 的格边长冒烟只跑 L1/L2,13×13 与 14×14 无覆盖。断言改动后按 `game/tests/README.md`「能力清单」核对受影响行(验证集清单已退役——test-conventions §8.3)。 +**实况**:扩板已落地(四档板型 10/12/13/14 在跑),2026-10-26 文档面已按单点维护归一:①格边长口径的唯一出处收敛到 [战斗屏规格](design/gdd/ux/battle.md)「棋盘格尺寸公式」——公式含格间距与垂直预留,可读下限 30px,1280 档算例 43/36/33/30 标注为随布局变化的非契约值;②[美术圣经](design/art/art-bible.md) §8.1 改为指路 battle.md、只锁资产侧两条比例,§8.2 甲类整数倍纪律已剔除砖面 tile(瓦片随格边长整格铺满,允许非整数倍);③[HUD框架](design/gdd/systems/表现与支撑/HUD框架.md) 只留装板可行性不等式,删去 36/45 定值档与旧算例;④demo 原型 44px 那一路随 `src/` 退役消失。剩余为代码面两处与口径不同步:`game/probe/layout_probe.gd:15` 的格子可读门槛 `MIN_CELL := 36.0` 仍取 12×12 的旧锚点值,比 battle.md 的可读下限 30px 严——现探针只进 10×10 的 L1 故仍全绿,一旦覆盖 13×13(33px)与 14×14(30px)即会误报红;`game/tests/unit/core/board_gen_reward_test.gd:53` 的格边长冒烟只跑 L1/L2,13×13 与 14×14 无覆盖。断言改动后须同批重生成 `game/tests/验证集清单.md`(`tools/extract_checks.py` 手工触发)。 **改动面**:游戏代码 @@ -311,6 +295,17 @@ **状态**:待写卡 +#### 圣经口号型条款机检化批(2026-10-06 登记,来源=审计发现) + +**实况**:审计 P2-4——圣经约 55% 条款属口号型(如「暖倾向无压抑」「同一圆角率」无值),与可机检条款混排。目标=逐条审查标注「可机检/目检/口号」三档分行,口号型逐步改写为带值或带判据条款。工程量=全文级(541 行逐条),非一轮能毕。**2026-10-06 三档标注格式已拍板**(←机检/←目检/←方向性,规范见 art-bible 头注「条款验收标注纪律」;数值类推荐「待定现用」态)——本批执行=按此格式分章补标,每章交付用户抽验。挂账:随整屏样张闸前整备批启动,分章推进。 + +**缺口**:还差整屏样张闸前整备批的启动——格式已定,差启动时点与分章执行。 +- 相关文档:[art-bible](design/art/art-bible.md) 全文 · 样张批前置整备 + +**改动面**:文档+工具代码 + +**状态**:待文档设计(备选) + #### 战斗区背景图替换——B 区舞台插画 + 临时光柱退役(2026-10-07 登记,来源=会话提及) **实况**:B 区舞台现行=程序化平色底(`UiKit.STAGE_BG`,`battle_stage_view.gd:47`)+ **顶光柱氛围层**(`:55-63` 四柱,x 基准 110/900/1055/1210@1280,60 段叠层矩形自顶向下渐宽窄渐隐〔顶 α26→0〕,层序=bg < 光柱 < 世界)——二者均系 §6.1 方向稿的**临时占位**,用户口径「光柱只是临时方案,换正式战斗背景后效果更好」。正式件已立资产契约:`stage/stage_bg.png`(源 1280×270,B 区全高含明细带底;三关换挡走 §4.3 `wall_ambient` tint 槽,不按板出三张),属审计 P1-2 查得的「B 区舞台背景插画 0 张」缺口。**光柱在背景到位后顺带退役或吸收进背景绘制**(不再作为独立程序层)。**与立绘探顶解耦**:大体档「允许向上溢出盒顶 N px」的判定(2026-10-07 用户裁甲案)**不依赖光柱是否存在**,换背景零返工。 @@ -402,23 +397,33 @@ **状态**:待写卡(备选) -#### tests/README manifest 计数行漂移订正——「64 条」对实际 65 条(2026-10-28 登记,来源=批次移交) +#### 闸与探针批上下文挂死防护——probe_gate 进程树回收与 run_all 阻塞面超时(2026-10-25 登记,来源=批次移交) -**实况**:证据=单元闸挂死守护批收尾实数(合并提交 4afe6c47,报告 reports/工程改动报告-单元闸挂死守护-2026-10-28.md)——`game/tests/run_all.gd` manifest 实为 65 条(62 单元+3 集成,grep '{"path"' 计数),`game/tests/README.md`「套件 manifest 现况」行仍记「64 条(61 单元+3 集成,2026-10-28 实数)」。漂移先于该批存在(主区与任务分支同值),该批未动套件数不属其 scope,登记归文档批订正。 +**实况**:证据=迭代 36 批末闸机观测(非本批引入)。原登记两件存量噪声,件①(battle_log_view.gd:17 类型声明错致运行期 TypedArray 错误、日志失真)已随迭代 38 卡⑥ 收口出账(git 提交 ce49a8ee),本条只剩件②:`level_shop_economy_test` 在闸批量上下文存在挂死形态(直跑恒绿),`tools/probe_gate.sh` 的 per-suite timeout 只杀 wrapper 不杀引擎子进程,孤儿泄漏会殃及后续全部 Godot 启动(批末四次闸跑批中断的根源,处置=清杀泄漏进程+删重建 `game/.godot`)。挂死防护=闸壳/测试基建,按改动四类属**工程改动走直改通道**。 +- 直改落点:tools/probe_gate.sh(per-suite 进程树回收)+game/tests/run_all.gd(OS.execute 阻塞面超时守护)——测试基建直改。**2026-10-28 用户裁四批全做,probe_gate 件已落**(run_suite 整树回收:启动记 WINPID → 超时先 `taskkill /T` 杀全树再 `kill -9` 收 MSYS 侧〔顺序颠倒会让 /T 走不到已死父进程〕→ 标志文件判超时〔taskkill 强杀后 MSYS wait 可能报 0〕;默认五段实跑全绿+超时/正常双路径机制验证通过)。run_all.gd 件属代码面(game/tests),按车道铁律留待触及测试基建的 worktree 批顺带,本条目保持待执行。**2026-10-28 迭代 40 批末补实证(阻塞面挂死实拍)**:迁移类改动的回退使测试 `_init` 在 `quit()` 前中断(红行形态 `Invalid call. Nonexistent function`),进程挂至外层超时 124——卡① wr1/wr4/wr9 取证轮三例复现,与件②同根(阻塞面无超时守护),实证链在案(实现报告 §三第 1 条)。 -**改动面**:文档 + +**改动面**:工具代码 **状态**:待执行 -#### 主角棋盘小人像素管线重产——原「美术资产链路审计两项 P0」之 P0②(2026-10-09 登记,来源=审计发现) +#### undo_proof.py 退役——撤销证明禁令生效后的死工具(2026-10-28 登记,来源=批次移交) -**实况**:主角棋盘小人未走像素管线(插值糊图),重产事项未在任何登记处在册;审计报告 HTML 件从未进版本库、现已删除,现状以本条正文为准。2026-10-13 用户令随「UI 美化三层诊断」登记:本条属第三层「观感提升」,Alpha 阶段需完成。原 P0①(文档面:两处引用已丢失的冻结样张路径)2026-10-28 按用户裁乙销号——art-bible §1.4 冻结记录与 art-status-checklist P0 样张行删路径、留「产物原件已丢失」,机检报告凭据(git 提交 7a847c7)保留。 +**实况**:证据=test-conventions §8.4 现行口径(2026-10-24/2026-10-28 用户两裁:禁变异类测试+禁撤销类取证,验收唯一主判据=真实入口断言当轮执行并通过;AGENTS.md 红线「验收闭合」与 req-card SKILL 第 8 条同批换代)。`tools/undo_proof.py`(159 行,挂死防护批产物)为撤销证明的自动化载体(三连红绿回执并自还原),服务对象已被禁令整体废止——工具本体退役删除。同族残留顺带裁:`.zcode|.qoder/skills/iter/evals/files/mock-card-iter40-1.md` 表头「撤销/变异证明」字样(两份,反向残留);`tools/pipeline_gate.py` 是否加「变异测试/撤销证明」短语级扫描防复发(**短语级**——按「变异」单词扫会命中地形变异等玩法词,实测假红面大)。 -**缺口**:还差美术面的重产(按像素管线重画主角棋盘小人),归美术设计段。 +**改动面**:工具代码 -**改动面**:美术素材 +**状态**:待执行 -**状态**:待美术设计 +#### 美术资产链路审计两项 P0 — 待用户裁决(2026-10-09 登记,来源=审计发现) + +**实况**:**P0①(文档面)**:`design/art/art-bible.md` 第 68/527 行与 art-status-checklist 第 13 行至今仍引用已丢失的冻结样张 sample-batch-v3-final-2026-09-30.png(磁盘实测零命中),死指涉待修复。**P0②(美术面)**:主角棋盘小人未走像素管线(插值糊图),重产事项未在任何登记处在册。审计报告 HTML 件从未进版本库、现已删除,两项 P0 的现状以本条正文为准。裁=修复死指涉+登记重产,或判过期随旧管线退役。2026-10-13 用户令随「UI 美化三层诊断」登记:本条属第三层「观感提升」,Alpha 阶段需完成。 + +**缺口**:还差用户的路线裁决(修复死指涉+登记重产,还是判过期随旧管线退役)——裁决后文档面修复与美术重产登记各按轨推进。 + +**改动面**:文档+美术素材 + +**状态**:待文档设计+待美术设计 #### 技术债观察登记——长函数余量与 lambda 连接防回归(2026-10-15 登记,来源=批次移交)