diff --git a/game/README.md b/game/README.md index 962962a5..4ce89c8d 100644 --- a/game/README.md +++ b/game/README.md @@ -50,7 +50,7 @@ Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://sim/adv **试玩画像口径(2026-09-19 用户拍板固化——全项目试玩/扫描/面板模拟一律只用以下两档,随机策略口径退役)**:`policy="normal"` = **普通画像·基础定式**(单数字满足规则——满足即标/满足即开,无处可推盲挖边界格、不刷新;教学后正常玩家的下限近似);`policy="perfect"` = **完美画像**(single+subset 全推理零盲挖/卡死刷新——技能上界仪)。实现单一出口 = `sim/auto_player.gd`(novice_action / perfect_action)经 `sim/level_autoplay.gd`,调试浮窗批量模拟与各扫描工具自动同口径;战斗层冒烟夹具与调试转储的 `choose_dig` 直调是机制测试夹具、不承载画像语义。分级依据与新旧读数(乱点 0%/5% → 基础定式 65%/90%)见 auto_player.gd 普通策略档节与台账 77。 单关诊断工具(level_debug.gd)随单关直入口口径退役(2026-09-23)——败退归因走 balance_verifier 冒险段(层/节点/敌人人读归因)。 -战斗级工具(v1 遗产):`dump_battle.gd`(结算流水)/ `monte_carlo.gd`(战斗期望)/ `experiment_density.gd`(密度调参)。 +战斗级工具(v1 遗产):`dump_battle.gd`(结算流水)/ `monte_carlo.gd`(战斗期望)。 ## 范围(v1 范围 + v2 追加,用户 2026-09-10/11 确认) diff --git a/game/core/adventure_runner.gd b/game/core/adventure_runner.gd index b7cc5b57..2ee4af3d 100644 --- a/game/core/adventure_runner.gd +++ b/game/core/adventure_runner.gd @@ -36,7 +36,7 @@ var _refresh_bank: RefreshBank = null # 刷新账件(_init 建——三源 var _loadout: AttuneLoadout = null # 同调配装件(_init 建;充能单源化——物化行唯一事实源) var _bp_store: BreakpointStore # 断点组装与回放件(_init 建;六成员+27 键登记单源+读写侧机组随件——宿主读写经件) var adventure_inventory: TreasureItems # 冒险级秘宝库存(单源化——同调未用行跨关存活的承载面;关卡域语义归 level 使用面) -var _tree_rules: Array[Variant] = [] # 树 rule 键集(Fork 激活侧 effects 透传;下传 enc.tree_rules 消费面) +var _tree_rules: Dictionary[Variant, Variant] = {} # 树规则参数字典(Fork 激活侧 effects_* 透传;下传 enc.tree_rules 消费面) var _shop_mode := "legacy" # 商店模式("legacy"=固定三件加定价〔off 对照承载,缺省零扰动〕/"pool"=共享池抽 3 买 1 重抽;真实入口 main.gd 显式投 pool) @@ -123,7 +123,7 @@ func start_adventure(seed_val: int, params: Dictionary = {}) -> bool: # 特性开局注入(MC 成型画像载体:params.relics_seed 非空=开局持有集〔七流派 # 各 2 件档一成型——测量面专用;真实局规则改写型特性只走局内随机渠道,红线②层级纪律不破〕) var feat_seed: Array[Variant] = params.get("relics_seed", []) if params.get("relics_seed", []) is Array else [] - _tree_rules = params.get("tree_rules", []) if params.get("tree_rules", []) is Array else [] # A/B 面 rule 键集 + _tree_rules = params.get("tree_rules", {}) if params.get("tree_rules", {}) is Dictionary else {} # A/B 面规则键参数字典 if not feat_seed.is_empty(): for r in feat_seed: _stats.feats.append(String(r)) @@ -163,7 +163,7 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool: _attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive _refresh_bank.set_refresh_x_cap(_attune_refresh_x_total()) # 刷新账 X 基数预算注入(续玩参数面同 start) _bp_store.restore_stop_bank_fragments(bp, params) # 停点件/刷新账片段回填(件侧——凭证计数;刷新存量/Y 池缺键回退参数面) - _tree_rules = params.get("tree_rules", []) if params.get("tree_rules", []) is Array else [] # A/B 面 rule 键集(续玩参数面同 start_adventure 同式,消费面 set_tree_rules 下传) + _tree_rules = params.get("tree_rules", {}) if params.get("tree_rules", {}) is Dictionary else {} # A/B 面规则键参数字典(续玩参数面同 start_adventure 同式,消费面 set_tree_rules 下传) # 续玩计数器回填(adventure 域计数——缺键 = 旧版断点防御空集;_reset_curio_state 已清零在先——桶归宿主字典) var bp_counters = _bp_store._bp_read("adventure_counters", bp) if bp_counters is Dictionary: diff --git a/game/core/battle_fx_host.gd b/game/core/battle_fx_host.gd index 22eaa6b9..67478a1d 100644 --- a/game/core/battle_fx_host.gd +++ b/game/core/battle_fx_host.gd @@ -179,7 +179,7 @@ func flush_pending_reveals(ri: int) -> void: # 效果引擎订阅重建(每战从持有集/装备集重建,中途获得下一战生效;树规则合成订阅同面装载。 # 装载序=feats 表序→treasure 表序→树规则,写死保种子复放。计数器四桶经 counters_ctx 引用宿主容器,无回灌时点) -func rebuild_subscriptions(feats: Array, attunement: Array, tree_rules: Array) -> void: +func rebuild_subscriptions(feats: Array, attunement: Array, tree_rules: Dictionary[Variant, Variant]) -> void: engine.rebuild(feats, attunement, tree_rules) diff --git a/game/core/battle_orchestrator.gd b/game/core/battle_orchestrator.gd index 9b75544f..56e121e0 100644 --- a/game/core/battle_orchestrator.gd +++ b/game/core/battle_orchestrator.gd @@ -115,7 +115,7 @@ func _fx_host_fact(fact: String) -> Variant: match fact: "nest_count": var n := 0 - for eff in _status_effects.query_active("enemy"): + for eff in _status_effects.query_active_side("enemy"): if String(eff.effect_id) == "weaken": n += 1 return n @@ -348,7 +348,7 @@ func _begin_battle_fx_state(enc: Dictionary) -> void: # 树 rule 键消费(Fork 激活侧效果=效果引擎合成 # 订阅(rebuild 注入——round_start 首轮 AP+1 / damage_taken 减伤+1)。其余 rule 键 # =stat 面投影不入战斗(T8 表内容 provisional 口径披露)) - var tree_rules: Array[Variant] = enc.get("tree_rules", []) as Array + var tree_rules: Dictionary[Variant, Variant] = enc.get("tree_rules", {}) as Dictionary # 效果引擎订阅重建(装载序=feats 表序→treasure 表序→树规则,写死保种子复放——细则见 battle_fx_host.rebuild_subscriptions) _fx_host.rebuild_subscriptions(feat_ids, _facts.attunement, tree_rules) @@ -510,19 +510,15 @@ func _defender_ac(unit: Dictionary) -> int: return _damage.defender_ac(unit) -func _pool_effective(effect_id: String) -> int: - var side := "hero" - 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): +func _pool_effective(effect_id: String, owner_key: String) -> int: + for eff in _status_effects.query_active(owner_key): if eff.effect_id == effect_id: return eff.effective_value return 0 func _emit_composition_effects(side: String, ri: int) -> void: - for eff in _status_effects.query_active(side): + for eff in _status_effects.query_active_side(side): if eff.round_index < ri: # 续存实例(前轮授予)——每结算兑付一次的可见面(合并实例只持首授归因——增益状态 义务⑦) _emit_row({"type": "effect", "source_class": eff.source_class, "origin_cell": eff.origin_cell}, ri, @@ -536,10 +532,10 @@ func _emit_settlement_snapshot(verdict: String) -> void: for u in _enemy_system.query_alive("enemy"): enemies.append({"id": String(u.enemy_id), "hp": int(u.hp), "max_hp": int(u.max_hp), "atk": int(u.atk), "pending_skill": _enemy_system.pending_action(u), "armor": String(u.get("armor_type", "light")), "armor_class": int(u.get("armor_class", 0))}) var buffs: Array[Dictionary] = [] - for eff in _status_effects.query_active("hero"): - buffs.append({"id": eff.effect_id, "side": "hero", "layers": eff.layers, "left": eff.remaining_settlements}) - for eff in _status_effects.query_active("enemy"): - buffs.append({"id": eff.effect_id, "side": "enemy", "layers": eff.layers, "left": eff.remaining_settlements}) + for eff in _status_effects.query_active_side("hero"): + buffs.append({"id": eff.effect_id, "side": "hero", "owner": String(eff.owner_key), "layers": eff.layers, "left": eff.remaining_settlements}) + for eff in _status_effects.query_active_side("enemy"): + buffs.append({"id": eff.effect_id, "side": "enemy", "owner": String(eff.owner_key), "layers": eff.layers, "left": eff.remaining_settlements}) var mines_hit := 0 var safe_n := 0 for evt in _facts.reveals: @@ -651,8 +647,9 @@ func _roll_gp_drop() -> int: # 财富之道·聚(战斗掉落 GP 乘算·入账前:只乘战斗掉落两成分〔掷定+关卡加成〕, # 店内 escrow 不乘〔范围=仅战斗掉落渠道〕;本函数返回值=结算框「GP 增量」行 # 显示源,落在入账前=显示即实得;乘数 provisional 150%) - if (_facts.encounter.get("tree_rules", []) as Array).has("economy_hoard"): - drop = int(round(float(drop) * 1.5)) + var hoard: Dictionary[Variant, Variant] = (_facts.encounter.get("tree_rules", {}) as Dictionary).get("economy_hoard", {}) + if not hoard.is_empty(): + drop = int(round(float(drop) * float(hoard["mult_pct"]) / 100.0)) # 乘数读规则键参数 mult_pct return drop + _reward_grant.escrow @@ -666,12 +663,6 @@ func _roll_gp_drop() -> int: func presentation_snapshot() -> Dictionary: var enemies: Array[Dictionary] = [] var all: Array[Variant] = _enemy_system.all_enemies() - # 敌方增益投影(side-wide 状态——蓄力/硬化/削弱/暴走 percent 行 + 警觉 ac 行) - var enemy_effects: Array[Dictionary] = [] - for eff in _status_effects.query_active("enemy"): - 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)) @@ -689,7 +680,7 @@ func presentation_snapshot() -> Dictionary: "pending_skill": pending, "pending_type": String(pending_row.get("type", "")) if not pending_row.is_empty() else "", "pending_name": String(pending_row.get("display_name", "")) if not pending_row.is_empty() else "", - "effects": enemy_effects, + "effects": _enemy_own_effects(u), "alive": alive, }) var hero := {} @@ -705,7 +696,7 @@ func presentation_snapshot() -> Dictionary: "refresh_used": _facts.refresh_used, # 受理计数入呈现成文面(HUD 余量单源读此处) "hero": hero, "enemies": enemies, - "hero_effects": _status_effects.query_active("hero").duplicate(true), # 值拷贝出户(快照消费方直改不渗入状态容器) + "hero_effects": _status_effects.query_active(StatusContainer.OWNER_HERO).duplicate(true), # 值拷贝出户(快照消费方直改不渗入状态容器) # 整图变异投影(呈现横幅/常驻角标唯一读取面; # 空数组 = 无全局型合法态:普通图与全局池空回落图恒空) "board_variants": (minesweeper.current_board().board_variant_ids().duplicate() @@ -713,6 +704,18 @@ func presentation_snapshot() -> Dictionary: } +# 敌方增益逐卡投影(呈现各读各的——增益状态 v1.5):每卡只读它自己名下实例 +# (蓄力/硬化/削弱/暴走 percent 行 + 警觉 ac 行;死亡个体桶已清,投影恒空) +func _enemy_own_effects(u: Dictionary) -> Array[Dictionary]: + var own: Array[Dictionary] = [] + if bool(u.get("alive", false)): + for eff in _status_effects.query_active(StatusContainer.owner_key_for(u)): + 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"]: + own.append({"effect_id": eff.effect_id, "display_name": String(eff.display_name), "layers": int(eff.layers)}) + return own + + # 战斗进行中谓词(hero 在场且存活)——组合根高频相位判断的低成本面 func is_battle_live() -> bool: return battle_result == "" and not _enemy_system.hero_unit().is_empty() diff --git a/game/core/board_data.gd b/game/core/board_data.gd index c1539e62..fa7651b9 100644 --- a/game/core/board_data.gd +++ b/game/core/board_data.gd @@ -4,9 +4,7 @@ class_name BoardData extends RefCounted -# 出生 3×3 区格数(W×H−9 布雷域基的双落点收口单点: -# 棋盘生成 _attempt/_finalize 与 BoardGenTable 布雷数下限校验共用;与 spawn_cells() 的 3×3 几何同源) -const SPAWN_AREA_CELLS := 9 +var spawn_edge: int = 0 # 出生区边长(奇数——表头 board_gen.spawn_edge 经生成器注入;spawn_cells() 几何与 W×H−edge² 布雷域基同源导出) var width: int = 0 var height: int = 0 @@ -69,9 +67,10 @@ func neighbors8(c: Vector2i) -> Array: func spawn_cells() -> Array: + var half := spawn_edge / 2 var out: Array[Vector2i] = [] - for dy in range(-1, 2): - for dx in range(-1, 2): + for dy in range(-half, half + 1): + for dx in range(-half, half + 1): var c := Vector2i(spawn_center.x + dx, spawn_center.y + dy) if in_bounds(c): out.append(c) diff --git a/game/core/board_generator.gd b/game/core/board_generator.gd index accfd631..e6d17342 100644 --- a/game/core/board_generator.gd +++ b/game/core/board_generator.gd @@ -14,26 +14,26 @@ var fatal_handler: Callable = Callable() # ==================== 生成入口 ==================== -# 目标雷数 N = round(d×(W×H−9))(params 快照键:density/w/h)——布雷定数与产物断言两处的单点公式 +# 目标雷数 N = round(d×(W×H−edge²))(params 快照键:density/w/h/spawn_edge)——布雷定数与产物断言两处的单点公式 static func target_mine_count(params: Dictionary) -> int: - return int(round(float(params.density) * float(int(params.w) * int(params.h) - BoardData.SPAWN_AREA_CELLS))) + return int(round(float(params.density) * float(int(params.w) * int(params.h) - int(params.spawn_edge) * int(params.spawn_edge)))) # rng_stream = 一次 open_stream("board_gen") 实例(一张图的全部取数;重试共用——attempt 边界非 scope 事件)。 -# db 参数不标具体类型原因:冒烟工具注入 FakeDB 桩(experiment_density.gd 等)——typed 形参会拒收桩表 +# db 参数不标具体类型原因:冒烟工具注入 FakeDB 桩(calibrate_boards.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.combat.board_gen.resolve_row(L, D) if row.is_empty(): _fatal("BoardGen 行缺失 (%d,%d)" % [L, D]) return null - var params := _board_params(row, L, D) + var params := _board_params(row, L, D, int(db.combat.board_gen.spawn_edge)) # 表头直索引——缺成员即崩=fail-fast 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: +func _board_params(row: Dictionary, L: int, D: int, spawn_edge: int) -> Dictionary: return { "L": L, "D": D, "w": int(row.board_width), "h": int(row.board_height), @@ -47,6 +47,7 @@ func _board_params(row: Dictionary, L: int, D: int) -> Dictionary: # r0_cap=0 闸关。正式表行全行投值,加载期校验在 BoardGenTable.validate) "shave_r": int(row.get("shave_ring_radius", 1)), "r0_cap": int(row.get("r0_max_cap", 0)), + "spawn_edge": spawn_edge, # 出生区边长(表头单点——经 generate 直索引注入) } @@ -100,11 +101,12 @@ func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Boar var b := BoardData.new() b.width = params.w b.height = params.h + b.spawn_edge = int(params.spawn_edge) b.spawn_center = Vector2i(int((params.w - 1) / 2.0), int((params.h - 1) / 2.0)) 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;均匀布雷后"先布后删"(_populate_mines 承载——Fisher-Yates 取号段整体随迁) + # ② 布雷:N = round(d×(W×H−edge²)) 先定数;域 = 全格 − 出生区(edge²);均匀布雷后"先布后删"(_populate_mines 承载——Fisher-Yates 取号段整体随迁) var removed := _populate_mines(b, params, rng_stream) # ③ 变异布置(地形变异 框架面):布置约束与算法随首批变异类型定稿(生成期布置接口结构位), # 定稿前零布置——清单为空是合法态(P0),静默产出无变异图。 @@ -148,7 +150,7 @@ func _populate_mines(b: BoardData, params: Dictionary, rng_stream: RandomNumberG 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: + if maxi(absi(c.x - b.spawn_center.x), absi(c.y - b.spawn_center.y)) <= (int(b.spawn_edge) - 1) / 2 + shave_r: ring[c] = true var removed := 0 for c in ring.keys(): diff --git a/game/core/board_installer.gd b/game/core/board_installer.gd index f8b7c123..88c300d8 100644 --- a/game/core/board_installer.gd +++ b/game/core/board_installer.gd @@ -122,7 +122,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.combat.variation.pack_max))) + var pack_size: int = mini(total - placed.size(), stream.randi_range(int(db.combat.variation.pack_min), int(db.combat.variation.pack_max))) var free: Array[Vector2i] = [] for c in candidates: # 新片种子/生长格禁与其他已布片 8 邻贴接——否则两片粘连成一个超 pack_max 的连通分量 diff --git a/game/core/config_db_resolver.gd b/game/core/config_db_resolver.gd index 37668e44..f8fb164b 100644 --- a/game/core/config_db_resolver.gd +++ b/game/core/config_db_resolver.gd @@ -107,7 +107,8 @@ func _warn_resolve(table_id: String, category: String, message: String, l_key: i _diag.call("warning", category, message) -# 难度曲线与Boss DifficultyTable 消费面(敌人系统.spawn 调用):(L,D) 精确匹配修饰乘数,越域值 clamp(GDD Edge Case——组合事故非配置事故) +# 难度曲线与Boss DifficultyTable 消费面(敌人系统.spawn 调用):(L,D) 精确匹配修饰乘数, +# 越域值 clamp(域常量读 DifficultyTable.MULT_MIN/MULT_MAX 单点——使用时 clamp 口径,warning 预登记同族) func resolve_difficulty_modifiers(L: int, D: int) -> Dictionary: var dt = _difficulty_get.call() if dt == null: @@ -115,8 +116,8 @@ func resolve_difficulty_modifiers(L: int, D: int) -> Dictionary: for row in dt.rows: if int(row.get("level_index", 0)) == L and int(row.get("node_depth", -1)) == D: return { - "hp_mult": clampf(float(row.get("hp_mult", 1.0)), 0.1, 10.0), - "atk_mult": clampf(float(row.get("atk_mult", 1.0)), 0.1, 10.0), + "hp_mult": clampf(float(row.get("hp_mult", 1.0)), DifficultyTable.MULT_MIN, DifficultyTable.MULT_MAX), + "atk_mult": clampf(float(row.get("atk_mult", 1.0)), DifficultyTable.MULT_MIN, DifficultyTable.MULT_MAX), } return {} diff --git a/game/core/curio_deck.gd b/game/core/curio_deck.gd index 4df30645..d7cb9623 100644 --- a/game/core/curio_deck.gd +++ b/game/core/curio_deck.gd @@ -95,14 +95,15 @@ func consume_deck_token(kind: String, tier: String, feat_id: String = "") -> boo # 已拿上限判定(身份维度:kind 走 curio_cap、relic 走 feats 行 cap 字段; # taken 只在兑付时 +1〔拿了才计〕、刷出不计)。缺省放行=白名单外 kind 兜底。 func cap_ok(kind: String, feat_id: String = "") -> bool: - var cap := 99 if kind == "relic": 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 row.is_empty(): + push_error("[奇物牌库] relic 令牌引用悬空 feat_id=%s(feats 表无此行——配置违约,禁兜底)" % feat_id) + return false + return taken.get("relic:" + feat_id, 0) < int(row["cap"]) # 直索引——schema 必填保证在键 if not db.run.adventure.CURIO_KINDS.has(kind): return true - cap = int(db.run.adventure.curio_cap(kind)) + var cap: int = int(db.run.adventure.curio_cap(kind)) return int(taken.get(kind, 0)) < cap diff --git a/game/core/damage_controller.gd b/game/core/damage_controller.gd index 3ebf3137..09f9344c 100644 --- a/game/core/damage_controller.gd +++ b/game/core/damage_controller.gd @@ -89,10 +89,11 @@ func _per_value(name: String, payload: Dictionary) -> int: return 0 -# 巢穴控制数(fact nest_count 取数面——敌方 weaken 实例数,猎巢人订阅面) +# 巢穴控制数(fact nest_count 取数面——敌方名下 weaken 实例总数,猎巢人订阅面; +# 个体化后削弱为累加行、每体各持一条——总数 = 被削弱个体数) func _nest_count() -> int: var n := 0 - for eff in effects.query_active("enemy"): + for eff in effects.query_active_side("enemy"): if String(eff.effect_id) == "weaken": n += 1 return n @@ -116,8 +117,8 @@ func submit(t: Dictionary) -> int: # board_flat_hero 的键并入行载荷,board_blast/board_typed 带 batch_key 供未生效行归因)。 # attack_commit 发射点(我方伤害单据开立入口; # 命中挂单据 context 随行——施加面在 _resolve_hero_attack 管线算术前逐族施加,开单时点=主角攻击 -# 单恒为段内首单(结账现场与开单现场一致)。载荷 atk/target/matchup + 挖掘 streak 事实〔连珠矢/ -# 执拗订阅面〕+巢穴控制数〔猎巢人〕) +# 单恒为段内首单(结账现场与开单现场一致)。载荷 atk/target/matchup + 挖掘 streak 事实 +# (执拗订阅面)+巢穴控制数〔猎巢人〕) # spec 束必载键(缺载 assert——fail-fast):channel/base_value/target_rule/ri; # 可选键缺省=原形缺省:origin_cell=(-1,-1)/attack_type=""/rider=0/highlight=""/context={}。 # 返回单据字典九栏目+requires/source_unit 逐字不变(契约 8)。 @@ -196,6 +197,7 @@ func _on_unit_died(u: Dictionary) -> void: for t in _queue: if is_same(t.source_unit, u): t["cancelled"] = true # 死亡即不动手——该敌未结单据作废(静默零行零 commit) + effects.purge_owner(StatusContainer.owner_key_for(u)) # 个体死亡即清名下实例(增益状态 v1.5——不转移不并回) # ---- 兑付管线(单点——所有伤害经过同一道门)---- @@ -356,7 +358,7 @@ func _hero_attack_base_value(t: Dictionary, ctx: Dictionary) -> float: # 掷点兑现(rolled 分量/payload 归因/池乘区/下限/英雄侧 rider 族/落地扣血/击杀标记; -# 击杀发射面拆 _kill_overflow)。返回 {landed, payload, hero_rider}(交付步消费)。 +# 击杀发射面拆 _kill_effects)。返回 {landed, payload, hero_rider}(交付步消费)。 func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> Dictionary: var target = ctx["target"] var ac_hits: Array[Variant] = ctx["ac_hits"] @@ -370,7 +372,7 @@ func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> D payload["tier"] = int(ctx["discount"]) if bool(ctx["crit"]): payload["crit"] = true - var pooled: int = DamageMath.pool_damage(rolled, clamped_pool("hero_offense"), clamped_pool("enemy_defense")) + var pooled: int = DamageMath.pool_damage(rolled, clamped_pool("hero_offense", StatusContainer.OWNER_HERO), clamped_pool("enemy_defense", StatusContainer.owner_key_for(target))) var landed: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), pooled) # 加法族:英雄侧 rider(enemy_skill rider 对称位补齐口径)——后置必中附加,独立于掷点与乘区 # (奥术余烬「普攻命中后恒定结算」语义);猎巢人巢控光环(per nest_count)/矿工的执拗开格加成 @@ -391,31 +393,16 @@ func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> D units.apply_damage(target, landed) payload["killed"] = not target.alive # 击杀标记在扣血后取 if bool(payload.get("killed", false)): - _kill_overflow(t, target, landed) + _kill_effects(t, target) return {"landed": landed, "payload": payload, "hero_rider": hero_rider} -# kill 发射+施加(敌人死亡确认;贯穿射击溢出弹射订阅面:对下一存活敌 -# 以穿刺类型弹射本体 value%〔无次敌=弹射落空〕。B 模式入队段尾 flush 兑现;A 模式递归 resolve) -func _kill_overflow(t: Dictionary, target: Dictionary, landed: int) -> void: - for h in _fx_hits("kill", {"target": String(target.display_name), "target_id": String(target.get("enemy_id", "")), "overflow": landed}): +# kill 发射+施加(敌人死亡确认;订阅面=reveal_tiles 击杀揭示——斩首学者通道。 +# B 模式入队段尾 flush 兑现;A 模式端口路由宿主入队) +func _kill_effects(t: Dictionary, target: Dictionary) -> void: + for h in _fx_hits("kill", {"target": String(target.display_name), "target_id": String(target.get("enemy_id", ""))}): if String(h.get("kind", "")) == "reveal_tiles": _port.reveal_effect() # 斩首学者——击杀揭示 1 格(端口路由宿主入队,授予段末兑付) - continue - if String(h.get("kind", "")) != "overflow": - continue - var next_target = units.default_target() - if next_target != null: - # source_class 用中性名(fx_overflow 泛用通道,不挂件名) - var of_t := open_ticket(DamageController.KIND_BOARD_TYPED, "fx_overflow", "贯穿弹射", - {"channel": "board", - "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: - _queue.append(of_t) - else: - _resolve(of_t) # 交付:即时执行族(碎甲震击打重甲附加「护甲裂解」——复用 off_guard,零新状态行; @@ -424,8 +411,8 @@ func _hero_attack_deliver(t: Dictionary, ctx: Dictionary, redemption: Dictionary var payload: Dictionary[Variant, Variant] = redemption["payload"] for h in (ctx["ac_hits"] as Array): if String(h.get("kind", "")) == "apply_status": - # source_class 用中性名(fx_apply_status 泛用通道,不挂件名) - effects.apply_effect(String(h.get("status_id", "")), {"source_class": "fx_apply_status"}, -1) + # source_class 用中性名(fx_apply_status 泛用通道,不挂件名);落点 = 被命中者个体(增益状态 v1.5) + effects.apply_effect(String(h.get("status_id", "")), StatusContainer.owner_key_for(ctx["target"]), {"source_class": "fx_apply_status"}, -1) payload["cracked"] = true var label := _attack_label(String(t.display), bool(ctx["crit"]), int(ctx["discount"])) if int(redemption["hero_rider"]) > 0: @@ -473,7 +460,7 @@ func _enemy_roll(t: Dictionary) -> Dictionary: 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.combat.settlement.damage_roll.crit_multiplier), cdelta) - var pooled: int = DamageMath.pool_damage(rolled, clamped_pool("hero_defense"), clamped_pool("enemy_output")) + var pooled: int = DamageMath.pool_damage(rolled, clamped_pool("hero_defense", StatusContainer.OWNER_HERO), clamped_pool("enemy_output", StatusContainer.owner_key_for(t.source_unit))) 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} @@ -593,13 +580,13 @@ func defender_ac(unit: Dictionary) -> int: var total: int = _hero_ac_override if total < 0: total = int(db.combat.hero.armor_class) - for eff in effects.query_active("hero"): + for eff in effects.query_active(StatusContainer.OWNER_HERO): 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"): + for eff_e in effects.query_active(StatusContainer.owner_key_for(unit)): 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) @@ -625,10 +612,12 @@ func _discount_shift_up(discount: int, shift: int) -> int: return int(tbl[mini(tbl.size() - 1, idx + shift)]) -# 池内加算 → clamp [-100,+100](池间乘算归 DamageMath.pool_damage 调用侧) -func clamped_pool(pool: String) -> int: +# 池内加算 → clamp [-100,+100](池间乘算归 DamageMath.pool_damage 调用侧); +# 池按个体读(增益状态 v1.5):主角侧池传 OWNER_HERO,敌方侧池传被打者/施放者个体键—— +# 乘区只合计该个体名下实例(B 敌的削弱不进 A 敌的账) +func clamped_pool(pool: String, owner_key: String) -> int: var total := 0 - for eff in effects.query_active("hero") + effects.query_active("enemy"): + for eff in effects.query_active(owner_key): var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff.effect_id) if row.is_empty(): continue diff --git a/game/core/effect_engine.gd b/game/core/effect_engine.gd index c2d3a331..5b66a023 100644 --- a/game/core/effect_engine.gd +++ b/game/core/effect_engine.gd @@ -26,7 +26,7 @@ const MINE_EVENTS := ["dig_reveal", "dig_paid", "seal_hit", "mine_hit", "seal_se ## grant_gp/bias_wallet、counter_add/counter_spend、reveal_tiles/remove_mines/refresh_pick、 ## bias_rewards/preview。需求卡片标题计 23 系笔误,落码按行内容全收——commit 留痕) const EFFECT_KINDS := ["add_ticket_rider", "modify_ticket", "damage_mult", "open_ticket", - "attack_bonus", "attack_segments", "yield_mult", "spawn_spell", "overflow", "grant_material", + "attack_bonus", "yield_mult", "spawn_spell", "grant_material", "grant_ap", "grant_gp", "bias_wallet", "shield", "spike", "apply_status", "counter_add", "counter_spend", "reveal_tiles", "remove_mines", "refresh_pick", "bias_rewards", "preview", @@ -100,7 +100,7 @@ func _init(db_ref: ConfigDb = null) -> void: ## 集过滤)→树规则合成行。同一数据行的多个 trigger 按行内数组序。计数器四桶由宿主持有、随宿主生灭, ## 本方法只重建订阅集(运行时可变状态=零;battle/level 桶的清零由宿主 new 天然承载,round 桶清零 ## 在 dispatch 的 round_start 特判内整桶 clear,adventure 桶随宿主字典引用跨战持久——无副本搬运)。 -func rebuild(feat_ids: Array, item_ids: Array, tree_rules: Array = []) -> void: +func rebuild(feat_ids: Array, item_ids: Array, tree_rules: Dictionary[Variant, Variant] = {}) -> void: _triggers.clear() _load_feat_triggers(feat_ids) _load_attune_triggers(item_ids) @@ -134,42 +134,42 @@ func _load_attune_triggers(item_ids: Array) -> void: ## 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: - _push_tree_rule(String(key_v)) +func _push_tree_rules(tree_rules: Dictionary[Variant, Variant]) -> void: + for rk in tree_rules: + _push_tree_rule(String(rk), tree_rules[rk] as Dictionary) -## 单树规则合成行 push(六键 match 分派——词表封闭,未知键零订阅零扰动) -func _push_tree_rule(rk: String) -> void: +## 单树规则合成行 push(六键 match 分派——词表封闭,未知键零订阅零扰动;参数全读规则键参数字典) +func _push_tree_rule(rk: String, params: Dictionary[Variant, Variant]) -> 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}}, {}) + "do": {"kind": "grant_ap", "mode": "queue", "value": int(params["ap_bonus"])}}, {}) "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}}, {}) + "do": {"kind": "shield", "value": int(params["shield"])}}, {}) "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}}, {}) + "when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": int(params["cap_per_level"])}], + "do": {"kind": "damage_mult", "value": int(params["halve_pct"])}}, {}) _push_trigger("tree", rk, {"on": "mine_hit", - "when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}], + "when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": int(params["cap_per_level"])}], "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}}, {}) + "when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": int(params["cap_per_level"])}], + "do": {"kind": "grant_ap", "mode": "queue", "value": int(params["ap_refund"])}}, {}) _push_trigger("tree", rk, {"on": "seal_missed", - "when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}], + "when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": int(params["cap_per_level"])}], "do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_guard_used", "value": 1}}, {}) @@ -461,7 +461,7 @@ static func _vtr_from_refs(rid: String, tr: Dictionary, row: Dictionary, errs: A ## 加法族(数值相加,可交换):add_ticket_rider、attack_bonus、grant_material、grant_ap(非 transfer)、 ## counter_add、shield——发射点逐条累加收集器。 ## 乘法族(收集后一次施加,正负分桶):yield_mult、damage_mult。 -## 任意真族(任一命中即生效):spawn_spell、overflow、spike。 +## 任意真族(任一命中即生效):spawn_spell、spike。 ## 按序改写族(收集→单点按声明序施加):modify_ticket 及一切改写窗口效果。 ## 按序消耗族(按订阅序消耗共享资源,耗尽即止;可选 fallback):counter_spend。 ## 即时执行族(每条命中各自执行一次,执行序=声明序):open_ticket、grant_ap(mode:transfer)、 diff --git a/game/core/level_runner.gd b/game/core/level_runner.gd index 502c6d0a..46619890 100644 --- a/game/core/level_runner.gd +++ b/game/core/level_runner.gd @@ -91,7 +91,7 @@ func _init(db_ref: ConfigDb, rng_ref: RngServiceCore, ctx: Dictionary = {}) -> v treasure_inventory = ctx["treasure_inventory"] # 冒险级库存共享(同调未用行跨关存活——单源化承载面) _treasure_unlocked = ctx.get("treasure_unlocked", {}) if ctx.get("treasure_unlocked", {}) is Dictionary else {} _attunement = ctx.get("attunement", []) if ctx.get("attunement", []) is Array else [] # 活引用注入(契约 6——禁快照) - _tree_rules = ctx.get("tree_rules", []) if ctx.get("tree_rules", []) is Array else [] # 活引用注入 + _tree_rules = ctx.get("tree_rules", {}) if ctx.get("tree_rules", {}) is Dictionary else {} # 活引用注入(规则键参数字典) _hero_attack_type = String(ctx.get("hero_attack_type", "")) _hero_armor_type = String(ctx.get("hero_armor_type", "")) _voucher_rap = maxi(0, int(ctx.get("voucher_rap", 0))) @@ -661,8 +661,8 @@ func current_battle() -> Variant: # 特性持有集(begin_battle enc.feats 求值面;**共享引用不复制**——关内奖励节点兑付的 relic 须当关生效) var _feats: Array[Variant] = [] -# 树 rule 键集(begin_battle enc.tree_rules 携带;Fork 激活侧 effects——构造注入活引用) -var _tree_rules: Array[Variant] = [] +# 树规则参数字典(begin_battle enc.tree_rules 携带;Fork 激活侧 effects_*——构造注入活引用) +var _tree_rules: Dictionary[Variant, Variant] = {} # 特性持有集读面(HUD C 区特性段数据源) diff --git a/game/core/meta_progression.gd b/game/core/meta_progression.gd index e762d613..abf252ff 100644 --- a/game/core/meta_progression.gd +++ b/game/core/meta_progression.gd @@ -384,9 +384,10 @@ func tree_buy_fork(node_id: String) -> bool: return save.write_to_disk() -# 树规则键投影(激活 Fork 侧 effects 并入) -func tree_rule_keys() -> Array: - var out: Array[Variant] = [] +# 树规则参数字典投影(激活 Fork 侧 effects_* 参数字典并入——键=规则键,值=该键参数字典原样透传; +# 首遇优先去重语义保持) +func tree_rules() -> Dictionary[Variant, Variant]: + var out: Dictionary[Variant, Variant] = {} 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": @@ -397,7 +398,7 @@ func tree_rule_keys() -> Array: var fx: Dictionary[Variant, Variant] = row.get("effects_" + active, {}) as Dictionary for k in fx.keys(): if not out.has(String(k)): - out.append(String(k)) + out[String(k)] = fx[k] return out diff --git a/game/core/minesweeper_rules.gd b/game/core/minesweeper_rules.gd index 24163cf8..c6c4b518 100644 --- a/game/core/minesweeper_rules.gd +++ b/game/core/minesweeper_rules.gd @@ -368,6 +368,19 @@ func fx_reveal(c: Vector2i) -> bool: return true +## 挖一格→真实翻开集只读预览(sim 信息面出口——与 _classify 同口径:本格封印视为已隐式退役、 +## 其余封印照挡;复用 _chain_closure 闭包;零状态写入、零事件发射) +func preview_reveal(c: Vector2i) -> Array[Vector2i]: + var sim_seals := _seals.duplicate() + sim_seals.erase(c) + var closure := _chain_closure(c, sim_seals) + var out: Array[Vector2i] = [] + for cc in closure.keys(): + if cc != c and not _revealed.has(cc): + out.append(cc) # F2 口径:新开格集——不含首格与已开格 + return out + + # 连锁闭包:μX.[O∪{c} ⊆ X ∧ ∀x∈X:(x∈S ∧ num(x)=0 ⇒ N₈(x)∩S∖G ⊆ X)]——连锁不越封印 func _chain_closure(start: Vector2i, seal_set: Dictionary) -> Dictionary: var chain := {start: true} diff --git a/game/core/node_map_generator.gd b/game/core/node_map_generator.gd index e07c67a5..bcf3044e 100644 --- a/game/core/node_map_generator.gd +++ b/game/core/node_map_generator.gd @@ -9,7 +9,6 @@ extends RefCounted const NODE_TYPES := ["battle", "boss", "reward", "rest", "shop"] const FILLER_TYPES := ["battle", "reward", "rest"] -const MAX_WIDTH := 4 # 产出约束·宽度=分叉生长(同深度节点数由岔口长出 1→2→3→4,禁摆平行层) # 多终点图的支路互斥段必须各有独有类型(契约 5③),而商店与 Boss 是每条支路共有件, # 独有类型只能来自 battle/reward/rest 三件 ⇒ 归属大岔口各区域的填充节点取互斥签名类型。 # 入口与主链段(大岔口上游,不属任何支路互斥段)恒为战斗节点——承载战斗保底与岔口下一跳互异基准形。 @@ -66,40 +65,11 @@ 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 + # 域校验并单点(R7——参数档域合法性唯一在 LevelGenParams.validate 加载期; + # 生成器只留可行性判定 _cp_structural——schema 无此判定的生成器职责) 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)) - var en := int(params.get("entry_count", 0)) - var bf := int(params.get("battle_floor", -1)) - var bd := float(params.get("branch_density", -1.0)) - if L < 6: - errs.append({"category": "domain", "message": "层深 %d 低于下限 6(层高已锁)" % L}) - if e < 1 or e > 3: - errs.append({"category": "domain", "message": "出口个数 %d 越域 [1,3]" % e}) - if en < 1: - errs.append({"category": "domain", "message": "入口个数 %d 越域(≥1)" % en}) - if bd < 0.0 or bd > 1.0: - errs.append({"category": "domain", "message": "分支密度 %f 越域 [0,1]" % bd}) - if bf < 0: - errs.append({"category": "domain", "message": "战斗保底 %d 越域(≥0)" % bf}) - if int(params.get("fork_ways_min", 2)) < 2 or int(params.get("fork_ways_max", 3)) > 3: - errs.append({"category": "domain", "message": "岔口去路数域越界(锁 2/3)"}) - var sum := 0.0 - for rk in ["battle_ratio", "reward_ratio", "rest_ratio", "shop_ratio"]: - sum += float(params.get(rk, -1.0)) - if not is_equal_approx(sum, 1.0): - errs.append({"category": "domain", "message": "配比四类合计 %f ≠ 1" % sum}) - return errs - - ## 结构可行性段:主链段容量与战斗保底可放性(多出口按区域签名累加可放槽) static func _cp_structural(params: Dictionary) -> Array[Dictionary]: var errs: Array[Dictionary] = [] @@ -271,8 +241,8 @@ func _vo_width_rules(nodes: Array, errs: Array) -> void: var d2: int = int(n["depth"]) per_depth[d2] = int(per_depth.get(d2, 0)) + 1 for d3 in per_depth: - if int(per_depth[d3]) > MAX_WIDTH: - errs.append({"category": "structural", "message": "深度 %d 节点数 %d 超分叉生长上限 4(平行层摆放)" % [d3, per_depth[d3]]}) + if int(per_depth[d3]) > int(_params["width_max"]): + errs.append({"category": "structural", "message": "深度 %d 节点数 %d 超分叉生长上限(width_max 表值——平行层摆放)" % [d3, per_depth[d3]]}) ## 计数规则③·参数档对照:战斗保底/入口数/终点数/层高(params 为空时跳过) @@ -358,7 +328,7 @@ func _build_region(r: int, e: int, L: int, prevs: Array, terminals: Array) -> vo ## 侧支节点与主去路同深(保持「深度=自入口最长路径步数」不变量),两支各自汇入主去路的后继, ## 于是支路互斥段={主去路节点} 与 {侧支节点}——两支类型互异即同时满足「下一跳互异」与「各有独有类型」。 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 wanted := maxi(int(round(float(_params["branch_density"]) * float(L))), 1) # 死上限 24 删(不可达值——下限 1 保留防零岔口) var anchors := _collect_fork_anchors(L, t, e) anchors = _shuffle(rg, anchors) var selected := {} # 已扩岔节点 id @@ -377,10 +347,10 @@ func _add_forks(rg: RandomNumberGenerator, L: int, t: int, e: int) -> void: continue 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 extra := 2 if (int(_params["fork_ways_max"]) >= 3 and rg.randf() < float(_params["fork_third_way_pct"]) / 100.0) else 1 var made := 0 for k in range(extra): - if _depth_count(int(v["depth"])) >= MAX_WIDTH: + if _depth_count(int(v["depth"])) >= int(_params["width_max"]): break var wid := _mk("battle", int(v["depth"])) (a["next"] as Array).append(wid) @@ -452,7 +422,7 @@ func _fill_ratios(rg: RandomNumberGenerator) -> void: 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 + var rest_first := rg.randf() < float(_params["fill_rest_first_pct"]) / 100.0 for i in range(want): if i >= free.size(): break diff --git a/game/core/reward_grant.gd b/game/core/reward_grant.gd index ae265754..0e468f10 100644 --- a/game/core/reward_grant.gd +++ b/game/core/reward_grant.gd @@ -223,8 +223,9 @@ func _dispatch_row(row: Dictionary, att: Dictionary) -> bool: return false -# percent 行兑付:GP 型行走战斗内缓冲记账;其余 apply_effect 入容器; -# 满层拒收 = "奖励未生效"行(零静默) +# percent 行兑付:GP 型行走战斗内缓冲记账;其余 apply_effect 入容器(逐个体——enemy 侧行 = +# 全体存活敌逐只各发一条实例,hero 侧行 = 主角单桶,增益状态 v1.5 目标规则「全体存活敌」); +# 满层拒收 = "奖励未生效"行(零静默——全体皆拒才记一行;部分个体满层拒由该个体容器内原子拒绝) func _apply_percent_row(row: Dictionary, att: Dictionary) -> bool: var eff := String(row.effect_id) if eff == "gp_gain" or eff == "gp_loss": @@ -236,11 +237,20 @@ func _apply_percent_row(row: Dictionary, att: Dictionary) -> bool: var eff_mag: int = int(srow.magnitude) if prem_pct != 100: eff_mag = int(round(eff_mag * prem_pct / 100.0)) - var ok: bool = status_effects.apply_effect(eff, _grant_src(att, prem_pct, eff_mag)) - if ok: + var owner_keys: Array[Variant] = [] + if String(srow.target_side) == "enemy": + for u in units.query_alive("enemy"): + owner_keys.append(StatusContainer.owner_key_for(u)) + else: + owner_keys.append(StatusContainer.OWNER_HERO) + var granted := 0 + for okey in owner_keys: + if status_effects.apply_effect(eff, String(okey), _grant_src(att, prem_pct, eff_mag)): + granted += 1 + if granted > 0: emit_cb.call({"type": "effect", "source_class": String(att.get("source_class", "")), "origin_cell": att.get("cell", Vector2i(-1, -1))}, int(att.get("ri", 0)), String(srow.display_name) + String(att.get("label_suffix", "")), eff_mag, - {"effect_id": eff, "pool_value": int(pool_effective_cb.call(eff))}) + {"effect_id": eff, "pool_value": int(pool_effective_cb.call(eff, String(owner_keys[0])))}) return true emit_cb.call({"type": "reward_ineffective", "source_class": String(att.get("source_class", "")), "origin_cell": att.get("cell", Vector2i(-1, -1)), "highlight": String(att.get("highlight", ""))}, int(att.get("ri", 0)), "奖励未生效(满层)%s" % String(att.get("label_suffix", "")), 0, diff --git a/game/core/settlement/combat_segment.gd b/game/core/settlement/combat_segment.gd index cf1c7b29..0c047327 100644 --- a/game/core/settlement/combat_segment.gd +++ b/game/core/settlement/combat_segment.gd @@ -1,8 +1,8 @@ class_name CombatSegment extends RefCounted # ③ 战斗段 Segment(P1-1 结算巨函数拆段;段边界=审计五段〔裁点③甲〕)。 -# 职责:案C 闸复位 + 本轮扫雷事实推导(执拗 streak/连珠矢段数)+ 蓄力层注入 + 主角先手 -# (含连珠矢分段开单)+ 敌方行动兑现 + 地图伤害账殿后(补提交统一结账)。 +# 职责:案C 闸复位 + 本轮扫雷事实推导(执拗 streak 取数面)+ 蓄力层注入 + 主角先手 +# + 敌方行动兑现 + 地图伤害账殿后(补提交统一结账)。 # 调用序:主角先手 → 逐敌还手 → 地图伤害账殿后〔案C〕;hero hp=0 短路。 # 行发射/开单单点归宿主(oc)——案C 暂存闸归 RewardGrant(段持三引用直调,零转发壳)—— # 本函数体为原编排器 _combat_segment 逐字搬运(🔴 零行为变更契约)。 @@ -21,7 +21,7 @@ func _init(host, fx_host_ref: BattleFxHost, reward_grant_ref: RewardGrant) -> vo func run(ri: int) -> void: reward_grant.defer["on"] = false # 案C 闸复位——战斗段起开单即入队(主角/敌方/补提交的地图单按开单序统一结账) - # 本轮扫雷事实推导(执拗 streak/连珠矢段数共用取数面):本轮 safe 事件数= + # 本轮扫雷事实推导(执拗 streak 取数面):本轮 safe 事件数= # streak;踩雷即清零(mine_hit 监听同口——执拗「踩雷清零」承载)。攻击在战斗段=授予段后 # 同一次 _settle 内,facts 已定。 var round_safe := 0 @@ -56,18 +56,6 @@ func _hero_strike(ri: int) -> void: "ri": ri, "origin_cell": Vector2i(-1, -1), "attack_type": String(oc._enemy_system.hero_unit().get("attack_type", "normal"))}) oc._damage.submit(ht) - # 连珠矢(内容数据驱动扩展批 T3——attack_commit 订阅施加面:开格节奏变攻击段数,本轮每开 - # ~3 安全格 +1 段穿刺附加(上限 cap;段值 provisional 固定值不乘 atk——台账披露)。施加位 - # 紧随主角攻击单开立=迁移前开单位(先于敌方行动单入队——flush 兑现序逐位一致) - for h in oc._hits_of(ht.context.get("fx_attack_hits", []) as Array, "attack_segments"): - var segments := mini(int(h.get("cap", 2)), int(oc.fx_host().facts_inject["dig_streak"]) / maxi(1, int(h.get("divisor", 3)))) - for s in segments: - # source_class 用中性名;attack_segments 兑付通道保留泛用面 - var ct := oc._damage.open_ticket(DamageController.KIND_BOARD_TYPED, "fx_streak_segments", "连击分段", - {"channel": "board", "base_value": int(h.get("value", 0)), - "target_rule": DamageController.TARGET_FRONT_ALIVE, "ri": ri, - "origin_cell": oc.minesweeper.hero_pos(), "attack_type": "pierce"}) - oc._damage.submit(ct) # 敌方行动(敌人技能池批——本拍动作开轮已摇定,此处按技能行兑现;行序 = 生成序)。 @@ -83,9 +71,11 @@ func _enemy_actions(ri: int) -> void: continue if String(srow.type) == "buff" or String(srow.type) == "debuff": # 增益/妨害技 = 0 伤害施放行:不掷不折;状态授予即数值兑现,技能冷却与增益持续两计时器分账。 - # 落点由状态行 target_side 路由(buff → 敌方侧 / debuff → 主角侧——容器零改动,proposal-status-vocabulary §1)。 + # 落点按目标规则(增益状态 v1.5〈目标规则与个体归属〉):buff → 施放者个体(「自己」—— + # 敌方增益技只强化施放者自己,不再全体受益);debuff → 主角(off_guard/sapped 同义主角侧)。 var dur: int = int(srow.get("buff_duration", 1)) - oc._status_effects.apply_effect(String(srow.buff_ref), {"source_class": "enemy_action", "origin_cell": Vector2i(-1, -1), "round_index": ri}, dur) + var owner_key: String = StatusContainer.owner_key_for(u) if String(srow.type) == "buff" else StatusContainer.OWNER_HERO + oc._status_effects.apply_effect(String(srow.buff_ref), owner_key, {"source_class": "enemy_action", "origin_cell": Vector2i(-1, -1), "round_index": ri}, dur) oc._emit_row({"type": "cast", "source_class": "enemy_action", "origin_cell": Vector2i(-1, -1)}, ri, "%s 施放 %s" % [u.display_name, String(srow.display_name)], 0, {"skill_id": skill_id, "effect_id": String(srow.buff_ref), "duration": dur}) oc._enemy_system.commit_action(u) continue diff --git a/game/core/shop_session.gd b/game/core/shop_session.gd index be90aa47..8c1ceaac 100644 --- a/game/core/shop_session.gd +++ b/game/core/shop_session.gd @@ -53,12 +53,14 @@ func current_price(prow: Dictionary) -> int: + int(prow.get("price_increment", 0)) * purchased_tier(String(prow.get("product_id", "")))) -# 散折价单点(财富之道·散 economy_spend 激活时商品购买价 ×spend_discount〔表 knob,缺省 0.8〕, +# 散折价单点(财富之道·散 economy_spend 激活时商品购买价 ×discount_pct/100〔规则键参数字典—— +# 折率读 economy_spend 参数,零表字段〕, # floor 取整偏向玩家、下限 1 防 1 元商品折成 0;范围=商品标价定义边界〔legacy 商品面 current_price # + 池货架面 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.run.shop.spend_discount)))) + var spend_params: Dictionary[Variant, Variant] = _rules()["economy_spend"] as Dictionary + return maxi(1, int(floor(float(price) * float(spend_params["discount_pct"]) / 100.0))) return price @@ -152,7 +154,7 @@ func _draw_shelf() -> void: continue seen.append(k) shelf.append(tok) - if shelf.size() >= 3: + if shelf.size() >= int(db.run.shop.shelf_size): break # T5 专长凭证注入(非 Boss 渠道唯一专长入口——货架与节点共享保底计数;判定含 pity 记账,替换末位占一席。 # 取数复用本函数 shop_draw scope 的 stream——零新 scope,确定性序列不漂移) @@ -329,8 +331,8 @@ func _level_index() -> int: return int((_refs["level_row"] as Dictionary).get("level_index", 0)) -func _rules() -> Array: - return _refs["tree_rules"] as Array +func _rules() -> Dictionary[Variant, Variant]: + return _refs["tree_rules"] as Dictionary func _attunement() -> Array: diff --git a/game/core/status_container.gd b/game/core/status_container.gd index 391f2bd0..cac4f904 100644 --- a/game/core/status_container.gd +++ b/game/core/status_container.gd @@ -1,12 +1,25 @@ ## VERTICAL SLICE - NOT FOR PRODUCTION -## 增益状态统一容器:三方效果同构实例、单一时限量纲(先兑付后衰减)、按行叠层与授予时快照抗热重载(垂直切片验证:增益减益逐行归因)。 +## 增益状态统一容器:三方效果同构实例、单一时限量纲(先兑付后衰减)、按行叠层与授予时快照抗热重载。 +## 实例按个体归属(增益状态 v1.5):主角一桶+每只敌各一桶——全体授予=授予路径逐只各发一条实例, +## 合并只在同体内(同名状态不同个体互不相干),个体死亡即清名下实例(purge_owner 单点)。 ## 零随机零信号——纯被动服务;授予唯一入口 apply_effect,满层拒绝零写入(拒收记未生效行归调用方)。 class_name StatusContainer extends RefCounted +const OWNER_HERO := "hero" # 主角个体键(单桶——主角全体状态唯一的归属键) + var db: ConfigDb var _open := false -var _instances: Array[Dictionary] = [] # 插入序;元素 = {effect_id, layers, remaining, source_class, origin_cell, round_index, snap_m, snap_unit, snap_display} +var _buckets: Dictionary[Variant, Variant] = {} # owner_key -> Array[Dictionary](桶内插入序);元素 = {effect_id, layers, remaining, source_class, origin_cell, round_index, snap_m, snap_unit, snap_display} + + +# 个体键约定单点(拼写禁散落第二处——授予/死亡清/消费面一律经此导出): +# 主角 = OWNER_HERO;敌人 = "e<生成序>"(生成序 = 开战逐只编号 handle_id 的 1 基序,沿顺位轴)。 +static func owner_key_for(u: Dictionary) -> String: + if bool(u.get("is_hero", false)): + return OWNER_HERO + assert(u.has("handle_id"), "[增益状态] 敌单位缺 handle_id(个体键导出沿生成序轴——单位违约)") + return "e%d" % (int(u.get("handle_id", -1)) + 1) func _init(db_ref: ConfigDb) -> void: @@ -18,35 +31,62 @@ func notify_battle_start() -> bool: push_warning("[增益状态] Active 下重复 notify_battle_start(EC11)") return false _open = true - _instances.clear() + _buckets.clear() return true # 唯一授予入口(挖掘期零直写——战斗结算 结算编排期统一调用)。 +# owner_key(个体归属——增益状态 v1.5):实例挂到该个体桶,合并/衰减/查询全在桶内; # duration_override(敌人技能池批——增益技施放):技能行 buff_duration 覆盖状态行时长(-1 = 状态表值)。 # 施放是持续量的 owner(proposal §2.2 字段词汇表"buff_duration 增益持续回合数");其他授予路径传缺省。 -func apply_effect(effect_id: String, source: Dictionary, duration_override: int = -1) -> bool: +func apply_effect(effect_id: String, owner_key: String, source: Dictionary = {}, duration_override: int = -1) -> bool: if not _open: push_warning("[增益状态] 域闭授予(EC1)") return false + if owner_key == "": + push_warning("[增益状态] 空 owner_key 授予(个体归属违约)") + return false + return _apply_open(effect_id, owner_key, source, duration_override) + + +# 开域授予主路径(守卫已过——未知行警告与三臂受理;单出口布尔受理) +func _apply_open(effect_id: String, owner_key: String, source: Dictionary, duration_override: int) -> bool: + var accepted := false 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) var snap := _grant_snapshot(row, source) + var bucket: Array[Dictionary] = _bucket_of(owner_key) + var hit: Dictionary = {} + for inst in bucket: + if inst.effect_id == effect_id: + hit = inst + break match policy: "independent": - _instances.append(_new_instance(effect_id, 1, dur, source, snap)) - return true + bucket.append(_new_instance(effect_id, 1, dur, source, snap)) + accepted = true "accumulate": - return _apply_accumulate(effect_id, dur, snap, source) + # 命中加时长未命中追加(R += D 取本次授予生效时长;L 不变;快照沿首授) + if hit.is_empty(): + bucket.append(_new_instance(effect_id, 1, dur, source, snap)) + else: + hit.remaining = int(hit.remaining) + dur + accepted = true "stack": - return _apply_stack(effect_id, dur, snap, source, max_stack) - push_warning("[增益状态] 未知 stack_policy") - return false + # 满层拒零写入·未满加层·未命中追加(stack 授予不触碰 R;拒收由战斗结算记"奖励未生效"行) + if hit.is_empty(): + bucket.append(_new_instance(effect_id, 1, dur, source, snap)) + accepted = true + elif int(hit.layers) < int(row.max_stack): + hit.layers = int(hit.layers) + 1 + accepted = true + _: + push_warning("[增益状态] 未知 stack_policy") + return accepted ## 授予时快照域(抗表重载——EC9;封印溢价贯穿——迭代14② T4 percent 形态同乘快照、缺省路径零扰动) @@ -57,27 +97,13 @@ func _grant_snapshot(row: Dictionary, source: Dictionary) -> Dictionary: return {"m": snap_m, "unit": String(row.unit), "display": String(row.display_name)} -## 累加臂:命中加时长未命中追加(R += D 取本次授予生效时长——覆盖值 ≥1 按覆盖值;L 不变;快照沿首授;每次授予必有剩余次数变化) -func _apply_accumulate(effect_id: String, dur: int, snap: Dictionary, source: Dictionary) -> bool: - for inst in _instances: - if inst.effect_id == effect_id: - inst.remaining = int(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 _bucket_of(owner_key: String) -> Array[Dictionary]: + if not _buckets.has(owner_key): + var fresh: Array[Dictionary] = [] + _buckets[owner_key] = fresh + return fresh + return _buckets[owner_key] func _new_instance(effect_id: String, layers: int, dur: int, source: Dictionary, snap: Dictionary) -> Dictionary: @@ -94,16 +120,15 @@ func _new_instance(effect_id: String, layers: int, dur: int, source: Dictionary, } -# 只读投影(插入序稳定枚举;六归因字段 + display_name + 授予时快照) -func query_active(side: String) -> Array: +# 只读投影(个体桶插入序稳定枚举;owner_key 随行出户 + 六归因字段 + display_name + 授予时快照) +func query_active(owner_key: String) -> Array: var out: Array[Dictionary] = [] - for inst in _instances: + for inst in _buckets.get(owner_key, [] as Array[Dictionary]): 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: - continue out.append({ + "owner_key": owner_key, "effect_id": inst.effect_id, "layers": inst.layers, "remaining_settlements": inst.remaining, @@ -118,26 +143,47 @@ func query_active(side: String) -> Array: return out -# 轮末衰减:先兑付后衰减;每实例恰 −1;归 0 移除;返回移除数 k(恰一次执行由 战斗结算 编排信任承载) +# 侧汇总投影(主角侧 = hero 桶;敌方侧 = 各存活个体桶并集——死亡个体桶已随 purge_owner 删除, +# 并集即全部在册实例;数值与旧阵营共享读法等价,服务计数类汇总消费面) +func query_active_side(side: String) -> Array: + if side == OWNER_HERO: + return query_active(OWNER_HERO) + var out: Array[Dictionary] = [] + for key in _buckets.keys(): + if String(key) != OWNER_HERO: + out.append_array(query_active(String(key))) + return out + + +# 轮末衰减:先兑付后衰减;逐桶各 −1(每实例每结算恰一次);归 0 移除;返回移除数 k(恰一次执行由 战斗结算 编排信任承载) func decay_round() -> int: if not _open: return 0 # EC13 域闭兜底读法——容器恒空,无独立分支不诊断 var k := 0 - var survivors: Array[Dictionary] = [] - for inst in _instances: - inst.remaining = int(inst.remaining) - 1 - if inst.remaining <= 0: - k += 1 - else: - survivors.append(inst) - _instances = survivors + for key in _buckets.keys(): + var bucket: Array[Dictionary] = _buckets[key] + var survivors: Array[Dictionary] = [] + for inst in bucket: + inst.remaining = int(inst.remaining) - 1 + if inst.remaining <= 0: + k += 1 + else: + survivors.append(inst) + _buckets[key] = survivors return k +# 个体死亡即清(死亡清唯一通道——死亡个体名下实例全部移除,不转移、不并回别处;返回移除数) +func purge_owner(owner_key: String) -> int: + var removed := (_buckets.get(owner_key, [] as Array[Dictionary]) as Array).size() + _buckets.erase(owner_key) + return removed + + func notify_battle_end() -> bool: if not _open: push_warning("[增益状态] BattleClosed 下重复 notify_battle_end(EC11)") return false _open = false - _instances.clear() # 全清不跨战斗 + _buckets.clear() # 全清不跨战斗 return true diff --git a/game/data/schemas/action_economy_table.gd b/game/data/schemas/action_economy_table.gd index 9621c45b..c51819c6 100644 --- a/game/data/schemas/action_economy_table.gd +++ b/game/data/schemas/action_economy_table.gd @@ -4,7 +4,7 @@ class_name ActionEconomyTable extends Resource -@export var ap_per_round: int = 6 # [1,32] knob(默认值 = 现行表值基线,随 AP 压速调整) +@export var ap_per_round: int # [1,32] knob(零哨兵——表行缺字段越域即加载致命) @export var dig_ap_cost: int = 1 # [1,8] knob(域下限 1 = 零预算系统失效拒绝) @export var seal_place_ap_cost: int = 1 # [0,8] knob(无 seal_pull_ap_cost 字段——拔印不占 AP) @export var rap_refresh_cost: int = 6 # [1,64] knob(默认值随 AP 压速调整) diff --git a/game/data/schemas/board_gen_table.gd b/game/data/schemas/board_gen_table.gd index a8322475..7212dc2d 100644 --- a/game/data/schemas/board_gen_table.gd +++ b/game/data/schemas/board_gen_table.gd @@ -5,6 +5,7 @@ class_name BoardGenTable extends Resource @export var rows: Array[Dictionary] = [] # Array[Dictionary] +@export var spawn_edge: int = 0 # 出生区边长(奇数 [3,5]——零哨兵:表头缺字段=加载期域校验拒) const SOLVER_RULES := ["single", "single_subset"] const COST_BUDGET := 16384 # F2c provisional 量级护栏(禁墙钟逃生) const TARGET_ABS_FLOOR := 0.85 # 战斗结算 修订——target 域下限(P1 绝对锚) @@ -13,6 +14,8 @@ const BOARD_TYPE_VOCABULARY := ["dungeon", "underdark", "deep_underdark"] # func validate() -> Array: var errs: Array[Dictionary] = [] + if spawn_edge < 3 or spawn_edge > 5 or spawn_edge % 2 == 0: + errs.append({"category": "domain", "message": "spawn_edge 越域(奇数 [3,5]——零缺省): %d" % spawn_edge}) if rows.is_empty(): errs.append({"category": "empty_table", "message": "BoardGenTable 为空"}) return errs @@ -73,8 +76,8 @@ func _validate_row_constraints(row: Dictionary) -> Array[Dictionary]: 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))) + # 布雷数下限(R3)——零雷板拒绝(−spawn_edge² = 出生区格数,表头单点导出) + var n_mines := int(round(d * float(w * h - spawn_edge * spawn_edge))) if n_mines < 1: errs.append({"category": "cross_field", "message": "(%d,%d) 布雷数下限违例 round(d×(W×H−9)) ≥ 1" % [L, D]}) # F2b min_safe 可行性 @@ -102,9 +105,9 @@ func _validate_shave_params(row: Dictionary) -> Array[Dictionary]: 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]}) + # 护栏(需求卡片 §二):cap ≥ 出生格数(spawn_edge² 表头导出)、cap ≤ 板格数一半 + if r0_cap < spawn_edge * spawn_edge: + errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 低于出生保障面 %d: %d" % [L, D, spawn_edge * spawn_edge, 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 diff --git a/game/data/schemas/difficulty_table.gd b/game/data/schemas/difficulty_table.gd index f7ff7cf5..6b4a4e7d 100644 --- a/game/data/schemas/difficulty_table.gd +++ b/game/data/schemas/difficulty_table.gd @@ -22,7 +22,7 @@ func validate() -> Array: seen[key] = true if L < 1 or D < 0: errs.append({"category": "domain", "message": "难度键越域 (%d,%d)" % [L, D]}) - # 乘数越域 = warning 级(使用时 clamp——组合事故非配置事故,难度曲线与Boss GDD Edge Case 预登记) + # 乘数越域 = warning 级(使用时 clamp 到 MULT_MIN/MULT_MAX——组合事故非配置事故,warning 预登记) for k in ["hp_mult", "atk_mult"]: var v: float = float(row.get(k, 1.0)) if v < MULT_MIN or v > MULT_MAX: diff --git a/game/data/schemas/feat_table.gd b/game/data/schemas/feat_table.gd index 4aa81b2e..1b36ad62 100644 --- a/game/data/schemas/feat_table.gd +++ b/game/data/schemas/feat_table.gd @@ -1,4 +1,5 @@ ## VERTICAL SLICE - NOT FOR PRODUCTION + ## 专长(奇物·Feat)定义表 schema:逐 feat_id 行订阅式效果声明的类型化读取(空表合法态;垂直切片验证:订阅行经效果引擎统一解释执行)。 ## 🔴 层级纪律:专长只走局内随机渠道(Boss 三选一/凭证开启)——本表行永不入同调位表/局外树。 ## triggers 订阅行四词表封闭校验单点在 EffectEngine.validate_trigger_row——本表不自建第二套校验。 @@ -36,8 +37,7 @@ func validate() -> Array: errs.append({"category": "domain", "message": "%s display_name 空" % rid}) if not RARITIES.has(String(row.get("rarity", ""))): errs.append({"category": "enum", "message": "%s rarity 出域 %s" % [rid, str(RARITIES)]}) - if row.has("cap") and int(row.get("cap", 1)) < 1: - errs.append({"category": "domain", "message": "%s cap 出域 [1,∞): %d" % [rid, int(row.get("cap", 1))]}) + errs.append_array(_validate_cap(row, rid)) if not row.has("triggers"): errs.append({"category": "domain", "message": "%s 缺 triggers(订阅形态唯一合法行形)" % rid}) else: @@ -48,6 +48,16 @@ func validate() -> Array: return errs +## cap 域(必填零兜底——缺行拒、越域 [1,99] 拒;域=config_db_cross_validator 在册口径) +func _validate_cap(row: Dictionary, rid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + if not row.has("cap"): + errs.append({"category": "domain", "message": "%s 缺 cap(必填——零兜底)" % rid}) + elif int(row.get("cap", 0)) < 1 or int(row.get("cap", 0)) > 99: + errs.append({"category": "domain", "message": "%s cap 越域 [1,99]: %d" % [rid, int(row.get("cap", 0))]}) + return errs + + func row_by_id(feat_id: String) -> Dictionary: for row in rows: if String(row.get("feat_id", "")) == feat_id: diff --git a/game/data/schemas/level_gen_params.gd b/game/data/schemas/level_gen_params.gd index 53284bcd..40563cc5 100644 --- a/game/data/schemas/level_gen_params.gd +++ b/game/data/schemas/level_gen_params.gd @@ -7,7 +7,8 @@ extends Resource const RATIO_KEYS := ["battle_ratio", "reward_ratio", "rest_ratio", "shop_ratio"] const ROW_KEYS := ["level_index", "layers", "branch_density", "entry_count", "exit_count", - "battle_floor", "fork_ways_min", "fork_ways_max"] + RATIO_KEYS + "battle_floor", "fork_ways_min", "fork_ways_max", + "fork_third_way_pct", "fill_rest_first_pct", "width_max"] + RATIO_KEYS @export var rows: Array[Dictionary] = [] # 每行 = ROW_KEYS 键集(level_index 唯一正整数) @@ -65,6 +66,16 @@ func _validate_row_shape(row: Dictionary, L: int, key: String, layers_by_level: 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]}) + # 域并单点(关卡节点图生成.md v1.4 调参表——生成器侧域校验退役,唯此一处) + var ftw: float = float(row.get("fork_third_way_pct", -1.0)) + if ftw < 0.0 or ftw > 100.0: + errs.append({"category": "domain", "message": "%s fork_third_way_pct %f 越域 [0,100]" % [key, ftw]}) + var frf: float = float(row.get("fill_rest_first_pct", -1.0)) + if frf < 0.0 or frf > 100.0: + errs.append({"category": "domain", "message": "%s fill_rest_first_pct %f 越域 [0,100]" % [key, frf]}) + var wmax: int = int(row.get("width_max", 0)) + if wmax < 2 or wmax > 6: + errs.append({"category": "domain", "message": "%s width_max %d 越域 [2,6]" % [key, wmax]}) var ratio_sum := 0.0 for rk in RATIO_KEYS: var v: float = float(row.get(rk, -1.0)) diff --git a/game/data/schemas/meta_tree_table.gd b/game/data/schemas/meta_tree_table.gd index e88ceae2..352264c7 100644 --- a/game/data/schemas/meta_tree_table.gd +++ b/game/data/schemas/meta_tree_table.gd @@ -23,6 +23,16 @@ const SPINE_ROW_GATE := 4 ## 无 fg_mastery 克制专精组——类型表达归锻造工坊) const FORK_GROUPS := ["fg_posture", "fg_sense", "fg_economy"] +## 规则键参数域表(键集与取值域逐字=天赋树.md v0.12 规则键参数表——文档定域、schema 执行) +const RULE_KEY_PARAMS := { + "posture_first_strike": {"ap_bonus": [1, 4]}, + "posture_bulwark": {"shield": [1, 9]}, + "sense_sharp": {"halve_pct": [1, 99], "cap_per_level": [1, 9]}, + "sense_guard": {"ap_refund": [1, 4], "cap_per_level": [1, 9]}, + "economy_hoard": {"mult_pct": [101, 300]}, + "economy_spend": {"discount_pct": [1, 99]}, +} + func validate() -> Array: var errs: Array[Variant] = [] @@ -42,6 +52,7 @@ func validate() -> Array: 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_rule_key_params(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)) @@ -101,6 +112,43 @@ func _validate_row_effects(row: Dictionary, nid: String) -> Array[Variant]: return errs +## 规则键参数域校验:effects/effects_a/effects_b 中凡 RULE_KEYS 成员的值必须是 Dictionary、 +## 键集恰等 RULE_KEY_PARAMS[键] 的键集(缺参数/多参数即拒)、每值 int 且在闭域内(零缺省——缺字段=加载期报错) +func _validate_rule_key_params(row: Dictionary, nid: String) -> Array[Variant]: + var errs: Array[Variant] = [] + for fx_key in ["effects", "effects_a", "effects_b"]: + var fx: Dictionary[Variant, Variant] = row.get(fx_key, {}) as Dictionary + for rk in fx.keys(): + if not RULE_KEYS.has(String(rk)): + continue + errs.append_array(_validate_one_rule_key_params(nid, fx_key, String(rk), fx[rk])) + return errs + + +## 单规则键参数字典校验(值须为 Dictionary、键集恰等参数域表、每值 int 且在闭域内) +func _validate_one_rule_key_params(nid: String, fx_key: String, rk: String, params_v: Variant) -> Array[Variant]: + var errs: Array[Variant] = [] + if not (params_v is Dictionary): + errs.append({"category": "domain", "message": "%s %s.%s 缺参数(规则键值须为参数字典——零缺省)" % [nid, fx_key, rk]}) + return errs + var params: Dictionary[Variant, Variant] = params_v + var expect: Dictionary = RULE_KEY_PARAMS[rk] + for pk in params.keys(): + if not expect.has(String(pk)): + errs.append({"category": "domain", "message": "%s %s.%s 多参数: %s(参数域表封闭)" % [nid, fx_key, rk, String(pk)]}) + elif not (params[pk] is int): + errs.append({"category": "domain", "message": "%s %s.%s.%s 越域(须 int)" % [nid, fx_key, rk, String(pk)]}) + else: + var dom: Array = expect[String(pk)] + var v := int(params[pk]) + if v < int(dom[0]) or v > int(dom[1]): + errs.append({"category": "domain", "message": "%s %s.%s.%s 越域 [%d,%d]: %d" % [nid, fx_key, rk, String(pk), int(dom[0]), int(dom[1]), v]}) + for pk in expect.keys(): + if not params.has(String(pk)): + errs.append({"category": "domain", "message": "%s %s.%s 缺参数: %s(参数域表封闭)" % [nid, fx_key, rk, String(pk)]}) + 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] = [] diff --git a/game/data/schemas/shop_table.gd b/game/data/schemas/shop_table.gd index 768493e2..973979c0 100644 --- a/game/data/schemas/shop_table.gd +++ b/game/data/schemas/shop_table.gd @@ -13,7 +13,7 @@ extends Resource @export var price_a: float = 0.0 # 进店已购次数爬价因子(provisional——0 = 未投值即致命域) @export var price_b: float = 0.0 # 跨关递增因子(provisional) @export var relic_base_price: float = 15.0 # relic 保底卡基价(provisional——池令牌非货架种,词表外专枝) -@export var spend_discount: float = 0.8 # 财富之道·散 economy_spend 激活时的购买折扣(provisional) +@export var shelf_size: int = 0 # 货架位数(零哨兵——缺字段越域 [1,6] 即加载致命) const GP_TIERS := ["normal", "elite", "boss"] const PRODUCT_ATTRS := ["atk", "max_hp", "armor_class"] @@ -131,8 +131,8 @@ func _validate_price_factors(errs: Array[Dictionary]) -> void: errs.append({"category": "domain", "message": "price_b 越域 [1.0,2.0]: %f" % price_b}) if relic_base_price < 1.0 or relic_base_price > 99.0: 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}) + if shelf_size < 1 or shelf_size > 6: + errs.append({"category": "domain", "message": "shelf_size 越域 [1,6]: %d" % shelf_size}) # ---- 行族访问器(自 ConfigDB 归位——行集解析单点;消费面 db.run.shop. 直连)---- diff --git a/game/data/schemas/variation_table.gd b/game/data/schemas/variation_table.gd index 1b4af099..537fa9bf 100644 --- a/game/data/schemas/variation_table.gd +++ b/game/data/schemas/variation_table.gd @@ -9,9 +9,10 @@ extends Resource # 布置参数(表头字段;数量公式 = round(候选池格数 × percent_of_cells / 100) + (关卡序号 − 1) × per_level_bonus, # 连通片生长、单片 ≤ pack_max——图规模百分比 × 关卡递增,全连通、单片不超 4; # 百分比基数 = 候选池(安全格 − 初始展开区,即实际可挖格)而非总格数) -@export var percent_of_cells: int = 5 -@export var per_level_bonus: int = 2 -@export var pack_max: int = 4 +@export var percent_of_cells: int = 0 +@export var per_level_bonus: int = 0 +@export var pack_max: int = 0 +@export var pack_min: int = 0 # 成包下界(零哨兵——缺字段越域即加载致命) const SCOPES := ["region", "global"] # resource_face = 资源面偏斜(全局作用域变异——整图修摇号分布:生存族按 effect_value% @@ -24,6 +25,7 @@ const PLACEMENTS := ["closed_only", "any"] # 布置约束 func validate() -> Array: # 空清单是合法状态(地形变异 P0)——零行即零变体格,配置加载零错误、零警告 var errs: Array[Dictionary] = [] + errs.append_array(_validate_header_domains()) var seen := {} for row in rows: var head := _validate_row(row, seen) @@ -36,6 +38,20 @@ func validate() -> Array: return errs +## 表头域校验单点(地形变异.md v1.2 调参表 owner 域——零缺省,缺字段/越域即拒) +func _validate_header_domains() -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + if percent_of_cells < 0 or percent_of_cells > 50: + errs.append({"category": "domain", "message": "percent_of_cells 越域 [0,50]: %d" % percent_of_cells}) + if per_level_bonus < 0 or per_level_bonus > 9: + errs.append({"category": "domain", "message": "per_level_bonus 越域 [0,9]: %d" % per_level_bonus}) + if pack_min < 1 or pack_min > pack_max: + errs.append({"category": "domain", "message": "pack_min 越域 [1,pack_max=%d]: %d" % [pack_max, pack_min]}) + if pack_max < pack_min or pack_max > 9: + errs.append({"category": "domain", "message": "pack_max 越域 [pack_min=%d,9]: %d" % [pack_min, pack_max]}) + return errs + + ## 行域:variant_id 非空与查重/display_name/unlock_level/四词表(scope/effect/trigger/placement) func _validate_row(row: Dictionary, seen: Dictionary) -> Dictionary: var errs: Array[Dictionary] = [] diff --git a/game/data/tables/board_gen.tres b/game/data/tables/board_gen.tres index 15d9393b..6154d94b 100644 --- a/game/data/tables/board_gen.tres +++ b/game/data/tables/board_gen.tres @@ -4,6 +4,7 @@ [resource] script = ExtResource("1") +spawn_edge = 3 rows = [{ "level_index": 1, "node_depth": 0, @@ -411,7 +412,7 @@ rows = [{ ; 切片调参登记(SLICE-TUNED provisional,调参所有权仍归 C3): ; A1 GDD 参考行(d0.10/0.12)经"先布后删"环内移除后仅幸存 2-3 雷、R0/|S|约0.97、残余挖掘 2-4 格—— -; 掘近退化板(A2 反例 B 机制的低密度放大)。实验数据(8 种子均值,smoke/experiment_density.gd): +; 掘近退化板(A2 反例 B 机制的低密度放大)。实验数据(8 种子均值,出处=退役件 experiment_density.gd(git 历史可考)): ; d0.25 → L1 幸存雷 11/残余 13/inf 0.925/att 2.9 · L2 雷 19/残余 21/inf 0.908/att 7.9(seed42 实测 inf 0.889→target 定 0.88 首过),零降级告警。 ; target 收窄至 0.88/0.88(不低于 0.85 绝对锚)为密度上移的对价——B1 条款③⑤对账重跑义务随调参触发。 ; 2026-09-11 关卡扩展批:L1 增 D1–D4 行(Boss=Dmax=4)。 diff --git a/game/data/tables/feats.tres b/game/data/tables/feats.tres index 6bb2bf5c..28687a22 100644 --- a/game/data/tables/feats.tres +++ b/game/data/tables/feats.tres @@ -20,6 +20,7 @@ rows = [ "feat_id": "twin_pick", "display_name": "双管镐", "rarity": "common", + "cap": 1, "desc": "每第 2 次付费开格返 1 AP", "triggers": [ { @@ -52,6 +53,7 @@ rows = [ "feat_id": "geologist", "display_name": "地质学家", "rarity": "common", + "cap": 1, "desc": "每首开一种新类型资源格集 1 点,满 4 种→揭示 1 格", "triggers": [ { @@ -106,6 +108,7 @@ rows = [ "feat_id": "hard_hat", "display_name": "安全帽", "rarity": "excellent", + "cap": 1, "desc": "开安全格攒 1 层;触陷阱耗层,每层减 1 伤害(一次清零)", "triggers": [ { @@ -145,6 +148,7 @@ rows = [ "feat_id": "skip_mining", "display_name": "跳格采掘", "rarity": "excellent", + "cap": 1, "desc": "挖开空白格攒 1 点,3 点→揭示 1 格", "triggers": [ { @@ -199,6 +203,7 @@ rows = [ "feat_id": "blast_accounting", "display_name": "爆破会计", "rarity": "common", + "cap": 1, "desc": "轮末:本轮开格花费 AP≥5 → +1 GP", "triggers": [ { @@ -231,6 +236,7 @@ rows = [ "feat_id": "frontier_miner", "display_name": "边陲矿工", "rarity": "common", + "cap": 1, "desc": "挖开棋盘边缘格:产出 +1", "triggers": [ { @@ -253,6 +259,7 @@ rows = [ "feat_id": "seal_echo", "display_name": "封印回响", "rarity": "excellent", + "cap": 1, "desc": "封印命中计数;轮末计数≥2 → 返 1 AP(下轮)", "triggers": [ { @@ -301,6 +308,7 @@ rows = [ "feat_id": "warning_beacon", "display_name": "示警烟火", "rarity": "common", + "cap": 1, "desc": "封印命中:揭示周围 3×3 的资源类型(不揭安危)", "triggers": [ { @@ -316,6 +324,7 @@ rows = [ "feat_id": "seal_smith", "display_name": "封印匠", "rarity": "rare", + "cap": 1, "desc": "封印命中每第 3 次 → 该危险格中立移除", "triggers": [ { @@ -348,6 +357,7 @@ rows = [ "feat_id": "seal_stake", "display_name": "封印桩", "rarity": "common", + "cap": 1, "desc": "封印命中:本轮 AC +1", "triggers": [ { @@ -364,6 +374,7 @@ rows = [ "feat_id": "pain_memory", "display_name": "疼痛记忆", "rarity": "rare", + "cap": 1, "desc": "触陷阱攒 1 层;下次攻击附加层值伤害,用后清零", "triggers": [ { @@ -411,6 +422,7 @@ rows = [ "feat_id": "fuse_wire", "display_name": "保险丝", "rarity": "rare", + "cap": 1, "desc": "单次受伤 ≥5 攒 1 点,2 点→护盾 1 层", "triggers": [ { @@ -465,6 +477,7 @@ rows = [ "feat_id": "blood_insight", "display_name": "见血开窍", "rarity": "common", + "cap": 1, "desc": "触陷阱:揭示 1 格", "triggers": [ { @@ -480,6 +493,7 @@ rows = [ "feat_id": "trap_instinct", "display_name": "陷阱本能", "rarity": "excellent", + "cap": 1, "desc": "触陷阱:本轮攻击 +2(走攻击面板乘区)", "triggers": [ { @@ -503,6 +517,7 @@ rows = [ "feat_id": "chain_traps", "display_name": "连环陷阱", "rarity": "excellent", + "cap": 1, "desc": "同轮第 2 次及以后的触陷阱:每次额外 +1 层(疼痛记忆同账)", "triggers": [ { @@ -535,6 +550,7 @@ rows = [ "feat_id": "trap_burst", "display_name": "陷阱爆发", "rarity": "rare", + "cap": 1, "desc": "触陷阱:全体存活敌受陷阱伤×1.5 直伤", "triggers": [ { @@ -550,6 +566,7 @@ rows = [ "feat_id": "blood_honey", "display_name": "血蜜", "rarity": "common", + "cap": 1, "desc": "触陷阱:回复 2 生命(陷阱伤仍净亏血)", "triggers": [ { @@ -565,6 +582,7 @@ rows = [ "feat_id": "stone_skin", "display_name": "石肤", "rarity": "excellent", + "cap": 1, "desc": "受伤:AC +1(本场累计,上限 3)", "triggers": [ { @@ -582,6 +600,7 @@ rows = [ "feat_id": "last_stand", "display_name": "背水", "rarity": "rare", + "cap": 1, "desc": "生命≤50%:受伤额外 −2", "triggers": [ { @@ -604,6 +623,7 @@ rows = [ "feat_id": "breaker_strike", "display_name": "破封一击", "rarity": "excellent", + "cap": 1, "desc": "挖开带封印的危险格:+1 层(疼痛记忆同账)", "triggers": [ { @@ -627,6 +647,7 @@ rows = [ "feat_id": "map_exchange", "display_name": "弃图兑换", "rarity": "excellent", + "cap": 1, "desc": "停手:剩余 AP≥3 → 折为攻击伤害", "triggers": [ { @@ -682,6 +703,7 @@ rows = [ "feat_id": "last_drop", "display_name": "一滴不剩", "rarity": "common", + "cap": 1, "desc": "上轮 AP 耗尽 → +1 AP", "triggers": [ { @@ -705,6 +727,7 @@ rows = [ "feat_id": "market_close", "display_name": "收盘", "rarity": "common", + "cap": 1, "desc": "轮末:本轮开格 ≥8 → +1 GP", "triggers": [ { @@ -736,6 +759,7 @@ rows = [ "feat_id": "execution_scholar", "display_name": "斩首学者", "rarity": "common", + "cap": 1, "desc": "击杀:揭示 1 格", "triggers": [ { @@ -751,6 +775,7 @@ rows = [ "feat_id": "executioner", "display_name": "处刑台", "rarity": "excellent", + "cap": 1, "desc": "敌血量≤30%:主角对其伤害 +2", "triggers": [ { @@ -773,6 +798,7 @@ rows = [ "feat_id": "field_dressing", "display_name": "战地包扎", "rarity": "common", + "cap": 1, "desc": "战斗胜利:回复 3 生命", "triggers": [ { @@ -788,6 +814,7 @@ rows = [ "feat_id": "blood_tax", "display_name": "血税", "rarity": "rare", + "cap": 1, "desc": "本局每触一次陷阱,最大生命 −2;每封印命中一次,攻击 +1(下一战起生效)", "triggers": [ { @@ -812,6 +839,7 @@ rows = [ "feat_id": "venom_edge", "display_name": "淬毒刃纹", "rarity": "rare", + "cap": 1, "desc": "克制命中追加 +2 伤害", "triggers": [ { @@ -834,6 +862,7 @@ rows = [ "feat_id": "seal_burst", "display_name": "封印爆裂", "rarity": "rare", + "cap": 1, "desc": "封印命中:全体存活敌 3 点群伤", "triggers": [ { @@ -850,6 +879,7 @@ rows = [ "feat_id": "perfect_insurance", "display_name": "完美保险", "rarity": "rare", + "cap": 1, "desc": "本场首次封印落空,不计入完美封印判定", "triggers": [ { @@ -891,6 +921,7 @@ rows = [ "display_name": "省力周转", "rarity": "excellent", "cap": 1, + "cap": 1, "desc": "刷新半价:弃图刷新成本为基线 50%", "triggers": [ { @@ -908,6 +939,7 @@ rows = [ "display_name": "崩壁冲击", "rarity": "excellent", "cap": 1, + "cap": 1, "desc": "刷后群伤:成功刷新后全体存活敌各受 2 点", "triggers": [ { @@ -925,6 +957,7 @@ rows = [ "display_name": "矿道红利", "rarity": "rare", "cap": 1, + "cap": 1, "desc": "刷后GP:每次成功刷新固定 +1 GP", "triggers": [ { @@ -941,6 +974,7 @@ rows = [ "display_name": "石层缓冲", "rarity": "common", "cap": 1, + "cap": 1, "desc": "当轮减伤:本轮成功刷新后,每次受伤固定 −2(多刷不叠加)", "triggers": [ { @@ -973,6 +1007,7 @@ rows = [ "display_name": "蓄力周转", "rarity": "excellent", "cap": 1, + "cap": 1, "desc": "刷后蓄力:每次刷新 +1 层(上限 3),下一次攻击消费全部层数", "triggers": [ { @@ -1029,6 +1064,7 @@ rows = [ "display_name": "坑道小憩", "rarity": "common", "cap": 1, + "cap": 1, "desc": "当轮恢复:每次刷新累计 1 点(上限 3),本轮轮末合计回复", "triggers": [ { diff --git a/game/data/tables/level_gen_params.tres b/game/data/tables/level_gen_params.tres index 2aa43701..6245764b 100644 --- a/game/data/tables/level_gen_params.tres +++ b/game/data/tables/level_gen_params.tres @@ -16,7 +16,10 @@ rows = [{ "exit_count": 1, "battle_floor": 4, "fork_ways_min": 2, -"fork_ways_max": 3 +"fork_ways_max": 3, +"fork_third_way_pct": 35, +"fill_rest_first_pct": 50, +"width_max": 4 }, { "level_index": 2, "layers": 9, @@ -29,7 +32,10 @@ rows = [{ "exit_count": 2, "battle_floor": 6, "fork_ways_min": 2, -"fork_ways_max": 3 +"fork_ways_max": 3, +"fork_third_way_pct": 35, +"fill_rest_first_pct": 50, +"width_max": 4 }, { "level_index": 3, "layers": 13, @@ -42,7 +48,10 @@ rows = [{ "exit_count": 3, "battle_floor": 8, "fork_ways_min": 2, -"fork_ways_max": 3 +"fork_ways_max": 3, +"fork_third_way_pct": 35, +"fill_rest_first_pct": 50, +"width_max": 4 }] ; 节点图生成器核心批 卡① §8.1 参数档 provisional 取值表逐行填列(定值批落终值,数值全 provisional)。 diff --git a/game/data/tables/meta_tree.tres b/game/data/tables/meta_tree.tres index e91f16f5..27e4d049 100644 --- a/game/data/tables/meta_tree.tres +++ b/game/data/tables/meta_tree.tres @@ -113,10 +113,14 @@ rows = [{ "name_a": "先手压制", "name_b": "稳健拓荒", "effects_a": { - "posture_first_strike": 1 + "posture_first_strike": { + "ap_bonus": 1 + } }, "effects_b": { - "posture_bulwark": 1 + "posture_bulwark": { + "shield": 1 + } } }, { "node_id": "fork_sense", @@ -131,10 +135,16 @@ rows = [{ "name_a": "锋", "name_b": "守", "effects_a": { - "sense_sharp": 1 + "sense_sharp": { + "halve_pct": 50, + "cap_per_level": 2 + } }, "effects_b": { - "sense_guard": 1 + "sense_guard": { + "ap_refund": 1, + "cap_per_level": 2 + } } }, { "node_id": "fork_economy", @@ -149,10 +159,14 @@ rows = [{ "name_a": "聚", "name_b": "散", "effects_a": { - "economy_hoard": 1 + "economy_hoard": { + "mult_pct": 150 + } }, "effects_b": { - "economy_spend": 1 + "economy_spend": { + "discount_pct": 80 + } } }] diff --git a/game/data/tables/shop.tres b/game/data/tables/shop.tres index 60b6f6a1..c6afa528 100644 --- a/game/data/tables/shop.tres +++ b/game/data/tables/shop.tres @@ -4,6 +4,7 @@ [resource] script = ExtResource("1") +shelf_size = 3 gp_drops = [{ "tier": "normal", "display_name": "普通战斗", @@ -93,7 +94,6 @@ pool_shelf = [{ price_a = 1.8 price_b = 1.1 relic_base_price = 15.0 -spend_discount = 0.8 ; GP+商店房实施批(2026-09-20 用户拍板,设计权威 design/research/proposal-gp-shop-room-2026-09-20.md 裁决记录): ; 掉落=均值±方差掷定(gp_drop 封闭流)+关级加值;精英行为预留行(当前无消费方,随精英节点内容批启用); diff --git a/game/data/tables/variation.tres b/game/data/tables/variation.tres index 5f7b2636..d3bc68b6 100644 --- a/game/data/tables/variation.tres +++ b/game/data/tables/variation.tres @@ -7,6 +7,7 @@ script = ExtResource("1") percent_of_cells = 5 per_level_bonus = 0 pack_max = 4 +pack_min = 2 rows = [{ "variant_id": "thorn", "display_name": "荆棘丛", diff --git a/game/presentation/main.gd b/game/presentation/main.gd index 41c9713c..051ecd60 100644 --- a/game/presentation/main.gd +++ b/game/presentation/main.gd @@ -182,7 +182,7 @@ func _wire_adventure() -> void: # 冒险开局参数面构造(主菜单开新/续玩两入口共用:同调三键随开局下传。 # 取值口径与营地投影完全同源:装配集=save.attunement_equipped(save_manager.gd 访问器)、Y 刷新池= -# meta.attune_refresh_charges(当前等级行表值)、树 rule 键集=meta.tree_rule_keys(Fork 激活侧 effects); +# meta.attune_refresh_charges(当前等级行表值)、树规则参数字典=meta.tree_rules(Fork 激活侧 effects_*); # hero_override 沿用既有 meta 现算口径。static 形态=冒烟直呼单测本函数,免实例化整菜单场景)。 # 两根类型轴随开局下传(attack_type/armor_type——契约 2 所有权移交+契约 8 冒险开始快照; # 取值=meta.hero_type_axes()=存档选中路线类型轴(attack/armor 两键),未解锁/异常选中态回退锻造配置默认值)。 @@ -195,7 +195,7 @@ static func build_adventure_params(save_manager_ref: SaveManagerCore, meta_ref: "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 [], + "tree_rules": meta_ref.tree_rules() if meta_ref != null else {}, } params.merge(extra, true) return params @@ -630,10 +630,10 @@ func _on_refresh_confirmed() -> void: 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": - ui.battle.board_zone.toast.show_toast("RAP 不足 · 还差 %d 点(弃图刷新需 8)" % (cost - rap), "warn") + ui.battle.board_zone.toast.show_toast("RAP 不足 · 还差 %d 点(弃图刷新需 %d)" % [cost - rap, cost], "warn") ui.battle.board_zone.ap_bar.pulse_rap() else: - ui.battle.board_zone.toast.show_toast("本轮刷新次数已用完(每轮 2 次)", "warn") + ui.battle.board_zone.toast.show_toast("本轮刷新次数已用完(每轮 %d 次)" % int(db.run.action_economy.refresh_cap_per_round), "warn") ui.battle.board_zone.board_view.refresh() _refresh_presentations() diff --git a/game/presentation/playtest_logger.gd b/game/presentation/playtest_logger.gd index 4182051f..b8fb64b4 100644 --- a/game/presentation/playtest_logger.gd +++ b/game/presentation/playtest_logger.gd @@ -13,12 +13,15 @@ var _seed := 0 var _recent_steps: Array[Dictionary] = [] # 环形缓冲:最近结算行 var _recent_actions: Array[Dictionary] = [] # 环形缓冲:最近玩家动作 var _snap_count := 0 -var ap_per_round := 8 # 组合根注入(AP 消耗口径分母——行动经济 无推送信号) +var ap_per_round: int = 0 # 必填注入(零缺省——组合根 start_session 前显式设定,守卫拒未注入) var _ux_inputs := 0 # 本结算轮原始输入动作数(log_action 计数,快照后归零) # 开启会话(每次 start_level/start_battle 调用——按种子分文件)。返回日志路径。 func start_session(seed_val: int) -> String: + if ap_per_round < 1 or ap_per_round > 32: + push_error("[试玩日志器] ap_per_round 未注入或越域: %d——组合根必填注入" % ap_per_round) + return "" _seed = seed_val _recent_steps.clear() _recent_actions.clear() diff --git a/game/presentation/views/standee_body.gd b/game/presentation/views/standee_body.gd index 07ac8753..7c17035f 100644 --- a/game/presentation/views/standee_body.gd +++ b/game/presentation/views/standee_body.gd @@ -77,7 +77,7 @@ func name_line_probe() -> Dictionary: # ---- setter 面(presenter/组合根零改排) ---- # data = 战斗结算 presentation_snapshot enemies[] 投影:{order, display_name, hp, max_hp, enemy_id, -# pending_skill/pending_type/pending_name, effects[](敌方 side 增益——各立绘同值呈现)} +# pending_skill/pending_type/pending_name, effects[](该敌自己名下增益——各立绘各读各的,增益状态 v1.5 个体投影)} func set_data(d: Dictionary) -> void: st._display_name = String(d.get("display_name", "敌人")) st._enemy_id = String(d.get("enemy_id", "")) diff --git a/game/sim/adventure_monte_carlo.gd b/game/sim/adventure_monte_carlo.gd index 64277218..a669679d 100644 --- a/game/sim/adventure_monte_carlo.gd +++ b/game/sim/adventure_monte_carlo.gd @@ -47,6 +47,11 @@ func _initialize() -> void: var feat_mode := "off" if args.size() >= 7 and args[6] in ["off", "formed", "ab_a", "ab_b", "rules"]: feat_mode = args[6] + var db = load("res://core/config_db.gd").new() + if not db.load_tables("res://data/tables", false): + print("表加载失败") + quit(1) + return var feat_profile := {} if feat_mode == "formed": # 成型画像=五事件族覆盖 14 件 @@ -58,16 +63,11 @@ func _initialize() -> void: "last_drop", "market_close", "execution_scholar"]} elif feat_mode == "ab_a": - feat_profile = {"mode": "off", "tree_rules": ["posture_first_strike"]} + feat_profile = {"mode": "off", "tree_rules": _tree_rule_dict(db, ["posture_first_strike"])} elif feat_mode == "ab_b": - feat_profile = {"mode": "off", "tree_rules": ["posture_bulwark"]} + feat_profile = {"mode": "off", "tree_rules": _tree_rule_dict(db, ["posture_bulwark"])} elif feat_mode == "rules": - feat_profile = {"mode": "off", "tree_rules": Array(String(args[7] if args.size() >= 8 else "").split(",", false))} # V19——任意键集配对注入(Array 包裹防 PackedStringArray 被 adventure_runner is Array 守卫整组丢弃) - var db = load("res://core/config_db.gd").new() - if not db.load_tables("res://data/tables", false): - print("表加载失败") - quit(1) - return + feat_profile = {"mode": "off", "tree_rules": _tree_rule_dict(db, Array(String(args[7] if args.size() >= 8 else "").split(",", false)))} # V19——任意键集配对注入(规则键参数字典形态) var attune_profile := {} if attune_mode == "on": # 贪心画像:Slots=3(槽位判定源=树同调位节点点亮数,模拟画像=三节点 @@ -153,3 +153,21 @@ func _initialize() -> void: print("BATCH %s N=%d 终局计数=%s 日志=%s" % [policy, n, JSON.stringify(tally), log_path]) print("(口径注:零加成 = atk/hp 天赋格百分比均 0;关间大奖励(HP 上限提升+回满)按冒险结构设计自动兑付,未额外剥离 · 全程日志见 user://logs/ · 总耗时 %.1f 秒)" % [float(Time.get_ticks_msec() - t0) / 1000.0]) quit(0) + + +## 树规则参数字典构造(A/B 面注入单点——扫 meta_tree Fork 行 effects_a/b,命中 keys 集的键取其 +## 参数字典原样透传;db 参鸭子型沿 board_generator 先例——sim 面不在 S9 扫描面) +static func _tree_rule_dict(db, keys: Array[Variant]) -> Dictionary[Variant, Variant]: + var out: Dictionary[Variant, Variant] = {} + var keyset := {} + for k in keys: + keyset[String(k)] = true + for row in db.crossrun.meta_tree.rows: + if String(row.get("kind", "")) != "fork": + continue + for skey in ["effects_a", "effects_b"]: + var fx: Dictionary = row.get(skey, {}) as Dictionary + for rk in fx.keys(): + if keyset.has(String(rk)) and not out.has(String(rk)): + out[String(rk)] = fx[rk] + return out diff --git a/game/sim/auto_player.gd b/game/sim/auto_player.gd index a2d3b5b6..377dd8cd 100644 --- a/game/sim/auto_player.gd +++ b/game/sim/auto_player.gd @@ -146,6 +146,16 @@ static func _deduce(minesweeper) -> Dictionary: if not mines.has(c): mines[c] = true changed = true + # 0 格连锁算子(架构整改批补齐——与棋盘生成 _apply_chain_rule 对位):safe 集中未揭示格 + # 挖开必触发 0 格连锁(连锁只经安全格扩散=闭包集全安全,真实玩家挖 c 免费获得的信息面), + # 经 preview_reveal 真实闭包取新开格集并入 safe——双口径显式登记:unknown 不含 safe_set oracle + # 〔口径差异=board_generator 版含 oracle,本版 no-peek——:80-83 注释在账〕 + for c in (safe.keys() as Array).duplicate(): + if board.is_safe(c) and not minesweeper.is_revealed(c): + for rc in minesweeper.preview_reveal(c): + if not safe.has(rc): + safe[rc] = true + changed = true return {"safe": _row_major(safe), "mines": _row_major(mines)} diff --git a/game/sim/battle_driver.gd b/game/sim/battle_driver.gd index 9e0a908a..5081ac47 100644 --- a/game/sim/battle_driver.gd +++ b/game/sim/battle_driver.gd @@ -11,26 +11,84 @@ const AutoPlayer := preload("res://sim/auto_player.gd") const GUARD_ACTIONS := 400 # 单轮动作步防挂死上限(被收口探针原值逐一同值) -# 驱动一整轮挖掘。still_running = 调用方战斗活性谓词(缺省恒真): +# 驱动一整轮(驱动循环单点——通用签名)。still_running = 调用方战斗活性谓词(缺省恒真): # monte_carlo / skill_rhythm 传 `func(): return out.result == ""`(battle_finished 已发的轮内不再动手), # damage_probe 关卡驱动传 runner 绑定 + battle_result 谓词,battle 级探针传空即可。 -static func drive_round(settlement, still_running: Callable = Callable()) -> void: +# act_provider 契约(策略回调——func(settlement) -> Dictionary): +# {"kind":"stop"}/空字典 = 停手出环;单步 {"kind":"move"|"dig"|"seal"|"refresh","cell":V}(refresh 无 cell); +# 合步 {"kind":"move","cell":X,"next":{"kind":"dig","cell":Y}}——move 提交后查相位/轮次、变更即断弃 next。 +# on_action 契约(日志回调——func(submit_kind:String, cell:Vector2i, ok:bool, round_index:int)): +# submit_kind ∈ {move, dig, seal_place, refresh, stop}——轮末统一 stop 也经此回调(accept 取 outcomes[0].accepted); +# 空 Callable = 不记日志。 +# max_rejects:策略回调被拒连续上限(≤0 = 不设防——缺省形态零防线语义保持);ok 清零否则累加。 +# 缺省两参形态(act_provider 空)= AutoPlayer.choose_dig 原语义逐位:guard 400、异格先移、合步 dig、 +# 移位后相位/轮次离场断弃 next、轮末 stop——四调用方(dump_battle/monte_carlo/damage_probe/skill_rhythm)两参零改动。 +static func drive_round(settlement, still_running: Callable = Callable(), + act_provider: Callable = Callable(), on_action: Callable = Callable(), + max_rejects: int = 0) -> void: var start_round: int = settlement.round_index var minesweeper = settlement.minesweeper var guard := GUARD_ACTIONS + var rejects := 0 + var using_provider: bool = not act_provider.is_null() while _alive(still_running) and settlement.action_economy.phase == "Active" \ - and settlement.round_index == start_round and guard > 0: + and settlement.round_index == start_round and guard > 0 \ + and (max_rejects <= 0 or rejects < max_rejects): guard -= 1 - var plan := AutoPlayer.choose_dig(minesweeper) - if plan.is_empty(): + var act: Dictionary = act_provider.call(settlement) if using_provider else _default_act(minesweeper) + var kind := String(act.get("kind", "stop")) + if kind == "stop" or act.is_empty(): break - if plan.hero_cell != minesweeper.hero_pos(): - minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) - if settlement.action_economy.phase != "Active" or settlement.round_index != start_round: - break - minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) + if _submit_act(settlement, act, on_action, start_round): + rejects = 0 + else: + rejects += 1 if _alive(still_running) and settlement.action_economy.phase == "Active" and settlement.round_index == start_round: - minesweeper.settle_batch([{"action": "stop"}]) + var out_s: Array = settlement.minesweeper.settle_batch([{"action": "stop"}]) + _on(on_action, "stop", Vector2i(-1, -1), _accepted(out_s), start_round) + + +# 缺省策略(AutoPlayer.choose_dig 原生 plan 专用):同位直接 dig、异位先移合步 next=dig +static func _default_act(minesweeper) -> Dictionary: + var plan := AutoPlayer.choose_dig(minesweeper) + if plan.is_empty(): + return {"kind": "stop"} + if plan.hero_cell == minesweeper.hero_pos(): + return {"kind": "dig", "cell": plan.dig_cell} + return {"kind": "move", "cell": plan.hero_cell, "next": {"kind": "dig", "cell": plan.dig_cell}} + + +# 单步提交分派(move/dig/seal→settle_batch——seal 提交动作名 seal_place;refresh→player_request_refresh; +# 合步 move→next 递归提交——相位/轮次离场断弃,与原内联逐位同) +static func _submit_act(settlement, act: Dictionary, on_action: Callable, ri: int) -> bool: + var kind := String(act.get("kind", "stop")) + var cell: Vector2i = act.get("cell", Vector2i(-1, -1)) + var ok := false + if kind == "refresh": + ok = bool(settlement.player_request_refresh()) + _on(on_action, "refresh", Vector2i(-1, -1), ok, ri) + return ok + var action_name := "seal_place" if kind == "seal" else kind + var out_s: Array = settlement.minesweeper.settle_batch([{"action": action_name, "cell": cell}]) + ok = _accepted(out_s) + _on(on_action, action_name, cell, ok, ri) + if kind == "move": + var next: Dictionary = act.get("next", {}) + if next.is_empty(): + return ok + if settlement.action_economy.phase != "Active" or settlement.round_index != ri: + return ok # 移位后相位/轮次离场——断弃 next + _submit_act(settlement, next, on_action, ri) + return ok + + +static func _on(on_action: Callable, kind: String, cell: Vector2i, ok: bool, ri: int) -> void: + if not on_action.is_null(): + on_action.call(kind, cell, ok, ri) + + +static func _accepted(outcomes: Array) -> bool: + return not outcomes.is_empty() and bool(outcomes[0].accepted) static func _alive(still_running: Callable) -> bool: diff --git a/game/sim/calibrate_boards.gd b/game/sim/calibrate_boards.gd index 71daea40..86c04a8c 100644 --- a/game/sim/calibrate_boards.gd +++ b/game/sim/calibrate_boards.gd @@ -11,6 +11,7 @@ extends SceneTree class FakeDB: class _FakeBoardGen: var row: Dictionary + var spawn_edge: int = 0 # 表头成员(generate 直索引面——真值由 _initialize 逐行注入) func resolve_row(_L: int, _D: int) -> Dictionary: return row class _FakeCombat: @@ -42,9 +43,11 @@ func _initialize() -> void: # 初始展开收窄两参数透传(.get 缺省与生成器防御口径一致) "shave_ring_radius": int(row.get("shave_ring_radius", 1)), "r0_max_cap": int(row.get("r0_max_cap", 0)), + "spawn_edge": int(db.combat.board_gen.spawn_edge), # 出生区边长透传(读真表头、零字面量——组E FakeDB 模式不动) } var fdb := FakeDB.new() fdb.row = rd + fdb.combat.board_gen.spawn_edge = int(db.combat.board_gen.spawn_edge) # 表头注入(零字面量——读真表头) var mines: Array = [] var safes: Array = [] var r0s: Array = [] diff --git a/game/sim/experiment_density.gd b/game/sim/experiment_density.gd deleted file mode 100644 index 26879000..00000000 --- a/game/sim/experiment_density.gd +++ /dev/null @@ -1,72 +0,0 @@ -# VERTICAL SLICE - NOT FOR PRODUCTION -# 一次性实验:不同 mine_density 下的 R₀ 占比 / 幸存雷数 / 残余挖掘量 / 推理率——为切片临时调参提供数据。 -extends SceneTree - -func _initialize() -> void: - var db = load("res://core/config_db.gd").new() - db.load_tables("res://data/tables", true) - var board_generator = load("res://core/board_generator.gd").new() - print("=== 密度实验(L1 10×10 target0.88 / L2 12×12 target0.90,各 8 种子)===") - print("d\t|M|/N\tR₀\tR₀/|S|\t残余挖掘\tinf\tatt") - for d in [0.22, 0.25, 0.28, 0.30, 0.33]: - for cfg in [[10, 10, 0.88, 50], [12, 12, 0.90, 60]]: - var w: int = cfg[0] - var h: int = cfg[1] - var t: float = cfg[2] - var ms: int = cfg[3] - var agg := {"m": 0, "r0": 0, "rem": 0, "inf": 0.0, "att": 0, "n": 0} - for s in range(8): - var row := { - "level_index": 1, "node_depth": 0, "board_width": w, "board_height": h, - "mine_density": d, "inferability_target": t, "solver_rules": "single_subset", - "min_safe_count": ms, "max_gen_attempts": 16, - "board_type": "dungeon", - } - _patch_row(row, d) - var params := _params_from(row) - var rg := RandomNumberGenerator.new() - rg.seed = 1000 + s - var b = board_generator.generate(1, 0, rg, _db(db, row)) - if b == null: - continue - agg.m += b.mines.size() - agg.r0 += int(b.metrics.r0_count) - agg.rem += b.safe_cells().size() - int(b.metrics.r0_count) - agg.inf += float(b.metrics.inferability) - agg.att += int(b.metrics.attempts_used) - agg.n += 1 - if agg.n > 0: - var n := float(agg.n) - var n_placed: int = int(round(d * float(w * h - 9))) - print("%.2f %dx%d\t%d/%d\t%d\t%.2f\t%d\t%.3f\t%.1f" % [d, w, h, int(agg.m / n + 0.5), n_placed, int(agg.r0 / n + 0.5), agg.r0 / n / (w * h - int(agg.m / n + 0.5)), int(agg.rem / n + 0.5), agg.inf / n, agg.att / n]) - quit(0) - -# --- 简易 db 替身:只供给本实验的行 --- -var _row_override: Dictionary = {} - -func _patch_row(row: Dictionary, _d: float) -> void: - _row_override = row - -func _params_from(row: Dictionary) -> Dictionary: - return { - "L": 1, "D": 0, "w": int(row.board_width), "h": int(row.board_height), - "density": float(row.mine_density), "target": float(row.inferability_target), - "solver_rules": "single_subset", "min_safe": int(row.min_safe_count), - "max_attempts": int(row.max_gen_attempts), "board_type": "dungeon", - } - -class FakeDB: - 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.combat.board_gen.row = row - return f diff --git a/game/sim/experiment_density.gd.uid b/game/sim/experiment_density.gd.uid deleted file mode 100644 index 87cad61f..00000000 --- a/game/sim/experiment_density.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cg7jkrsc03rar diff --git a/game/sim/level_autoplay.gd b/game/sim/level_autoplay.gd index 60f23e6b..13f34021 100644 --- a/game/sim/level_autoplay.gd +++ b/game/sim/level_autoplay.gd @@ -20,9 +20,8 @@ # 运行前提:db 已 load_tables;run_level/run_adventure 均须全新 RngService(同种子 ⇒ 全程逐步可回放)。 # 实现注:信号处理体全部为静态方法 + bind 传状态——GDScript 多行 lambda 内嵌 match/控制流的解析不可靠。 extends RefCounted - const AutoPlayer := preload("res://sim/auto_player.gd") # 自动玩家策略单一实现 - +const BattleDriver := preload("res://sim/battle_driver.gd") # 庚案收敛:驱动循环单点(策略仍归 _decide_act 单点分派) # ── 普通档 v3 失误注入参数(踩雷期望=完美+2/板、每板少挖几个正确格)── # 标雷→误挖转化率:主踩雷源=卡死赌挖(不确定性概率命中),误挖只作偶发真手滑—— # 真人几乎不挖自己封印的格(p 过高会让首轮已证封印变保底命中,非失误形态) @@ -32,14 +31,12 @@ const NORMAL_SKIP_DIG_P := 0.05 # 空闲提前赌挖率:已证安全格在手时小概率先赌一格——完美系把不确定性攒到残局 # (残局 hazard→1=扫雷),真人早赌滚信息(低 hazard 期吃掉不确定性);p×~40 安全 AP/板 ≈ 4 次 const NORMAL_IDLE_GAMBLE_P := 0.10 - - static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: int, collect_rows: bool = false, logger = null, policy: String = "normal", route: String = "min") -> Dictionary: var own_session := logger == null if own_session: logger = load("res://presentation/playtest_logger.gd").new() + logger.ap_per_round = int(db.run.action_economy.ap_per_round) # 注入序前置(守卫拒后开——R5 必填) logger.start_session(seed_val) - 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) @@ -77,8 +74,6 @@ static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: if own_session: logger.close_session() return st - - # 单关驱动段(run_level 抽取——零自动推进停点 + 战斗自动玩家;policy 经 st 入 _autoplay_battle) # 入口停点(node_index 尚 -1 = 进节点前无 current_node)也经本分支驱动—— # 读 next_candidate_ids 候选(入口单候选)→ pick_next_node,同一 _enter_node 等价(零 RNG 漂移); @@ -100,8 +95,6 @@ static func _drive_level(runner, st: Dictionary, logger, policy: String, route: if this_settlement == null: break # level_finished 已发(settlement 置 null)而信号尚未置 result 的防御 _autoplay_battle(runner, this_settlement, st, logger) - - # 三关连探冒险驱动——AdventureRunner 编排:主种子恰一次 # (start_adventure 唯一 new_adventure 调用点)、大奖励立即确认、冒险终局置 result/summary。 # 返回结构 = run_level 同族 + summary / wallet_events / encounter_max_hp_seq / reward_events / @@ -116,8 +109,8 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_ var own_session := logger == null if own_session: logger = load("res://presentation/playtest_logger.gd").new() + logger.ap_per_round = int(db.run.action_economy.ap_per_round) # 注入序前置(守卫拒后开——R5 必填) logger.start_session(seed_val) - 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() @@ -137,7 +130,6 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_ "seal_placed": 0, "seal_hit": 0, "seal_miss": 0, "seal_hit_gain": 0, "seal_miss_penalty": 0, "prng": _policy_rng(seed_val), "mine_slips": 0, "skip_digs": 0, "shop_buys": 0, "adv": true, # v3 档位行为开关(选路评分/失误注入/商店购买只挂冒险测量面——单关夹具语义不变) - "level_maps": [], # 逐关节点图标识(生成图 = 关卡序号+本局主种子——MC 分层出数) } # 信号直连(拆件批 T3):runner 信号在 level_started 重取点重连(转发族退役——重取点前移到本回调) @@ -158,9 +150,9 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_ # 特性注入(formed 画像=开局注入七流派各 2 件档一成型集;测量面专用) if relic.has("seed_ids"): adv_params["relics_seed"] = relic.get("seed_ids", []) - # 树规则键注入(A/B 面读数载体:fork 激活侧 effects rule 键集透传) + # 树规则参数字典注入(A/B 面读数载体:fork 激活侧 effects_* 参数字典透传) if relic.has("tree_rules"): - adv_params["tree_rules"] = relic.get("tree_rules", []) + adv_params["tree_rules"] = relic.get("tree_rules", {}) if relic.get("tree_rules", {}) is Dictionary else {} # 商店分层注入(缺省 legacy 与既有调用方逐位一致零行为变更;pool=共享池货架面) var pool_shop: bool = String(shop.get("mode", "")) == "pool" if pool_shop: @@ -189,8 +181,6 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_ if own_session: logger.close_session() return st - - # 每关开始(autoplay 直连面):重取 runner 并重连三信号(恰好一次——同关守卫: # level_started 每关恰一次发射,bind Callable 每次新建故以关卡号守卫防重入) static func _on_autoplay_level_started(level_index: int, _display_name: String, adr, st: Dictionary, collect_rows: bool, logger) -> void: @@ -203,8 +193,6 @@ static func _on_autoplay_level_started(level_index: int, _display_name: String, r.step_row.connect(_on_step_row.bind(adr, st, collect_rows)) r.node_entered.connect(_on_adventure_node_entered.bind(adr, st, logger)) r.wallet_changed.connect(_on_adventure_wallet.bind(st, logger)) - - # 冒险模式节点采集(_on_node_entered 的 AdventureRunner 门面版——runner 逐关更替,经 adr.runner() 重取; # encounter_max_hp_seq 抓 effective_max_hp:node_entered 先于 begin_battle,encounter 尚为空字典, # 该值 = begin_battle 将收到 hero_max_hp 同一表达式〔规划书抓取点与代码事实冲突,按代码为准已登记〕) @@ -215,7 +203,6 @@ static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr, if not st.has("level_maps"): st["level_maps"] = [] st["level_maps"].append("%d#%d" % [int(r.level_row.get("level_index", 0)), int(adr.master_seed)]) # MC 分层出数(关卡×生成图标识) - if t == "battle" or t == "boss": st.battles += 1 st.last_battle_enemies = [] # 末场战斗敌方名序列按战斗重置(失败局终值 = 死亡遭遇) @@ -259,8 +246,6 @@ static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr, r.continue_after_node() if logger != null: logger.log_node(node, info, r.node_index) - - # 冒险钱包事件采集(wallet 行族——GP 跨关延续与冒险收束 clear 的验收取数面;同日志合规转发) static func _on_adventure_wallet(event: String, gp_val: int, extra: Dictionary, st: Dictionary, logger) -> void: st.wallet_events.append({"event": String(event), "gp": int(gp_val), @@ -268,8 +253,6 @@ static func _on_adventure_wallet(event: String, gp_val: int, extra: Dictionary, "source": String(extra.get("source", ""))}) if logger != null: logger.log_wallet(event, gp_val, extra) - - # 三选一停点驱动(确定性策略;TP0 起 316-f 判读:1 级最稀有、同档并列取最左; # 奖励事件随 reward_events 留档) static func _on_adventure_bonus(payload: Dictionary, st: Dictionary, adr) -> void: @@ -283,8 +266,6 @@ static func _on_adventure_bonus(payload: Dictionary, st: Dictionary, adr) -> voi var pick: Dictionary = _pick_token(candidates, String(st.get("feat_mode", "off"))) if not adr.stop().pick_curio(String(pick.get("kind", "")), String(pick.get("tier", "")), String(pick.get("feat_id", ""))): st.result = "advance_failed" - - # 三选一确定性选件(TP0 316-f——1 级优先度=最稀有〔四级:legendary>rare>excellent>common〕、2 级=最左; # 候选并列同档取最左=稳定序保证。空候选/未知档位 = 回退最左防御口径。 # 成型画像(relic 模式 on:候选含 relic 令牌时优先在 relic 集内按 316-f 择一 @@ -302,22 +283,11 @@ static func _pick_token(candidates: Array, feat_mode: String = "off") -> Diction var best_rank := -1 for tok in pool: var t: Dictionary = tok as Dictionary - var rank := 0 - match String(t.get("tier", "")): - "legendary": - rank = 4 - "rare": - rank = 3 - "excellent": - rank = 2 - "common": - rank = 1 + var rank: int = int(TreasureItemTable.TIER_RANK.get(String(t.get("tier", "")), 0)) # 档位排序读表(未知档 0=原 match 落空同语义) if best.is_empty() or rank > best_rank: best = t best_rank = rank return best - - # 三选一兑付完成 → 上下文分派(关卡完成停点=推进下一关 / 奖励节点=回图选路) static func _on_adventure_curio_resolved(result: Dictionary, st: Dictionary, adr) -> void: st.reward_events.append({"curio_resolved": result.duplicate(true)}) @@ -328,8 +298,6 @@ static func _on_adventure_curio_resolved(result: Dictionary, st: Dictionary, adr return if not adr.continue_to_next_level(): st.result = "advance_failed" - - # 商店贪心买齐:GP 局内死钱——买得起就买( affordable 集里挑最便宜的 # 反复买到达不上任何商品为止,guard 防死循环)。确定性(零随机流);购买出账/加算区入账全走 # runner.shop.buy_product 核心编排(与呈现层购买同一出账路径,wallet_changed 信号照常采集)。 @@ -350,8 +318,6 @@ static func _autoplay_shop(r, st: Dictionary) -> void: if not r.shop.buy_product(String(pick.get("product_id", ""))): return st.shop_buys = int(st.get("shop_buys", 0)) + 1 - - # 商店共享池贪心:货架三选一同构面贪心买齐——affordable 集挑最便宜反复买 # (每次买后全重抽、货架随买随换,枯竭/不可负担即止,guard 防死循环)。购买走 runner.shop.buy_pool_item # 核心编排(与呈现层购买同一出账+池消费路径,wallet_changed 信号照常采集)。确定性(零随机流)。 @@ -372,24 +338,18 @@ static func _autoplay_shop_pool(r, st: Dictionary) -> void: if not r.shop.buy_pool_item(String(pick.get("kind_id", ""))): return st.shop_buys = int(st.get("shop_buys", 0)) + 1 - - # 冒险终局(completed/failed/abandoned——result 载 summary.result,外层驱动循环随之收敛) static func _on_adventure_finished(summary: Dictionary, st: Dictionary, logger) -> void: st.summary = summary.duplicate(true) st.result = String(summary.get("result", "")) if logger != null: logger.log_level_end(summary) - - # 策略流 RNG:种子派生自局种子 × 黄金比率常数 + 偏移——与核心统一流 # (rng_service 单盐派生)零共享零扰动;逐局确定性保持(同种子 ⇒ 同决策序列 ⇒ 同回放) static func _policy_rng(seed_val: int) -> RandomNumberGenerator: var prng := RandomNumberGenerator.new() prng.seed = seed_val * 2654435761 + 912931 return prng - - # 路径轴选路(目标价值评分制):候选按节点类型评分取最高, # 同分 tie-break 沿 route 方向(min=小句柄/max=大句柄——verifier 双窗轴保持)。 # 评分语义(确定性,零随机流):rest=伤重高值(缺口比例×100)/shop=买得起高值(GP 局内死钱, @@ -438,8 +398,6 @@ static func _route_pick(runner, route: String, st: Dictionary) -> int: if better: best = c return int(best.id) - - static func _on_step_row(row: Dictionary, _runner, st: Dictionary, collect_rows: bool) -> void: if row.type == "damage" and row.source_class == "enemy_action": st.hero_dmg += int(row.value) @@ -470,8 +428,6 @@ static func _on_step_row(row: Dictionary, _runner, st: Dictionary, collect_rows: st.seal_miss_penalty += int(row.value) if collect_rows: st.rows.append(row) - - # 敌方显示名采集:敌方行动/施放行文本首词 = 单位显示名 #(攻击行「地精 撕咬(暴击)」、施放行「地精 施放 毒雾」——显示名不含空格,去重连续重复) static func _note_enemy(text: String, st: Dictionary) -> void: @@ -483,8 +439,6 @@ static func _note_enemy(text: String, st: Dictionary) -> void: var arr: Array = st.last_battle_enemies if arr.is_empty() or String(arr[arr.size() - 1]) != ename: arr.append(ename) - - static func _on_node_entered(node: Dictionary, info: Dictionary, runner, st: Dictionary, logger) -> void: var t := String(node.get("type", "")) if t == "battle" or t == "boss": @@ -506,8 +460,6 @@ static func _on_node_entered(node: Dictionary, info: Dictionary, runner, st: Dic # 不掺购买行为;冒险口径(run_adventure)已在 _on_adventure_node_entered 改贪心买齐 if logger != null: logger.log_node(node, info, runner.node_index) - - static func _on_level_finished(p: Dictionary, st: Dictionary, logger) -> void: st.result = String(p.get("result", "")) st.hp_left = int(p.get("hp", 0)) @@ -516,8 +468,6 @@ static func _on_level_finished(p: Dictionary, st: Dictionary, logger) -> void: st.defeat_node_type = String(p.get("node_type", "")) if logger != null: logger.log_level_end(p) - - static func _autoplay_battle(runner, settlement, st: Dictionary, logger) -> void: # 策略档经 _decide_act 单点分派(单循环承载三档——三档决策语义逐位不变,见 _decide_act)。 # 普通策略档 v3:完美策略 + 受控失误注入(标定依据=真人实证: @@ -534,40 +484,27 @@ static func _autoplay_battle(runner, settlement, st: Dictionary, logger) -> void round_guard -= 1 st.rounds += 1 var start_round: int = settlement.round_index - var action_guard := 400 - var rejects := 0 - while runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" \ - and settlement.round_index == start_round and action_guard > 0 and rejects < 3: - action_guard -= 1 - var act: Dictionary = _decide_act(st, settlement) - var kind := String(act.get("kind", "stop")) - if kind == "stop": - break - var ok := false - if kind == "move": - ok = _submit_intent(settlement, "move", act.get("cell"), logger, start_round) - elif kind == "dig": - ok = _submit_intent(settlement, "dig", act.get("cell"), logger, start_round) - elif kind == "seal": - ok = _submit_intent(settlement, "seal_place", act.get("cell"), logger, start_round) - elif kind == "refresh": - ok = bool(settlement.player_request_refresh()) - if logger != null: - logger.log_action("refresh", Vector2i(-1, -1), ok, {"round": start_round}) - if ok: - if kind == "refresh": - st.refreshes = int(st.refreshes) + 1 - elif kind == "seal": - st.seal_placed = int(st.get("seal_placed", 0)) + 1 # 封印数(η 分母=全部封印数〔Q5〕;失误注入转化封印不计数=转化后实为挖) - rejects = 0 - else: - rejects += 1 - if runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" and settlement.round_index == start_round: - var out_s: Array = settlement.minesweeper.settle_batch([{"action": "stop"}]) - if logger != null: - logger.log_action("stop", Vector2i(-1, -1), _accepted(out_s), {"round": start_round}) + # 内层单轮循环委托 BattleDriver.drive_round(驱动循环单点——庚案收敛:本类禁再持第二轮驱动循环; + # 策略分派/失误注入全保留在 _decide_act 原地,日志与计数经 _action_logger 回调承载) + BattleDriver.drive_round(settlement, + func() -> bool: return runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" and settlement.round_index == start_round, + _act_provider(st), _action_logger(st, logger), 3) +# 策略回调适配器(drive_round act_provider 契约——三档分派 perfect/novice/slip 全在 _decide_act 原地) +static func _act_provider(st: Dictionary) -> Callable: + return func(s) -> Dictionary: return _decide_act(st, s) + + +# 日志回调适配器(drive_round on_action 契约——log_action 带轮号 + refresh/seal 计数语义逐位同原内联) +static func _action_logger(st: Dictionary, logger) -> Callable: + return func(kind: String, cell: Vector2i, ok: bool, ri: int) -> void: + if logger != null: + logger.log_action(kind, cell, ok, {"round": ri}) + if ok and kind == "refresh": + st.refreshes = int(st.refreshes) + 1 + elif ok and kind == "seal_place": + st.seal_placed = int(st.get("seal_placed", 0)) + 1 # 封印数(η 分母=全部封印数〔Q5〕;失误注入转化封印不计数=转化后实为挖) # 普通档 v3 失误注入:三类—— # ①标雷误挖:把「封印已证雷」动作直接转成「挖该格」(真人手滑原型:知道是雷、位置点错, # 踩雷吃伤且该雷奖励报废);期望踩雷增量 ≈ 每板已证雷数 × NORMAL_MINE_SLIP_P(目标完美+2/板) @@ -598,8 +535,6 @@ static func _apply_normal_slips(act: Dictionary, settlement, st: Dictionary) -> if not plan.is_empty(): return _gamble_action(plan, settlement) return act - - # 赌挖动作组装(v3.4 抽出):目标邻接=直接挖,否则先移到目标的已开邻格(下一拍再挖) static func _gamble_action(plan: Dictionary, settlement) -> Dictionary: var ms = settlement.minesweeper @@ -609,8 +544,6 @@ static func _gamble_action(plan: Dictionary, settlement) -> Dictionary: if maxi(d.x, d.y) == 1: return {"kind": "dig", "cell": target} return {"kind": "move", "cell": plan.hero_cell} - - # 战斗决策单行分派: # perfect 档 = 完美策略单步决策(上界口径); # normal 档 v3 = 完美+受控失误注入(冒险测量面 adv=true);单关机制夹具保持 v2 新手画像(adv 缺省)。 @@ -621,15 +554,3 @@ static func _decide_act(st: Dictionary, settlement) -> Dictionary: if bool(st.get("adv", false)): return _apply_normal_slips(AutoPlayer.perfect_action(settlement.minesweeper, settlement), settlement, st) # v3:完美+失误 return AutoPlayer.novice_action(settlement.minesweeper) # 单关机制夹具:v2 新手画像保持 - - -static func _submit_intent(settlement, action: String, cell, logger, round_index: int) -> bool: - var out: Array = settlement.minesweeper.settle_batch([{"action": action, "cell": cell}]) - if logger != null: - logger.log_action(action, cell, _accepted(out), {"round": round_index}) - return _accepted(out) - - -static func _accepted(outcomes: Array) -> bool: - return not outcomes.is_empty() and bool(outcomes[0].accepted) - diff --git a/game/tests/integration/cross_system_flow_test.gd b/game/tests/integration/cross_system_flow_test.gd index c4593ae5..165ae097 100644 --- a/game/tests/integration/cross_system_flow_test.gd +++ b/game/tests/integration/cross_system_flow_test.gd @@ -142,6 +142,7 @@ func _smoke_playtest_logger() -> void: rng.set_master_seed(20260912) var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) var logger = load("res://presentation/playtest_logger.gd").new() + logger.ap_per_round = 8 # 必填注入(R5 守卫——夹具沿组合根表值 8) var path: String = logger.start_session(20260912) logger.attach_battle(settlement) var snaps: Array = [] diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index bd198255..49302252 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -186,6 +186,12 @@ func _suites() -> Array: {"path": "res://tests/unit/core/curio_refresh_paid_test.gd", "expect": 0}, # 三选一刷新免费双源+GP 付费档(对齐商店 MC-1:价曲线/优先序/停点清零/钱包单口) {"path": "res://tests/unit/core/level_map_type_vis_test.gd", "expect": 0}, # 信息迷雾——类型可见性(层距 N+Boss/候选豁免+单圈+N 读表+续玩同值) {"path": "res://tests/unit/presentation/pacing_queue_test.gd", "expect": 0}, # AC⑤/T9——排空零分叉/零丢行/延迟双态 + {"path": "res://tests/unit/presentation/settings_store_test.gd", "expect": 0}, # 减动效开关存储契约(持久化回读/缺省值/引擎动效档应用)〔断言补齐批〕 + {"path": "res://tests/unit/battle_settlement/boss_gate_test.gd", "expect": 0}, # Boss 节点开战必选 Boss 行集(行类路由+端到端)〔断言补齐批〕 + {"path": "res://tests/unit/data_config/effect_kind_retired_test.gd", "expect": 1, "negative": true}, # 甲案词表退役——overflow/attack_segments 误写加载致命〔架构整改〕 + {"path": "res://tests/unit/core/playtest_logger_guard_test.gd", "expect": 0}, # R5 必填注入守卫——未注入拒绝开会话〔架构整改〕 + {"path": "res://tests/unit/core/battle_driver_drive_test.gd", "expect": 0}, # R12 驱动循环契约——缺省确定性/provider/rejects/合步〔架构整改〕 + {"path": "res://tests/unit/core/auto_player_deduce_test.gd", "expect": 0}, # R15 0 格连锁算子——远端格经真实闭包入 safe〔架构整改〕 {"path": "res://tests/unit/presentation/standee_fx_sweep_test.gd", "expect": 0}, # 呈现域缺陷扫掠(d2 蓄力档/d4 庆祝句柄/d5+d8 互斥/d12 站桩按 id 解析) {"path": "res://tests/unit/presentation/tune_sim_batch_parity_test.gd", "expect": 0}, # 测速循环批双实现对拍(帧索引/运行态/进度串/stats/报告五面——信号步进 vs await 基线) {"path": "res://tests/unit/presentation/standee_present_values_test.gd", "expect": 0}, # 战斗一屏样张批 V5–V12(血条危险红/名字行素底不压地台与提档 FS_HINT/徽标/护甲 PNG 绑定/素材入包/地台色合并退役) diff --git a/game/tests/unit/battle_settlement/boss_gate_test.gd b/game/tests/unit/battle_settlement/boss_gate_test.gd new file mode 100644 index 00000000..bf32d8c4 --- /dev/null +++ b/game/tests/unit/battle_settlement/boss_gate_test.gd @@ -0,0 +1,48 @@ +# tests/unit/battle_settlement/boss_gate_test.gd — Boss 节点开战必选 Boss 行集(端到端) +# 断言面 = 行类路由(resolver:boss 行集全行 boss / battle 行集零 boss 行)+端到端 +# (begin_battle(node_type="boss") → 实例化敌人全部出自 boss 行集)。固定种子经 RngService 流口。 +# 运行:godot --headless --path game --script res://tests/unit/battle_settlement/boss_gate_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") + + +func _init() -> void: + _test_boss_node_spawns_boss_rowset() + quit(Kit.finish("boss_gate_test")) + + +func _test_boss_node_spawns_boss_rowset() -> void: + var db = load("res://core/config_db.gd").new() + var load_ok: bool = db.load_tables("res://data/tables", true) and db.diagnostics_count("error") == 0 + Kit.check("前置:表加载零 error", load_ok) + if not load_ok: + return + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(20261103) # 固定种子经 RngService 流口(RNG 单点) + # 行类路由面:resolver 预解析(boss 行集全行 row_kind=="boss";对照 battle 行集零 boss 行) + var boss_ids := {} + var battle_ids := {} + var boss_kind_ok := true + for row in db.combat.enemy.resolve_rowset(1, 4, "boss"): + boss_ids[String(row["enemy_id"])] = true + if String(row["row_kind"]) != "boss": + boss_kind_ok = false + var battle_kind_ok := true + for row in db.combat.enemy.resolve_rowset(1, 4, "battle"): + battle_ids[String(row["enemy_id"])] = true + if String(row["row_kind"]) == "boss": + battle_kind_ok = false + Kit.check("行类路由:boss 行集非空全行 boss、battle 行集非空零 boss 行〔断言补齐批 V10〕", + boss_kind_ok and battle_kind_ok and boss_ids.size() > 0 and battle_ids.size() > 0) + # 端到端:begin_battle(node_type="boss") → 实例化敌人行键全部出自 boss 行集 + var oc = load("res://core/battle_orchestrator.gd").new(db, rng) + var ok_boss: bool = oc.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(db.combat.hero.max_hp), "node_type": "boss"}) + Kit.check("前置:boss 节点开局受理(L1 D4 Boss 行集)", ok_boss) + if not ok_boss: + return + var all_from_boss := true + for u in oc._enemy_system.all_enemies(): + if not boss_ids.has(String(u["enemy_id"])): + all_from_boss = false + Kit.check("Boss 节点开战必选 Boss 行集(实例化敌人全部出自 boss 行集——跨宿主真实入口链)〔断言补齐批 V10〕", all_from_boss) diff --git a/game/tests/unit/battle_settlement/boss_gate_test.gd.uid b/game/tests/unit/battle_settlement/boss_gate_test.gd.uid new file mode 100644 index 00000000..548c836c --- /dev/null +++ b/game/tests/unit/battle_settlement/boss_gate_test.gd.uid @@ -0,0 +1 @@ +uid://dkmph2dfqjqer diff --git a/game/tests/unit/battle_settlement/damage_controller_test.gd b/game/tests/unit/battle_settlement/damage_controller_test.gd index b42bde9b..6430d0ac 100644 --- a/game/tests/unit/battle_settlement/damage_controller_test.gd +++ b/game/tests/unit/battle_settlement/damage_controller_test.gd @@ -35,6 +35,8 @@ func _init() -> void: _test_hero_death_halt(db) _test_ac_merge(db) _test_pool_clamp(db) + _test_pool_consumption_per_unit(db) + _test_defender_ac_per_unit(db) quit(Kit.finish("damage_controller_test")) @@ -45,7 +47,7 @@ func _on_row(head, ri, display, value, extra) -> void: "round_index": ri, "display": display, "value": value, "extra": extra, "highlight": head.get("highlight", "")}) -func _harness(db): +func _harness(db, depth: int = 0): var rng = RngServiceScript.new() rng.set_master_seed(TEST_SEED) var units = UnitContainerScript.new(db) @@ -53,7 +55,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({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp)}) + var spawn_ok: bool = units.spawn({"level_index": 1, "node_depth": depth, "hero_hp": int(db.combat.hero.max_hp)}) return {"db": db, "rng": rng, "units": units, "effects": effects, "ctrl": ctrl, "spawn_ok": spawn_ok} @@ -219,6 +221,7 @@ func _test_source_death_void(db) -> void: # 单位字典键集复刻(剥离 handle_id:生产键集含句柄,`==` 误命的可达条件被破坏,夹具刻意不携句柄) func _unit_fixture() -> Dictionary: return { + "handle_id": 0, # 生成序轴(个体化批——个体键导出沿 handle_id;手搭夹具同契约) "enemy_id": "fixture_enemy", "display_name": "地精", "hp": 5, "max_hp": 5, "atk": 1, @@ -342,7 +345,7 @@ func _test_ac_merge(db) -> void: Kit.check("前置:状态表存在 hero 侧 ac 型行", false) return var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0} - h.effects.apply_effect(String(ac_eff.effect_id), src) + h.effects.apply_effect(String(ac_eff.effect_id), StatusContainerScript.OWNER_HERO, src) var expected: int = override_v + int(ac_eff.magnitude) Kit.check("ac 型效果叠层入 AC(层数×幅度,不入池)", h.ctrl.defender_ac(h.units.hero_unit()) == expected) @@ -356,7 +359,7 @@ func _test_pool_clamp(db) -> void: return 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) + Kit.check("空效果池读 0(表 clamp 域含 0 前提)", h.ctrl.clamped_pool("hero_offense", StatusContainerScript.OWNER_HERO) == 0 and lo <= 0 and hi >= 0) # 找 hero_offense 池的 percent 型 hero 侧效果行 var pool_eff := {} for row in h.db.combat.status_effects.rows: @@ -369,12 +372,12 @@ func _test_pool_clamp(db) -> void: Kit.check("前置:hero_offense 池存在 percent 型 hero 侧行", false) return var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0} - var first_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), src) + var first_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), StatusContainerScript.OWNER_HERO, src) var expected1: int = clampi(int(pool_eff.magnitude), lo, hi) - Kit.check("池值 = 单实例 L×m 加算后 clamp", first_ok and h.ctrl.clamped_pool("hero_offense") == expected1) - var second_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), src) + Kit.check("池值 = 单实例 L×m 加算后 clamp", first_ok and h.ctrl.clamped_pool("hero_offense", StatusContainerScript.OWNER_HERO) == expected1) + var second_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), StatusContainerScript.OWNER_HERO, src) Kit.check("累加政策重复授予受理且层数恒 1(同名重复授予加时长)——池值不变", - second_ok and h.ctrl.clamped_pool("hero_offense") == expected1) + second_ok and h.ctrl.clamped_pool("hero_offense", StatusContainerScript.OWNER_HERO) == expected1) # ac 型效果不入 percent 池 var ac_eff2 := {} for row2 in h.db.combat.status_effects.rows: @@ -382,9 +385,9 @@ func _test_pool_clamp(db) -> void: ac_eff2 = row2 break if not ac_eff2.is_empty(): - var before: int = h.ctrl.clamped_pool("hero_offense") - h.effects.apply_effect(String(ac_eff2.effect_id), src) - Kit.check("ac 型效果不入池(percent 池隔离)", h.ctrl.clamped_pool("hero_offense") == before) + var before: int = h.ctrl.clamped_pool("hero_offense", StatusContainerScript.OWNER_HERO) + h.effects.apply_effect(String(ac_eff2.effect_id), StatusContainerScript.OWNER_HERO, src) + Kit.check("ac 型效果不入池(percent 池隔离)", h.ctrl.clamped_pool("hero_offense", StatusContainerScript.OWNER_HERO) == before) # stack 政策效果:全新容器授予至满层 → 再授拒绝(满层拒绝原子——容器零写入) var stack_eff := {} for row3 in h.db.combat.status_effects.rows: @@ -399,6 +402,47 @@ func _test_pool_clamp(db) -> void: var granted := 0 var max_n: int = int(stack_eff.max_stack) for i in range(max_n + 1): - if effects2.apply_effect(String(stack_eff.effect_id), src): + if effects2.apply_effect(String(stack_eff.effect_id), StatusContainerScript.OWNER_HERO, src): granted += 1 Kit.check("stack 政策恰授予 max_stack 层后拒绝(满层拒绝原子)", granted == max_n) + + +# ---- 个体池读数 / 个体 AC 合流(增益状态 v1.5——敌方状态个体化批)---- + +func _test_pool_consumption_per_unit(db) -> void: + var h = _harness(db, 3) # depth 3 = hobgoblin 行集(unit_count 2-3——个体对照面需 ≥2 敌) + if not h.spawn_ok: + return + var enemies: Array = h.units.all_enemies() + Kit.check("V6 前置:行集 ≥2 敌(个体池对照面)", enemies.size() >= 2) + if enemies.size() < 2: + return + var wmag: int = int(db.combat.status_effects.row_by_id("weaken").magnitude) + var lo: int = int(db.combat.settlement.core.pool_clamp_lo) + var hi: int = int(db.combat.settlement.core.pool_clamp_hi) + var src := {"source_class": "seal_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0} + var k1: String = StatusContainerScript.owner_key_for(enemies[0]) + var k2: String = StatusContainerScript.owner_key_for(enemies[1]) + Kit.check("V6 前置:e1 受理削弱", h.effects.apply_effect("weaken", k1, src)) + Kit.check("B 敌的削弱不进 A 敌的账:e1 池含 weaken 值(clamp 域内)、e2 恒 0〔个体化批 V6〕", + h.ctrl.clamped_pool("enemy_output", k1) == clampi(wmag, lo, hi) and h.ctrl.clamped_pool("enemy_output", k2) == 0) + + +func _test_defender_ac_per_unit(db) -> void: + var h = _harness(db, 3) + if not h.spawn_ok: + return + var enemies: Array = h.units.all_enemies() + Kit.check("V7 前置:行集 ≥2 敌(个体 AC 对照面)", enemies.size() >= 2) + if enemies.size() < 2: + return + var amag: int = int(db.combat.status_effects.row_by_id("alert").magnitude) + var src := {"source_class": "mine_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0} + var u1: Dictionary = enemies[0] + var u2: Dictionary = enemies[1] + var k1: String = StatusContainerScript.owner_key_for(u1) + var base1: int = int(u1.get("armor_class", 0)) + var base2: int = int(u2.get("armor_class", 0)) + Kit.check("V7 前置:e1 受理警觉", h.effects.apply_effect("alert", k1, src)) + Kit.check("alert 各算各的:e1 AC = 自身基数 + 警觉幅度、e2 AC = 自身基数(该敌自己名下 ac 型)〔个体化批 V7〕", + h.ctrl.defender_ac(u1) == base1 + amag and h.ctrl.defender_ac(u2) == base2) diff --git a/game/tests/unit/battle_settlement/effect_port_test.gd b/game/tests/unit/battle_settlement/effect_port_test.gd index 85a0afbb..acb419c8 100644 --- a/game/tests/unit/battle_settlement/effect_port_test.gd +++ b/game/tests/unit/battle_settlement/effect_port_test.gd @@ -33,7 +33,7 @@ func _init() -> void: func _enc(feats: Array) -> Dictionary: return {"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp), - "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "feats": feats, "tree_rules": [], + "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "feats": feats, "tree_rules": {}, "battle_index": 1, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "boss", "adv_bucket": {}} @@ -113,7 +113,7 @@ func _test_biz_fact_face_closed() -> void: var ctrl = orch.fx_host().damage # 阳性对照(载荷携带事实——harness 能见命中):合成行独占订阅面(rebuild 持有 id 过滤—— # 只挂阳性行),board_flat_hero 真实单据流,base_value 足量避开伤害下限保底 - orch.fx_host().rebuild_subscriptions([pos_id], [], []) + orch.fx_host().rebuild_subscriptions([pos_id], [], {}) var hp0 := int(ctrl.units.hero_unit().get("hp", 0)) var t_a: Dictionary = ctrl.open_ticket(ctrl.KIND_BOARD_FLAT_HERO, "probe_port", "闭面探测甲", {"channel": "board", "base_value": 10, "target_rule": "hero", "ri": 1, "origin_cell": orch.minesweeper.hero_pos()}) @@ -121,7 +121,7 @@ func _test_biz_fact_face_closed() -> void: var hp1 := int(ctrl.units.hero_unit().get("hp", 0)) Kit.check("V3 阳性对照:hp_ratio 载荷 when 通过——承伤减量恰 1(10→9)", hp0 - hp1 == 9) # 阴性对照(闭面外事实——宿主链答而业务面不答):订阅面换代为阴性合成行独占,同路径零减量=F10 现状形态守卫 - orch.fx_host().rebuild_subscriptions([neg_id], [], []) + orch.fx_host().rebuild_subscriptions([neg_id], [], {}) var t_b: Dictionary = ctrl.open_ticket(ctrl.KIND_BOARD_FLAT_HERO, "probe_port", "闭面探测乙", {"channel": "board", "base_value": 10, "target_rule": "hero", "ri": 1, "origin_cell": orch.minesweeper.hero_pos()}) ctrl.flush_submit(t_b) diff --git a/game/tests/unit/battle_settlement/refresh_feats_test.gd b/game/tests/unit/battle_settlement/refresh_feats_test.gd index 2c8850c5..7b7932c9 100644 --- a/game/tests/unit/battle_settlement/refresh_feats_test.gd +++ b/game/tests/unit/battle_settlement/refresh_feats_test.gd @@ -48,7 +48,7 @@ func _init() -> void: func _install_dt_probe() -> void: if _db.crossrun.feats.row_by_id("probe_dt_counter").is_empty(): (_db.crossrun.feats.rows as Array).append({ - "feat_id": "probe_dt_counter", "display_name": "承伤计数探针", "rarity": "common", + "feat_id": "probe_dt_counter", "display_name": "承伤计数探针", "rarity": "common", "cap": 1, "desc": "测试夹具:每次主角承伤计数 +1", "triggers": [{"on": "damage_taken", "do": {"kind": "counter_add", "counter_id": "dt_probe_hits", "value": 1}}], }) @@ -62,7 +62,7 @@ func _orch(seed_v: int): func _enc(feats: Array, voucher_rap: int = 0, armor: int = 10, hero_hp: int = 200) -> Dictionary: return {"level_index": 1, "node_depth": 4, "hero_hp": hero_hp, "hero_atk": 8, "hero_max_hp": 200, - "hero_armor_class": armor, "hero_ap_bonus": 0, "attunement": [], "feats": feats, "tree_rules": [], + "hero_armor_class": armor, "hero_ap_bonus": 0, "attunement": [], "feats": feats, "tree_rules": {}, "battle_index": 1, "voucher_rap": voucher_rap, "voucher_time_sand": 0, "node_type": "boss", "adv_bucket": {}} @@ -361,7 +361,7 @@ func test_no_feats_baseline() -> void: func _install_br_probe() -> void: if _db.crossrun.feats.row_by_id("probe_br_counter").is_empty(): (_db.crossrun.feats.rows as Array).append({ - "feat_id": "probe_br_counter", "display_name": "刷新事实探针", "rarity": "common", + "feat_id": "probe_br_counter", "display_name": "刷新事实探针", "rarity": "common", "cap": 1, "desc": "测试夹具:每次成功刷新计数 +1", "triggers": [{"on": "board_refreshed", "do": {"kind": "counter_add", "counter_id": "br_probe_hits", "value": 1}}], }) diff --git a/game/tests/unit/battle_settlement/roll_multiplier_test.gd b/game/tests/unit/battle_settlement/roll_multiplier_test.gd index df8438c4..723f8129 100644 --- a/game/tests/unit/battle_settlement/roll_multiplier_test.gd +++ b/game/tests/unit/battle_settlement/roll_multiplier_test.gd @@ -231,24 +231,24 @@ func _weaken_accumulate_probe() -> Dictionary: 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) + settlement._status_effects.apply_effect("weaken", "e1", src) settlement._status_effects.decay_round() # remaining 2 → 1(先兑付后衰减——只借衰减器,不结算) - settlement._status_effects.apply_effect("weaken", src) # 累加:remaining 1+2=3,层数不动 + settlement._status_effects.apply_effect("weaken", "e1", src) # 累加:remaining 1+2=3,层数不动 var regrant := _weaken_inst(settlement) - settlement._status_effects.apply_effect("weaken", src, 3) # 覆盖时长按覆盖值累加:3+3=6 + settlement._status_effects.apply_effect("weaken", "e1", src, 3) # 覆盖时长按覆盖值累加:3+3=6 var override_grant := _weaken_inst(settlement) - settlement._status_effects.apply_effect("weaken", src) # 同轮再授:6+2=8 + settlement._status_effects.apply_effect("weaken", "e1", src) # 同轮再授:6+2=8 var extra := _weaken_inst(settlement) if regrant.is_empty() or override_grant.is_empty() or extra.is_empty(): return {} # 走伤害控制器公有算术面 clamped_pool - return {"layers": int(extra.layers), "pool": int(settlement._damage.clamped_pool("enemy_output")), + return {"layers": int(extra.layers), "pool": int(settlement._damage.clamped_pool("enemy_output", "e1")), "after_regrant": int(regrant.remaining_settlements), "after_override": int(override_grant.remaining_settlements), "after_extra": int(extra.remaining_settlements), "snap_magnitude": int(regrant.snap_magnitude)} func _weaken_inst(settlement: Object) -> Dictionary: - for eff in settlement._status_effects.query_active("enemy"): + for eff in settlement._status_effects.query_active("e1"): if String(eff.effect_id) == "weaken": return eff return {} @@ -269,7 +269,7 @@ func _ac11_tier_samples() -> Array: var guard := 12 while settlement.battle_result == "" and guard > 0: guard -= 1 - settlement._status_effects.apply_effect("ac_up", {"source_class": "safe_cell", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index}) + settlement._status_effects.apply_effect("ac_up", "hero", {"source_class": "safe_cell", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index}) settlement.minesweeper.settle_batch([{"action": "stop"}]) var tiers: Array = [] for row in rows: @@ -368,7 +368,10 @@ func _hero_attack_row_with(opts: Dictionary, hero_atk_val: int, seed_val: int) - 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}) + # 个体键读数(个体化批):按状态行 target_side 路由个体——主角行归主角桶、敌方行归首敌(被打者) + var erow: Dictionary = _db.combat.status_effects.row_by_id(String(eff)) + var okey: String = "e1" if String(erow.get("target_side", "")) == "enemy" else "hero" + settlement._status_effects.apply_effect(String(eff), okey, {"source_class": "mine_hit", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index}) settlement.minesweeper.settle_batch([{"action": "stop"}]) for row in rows: if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "hero_attack": 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 2542adac..c89a7401 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 @@ -39,6 +39,7 @@ func _init() -> void: _smoke_skill_pool() _smoke_counter_buckets() _smoke_extended_rows() + _smoke_enemy_buff_targets_self() quit(Kit.finish("round_flow_skill_pool_test")) @@ -420,7 +421,7 @@ func _smoke_settlement_segments() -> void: if decays != 1 or verdicts != 1: decay_verdict_ok = false # 衰减/判定每结算恰一次 Kit.check("每轮行相位单调(授予→增益/减益→战斗→衰减→判定)", order_ok) - # 案C 段内序:段内伤害序 = 主角先手 →〔连珠矢随先手段〕→ 敌方逐个 → 地图账殿后。 + # 案C 段内序:段内伤害序 = 主角先手 → 敌方逐个 → 地图账殿后。 # 地图账 = 全部棋盘来源伤害行 + 主角倒下不兑留痕行;违序(先于先手或插进敌方还手中间)即失败。 var mapdmg_ok := true for ri_key in by_round.keys(): @@ -439,13 +440,13 @@ func _smoke_settlement_segments() -> void: var rw: Dictionary = rows_r[i] var sc := String(rw.get("source_class", "")) var is_board_acct: bool = (String(rw.get("type", "")) == "damage" \ - and not ["hero_attack", "enemy_action", "fx_streak_segments"].has(sc)) \ + and not ["hero_attack", "enemy_action"].has(sc)) \ or String(rw.get("forfeit", "")) == "hero_down" if not is_board_acct: continue if (first_hero >= 0 and i < first_hero) or (last_enemy >= 0 and i < last_enemy): mapdmg_ok = false - Kit.check("案C 段内序:地图伤害账殿后(主角先手→敌方逐个→地图账;连珠矢随先手段)", mapdmg_ok) + Kit.check("案C 段内序:地图伤害账殿后(主角先手→敌方逐个→地图账)", mapdmg_ok) Kit.check("衰减/判定段每结算恰一段(先兑付后衰减——含末轮)", decay_verdict_ok) @@ -994,7 +995,7 @@ func _smoke_counter_buckets() -> void: var RngS = load("res://core/rng_service.gd") var enc1 = {"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, - "feats": ["twin_pick"], "tree_rules": [], "battle_index": 1, + "feats": ["twin_pick"], "tree_rules": {}, "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle", "adv_bucket": led} var o1 = RigS.new(_db, _fresh_rng(779301)) @@ -1013,7 +1014,7 @@ func _smoke_counter_buckets() -> void: # V6——battle 桶随宿主销毁:第1战标错落 insurance_used(battle 桶无前缀),次战新编排器读 0。 var encA = {"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, - "feats": ["perfect_insurance"], "tree_rules": [], "battle_index": 1, + "feats": ["perfect_insurance"], "tree_rules": {}, "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle", "adv_bucket": {}} var oA = RigS.new(_db, _fresh_rng(779401)) @@ -1052,7 +1053,7 @@ func _smoke_extended_rows() -> void: var RigS = load("res://core/battle_orchestrator.gd") # 局部装配别名(_smoke_counter_buckets 同款——跨函数不共享局部量) var enc_fx = {"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": ["geologist"], "tree_rules": [], "battle_index": 1, + "feats": ["geologist"], "tree_rules": {}, "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle", "adv_bucket": {}} # ② 兑付行:有未揭示安全格 → 掷格揭示成功 → 扩展行带真实格与 fx_reveal 身份 @@ -1086,3 +1087,50 @@ func _smoke_extended_rows() -> void: Kit.check("卡④ 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) + + +# ---- 敌方增益技自体落点(增益状态 v1.5 目标规则「自己」——敌方状态个体化批)---- +# 真实入口=战斗结算编排链(技能施放):begin_battle → stop 结算推进一轮(战斗段施放+轮末衰减, +# 蓄力表值 2 拍衰减后剩 1 拍仍在池)→ 逐敌读敌方输出池。判据=施放者与被他敌的池读数差。 + +func _smoke_enemy_buff_targets_self() -> void: + var emp_mag: int = int(_db.combat.status_effects.row_by_id("empower").magnitude) + var sc_script = load("res://core/status_container.gd") + var found_seed := -1 + var found_ok := false + var carrier_pool := -999 + for s in range(1, 401): + var rg = load("res://core/rng_service.gd").new() + rg.set_master_seed(s) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rg) + var rows: Array = [] + settlement.step_row.connect(func(row): rows.append(row)) + # L1 depth 3(hobgoblin 行集 2-3 只——depth 0 单 goblin 常被主角先手一击秒杀,死亡即不动手、cast 不发生) + if not settlement.begin_battle({"level_index": 1, "node_depth": 3, "hero_hp": int(_db.combat.hero.max_hp)}): + continue + for turn in range(4): + if settlement.battle_result != "": + break + settlement.minesweeper.settle_batch([{"action": "stop"}]) # 推进一轮:施放→衰减(蓄力仍持 1 拍) + if settlement.battle_result != "": + break + var charge_casts := 0 + for row in rows: + if String(row.get("type", "")) == "cast" and String(row.get("skill_id", "")) == "charge": + charge_casts += 1 + if charge_casts != 1: + continue # 只取恰一次蓄力施放的时刻——判据单义 + var carriers: Array = [] + for u in settlement._enemy_system.query_alive("enemy"): + var okey: String = sc_script.owner_key_for(u) + if settlement._damage.clamped_pool("enemy_output", okey) != 0: + carriers.append(okey) + if carriers.size() == 1: + found_seed = s + carrier_pool = settlement._damage.clamped_pool("enemy_output", String(carriers[0])) + found_ok = carrier_pool == emp_mag + break + if found_seed > 0: + break + Kit.check("蓄力施放种子可达(恰一次 cast=charge,seed=%d)" % found_seed, found_seed > 0) + Kit.check("敌方增益技自体落点:施放者 output 池 = 蓄力幅度(%d,实测 %d)、他敌恒 0〔个体化批 V5〕" % [emp_mag, carrier_pool], found_ok) diff --git a/game/tests/unit/battle_settlement/seal_polarity_test.gd b/game/tests/unit/battle_settlement/seal_polarity_test.gd index 862711b1..547f394a 100644 --- a/game/tests/unit/battle_settlement/seal_polarity_test.gd +++ b/game/tests/unit/battle_settlement/seal_polarity_test.gd @@ -748,7 +748,7 @@ func _smoke_negative_dictionary() -> void: var snap_b: Dictionary = settlement._reward_economy.query_full_snapshot(by_type["blast"]) settlement.reward_grant().grant_routed(snap_b, "negative", {"source_class": "mine_hit", "cell": by_type["blast"], "ri": 1}) var alert_ok: bool = false - for eff_a in settlement._status_effects.query_active("enemy"): + for eff_a in settlement._status_effects.query_active_side("enemy"): if String(eff_a.effect_id) == "alert": alert_ok = true Kit.check("blast 型负向 → 警觉(敌方护甲等级 +1 状态在位)", alert_ok) diff --git a/game/tests/unit/battle_settlement/trigger_cond_test.gd b/game/tests/unit/battle_settlement/trigger_cond_test.gd index 026b88d6..fc6eb2c7 100644 --- a/game/tests/unit/battle_settlement/trigger_cond_test.gd +++ b/game/tests/unit/battle_settlement/trigger_cond_test.gd @@ -113,7 +113,7 @@ func _init() -> void: var orch = RigS.new(db, _rng(700001)) Kit.check_quiet(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": [], - "feats": [], "tree_rules": [], "battle_index": 1, "voucher_rap": 0, "voucher_time_sand": 0, + "feats": [], "tree_rules": {}, "battle_index": 1, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle", "adv_bucket": {}}), "V6 前置:战斗受理") # facts 组包经真实摇号路径消费(mines 全零时条件技 sneak_ambush 不入集合——facts 四键在位的可观察面) Kit.check_quiet(int(orch._rounds.mines_hits["total"]) == 0 and int(orch._rounds.mines_hits["last"]) == 0 and int(orch._rounds.mines_hits["round"]) == 0, diff --git a/game/tests/unit/battle_settlement/variant_placement_test.gd b/game/tests/unit/battle_settlement/variant_placement_test.gd index 7ac34fce..415c86de 100644 --- a/game/tests/unit/battle_settlement/variant_placement_test.gd +++ b/game/tests/unit/battle_settlement/variant_placement_test.gd @@ -28,6 +28,7 @@ func _init() -> void: _smoke_variant_placement(1) _smoke_variant_placement(2) _smoke_global_variant() + _test_pack_min_from_table() quit(Kit.finish("variant_placement_test")) @@ -331,3 +332,36 @@ func _errs_has_cross_field(errs: Array) -> bool: # ——互锁负样本/门控三维/资源面偏斜守恒与合成序/快照投影不变)---- # 取号计数桩(variant_place 取号断言——子类拦截计数,派生与取数语义零改动) + + +# ---- 成包下界读表(架构整改——pack_min 表头单点)---- +# 观测面局限申报(实现期裁定,详见实现报告/对照表):pack_min 消费面=randi_range 下界(软目标—— +# 8 邻贴接禁令下生长早停为合法态〔board_installer 注释明示〕,候选池碎片化使「产物零 void: + # ① 静态:布置面零字面量下界(randi_range(2, 退役——下界读 pack_min 表头单点) + var src := FileAccess.get_file_as_string("res://core/board_installer.gd") + Kit.check("布置面零字面量下界(randi_range(2, 退役、下界读表单点在位)〔架构整改 V10·静态〕", + not src.contains("randi_range(2,") and src.contains("int(db.combat.variation.pack_min)")) + # ② 同种子改值产物互异(扫种子取首个差异局——真实入口 begin_battle 布置段) + var vt = _db.combat.variation + var saved_min: int = int(vt.pack_min) + var found_diff := false + for s in range(42, 102): + var sigs := {} + for pm in [3, 1]: + vt.pack_min = pm + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(s) + var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) + if not settlement.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)}): + continue + var b = settlement.minesweeper.current_board() + sigs[pm] = str(b.variants.keys()) + vt.pack_min = saved_min + if String(sigs.get(3, "")) != "" and String(sigs.get(3, "")) != String(sigs.get(1, "")): + found_diff = true + break + vt.pack_min = saved_min + Kit.check("同种子 pack_min=3 vs 1 布置产物互异(下界进 randi_range——改值可观测)〔架构整改 V10〕", found_diff) \ No newline at end of file diff --git a/game/tests/unit/contracts/system_contracts_test.gd b/game/tests/unit/contracts/system_contracts_test.gd index 2d172f7c..4c212e80 100644 --- a/game/tests/unit/contracts/system_contracts_test.gd +++ b/game/tests/unit/contracts/system_contracts_test.gd @@ -25,6 +25,14 @@ func _init() -> void: _test_rng_tag_closure(db) _test_ticket_nine_columns() _test_status_boolean_acceptance(db) + _test_status_individual_isolation(db) + _test_status_grant_all_per_unit(db) + _test_status_death_clears(db) + _test_status_merge_per_unit(db) + _test_status_decay_and_clear(db) + _test_status_stack_full_reject(db) + _test_status_independent_branch(db) + _test_status_snapshot_first_grant(db) quit(Kit.finish("system_contracts_test")) @@ -112,13 +120,174 @@ func _test_status_boolean_acceptance(db) -> void: return var probe_id: String = String(any_row.effect_id) var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0} - Kit.check("域闭授予拒绝(EC1——战斗域外 apply_effect = false)", effects.apply_effect(probe_id, src) == false) - Kit.check("未知 effect_id 拒绝(EC2)", effects.apply_effect("no_such_effect_id__", src) == false) + Kit.check("域闭授予拒绝(EC1——战斗域外 apply_effect = false)", effects.apply_effect(probe_id, StatusContainerScript.OWNER_HERO, src) == false) + Kit.check("未知 effect_id 拒绝(EC2)", effects.apply_effect("no_such_effect_id__", StatusContainerScript.OWNER_HERO, src) == false) effects.notify_battle_start() - Kit.check("开域后受理已知 id(布尔受理口径 true 通道)", effects.apply_effect(probe_id, src) == true) - var active: Array = effects.query_active(String(any_row.target_side)) + Kit.check("开域后受理已知 id(布尔受理口径 true 通道)", effects.apply_effect(probe_id, StatusContainerScript.OWNER_HERO, src) == true) + var active: Array = effects.query_active(StatusContainerScript.OWNER_HERO) var projected := false for a in active: if String(a.effect_id) == probe_id: projected = int(a.effective_value) == int(a.layers) * int(a.snap_magnitude) Kit.check("受理后投影 effective_value = 层数×授予时快照(抗热重载 EC9 面)", projected) + + +# ---- B4 个体归属契约(增益状态 v1.5——敌方状态个体化批):个体隔离/全体逐体发/死亡即清/同体合并 ---- +# 个体键直传字面量("e1"/"e2"/"e3"——拼写约定单点在 StatusContainer.owner_key_for,测试面模拟授予路径逐只授) + +func _test_status_individual_isolation(db) -> void: + var effects = StatusContainerScript.new(db) + effects.notify_battle_start() + var src := {"source_class": "seal_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0} + var wrow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id("weaken") + Kit.check("前置:weaken 行在册(累加策略——个体合并语义载体)", not wrow.is_empty() and String(wrow.stack_policy) == "accumulate") + Kit.check("个体隔离:A 敌受理", effects.apply_effect("weaken", "e1", src) == true) + Kit.check("个体隔离:B 敌受理(同名不同体互不合并)", effects.apply_effect("weaken", "e2", src) == true) + Kit.check("个体隔离:A/B 各持一条(各体桶独立)〔个体化批 V1〕", + effects.query_active("e1").size() == 1 and effects.query_active("e2").size() == 1) + effects.decay_round() # A/B 各自 −1——先验证互不干扰后验证独立倒数 + var a_after: Array = effects.query_active("e1") + var b_after: Array = effects.query_active("e2") + var a_rem: int = int(a_after[0].remaining_settlements) if a_after.size() == 1 else -1 + var b_rem: int = int(b_after[0].remaining_settlements) if b_after.size() == 1 else -1 + Kit.check("个体隔离:各体独立倒数(A=表值−1、B=表值−1 同步各减)〔个体化批 V1〕", + a_rem == int(wrow.duration_settlements) - 1 and b_rem == int(wrow.duration_settlements) - 1) + Kit.check("个体隔离:投影携带 owner_key(逐个体承载出户)〔个体化批 V1〕", + a_after.size() == 1 and String(a_after[0].owner_key) == "e1" and String(b_after[0].owner_key) == "e2") + + +func _test_status_grant_all_per_unit(db) -> void: + var effects = StatusContainerScript.new(db) + effects.notify_battle_start() + var src := {"source_class": "mine_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0} + var erow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id("enrage") + Kit.check("前置:enrage 行在册(叠加策略——逐体叠层载体)", not erow.is_empty() and String(erow.stack_policy) == "stack") + # 模拟授予路径全体规则(reward_grant._apply_percent_row 同构):循环三敌逐只各发一条 + var keys: Array[String] = ["e1", "e2", "e3"] + var granted := 0 + for k in keys: + if effects.apply_effect("enrage", k, src): + granted += 1 + Kit.check("全体授予逐体发:三敌各受理一条(对全体=逐只各发)〔个体化批 V2〕", granted == 3) + var e2_ok: bool = effects.apply_effect("enrage", "e2", src) # e2 再授一层——各体独立合并 + Kit.check("全体授予逐体发:同体叠加至 2 层、异体层数不动〔个体化批 V2〕", + e2_ok and int(effects.query_active("e2")[0].layers) == 2 + and int(effects.query_active("e1")[0].layers) == 1 and int(effects.query_active("e3")[0].layers) == 1) + effects.decay_round() + var all_decayed := true + for k in keys: + for eff in effects.query_active(k): + if int(eff.remaining_settlements) != int(erow.duration_settlements) - 1: + all_decayed = false + Kit.check("全体授予逐体发:各体同轮独立衰减 −1(逐桶各减)〔个体化批 V2〕", all_decayed) + + +func _test_status_death_clears(db) -> void: + var effects = StatusContainerScript.new(db) + effects.notify_battle_start() + var src := {"source_class": "seal_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0} + effects.apply_effect("weaken", "e1", src) + effects.apply_effect("weaken", "e2", src) + Kit.check("死亡即清:purge_owner 返回移除数(e1 名下 1 条)〔个体化批 V3〕", effects.purge_owner("e1") == 1) + Kit.check("死亡即清:e1 桶清零(重 purge 零移除)〔个体化批 V3〕", effects.query_active("e1").is_empty() and effects.purge_owner("e1") == 0) + Kit.check("死亡即清:e2 不受影响(不转移不并回)〔个体化批 V3〕", effects.query_active("e2").size() == 1) + var side_all: Array = effects.query_active_side("enemy") + Kit.check("死亡即清:侧汇总只含存活个体实例〔个体化批 V3〕", side_all.size() == 1 and String(side_all[0].owner_key) == "e2") + + +func _test_status_merge_per_unit(db) -> void: + var effects = StatusContainerScript.new(db) + effects.notify_battle_start() + var src := {"source_class": "enemy_action", "origin_cell": Vector2i(-1, -1), "round_index": 0} + var wrow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id("weaken") + var w_dur: int = int(wrow.duration_settlements) + effects.apply_effect("weaken", "e1", src) + effects.apply_effect("weaken", "e1", src) + Kit.check("同体合并照旧:累加行同体再授时长累加(%d+%d——仍单实例)〔个体化批 V4〕" % [w_dur, w_dur], + effects.query_active("e1").size() == 1 and int(effects.query_active("e1")[0].remaining_settlements) == w_dur * 2) + var erow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id("enrage") + var max_stack: int = int(erow.max_stack) + var granted_n := 0 + for i in range(max_stack + 1): + if effects.apply_effect("enrage", "e2", src): + granted_n += 1 + Kit.check("同体合并照旧:叠加行满层拒绝(恰授 max_stack=%d 层)〔个体化批 V4〕" % max_stack, granted_n == max_stack) + Kit.check("同体合并照旧:满层时异体仍受理(拒绝只在同体内原子)〔个体化批 V4〕", effects.apply_effect("enrage", "e3", src) == true) + + +# ---- B4 结算链契约(断言补齐批——①增益状态结算链):衰减逐值/战斗结束全清/满层拒绝/独立并存/首授快照 ---- + +func _test_status_decay_and_clear(db) -> void: + var effects = StatusContainerScript.new(db) + effects.notify_battle_start() + var src := {"source_class": "seal_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0} + var wrow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id("weaken") + var w_dur: int = int(wrow.duration_settlements) + Kit.check("V1 前置:weaken 时长 ≥2(两拍衰减观测面)", w_dur >= 2) + Kit.check("V1 前置:e1 受理", effects.apply_effect("weaken", "e1", src)) + Kit.check("衰减逐值:首次 decay 返回移除数 0(未归零)〔断言补齐批 V1〕", effects.decay_round() == 0) + Kit.check("衰减逐值:每结算恰 −1(remaining=%d−1)〔断言补齐批 V1〕" % (w_dur - 1), + effects.query_active("e1").size() == 1 and int(effects.query_active("e1")[0].remaining_settlements) == w_dur - 1) + Kit.check("到期移除:第二次 decay 返回移除数 1〔断言补齐批 V2〕", effects.decay_round() == 1) + Kit.check("到期移除:归零实例当轮销毁(投影零行)〔断言补齐批 V2〕", effects.query_active("e1").is_empty()) + effects.apply_effect("weaken", "e2", src) + effects.apply_effect("attack_up", StatusContainerScript.OWNER_HERO, src) + Kit.check("V2 前置:两桶各持实例", effects.query_active_side("enemy").size() == 1 and effects.query_active(StatusContainerScript.OWNER_HERO).size() == 1) + Kit.check("战斗结束全清:notify_battle_end 返回 true 且全实例清空(两桶零行)〔断言补齐批 V2〕", + effects.notify_battle_end() and effects.query_active_side("enemy").is_empty() and effects.query_active(StatusContainerScript.OWNER_HERO).is_empty()) + + +func _test_status_stack_full_reject(db) -> void: + var effects = StatusContainerScript.new(db) + effects.notify_battle_start() + var src := {"source_class": "seal_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0} + var erow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id("enrage") + var max_stack: int = int(erow.max_stack) + for i in range(max_stack): + effects.apply_effect("enrage", "e1", src) + Kit.check("V3 前置:满层(恰 %d 层)" % max_stack, + effects.query_active("e1").size() == 1 and int(effects.query_active("e1")[0].layers) == max_stack) + var before_layers: int = int(effects.query_active("e1")[0].layers) + var before_rem: int = int(effects.query_active("e1")[0].remaining_settlements) + Kit.check("满层拒绝:再授返 false〔断言补齐批 V3〕", effects.apply_effect("enrage", "e1", src) == false) + Kit.check("满层拒绝:层数与剩余零写入(容器逐字不变——原子拒绝)〔断言补齐批 V3〕", + int(effects.query_active("e1")[0].layers) == before_layers and int(effects.query_active("e1")[0].remaining_settlements) == before_rem) + + +func _test_status_independent_branch(db) -> void: + var effects = StatusContainerScript.new(db) + effects.notify_battle_start() + var probe := { + "effect_id": "probe_independent_v4", + "display_name": "并存试验", + "target_side": "enemy", + "magnitude": 7, + "unit": "flat", + "duration_settlements": 2, + "stack_policy": "independent", + "max_stack": 1, + } + db.combat.status_effects.rows.append(probe) # 直改已加载表(运行时注入试验行——并存分支零内容行,夹具面) + var src := {"source_class": "seal_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0} + Kit.check("independent 并存:第一次授予受理〔断言补齐批 V4〕", effects.apply_effect("probe_independent_v4", "e1", src)) + Kit.check("independent 并存:第二次授予受理(并存——无合并无满层语义)〔断言补齐批 V4〕", effects.apply_effect("probe_independent_v4", "e1", src)) + var both: Array = effects.query_active("e1") + Kit.check("independent 并存:两实例各持时长 2 独立倒数〔断言补齐批 V4〕", + both.size() == 2 and int(both[0].remaining_settlements) == 2 and int(both[1].remaining_settlements) == 2) + db.combat.status_effects.rows.pop_back() # 测毕还原(表行零残留) + Kit.check("independent 试验行测毕还原(row_by_id 零命中)", db.combat.status_effects.row_by_id("probe_independent_v4").is_empty()) + + +func _test_status_snapshot_first_grant(db) -> void: + var effects = StatusContainerScript.new(db) + effects.notify_battle_start() + var src_first := {"source_class": "seal_hit", "origin_cell": Vector2i(3, 4), "round_index": 1} + var src_second := {"source_class": "mine_hit", "origin_cell": Vector2i(8, 9), "round_index": 5} + effects.apply_effect("weaken", "e1", src_first) + effects.apply_effect("weaken", "e1", src_second) # 累加:时长加、归因与快照沿首授 + var inst: Array = effects.query_active("e1") + Kit.check("首授快照:累加合并仍单实例〔断言补齐批 V5〕", inst.size() == 1) + Kit.check("首授快照:origin_cell/round_index/source_class 沿首授(合并实例只持首授归因)〔断言补齐批 V5〕", + String(inst[0].source_class) == "seal_hit" and inst[0].origin_cell == Vector2i(3, 4) and int(inst[0].round_index) == 1) + Kit.check("首授快照:幅度快照 = 首授时表值(抗热重载域)〔断言补齐批 V5〕", + int(inst[0].snap_magnitude) == int(db.combat.status_effects.row_by_id("weaken").magnitude)) diff --git a/game/tests/unit/core/adventure_feats_test.gd b/game/tests/unit/core/adventure_feats_test.gd index e362e441..6437559d 100644 --- a/game/tests/unit/core/adventure_feats_test.gd +++ b/game/tests/unit/core/adventure_feats_test.gd @@ -589,7 +589,7 @@ func _smoke_feats() -> void: var sv_c = h.get("set", null) EffectEngine.counter_add(orch.fx_host().counters_ctx, String(h.get("counter_id", "")), int(h.get("value", 0)) if sv_c == null else int(sv_c), sv_c != null) # 开格系 - fx.rebuild(["twin_pick"], [], []) + fx.rebuild(["twin_pick"], [], {}) for i in 4: var hits_dp: Array = fx.dispatch(orch.fx_host().counters_ctx, "dig_paid", {"ap_cost": 1}) var refund := false @@ -601,7 +601,7 @@ func _smoke_feats() -> void: Kit.check("双管镐:第 %d 次付费挖格返 1 AP(每第 2 次——mod_eq 预评估口径)" % (i + 1), refund) else: Kit.check("双管镐反例:第 %d 次不返(未满 2 次)" % (i + 1), not refund) - fx.rebuild(["geologist"], [], []) + 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) @@ -611,7 +611,7 @@ func _smoke_feats() -> void: 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) 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()) - fx.rebuild(["hard_hat"], [], []) + 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", {})) Kit.check("安全帽:开安全格攒层 ×2〔专长批 V3〕", EffectEngine.counter(orch.fx_host().counters_ctx, "helm_layers") == 2) @@ -621,7 +621,7 @@ func _smoke_feats() -> void: if String(h.get("kind", "")) == "damage_reduce": hh_reduce = EffectEngine.counter(orch.fx_host().counters_ctx, String(h.get("value_counter", ""))) Kit.check("安全帽:踩雷耗层减伤=层值 2(一次清零随施加面 set 行)〔专长批 V3〕", hh_reduce == 2) - fx.rebuild(["skip_mining"], [], []) + 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) @@ -630,24 +630,24 @@ func _smoke_feats() -> void: 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()) - fx.rebuild(["blast_accounting"], [], []) + 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) - fx.rebuild(["frontier_miner"], [], []) + 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()) # 封印系 - fx.rebuild(["seal_echo"], [], []) + 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") - fx.rebuild(["seal_smith"], [], []) + fx.rebuild(["seal_smith"], [], {}) var sm_remove := 0 for i in 3: var sm_dispatch: Array = fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}) @@ -656,14 +656,14 @@ func _smoke_feats() -> void: if String(h.get("kind", "")) == "remove_mines": sm_remove += 1 Kit.check("封印匠:标中每第 3 次→中立移除恰一次(频率闸死)〔专长批 V3〕", sm_remove == 1) - fx.rebuild(["seal_stake"], [], []) + 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") - fx.rebuild(["warning_beacon"], [], []) + fx.rebuild(["warning_beacon"], [], {}) Kit.check("示警烟火:标中揭示 3×3 资源类型(不揭安危位置)〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "seal_hit", {}), "preview_area").size() == 1) # 踩雷/受伤系 - fx.rebuild(["pain_memory"], [], []) + fx.rebuild(["pain_memory"], [], {}) apply_fx.call(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {})) apply_fx.call(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {})) var pm_hits: Array = fx.dispatch(orch.fx_host().counters_ctx, "attack_commit", {"atk": 10, "target": "敌", "matchup": "", "dig_streak": 0, "nest_count": 0}) @@ -671,7 +671,7 @@ func _smoke_feats() -> void: for h in orch._hits_of(pm_hits, "attack_bonus"): pm_bonus = EffectEngine.counter(orch.fx_host().counters_ctx, String(h.get("value_counter", ""))) Kit.check("疼痛记忆:攻击附加层值伤害=层值 2·用后清零(set 行随施加面)〔专长批 V3〕", pm_bonus == 2) - fx.rebuild(["fuse_wire"], [], []) + fx.rebuild(["fuse_wire"], [], {}) var fw_hits: Array = fx.dispatch(orch.fx_host().counters_ctx, "damage_taken", {"kind": "terrain", "amount": 6}) var fw_shield := 0 for h in fw_hits: @@ -682,43 +682,43 @@ func _smoke_feats() -> void: 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"], [], []) + fx.rebuild(["blood_insight"], [], {}) Kit.check("见血开窍:踩雷揭示 1 格〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {}), "reveal_tiles").size() == 1) - fx.rebuild(["trap_instinct"], [], []) + 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}) var ti_val := 0 for h in orch._hits_of(ti_hits, "attack_bonus"): ti_val = EffectEngine.counter(orch.fx_host().counters_ctx, String(h.get("value_counter", ""))) Kit.check("陷阱本能:踩雷本轮攻击 +2(attack_bonus 通道——面板加算)〔专长批 V3〕", ti_val == 2) - fx.rebuild(["chain_traps", "pain_memory"], [], []) + fx.rebuild(["chain_traps", "pain_memory"], [], {}) apply_fx.call(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {})) var ct_before: int = EffectEngine.counter(orch.fx_host().counters_ctx, "pain_layers") apply_fx.call(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {})) Kit.check("连环陷阱:同轮第 2 颗起每颗额外 +1 层(疼痛记忆同账)〔专长批 V3〕", EffectEngine.counter(orch.fx_host().counters_ctx, "pain_layers") == ct_before + 2) - fx.rebuild(["trap_burst"], [], []) + 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) - fx.rebuild(["blood_honey"], [], []) + fx.rebuild(["blood_honey"], [], {}) Kit.check("血蜜:踩雷回复 N 生命〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "mine_hit", {}), "heal").size() == 1) - fx.rebuild(["stone_skin"], [], []) + 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) - fx.rebuild(["last_stand"], [], []) + 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()) # 跨族桥 - fx.rebuild(["breaker_strike"], [], []) + 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()) # 节奏系 - fx.rebuild(["map_exchange"], [], []) + 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 载荷取值)——冒烟手动落账 @@ -728,41 +728,41 @@ func _smoke_feats() -> void: 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()) - fx.rebuild(["last_drop"], [], []) + 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()) - fx.rebuild(["market_close"], [], []) + 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) # 战斗系 - 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) - fx.rebuild(["executioner"], [], []) + fx.rebuild(["execution_scholar"], [], {}) + Kit.check("斩首学者:击杀揭示 1 格〔专长批 V3〕", _hits_kind(fx.dispatch(orch.fx_host().counters_ctx, "kill", {"target": "敌"}), "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()) - fx.rebuild(["field_dressing"], [], []) + 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) # 契约尖点 - fx.rebuild(["blood_tax"], [], []) + 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") # 迁入 2 件 - fx.rebuild(["venom_edge"], [], []) + 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()) - fx.rebuild(["seal_burst"], [], []) + fx.rebuild(["seal_burst"], [], {}) Kit.check("封印爆裂(迁入):标中全体敌群伤〔专长批 V3〕", _hits_kind(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([], [], []) + orch0.fx_host().rebuild_subscriptions([], [], {}) Kit.check("零持有订阅=零命中(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() diff --git a/game/tests/unit/core/auto_player_deduce_test.gd b/game/tests/unit/core/auto_player_deduce_test.gd new file mode 100644 index 00000000..380e7865 --- /dev/null +++ b/game/tests/unit/core/auto_player_deduce_test.gd @@ -0,0 +1,70 @@ +# tests/unit/core/auto_player_deduce_test.gd — 模拟玩家 0 格连锁算子〔架构整改 V15〕 +# 断言面 = AutoPlayer._deduce 补齐后:safe 集含「与所有已开格 8 邻不接」的远端格(该形态格 +# single/subset 约束不可达——唯一来源=0 格连锁经 preview_reveal 真实闭包);零盲挖语义回归 +# (mines 集零安全格)。真实入口=AutoPlayer._deduce(真板真流——begin_battle 装配链)。 +# 运行:godot --headless --path game --script res://tests/unit/core/auto_player_deduce_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const AutoPlayer := preload("res://sim/auto_player.gd") +const BattleDriver := preload("res://sim/battle_driver.gd") + + +func _init() -> void: + _smoke_zero_chain_rule() + quit(Kit.finish("auto_player_deduce_test")) + + +func _smoke_zero_chain_rule() -> void: + var db = load("res://core/config_db.gd").new() + var load_ok: bool = db.load_tables("res://data/tables", true) and db.diagnostics_count("error") == 0 + Kit.check("前置:表加载零 error", load_ok) + if not load_ok: + return + var found := false + var found_seed := -1 + var remote_ok := false + var mines_clean := false + for s in range(42, 142): + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(s) + var settlement = load("res://core/battle_orchestrator.gd").new(db, rng) + if not settlement.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp)}): + continue + var ms = settlement.minesweeper + BattleDriver.drive_round(settlement) # 真实挖一轮(0 连锁实挖触发——信息面展开) + if ms.current_board() == null: + continue + var dd: Dictionary = AutoPlayer._deduce(ms) + var safe: Array = dd["safe"] + var mines: Array = dd["mines"] + # 远端格:safe 中与所有已开格 8 邻距离 >1(single/subset 约束只覆盖已开数字格邻域—— + # 此形态格唯一来源=0 格连锁算子) + var revealed: Array = ms.revealed_cells() + var remote := false + for rc_v in safe: + var rc: Vector2i = rc_v + var touches := false + for rv in revealed: + var d: Vector2i = rc - rv + if maxi(absi(d.x), absi(d.y)) <= 1: + touches = true + break + if not touches: + remote = true + break + # 零盲挖:mines 集全落真雷(板面雷集) + var board = ms.current_board() + var clean := true + for mv in mines: + if not board.is_mine(mv): + clean = false + if remote and clean: + found = true + found_seed = s + remote_ok = true + mines_clean = true + break + Kit.check("0 格连锁种子可达(远端 safe 形态在案,seed=%d)〔架构整改 V15〕" % found_seed, found) + Kit.check("远端格入 safe 集(桥接 0 区域传播——改前约束模型必缺)〔架构整改 V15〕", remote_ok) + Kit.check("零盲挖语义保持(mines 集零安全格)〔架构整改 V15〕", mines_clean) diff --git a/game/tests/unit/core/auto_player_deduce_test.gd.uid b/game/tests/unit/core/auto_player_deduce_test.gd.uid new file mode 100644 index 00000000..d7c3b2c4 --- /dev/null +++ b/game/tests/unit/core/auto_player_deduce_test.gd.uid @@ -0,0 +1 @@ +uid://cggeuptv8skhl diff --git a/game/tests/unit/core/battle_driver_drive_test.gd b/game/tests/unit/core/battle_driver_drive_test.gd new file mode 100644 index 00000000..43ba5e85 --- /dev/null +++ b/game/tests/unit/core/battle_driver_drive_test.gd @@ -0,0 +1,150 @@ +# tests/unit/core/battle_driver_drive_test.gd — drive_round 驱动循环契约〔架构整改 V13〕 +# 断言面 = BattleDriver.drive_round 通用签名四形态: +# ① 缺省两参(真编排器同种子两跑逐位——确定性自证;四调用方零改动回归由全套承担); +# ② provider 形态(桩 settlement——策略回调产出 dig/seal_place/refresh 各一、日志回调收四元组); +# ③ max_rejects(策略恒返被拒动作——第 3 拒后停手出环); +# ④ 合步 next(move 后相位变更即断弃 dig)。 +# 桩形态沿 seal_polarity_test 状态袋先例(手搭 settlement——引用步骤不复制代码)。 +# 运行:godot --headless --path game --script res://tests/unit/core/battle_driver_drive_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const BattleDriver := preload("res://sim/battle_driver.gd") + +const Vector2iArray := [] + + +class _StubEcon: + var phase := "Active" + + +class _StubMS: + var log: Array = [] + var accept := true + var reject_cells := {} + var on_submit: Callable = Callable() # 提交后钩子(④ 相位变更注入) + + func settle_batch(actions: Array) -> Array: + var out: Array = [] + for a in actions: + var cell: Vector2i = a.get("cell", Vector2i(-1, -1)) + var ok := accept and not reject_cells.has(cell) + log.append({"action": String(a.get("action", "")), "cell": cell, "ok": ok}) + out.append({"accepted": ok, "outcome": {}, "reject_reason": "" if ok else "stub_reject"}) + if on_submit.is_valid(): + on_submit.call(String(a.get("action", ""))) + return out + + +class _StubSettlement: + var round_index := 1 + var battle_result := "" + var action_economy := _StubEcon.new() + var minesweeper := _StubMS.new() + var refresh_calls := 0 + + func player_request_refresh() -> bool: + refresh_calls += 1 + return true + + +func _init() -> void: + _smoke_drive_round_contract() + quit(Kit.finish("battle_driver_drive_test")) + + +# ① 缺省两参(真编排器):同种子两跑整轮驱动逐位(确定性自证——动作序列快照经 cell_revealed 事件流) +func _smoke_drive_round_contract() -> void: + var db = load("res://core/config_db.gd").new() + var load_ok: bool = db.load_tables("res://data/tables", true) and db.diagnostics_count("error") == 0 + Kit.check("前置:表加载零 error", load_ok) + if not load_ok: + return + var sigs: Array = [] + for run in range(2): + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(777) + var settlement = load("res://core/battle_orchestrator.gd").new(db, rng) + if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp)}): + Kit.check("前置:开局受理(run %d)" % run, false) + return + var moves: Array = [] + settlement.minesweeper.cell_revealed.connect(func(evt): moves.append(str(evt.get("cell", "")) + ":" + String(evt.get("kind", "")))) + BattleDriver.drive_round(settlement) + sigs.append(str(moves)) + Kit.check("缺省两参:真编排器同种子两跑事件流逐位(驱动语义确定性自证)〔架构整改 V13〕", sigs[0] == sigs[1] and String(sigs[0]) != "") + _provider_shapes() + _max_rejects() + _combined_step_next() + + +# ② provider 形态:策略回调产出 dig/seal_place/refresh 各一——提交与日志回调四元组在案 +func _provider_shapes() -> void: + var st := _StubSettlement.new() + var script := [ {"kind": "dig", "cell": Vector2i(1, 1)}, {"kind": "seal", "cell": Vector2i(2, 2)}, {"kind": "refresh"}, {"kind": "stop"} ] + var logs: Array = [] + var provider := func(_s) -> Dictionary: + return script.pop_front() if script.size() > 0 else {"kind": "stop"} + var logger := func(kind: String, cell: Vector2i, ok: bool, ri: int) -> void: + logs.append({"kind": kind, "cell": cell, "ok": ok, "ri": ri}) + BattleDriver.drive_round(st, Callable(), provider, logger, 0) + Kit.check("provider 形态:dig 提交在案(桩日志 action=dig cell=(1,1))〔架构整改 V13〕", + logs.size() >= 1 and String(logs[0]["kind"]) == "dig" and logs[0]["cell"] == Vector2i(1, 1) and bool(logs[0]["ok"])) + Kit.check("provider 形态:seal 提交动作名 seal_place(提交分派换名)〔架构整改 V13〕", + logs.size() >= 2 and String(logs[1]["kind"]) == "seal_place" and logs[1]["cell"] == Vector2i(2, 2)) + Kit.check("provider 形态:refresh 经 player_request_refresh(计数面外——桩 refresh_calls=1)+回调四元组 round_index 同轮〔架构整改 V13〕", + st.refresh_calls == 1 and logs.size() >= 3 and String(logs[2]["kind"]) == "refresh" and int(logs[2]["ri"]) == 1) + Kit.check("provider 形态:轮末 stop 统一回调(submit_kind=stop)〔架构整改 V13〕", + logs.size() >= 4 and String(logs[logs.size() - 1]["kind"]) == "stop") + + +# ③ max_rejects:策略恒返被拒动作——第 3 拒后停手出环(循环次数=3) +func _max_rejects() -> void: + var st := _StubSettlement.new() + st.minesweeper.accept = false + st.minesweeper.reject_cells = {Vector2i(9, 9): true} + var provider := func(_s) -> Dictionary: + return {"kind": "dig", "cell": Vector2i(9, 9)} + var logs: Array = [] + var logger := func(kind: String, _cell: Vector2i, _ok: bool, _ri: int) -> void: + logs.append(kind) + BattleDriver.drive_round(st, Callable(), provider, logger, 3) + var dig_rejects := 0 + for k in logs: + if String(k) == "dig": + dig_rejects += 1 + Kit.check("max_rejects=3:第 3 拒后停手出环(被拒 dig 日志恰 3 行——轮末 stop 另行在案)〔架构整改 V13〕", dig_rejects == 3) + Kit.check("max_rejects=3:轮末 stop 回调在案〔架构整改 V13〕", logs.size() >= 1 and String(logs[logs.size() - 1]) == "stop") + + +# ④ 合步 next:move 后相位变更即断弃 dig(桩 on_submit 钩子把 phase 推离 Active) +func _combined_step_next() -> void: + var st := _StubSettlement.new() + st.minesweeper.on_submit = func(action: String) -> void: + if action == "move": + st.action_economy.phase = "Settling" # 移位触发结算相位变更 + var provider := func(_s) -> Dictionary: + return {"kind": "move", "cell": Vector2i(3, 3), "next": {"kind": "dig", "cell": Vector2i(3, 4)}} + BattleDriver.drive_round(st, Callable(), provider, Callable(), 0) + var dig_count := 0 + for entry in st.minesweeper.log: + if String(entry["action"]) == "dig": + dig_count += 1 + Kit.check("合步 next:move 后相位变更断弃 dig(提交日志零 dig——与原内联逐位同)〔架构整改 V13〕", dig_count == 0) + # 对照:相位不变时合步 dig 照常提交 + var st2 := _StubSettlement.new() + var fired := {"once": false} + var provider2 := func(_s) -> Dictionary: + if bool(fired["once"]): + return {"kind": "stop"} + fired["once"] = true + return {"kind": "move", "cell": Vector2i(3, 3), "next": {"kind": "dig", "cell": Vector2i(3, 4)}} + BattleDriver.drive_round(st2, Callable(), provider2, Callable(), 0) + var dig2 := 0 + var move2 := 0 + for entry in st2.minesweeper.log: + if String(entry["action"]) == "dig": + dig2 += 1 + if String(entry["action"]) == "move": + move2 += 1 + Kit.check("合步 next:相位不变时 dig 照常提交(move+dig 两行)〔架构整改 V13〕", dig2 == 1 and move2 == 1) diff --git a/game/tests/unit/core/battle_driver_drive_test.gd.uid b/game/tests/unit/core/battle_driver_drive_test.gd.uid new file mode 100644 index 00000000..f02efce4 --- /dev/null +++ b/game/tests/unit/core/battle_driver_drive_test.gd.uid @@ -0,0 +1 @@ +uid://dj6k6mmyk26pc diff --git a/game/tests/unit/core/board_gen_reward_test.gd b/game/tests/unit/core/board_gen_reward_test.gd index 441314fc..e7dd0c4f 100644 --- a/game/tests/unit/core/board_gen_reward_test.gd +++ b/game/tests/unit/core/board_gen_reward_test.gd @@ -130,6 +130,7 @@ func _smoke_reward_identity_unit() -> void: class _BoardRowStub: class _StubBoardGen: var row: Dictionary + var spawn_edge: int = 3 # 表头成员(generate 直索引面——现行边长 3) func resolve_row(_L: int, _D: int) -> Dictionary: return row class _StubCombat: @@ -200,22 +201,23 @@ func _smoke_board_generator() -> void: # ③ ring_removed 量级:≤ 域内候选危险格数(5×5−出生 9=16——无全域剃雷); # ④ 闸压线行为:cap=9(出生保障面下限)时出图必 r0≤9 或 accept_worst 非法标记(三分出口诚实降级,玩家永不卡死) var cap_val: int = int(row.get("r0_max_cap", 0)) - Kit.check("L%d r0_max_cap 正式行投值在位(%d ≥ 出生保障面 9——板面 30%% 锚)" % [L, cap_val], - cap_val >= BoardData.SPAWN_AREA_CELLS) + var spawn_cells_n: int = int(_db.combat.board_gen.spawn_edge) * int(_db.combat.board_gen.spawn_edge) + Kit.check("L%d r0_max_cap 正式行投值在位(%d ≥ 出生保障面 %d——板面 30%% 锚)" % [L, cap_val, spawn_cells_n], + cap_val >= spawn_cells_n) Kit.check("L%d 结构断言 R₀=%d ≤ cap=%d(R₀≤cap 重掷闸承载)" % [L, int(b1.metrics.r0_count), cap_val], int(b1.metrics.r0_count) <= cap_val) Kit.check("L%d r0_cap_rejected 尝试事实指标在位(本图 %d 次触发)" % [L, int(b1.metrics.get("r0_cap_rejected", -1))], int(b1.metrics.get("r0_cap_rejected", -1)) >= 0) - Kit.check("L%d ring_removed=%d ≤ 剃雷域候选危险格数((3+2r)²−9——全域剃雷退役)" % [L, int(b1.metrics.get("ring_removed", 99))], - int(b1.metrics.get("ring_removed", 99)) <= (2 + int(row.get("shave_ring_radius", 1))) * (2 + int(row.get("shave_ring_radius", 1))) * 4 - 5) + Kit.check("L%d ring_removed=%d ≤ 剃雷域候选危险格数((edge+2r)²−edge²——全域剃雷退役)" % [L, int(b1.metrics.get("ring_removed", 99))], + int(b1.metrics.get("ring_removed", 99)) <= (int(_db.combat.board_gen.spawn_edge) + 2 * int(row.get("shave_ring_radius", 1))) * (int(_db.combat.board_gen.spawn_edge) + 2 * int(row.get("shave_ring_radius", 1))) - int(_db.combat.board_gen.spawn_edge) * int(_db.combat.board_gen.spawn_edge)) var row9: Dictionary = row.duplicate() - row9["r0_max_cap"] = BoardData.SPAWN_AREA_CELLS + row9["r0_max_cap"] = int(_db.combat.board_gen.spawn_edge) * int(_db.combat.board_gen.spawn_edge) # cap 压线=出生保障面(spawn_edge² 推导——语义 9 不减) row9["max_gen_attempts"] = 4 var stub9 := _BoardRowStub.new() 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)) + Kit.check("L1 cap 压线 9:出图 R₀=%d %s(三分出口诚实降级——超阈即 accept_worst 非法标记,玩家永不卡死)" % [int(b9.metrics.r0_count), "≤9" if int(b9.metrics.r0_count) <= spawn_cells_n else ">9→illegal_product"], + b9 != null and (int(b9.metrics.r0_count) <= spawn_cells_n or b9.illegal_product)) Kit.check("L1 cap 压线出图指标集完整(accept_mode=%s / 闸触发 %d 次)" % [String(b9.metrics.get("accept_mode", "")), int(b9.metrics.get("r0_cap_rejected", 0))], int(b9.metrics.get("r0_cap_rejected", 0)) >= 0 and String(b9.metrics.get("accept_mode", "")) != "") diff --git a/game/tests/unit/core/board_generator_test.gd b/game/tests/unit/core/board_generator_test.gd index e4340644..4acd338d 100644 --- a/game/tests/unit/core/board_generator_test.gd +++ b/game/tests/unit/core/board_generator_test.gd @@ -6,6 +6,7 @@ extends SceneTree const Kit := preload("res://tests/unit/test_kit.gd") const BoardGeneratorScript := preload("res://core/board_generator.gd") +const BoardDataScript := preload("res://core/board_data.gd") const RngServiceScript := preload("res://core/rng_service.gd") var _db: ConfigDb @@ -19,6 +20,7 @@ func _init() -> void: return _test_generate_shape_and_mines() _test_target_mine_count_single_source() + _test_spawn_edge_from_table() _test_digits_equal_neighbor_mine_count() _test_same_seed_bit_identical() _test_clearable_and_inferability_selfconsistent() @@ -66,9 +68,11 @@ func _test_target_mine_count_single_source() -> void: var params := { "w": int(row.board_width), "h": int(row.board_height), "density": float(row.mine_density), + "spawn_edge": int(_db.combat.board_gen.spawn_edge), } var from_fn: int = BoardGeneratorScript.target_mine_count(params) - var from_expr: int = int(round(float(params.density) * float(params.w * params.h - BoardData.SPAWN_AREA_CELLS))) + var edge: int = int(params.spawn_edge) + var from_expr: int = int(round(float(params.density) * float(params.w * params.h - edge * edge))) checked += 1 if from_fn != from_expr: bad += 1 @@ -80,7 +84,8 @@ func _test_target_mine_count_single_source() -> void: 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)})) + "w": int(row1.board_width), "h": int(row1.board_height), "density": float(row1.mine_density), + "spawn_edge": int(_db.combat.board_gen.spawn_edge)})) func _test_digits_equal_neighbor_mine_count() -> void: @@ -124,3 +129,36 @@ func _test_clearable_and_inferability_selfconsistent() -> void: var safe_total := b.width * b.height - b.mines.size() Kit.check("清图判定与安全格总数同在(安全格 %d 个,可清=%s)" % [safe_total, str(clearable)], safe_total > 0) + + +# ---- V8 出生区边长表单点(架构整改——spawn_edge 表头驱动布雷域基/几何/护栏全导出)---- + +func _test_spawn_edge_from_table() -> void: + # ① 纯函数对拍:edge=5 夹具 → target = round(d×(W×H−25))(25 格布雷域基可观测) + var p5 := {"w": 12, "h": 12, "density": 0.15, "spawn_edge": 5} + Kit.check("V8 纯函数:edge=5 布雷域基 25 格(target=round(d×(144−25)))〔架构整改 V8〕", + BoardGeneratorScript.target_mine_count(p5) == int(round(0.15 * (144 - 25)))) + # ② BoardData 几何:spawn_cells() 按 spawn_edge 导出(5→25 / 3→9) + var b5 = BoardDataScript.new() + b5.width = 12 + b5.height = 12 + b5.spawn_center = Vector2i(5, 5) + b5.spawn_edge = 5 + Kit.check("V8 几何:edge=5 → spawn_cells 25 格(in_bounds 过滤前满方)〔架构整改 V8〕", b5.spawn_cells().size() == 25) + var b3 = BoardDataScript.new() + b3.width = 12 + b3.height = 12 + b3.spawn_center = Vector2i(5, 5) + b3.spawn_edge = 3 + Kit.check("V8 几何:edge=3 → spawn_cells 9 格(与退役常量同值——逐位回归)〔架构整改 V8〕", b3.spawn_cells().size() == 9) + # ③ 真路径回归:真表头 spawn_edge=3 驱动出图(spawn_cells 9 格——生成器注入面) + var b := _generate(1, 0, 74000) + Kit.check("V8 真路径:真表头 edge=3 出图 spawn_cells 9 格〔架构整改 V8〕", b != null and b.spawn_cells().size() == 9) + # ④ 表头域校验:edge=5 时 r0_max_cap=9 低于出生保障面 25 → validate 拒(护栏读表头) + var tbl = load("res://data/schemas/board_gen_table.gd").new() + tbl.spawn_edge = 5 + var probe_row := {"level_index": 1, "node_depth": 0, "board_width": 12, "board_height": 12, "mine_density": 0.15, + "inferability_target": 0.95, "solver_rules": "single_subset", "min_safe_count": 3, "max_gen_attempts": 8, + "board_type": "dungeon", "shave_ring_radius": 1, "r0_max_cap": 9} + tbl.rows.append(probe_row) # typed Array[Dictionary]——append 面 + Kit.check("V8 护栏:edge=5 表头下 r0_cap=9 低于出生保障面 25 被拒〔架构整改 V8〕", not tbl.validate().is_empty()) diff --git a/game/tests/unit/core/effect_words_t1_test.gd b/game/tests/unit/core/effect_words_t1_test.gd index 12a17964..0b1a196b 100644 --- a/game/tests/unit/core/effect_words_t1_test.gd +++ b/game/tests/unit/core/effect_words_t1_test.gd @@ -59,7 +59,7 @@ func _init() -> void: var fx = EffectEngineScript.new(null) var ctx_f := EffectContext.new() var hits: Array = [] - fx.rebuild([], [], []) + fx.rebuild([], [], {}) fx._push_trigger("test", "v1_row", {"on": "dig_stop", "when": [{"fact": "ap_left", "op": "gte", "value": 3}], "do": {"kind": "counter_add", "counter_id": "stop_swap", "value_event": "ap_left"}}, {}) @@ -73,7 +73,7 @@ func _init() -> void: # ⑤ mod_eq 算子(双管镐落法——预评估口径每第 2 次判定;battle 桶无前缀) var fx2 = EffectEngineScript.new(null) var ctx2 := EffectContext.new() - fx2.rebuild([], [], []) + fx2.rebuild([], [], {}) fx2._push_trigger("test", "mod_row", {"on": "dig_paid", "when": [{"fact": "counter", "id": "dbl_digs", "op": "mod_eq", "value": 2, "ref": 1}], "do": {"kind": "grant_ap", "value": 1}}, {}) @@ -108,7 +108,7 @@ func _init() -> void: # ⑦ V4——前缀归桶清零/消耗语义(round_ 桶随 round_start 整桶清空;battle 无前缀计数跨 round_start 存活) var fx5 = EffectEngineScript.new(null) var ctx5 := EffectContext.new() - fx5.rebuild([], [], []) + fx5.rebuild([], [], {}) fx5._push_trigger("test", "round_row", {"on": "mine_hit", "do": {"kind": "counter_add", "counter_id": "round_mines", "value": 1}}, {}) for h in fx5.dispatch(ctx5, "mine_hit", {}): @@ -126,7 +126,7 @@ func _init() -> void: # battle 桶对照:无前缀 helm_layers 不受 round_start 影响(跨 round_start 存活) var fx6 = EffectEngineScript.new(null) var ctx6 := EffectContext.new() - fx6.rebuild([], [], []) + fx6.rebuild([], [], {}) fx6._push_trigger("test", "battle_row", {"on": "mine_hit", "do": {"kind": "counter_add", "counter_id": "helm_layers", "value": 1}}, {}) for h in fx6.dispatch(ctx6, "mine_hit", {}): diff --git a/game/tests/unit/core/feat_vouchers_test.gd b/game/tests/unit/core/feat_vouchers_test.gd index c17b8881..04839ab6 100644 --- a/game/tests/unit/core/feat_vouchers_test.gd +++ b/game/tests/unit/core/feat_vouchers_test.gd @@ -165,7 +165,7 @@ func _init() -> void: var orch = RigScript.new(db, rig_rng) Kit.check_quiet(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": ["perfect_insurance"], "tree_rules": ["sense_guard"], "battle_index": 1, + "feats": ["perfect_insurance"], "tree_rules": {"sense_guard": {"ap_refund": 1, "cap_per_level": 2}}, "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}), "保险 rig:战斗启动(feats=perfect_insurance 与 tree_rules=sense_guard 双订阅 seal_missed)〔卡② R2〕") @@ -215,7 +215,7 @@ func _init() -> void: var orch2 = RigScript.new(db, rig_rng2) Kit.check_quiet(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": ["perfect_insurance"], "tree_rules": [], "battle_index": 2, + "feats": ["perfect_insurance"], "tree_rules": {}, "battle_index": 2, "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〕") @@ -240,7 +240,7 @@ func _init() -> void: o10.step_row.connect(func(row): rows10.append(row)) Kit.check_quiet(o10.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, + "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"}), "A10 rig:无保险战斗启动〔卡① N7〕") @@ -267,7 +267,7 @@ func _init() -> void: o11.step_row.connect(func(row): rows11.append(row)) Kit.check_quiet(o11.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": ["perfect_insurance"], "tree_rules": [], "battle_index": 1, + "feats": ["perfect_insurance"], "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"}), "A11 rig:带保险战斗启动〔卡① N7〕") diff --git a/game/tests/unit/core/level_shop_economy_test.gd b/game/tests/unit/core/level_shop_economy_test.gd index 45069651..2fcac170 100644 --- a/game/tests/unit/core/level_shop_economy_test.gd +++ b/game/tests/unit/core/level_shop_economy_test.gd @@ -251,6 +251,8 @@ func _init() -> void: _smoke_shop_graph() _smoke_multi_terminal_settlement() _smoke_shop_pool() + _test_shelf_size_from_table() + _test_spend_discount_from_rule_key() _test_shop_node_entered_payload() _smoke_generated_coverage() quit(Kit.finish("level_shop_economy_test")) @@ -261,7 +263,6 @@ func _init() -> void: func _smoke_table_migration() -> void: print("--- 数值入表(shop 两 knob + hero 显示名 · 原值迁移)---") 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) @@ -1554,3 +1555,42 @@ func _has_err_category(errs: Array, category: String) -> bool: # ⑦ 实装探针(妨害技蛛网束缚 + 毒爆必中附加分量,实装面 = L2 Boss 卓尔): # 停手轮驱动 (2,9)——零挖掘零封印 → 零池,伤害行可逐拍精确复算;种子 hunt 至少各命一粒毒爆伤害行 # 与蛛网束缚施放行;毒分量随开战掷定的攻击基线缩放(atk 区间 2–4 → rider 1–3)。 + + +# ---- 货架位数读表(架构整改——shelf_size 表头单点:进店与重抽同源)---- + +func _test_shelf_size_from_table() -> void: + var autoplay = load("res://sim/level_autoplay.gd") + var st = _db.run.shop + var saved: int = int(st.shelf_size) + st.shelf_size = 5 # 运行时表头改值夹具(测毕还原——真表行值零触碰) + var got5 := -1 + for s in range(42, 92): + var a9 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(s)) + a9.set_hero_override(900, 400) + if not a9.start_adventure(s, {"shop_mode": "pool"}): + continue + if not _adv_drive_to_shop(a9, _adv_st(), autoplay): + continue + got5 = (a9.runner().shop.shop_shelf() as Array).size() + break + st.shelf_size = saved + Kit.check("夹具 shelf_size=5 → 货架五件可观测(实测 %d;真表值 3 回归=既有「进店抽 3」断言承载)〔架构整改 V9〕" % got5, got5 == 5) + + +# ---- 散折价收回规则键(架构整改 R2——shop.spend_discount 表字段退役,折价读 economy_spend.discount_pct)---- + +func _test_spend_discount_from_rule_key() -> void: + # 字段真删:shop.tres 加载零诊断 + spend_discount 属性不在位 + Kit.check("shop.tres 加载零诊断(spend_discount 字段真删——schema 与表行同卸)〔架构整改 V2〕", + _db.diagnostics_count("error") == 0 and _db.run.shop.get("spend_discount") == null) + # 80% 折价值逐位同(真实入口=LevelRunner 装配链 shop_session.current_price,economy_spend 注入) + var bonus: int = int(_db.run.shop.price_level_bonus_at(0)) + var spend_dict: Dictionary = {"economy_spend": {"discount_pct": 80}} + var lr_on = load("res://core/level_runner.gd").new(_db, _fresh_rng(777005), {"tree_rules": spend_dict}) + var lr_off = load("res://core/level_runner.gd").new(_db, _fresh_rng(777006), {"tree_rules": {}}) + var p_on: int = int(lr_on.shop.current_price({"base_price": 100, "price_increment": 0})) + var p_off: int = int(lr_off.shop.current_price({"base_price": 100, "price_increment": 0})) + var expect_on: int = maxi(1, int(floor(float(100 + bonus) * float(spend_dict["economy_spend"]["discount_pct"]) / 100.0))) + Kit.check("散激活:折后价 = (基价+关卡加成)×discount_pct/100 floor(%d vs 期望 %d)〔架构整改 V2〕" % [p_on, expect_on], p_on == expect_on) + Kit.check("散未激活:原价(无 economy_spend 键——%d)〔架构整改 V2〕" % p_off, p_off == 100 + bonus) diff --git a/game/tests/unit/core/meta_progression_forge_test.gd b/game/tests/unit/core/meta_progression_forge_test.gd index 0a3f5373..7048c521 100644 --- a/game/tests/unit/core/meta_progression_forge_test.gd +++ b/game/tests/unit/core/meta_progression_forge_test.gd @@ -37,6 +37,10 @@ func _init() -> void: _smoke_forge_axes() _smoke_tree() _smoke_tree_rules() + _test_forge_buy_reject() + _test_blessing_no_forge_leak() + _test_default_axes_pre_key() + _test_armor_switch_dual_axes() quit(Kit.finish("meta_progression_forge_test")) @@ -50,7 +54,8 @@ const _META_SMOKE_PATHS := ["user://smoke_meta_buy.json", "user://smoke_meta_dry "user://smoke_meta_tree.json", "user://smoke_meta_career.json", "user://smoke_meta_career_v5.json", "user://smoke_meta_treasure_unlock.json", "user://smoke_meta_treasure_blank.json", "user://smoke_meta_forge.json", "user://smoke_meta_forge2.json", "user://smoke_meta_forge3.json", - "user://smoke_meta_forge4.json", "user://smoke_meta_forge5.json"] + "user://smoke_meta_forge4.json", "user://smoke_meta_forge5.json", + "user://smoke_meta_reject.json", "user://smoke_meta_blessing.json", "user://smoke_meta_axesw.json"] func _meta_smoke_cleanup() -> void: @@ -309,7 +314,7 @@ func _meta_settle_and_workshop() -> void: 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() + var rules_e: Dictionary[Variant, Variant] = mp_e.tree_rules() Kit.check("开局传参夹具前置:Fork 侧 a 激活 rule 键非空 + Y=Lv2 行表值 2(防断言空转)", rules_e.has("posture_first_strike") and mp_e.attune_refresh_charges() == 2) var p_new: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e, @@ -319,7 +324,7 @@ func _meta_settle_and_workshop() -> void: Kit.check("主菜单开新冒险参数面:Y 刷新池 = 当前等级行表值(营地升几档开局就给几次)", int(p_new.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges()) Kit.check("主菜单开新冒险参数面:树 rule 键集 = Fork 激活侧 effects(点哪侧带哪侧)", - (p_new.get("tree_rules", []) as Array) == rules_e) + (p_new.get("tree_rules", {}) as Dictionary) == rules_e) Kit.check("主菜单开新冒险参数面:入口特有键(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") var p_resume: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e, @@ -327,7 +332,7 @@ func _meta_settle_and_workshop() -> void: Kit.check("主菜单继续上局参数面:三键与开新冒险同源同值(续玩不吃白板开局)", (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) + and (p_resume.get("tree_rules", {}) as Dictionary) == rules_e) # 续玩路径 tree_rules 受理断言:直呼 runner 续玩参数面 # (v2 旧断点夹具同式最小断点——smoke ⑫ 受理路径同形)验 _tree_rules 与传入一致。 var adr_res = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(19)) @@ -339,7 +344,7 @@ func _meta_settle_and_workshop() -> void: 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()._facts.encounter.get("tree_rules", []) as Array) == rules_e)) + and ((adr_res.runner().current_battle()._facts.encounter.get("tree_rules", {}) as Dictionary) == rules_e)) _meta_smoke_cleanup() @@ -418,6 +423,12 @@ func _smoke_forge_axes() -> void: Kit.check("长剑二级购:扣 30、等级→2;轻甲独立零扰动(六路线各练各的)〔锻造工坊批 V2〕", 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) + # V12:长剑购买链走至 3 级(45 档扣款——余额 50−45=5,几何价第三档);链后续夹具段余额基准 + # 由 add_keystone 补回(注入口径——直写存档域构造状态,下游 V3 段 50−20 语义不变) + Kit.check("长剑三级购:扣 45、等级→3(余额 50−45=5)〔断言补齐批 V12〕", + mp2.forge_buy_level("sword") and sv2.meta_state.forge_state().levels.get("sword", 0) == 3 + and sv2.meta_state.keystone_balance() == 5) + sv2.meta_state._add_keystone(45) # V2③:余额不足拒且零副作用(等级/选中/解锁三面零变化) var sv2d = _meta_new_save(_META_SMOKE_PATHS[14]) var mp2d = load("res://core/meta_progression.gd").new(_db, sv2d) @@ -605,10 +616,10 @@ func _smoke_tree() -> void: Kit.check("零币切态 b(购买解锁后 A/B 可切)", 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("规则键投影=激活态效果键(B 态稳健拓荒——tree_rules 随切态换键集)", + (mp.tree_rules() as Dictionary).has("posture_bulwark") and not (mp.tree_rules() as Dictionary).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")) + mp.tree_toggle_fork("fg_posture", "a") and (mp.tree_rules() as Dictionary).has("posture_first_strike")) # 彩蛋特化觉醒(V8) Kit.check("彩蛋不经购买(手动购亮=红——觉醒唯一入口=特化判定)", not mp.tree_can_buy("easter_ley")) Kit.check("觉醒前置未满足不亮(两线未拉满)", sv.meta_state.tree_state().nodes.get("easter_ley", 0) == 0) @@ -629,7 +640,7 @@ func _smoke_tree_rules() -> void: print("--- 天赋树六键行为(V10:窗/限次/聚散/守侧)---") # 开局姿态组:每关首轮窗(battle_index 事实——引擎直构 + 自建 ctx) var fx = load("res://core/effect_engine.gd").new(null) - fx.rebuild([], [], ["posture_first_strike", "posture_bulwark"]) + fx.rebuild([], [], {"posture_first_strike": {"ap_bonus": 1}, "posture_bulwark": {"shield": 1}}) var ctx_p := EffectContext.new() Kit.check("先手压制:每关首战首轮窗命中(battle_index=1 且 round=1 → grant_ap queue)〔天赋树批 V10〕", (fx.dispatch(ctx_p, "round_start", {"battle_index": 1, "round_index": 1}, Callable()) as Array).size() == 1) @@ -641,7 +652,7 @@ func _smoke_tree_rules() -> void: and (fx.dispatch(ctx_p, "damage_taken", {"battle_index": 2, "round_index": 1}, Callable()) as Array).is_empty()) # 危险直觉·锋:限次闸(引擎直构——mine_hit 挂点现成;限次计数住 level 桶,跨战持久=共享同一字典引用) var fz = load("res://core/effect_engine.gd").new(null) - fz.rebuild([], [], ["sense_sharp"]) + fz.rebuild([], [], {"sense_sharp": {"halve_pct": 50, "cap_per_level": 2}}) var lvl_shared := {} # 同一关的 level 桶(两编排器共享引用 = 跨战持久,替代旧副本搬运) var ctx_fz := EffectContext.new() ctx_fz.level_bucket = lvl_shared @@ -658,7 +669,7 @@ func _smoke_tree_rules() -> void: Kit.check("锋:限次耗尽全额(counter 2 不<2 闸关——每关限次 provisional 2)〔无状态化 V5〕", (fz.dispatch(ctx_fz, "mine_hit", {}, Callable()) as Array).is_empty()) var fz2 = load("res://core/effect_engine.gd").new(null) - fz2.rebuild([], [], ["sense_sharp"]) + fz2.rebuild([], [], {"sense_sharp": {"halve_pct": 50, "cap_per_level": 2}}) var ctx_fz2 := EffectContext.new() ctx_fz2.level_bucket = lvl_shared # 挂同一 level 桶引用(新形态跨战持久:无 export/seed,同字典即持久) Kit.check("锋:限次跨战持久(level 桶共享引用——替代旧副本搬运链)〔无状态化 V4〕", @@ -668,7 +679,7 @@ func _smoke_tree_rules() -> void: var orch_nc = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777003)) orch_nc.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, + "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"}) # 真实战斗上下文(状态容器开域——dispatch 链受理) var nc_rows: Array = [] # step_row 采集盒(lambda 捕引用容器——捕获变量赋值不出闭包) @@ -692,7 +703,7 @@ func _smoke_tree_rules() -> void: Kit.check("守侧 rig:战斗编排启动(tree_rules=sense_guard 携带)", orch.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, - "feats": [], "tree_rules": ["sense_guard"], "battle_index": 1, + "feats": [], "tree_rules": {"sense_guard": {"ap_refund": 1, "cap_per_level": 2}}, "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"})) var board = orch.minesweeper.current_board() @@ -722,7 +733,7 @@ func _smoke_tree_rules() -> void: Kit.check("守⑤ rig:次战编排启动(新战新队列实例)", orch2.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, - "feats": [], "tree_rules": ["sense_guard"], "battle_index": 1, + "feats": [], "tree_rules": {"sense_guard": {"ap_refund": 1, "cap_per_level": 2}}, "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"})) Kit.check("守⑤:末轮判出不返(queue 只在 notify_round_start 兑现、战亡即弃——无跨战泄漏)", @@ -730,33 +741,103 @@ func _smoke_tree_rules() -> void: # 树限次按关清零改由 LevelRunner.level_counters 随 new 承载(旧硬编码名单冒烟断言随函数删除——换代同生死;新语义由 V4/V5 段承载) # 财富之道·散:商品标价折价(level_runner 计价单点) var lr = load("res://core/level_runner.gd").new(_db, _fresh_rng(777004), - {"tree_rules": []}) # 构造注入(拆件批 T3——setter 群退役) + {"tree_rules": {}}) # 构造注入(拆件批 T3——setter 群退役) Kit.check("散未激活:商品标价原价(无 economy_spend 键)", int(lr.shop.current_price({"base_price": 100, "price_increment": 0})) == 100 + int(_db.run.shop.price_level_bonus_at(0))) + var spend_dict: Dictionary = {"economy_spend": {"discount_pct": 80}} lr = load("res://core/level_runner.gd").new(_db, _fresh_rng(777005), - {"tree_rules": ["economy_spend"]}) # 构造注入(拆件批 T3——setter 群退役) + {"tree_rules": spend_dict}) # 构造注入(拆件批 T3——setter 群退役) Kit.check("散激活:商品购买价 ×80%(provisional 折率——floor 偏向玩家、下限 1;刷新价不折=另一计价点零触碰)", - int(lr.shop.current_price({"base_price": 100, "price_increment": 0})) == maxi(1, int(floor(float(100 + int(_db.run.shop.price_level_bonus_at(0))) * float(_db.run.shop.spend_discount)))) - and int(lr.shop.current_price({"base_price": 1, "price_increment": 0})) == maxi(1, int(floor(float(1 + int(_db.run.shop.price_level_bonus_at(0))) * float(_db.run.shop.spend_discount))))) + int(lr.shop.current_price({"base_price": 100, "price_increment": 0})) == maxi(1, int(floor(float(100 + int(_db.run.shop.price_level_bonus_at(0))) * float(spend_dict["economy_spend"]["discount_pct"]) / 100.0))) + and int(lr.shop.current_price({"base_price": 1, "price_increment": 0})) == maxi(1, int(floor(float(1 + int(_db.run.shop.price_level_bonus_at(0))) * float(spend_dict["economy_spend"]["discount_pct"]) / 100.0)))) # 财富之道·聚:战斗掉落乘算·入账前(编排 _roll_gp_drop——同种子配对对拍 1.5×) var orch3 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777005)) Kit.check("聚 rig:基线编排启动(无增益侧)", orch3.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, - "feats": [], "tree_rules": [], "battle_index": 1, + "feats": [], "tree_rules": {}, "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"})) - orch3._facts.encounter["tree_rules"] = [] + orch3._facts.encounter["tree_rules"] = {} var gp_base: int = orch3._roll_gp_drop() var orch4 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777005)) Kit.check("聚 rig:增益编排启动(tree_rules=economy_hoard)", orch4.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40, "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {}, - "feats": [], "tree_rules": ["economy_hoard"], "battle_index": 1, + "feats": [], "tree_rules": {"economy_hoard": {"mult_pct": 150}}, "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"})) - orch4._facts.encounter["tree_rules"] = ["economy_hoard"] + orch4._facts.encounter["tree_rules"] = {"economy_hoard": {"mult_pct": 150}} var gp_hoard: int = orch4._roll_gp_drop() Kit.check("聚:战斗掉落乘算 ×1.5(provisional——同种子配对 %d→%d,入账前=结算框显示实得)", - gp_hoard == int(round(float(gp_base) * 1.5)) and gp_hoard >= gp_base) + gp_hoard == int(round(float(gp_base) * float({"economy_hoard": {"mult_pct": 150}}["economy_hoard"]["mult_pct"]) / 100.0)) and gp_hoard >= gp_base) _meta_smoke_cleanup() + + +# ---- 锻造契约补齐(断言补齐批——⑥锻造工坊):拒购零副作用/祝福件零泄漏/默认值解耦/甲型双轴 ---- + +func _test_forge_buy_reject() -> void: + var sv = _meta_new_save("user://smoke_meta_reject.json") + var mp = load("res://core/meta_progression.gd").new(_db, sv) + sv.meta_state._add_keystone(10) # 低于首购价 20——余额不足面 + var fs0: Dictionary = sv.meta_state.forge_state().duplicate(true) + var bal0: int = sv.meta_state.keystone_balance() + Kit.check("余额不足拒购:sword false、等级与余额零变化〔断言补齐批 V13〕", + not mp.forge_buy_level("sword") and sv.meta_state.forge_state() == fs0 and sv.meta_state.keystone_balance() == bal0) + sv.meta_state._add_keystone(1000) # 充值后未解锁面不变(spear 直购未走/练满未达) + Kit.check("未解锁拒购:spear false、等级与余额零变化〔断言补齐批 V13〕", + not mp.forge_buy_level("spear") and sv.meta_state.forge_state() == fs0 + and sv.meta_state.keystone_balance() == bal0 + 1000) + + +func _test_blessing_no_forge_leak() -> void: + var sv = _meta_new_save("user://smoke_meta_blessing.json") + var mp = load("res://core/meta_progression.gd").new(_db, sv) + var fs0: Dictionary = sv.meta_state.forge_state().duplicate(true) + # 真实入口=商店购买口(LevelRunner 装配的 shop_session.buy_product——祝福件走 GP 钱包+局内加算区) + var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(80)) + var lvl_ok: bool = runner.start_level(1, 80) + Kit.check("V14 前置:关卡启动(L1 行集)", lvl_ok) + if not lvl_ok: + return + runner.gp = 100 + Kit.check("V14 前置:攻击祝福受理", runner.shop.buy_product("atk_up")) + Kit.check("V14 前置:护甲祝福受理", runner.shop.buy_product("armor_up")) + Kit.check("祝福件零泄漏:购买后 forge_state 投影逐位不变(levels/选中/解锁三面)〔断言补齐批 V14〕", + sv.meta_state.forge_state() == fs0) + Kit.check("祝福件住局内加算区(atk +1 在加算日记——通道分离实证)", runner.journal().bonus_total("atk") == 1) + + +func _test_default_axes_pre_key() -> void: + var sv = _meta_new_save("user://smoke_meta_axesw.json") + var mp = load("res://core/meta_progression.gd").new(_db, sv) + var ax: Dictionary = mp.hero_type_axes() + Kit.check("树钥匙未点亮:hero_type_axes 返回锻造配置默认 normal/light(默认值常驻解耦)〔断言补齐批 V15〕", + String(ax["attack"]) == "normal" and String(ax["armor"]) == "light") + + +func _test_armor_switch_dual_axes() -> void: + var sv = _meta_new_save("user://smoke_meta_axesw.json") + sv.meta_state._add_keystone(1000) + var mp = load("res://core/meta_progression.gd").new(_db, sv) + Kit.check("V16 前置:重甲直购解锁+首购+选中(200+20 钥石)", + mp.forge_buy_unlock("heavy") and mp.forge_buy_level("heavy") and mp.forge_select("armor", "heavy")) + var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(81)) + var p: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv, mp) + var adv_ok: bool = adr.start_adventure(81, p) + Kit.check("V16 前置:参数面冒险启动", adv_ok) + if not adv_ok: + return + Kit.check("甲型双轴:heavy 选中 → AC 轴 12 基础+路线 Lv1=13(light 同级 11 的 +2 档差)〔断言补齐批 V16〕", + int(adr.runner().effective_ac()) == 13 and String(mp.hero_type_axes()["armor"]) == "heavy") + Kit.check("甲型双轴:挨普通攻击修正 −1(克制矩阵 normal→heavy)〔断言补齐批 V16〕", + int(_db.combat.settlement.damage_roll.armor_effectiveness["normal"]["heavy"]) == -1) + Kit.check("V16 前置:切回 light(初始持有已解锁可切)", mp.forge_select("armor", "light")) + var adr2 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(82)) + var p2b: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv, mp) + var adv2_ok: bool = adr2.start_adventure(82, p2b) + Kit.check("V16 前置:切回后新参数面冒险启动(快照冻结契约——新 runner 读新选中)", adv2_ok) + if not adv2_ok: + return + Kit.check("甲型双轴:切回 light 复原(AC 10+0)且 heavy 等级保留(Lv1 不丢)〔断言补齐批 V16〕", + int(adr2.runner().effective_ac()) == 10 and sv.meta_state.forge_state().levels.get("heavy", 0) == 1) diff --git a/game/tests/unit/core/minesweeper_rules_test.gd b/game/tests/unit/core/minesweeper_rules_test.gd index 3552ff8d..0adad56d 100644 --- a/game/tests/unit/core/minesweeper_rules_test.gd +++ b/game/tests/unit/core/minesweeper_rules_test.gd @@ -40,6 +40,7 @@ func _init() -> void: _test_reveal_advances() _test_seal_place() _test_reject_out_of_catalog() + _test_preview_reveal_readonly() quit(Kit.finish("minesweeper_rules_test")) @@ -110,3 +111,44 @@ func _test_reject_out_of_catalog() -> void: var o: Dictionary = (out[0] as Dictionary) if not out.is_empty() else {} Kit.check("目录外意图被拒(不静默吞掉)——实际载荷 %s" % str(o).substr(0, 80), out.size() == 1 and String(o.get("outcome", o.get("result", ""))) == "rejected") + + +# ---- V14 棋盘揭示预览只读口(架构整改——preview_reveal 与实挖揭示集同口径、零状态写入零事件)---- + +func _test_preview_reveal_readonly() -> void: + var events := 0 + _rules.cell_revealed.connect(func(_evt): events += 1) # 零发射哨兵(preview 全程不触发揭示事件) + # 形态 A:封印在场——preview(未开格) 不越封印且零状态写入 + var before_revealed := _rules.revealed_cells().size() + var before_seals := _rules.seal_count() + var c_open := _unrevealed_safe() + var prev_a: Array = _rules.preview_reveal(c_open) + Kit.check("V14 形态A:preview 返回集(真板形态——%d 格)" % prev_a.size(), prev_a.size() >= 0) + Kit.check("V14 形态A:调用前后零状态写入(revealed/seal 计数零变化)〔架构整改 V14〕", + _rules.revealed_cells().size() == before_revealed and _rules.seal_count() == before_seals) + # 形态 B:挖封印格——本格封印隐式退役只入模拟集,真实封印计数不动 + var seal_c := _unrevealed_safe() + _rules.settle_batch([{"action": "seal_place", "cell": seal_c}]) + var sealed_before := _rules.seal_count() + var prev_b: Array = _rules.preview_reveal(seal_c) + Kit.check("V14 形态B:preview(封印格) 零状态写入(seal_count 不变——隐式退役只入模拟集)〔架构整改 V14〕", + _rules.seal_count() == sealed_before) + # 同口径对照:preview(c) = settle_batch(dig c) 后 revealed 差集 ∖ {c}(F2——不含首格与已开格) + var dig_c := _unrevealed_safe() + var prev_c: Array = _rules.preview_reveal(dig_c) + var before := {} + for rc in _rules.revealed_cells(): + before[rc] = true + _rules.settle_batch([{"action": "dig", "cell": dig_c}]) + var after_diff := [] + for rc in _rules.revealed_cells(): + if not before.has(rc) and rc != dig_c: + after_diff.append(rc) + Kit.check("V14 同口径:preview(c) = 实挖揭示差集 ∖ {c}(F2——%d vs %d 格逐位)〔架构整改 V14〕" % [prev_c.size(), after_diff.size()], + str(prev_c) == str(after_diff)) + var events_before_previews := events # 三次 preview 调用前快照(此前增量=夹具自身实挖——与 preview 无关) + var _pa: Array = _rules.preview_reveal(_unrevealed_safe()) + var _pb: Array = _rules.preview_reveal(_unrevealed_safe()) + var _pc: Array = _rules.preview_reveal(_unrevealed_safe()) + Kit.check("V14 零事件:preview 全程 cell_revealed 零发射(前后增量 0——实测 %d→%d)〔架构整改 V14〕" % [events_before_previews, events], + events == events_before_previews) diff --git a/game/tests/unit/core/node_map_generator_test.gd b/game/tests/unit/core/node_map_generator_test.gd index 57054f1f..1c1e8acb 100644 --- a/game/tests/unit/core/node_map_generator_test.gd +++ b/game/tests/unit/core/node_map_generator_test.gd @@ -29,6 +29,8 @@ func _init() -> void: _test_shop_and_attribution() _test_battle_floor() _test_exit_structure() + _test_boss_exit_per_region() + _test_gen_params_table_driven() _test_rng_single_point() _test_negative_fixtures() quit(Kit.finish("node_map_generator_test")) @@ -303,6 +305,41 @@ func _terminals_reachable(g: Dictionary, id: int) -> int: return c +# ---- V9 Boss 出场门控(难度曲线与Boss——每区域恰一 Boss 终点)---- + +func _test_boss_exit_per_region() -> void: + # 真实生成多区域图:全部终点 type=="boss"、boss 终点数==exit_count、深度=L−r(r∈[0,exit_count) + # ——每区域恰一 boss、深度互异即分属不同区域) + # 节点 depth 字段=入口起最长路径深(生成器校验轴);区域序号 r 的可观测形态=最深 boss(r=0) + # 与该 boss 的深度差(同层 boss 深度 = d0 − r——区域段 L−r 的 1 步差) + var all_ok := true + for L in [1, 2, 3]: + for s in [42, 43, 44]: + var g := _graph(L, s) + var by_id := {} + for n in g["nodes"]: + by_id[int(n["id"])] = n + var exits := int(_params(L)["exit_count"]) + var d0 := 0 + for tid in g["terminals"]: + d0 = maxi(d0, int(by_id[int(tid)]["depth"])) + var per_region := {} + for tid in g["terminals"]: + var tn: Dictionary = by_id[int(tid)] + if String(tn["type"]) != "boss": + all_ok = false + var r := d0 - int(tn["depth"]) + if r < 0 or r >= exits: + all_ok = false + per_region[r] = int(per_region.get(r, 0)) + 1 + if per_region.size() != exits: + all_ok = false + for r in per_region: + if int(per_region[r]) != 1: + all_ok = false + Kit.check("每区域恰一 Boss 终点(type=boss × exit_count 个、深度=d0−r 互异分域——区域段 L−r 的可观测形态)〔断言补齐批 V9〕", all_ok) + + # ---- V10 RNG 单点(纯流面 counter 不变)---- func _test_rng_single_point() -> void: @@ -441,3 +478,57 @@ func _has_msg(errs: Array, needle: String) -> bool: if String(e["message"]).contains(needle): return true return false + + +# ---- V6 节点图三参数表值驱动(架构整改——fork_third_way_pct/fill_rest_first_pct/width_max 入 level_gen_params)---- + +func _test_gen_params_table_driven() -> void: + # ① 同种子真实表行产图逐位(表值驱动回归锚) + var g1 := _graph(2, 42) + var g2 := _graph(2, 42) + Kit.check("表值驱动:同种子产图逐位复现〔架构整改 V6〕", _sig(g1) == _sig(g2)) + # ② width_max=2 夹具 → 各深度节点数 ≤2(宽度上限读表可观测) + var p2: Dictionary = _params(2).duplicate(true) + p2["width_max"] = 2 + var gw: Dictionary = _gen.generate(p2, 42) + var per_depth := {} + for n in gw["nodes"]: + var d2: int = int(n["depth"]) + per_depth[d2] = int(per_depth.get(d2, 0)) + 1 + var width_ok := true + for d3 in per_depth: + if int(per_depth[d3]) > 2: + width_ok = false + Kit.check("width_max=2 夹具:各深度节点数 ≤2(宽度上限读表可观测)〔架构整改 V6〕", width_ok) + # ③ fork_third_way_pct=0 夹具 → 无节点去路数超 fork_ways_min(三去路概率读表可观测) + var p0: Dictionary = _params(2).duplicate(true) + p0["fork_third_way_pct"] = 0 + var g0: Dictionary = _gen.generate(p0, 42) + var ways_ok := true + for n in g0["nodes"]: + if (n["next"] as Array).size() > int(p0["fork_ways_min"]): + ways_ok = false + Kit.check("fork_third_way_pct=0 夹具:无节点去路数超 fork_ways_min(三去路概率读表可观测)〔架构整改 V6〕", ways_ok) + # ④ fill_rest_first_pct=100 vs 0 → 填充交替起点可观测变化(交替起始骰读表)。 + # 夹具=L1 真实表行改 branch_density=0/battle_floor=0:fill 段只在单出口图跑(e==1——_assign_types)、 + # 岔口修型段会覆盖侧链头填充位——无岔口+零保底组合让填充差异直达产物(多种子扫过 diff 恒真) + var p100: Dictionary = _params(1).duplicate(true) + p100["branch_density"] = 0.0 + p100["battle_floor"] = 0 + p100["fill_rest_first_pct"] = 100 + var g100: Dictionary = _gen.generate(p100, 40) + var p000: Dictionary = _params(1).duplicate(true) + p000["branch_density"] = 0.0 + p000["battle_floor"] = 0 + p000["fill_rest_first_pct"] = 0 + var g000: Dictionary = _gen.generate(p000, 40) + var rest100 := 0 + for n in g100["nodes"]: + if String(n["type"]) == "rest": + rest100 += 1 + var rest000 := 0 + for n in g000["nodes"]: + if String(n["type"]) == "rest": + rest000 += 1 + Kit.check("fill_rest_first_pct=100 vs 0 夹具:填充交替起点产物互异(%d vs %d rest)〔架构整改 V6〕" % [rest100, rest000], + _sig(g100) != _sig(g000) and rest100 > rest000) diff --git a/game/tests/unit/core/node_map_growth_test.gd b/game/tests/unit/core/node_map_growth_test.gd index 86d3227f..3ab25ad9 100644 --- a/game/tests/unit/core/node_map_growth_test.gd +++ b/game/tests/unit/core/node_map_growth_test.gd @@ -66,11 +66,13 @@ func _test_fail_fast_static(gen, db) -> void: 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} + "fork_ways_min": 2, "fork_ways_max": 3, + "fork_third_way_pct": 35, "fill_rest_first_pct": 50, "width_max": 4} Kit.check("层深 6 放三出口无解 → check_params 拦截〔fv4〕", not NodeMapGenerator.check_params(unsolvable).is_empty()) var overfloor := {"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": 1, "battle_floor": 99, - "fork_ways_min": 2, "fork_ways_max": 3} + "fork_ways_min": 2, "fork_ways_max": 3, + "fork_third_way_pct": 35, "fill_rest_first_pct": 50, "width_max": 4} Kit.check("战斗保底超可放数无解 → check_params 拦截〔fv4〕", not NodeMapGenerator.check_params(overfloor).is_empty()) @@ -96,7 +98,8 @@ func _trigger_fatal() -> void: # 域内合法但深度预算无解:层深 6 放三出口(check_params 拦下 → 致命中止) 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} + "fork_ways_min": 2, "fork_ways_max": 3, + "fork_third_way_pct": 35, "fill_rest_first_pct": 50, "width_max": 4} gen.generate(unsolvable, 42) # 致命封印未置位=fail-fast 静默化(缺陷)→ 显式退出码 2 与致命停机码 1 区分 if gen.is_fatal(): diff --git a/game/tests/unit/core/playtest_logger_guard_test.gd b/game/tests/unit/core/playtest_logger_guard_test.gd new file mode 100644 index 00000000..1f1ec9eb --- /dev/null +++ b/game/tests/unit/core/playtest_logger_guard_test.gd @@ -0,0 +1,36 @@ +# tests/unit/core/playtest_logger_guard_test.gd — 试玩日志器 ap_per_round 必填注入守卫〔架构整改 V5〕 +# 断言面 = PlaytestLogger.start_session():未注入(0)返回空串+零文件产出;注入 8 返回非空路径。 +# 真实入口=PlaytestLogger.start_session(R5 必填注入——组合根注入序前置的守卫面)。 +# 运行:godot --headless --path game --script res://tests/unit/core/playtest_logger_guard_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") + + +func _init() -> void: + _smoke_required_injection() + quit(Kit.finish("playtest_logger_guard_test")) + + +func _smoke_required_injection() -> void: + # 未注入(缺省 0):start_session 返回空串、零文件产出 + var lg0 = load("res://presentation/playtest_logger.gd").new() + var p0: String = lg0.start_session(42) + Kit.check("未注入拒绝开会话:start_session 返回空串〔架构整改 V5〕", p0 == "") + var f0 := FileAccess.file_exists(String(lg0._path)) + Kit.check("未注入零文件产出(_path 留空、盘上无会话件)〔架构整改 V5〕", not f0 and String(lg0._path) == "") + # 注入 8:返回非空路径(会话开启) + var lg8 = load("res://presentation/playtest_logger.gd").new() + lg8.ap_per_round = 8 + var p8: String = lg8.start_session(42) + Kit.check("注入 8 返回非空路径(守卫放行——会话开启)〔架构整改 V5〕", p8 != "") + if lg8._file != null: + lg8._file.close() + var f8 := FileAccess.file_exists(p8) + Kit.check("注入后会话件在盘〔架构整改 V5〕", f8) + if f8: + DirAccess.remove_absolute(ProjectSettings.globalize_path(p8)) + # 越域拒绝(33>32 上界) + var lg33 = load("res://presentation/playtest_logger.gd").new() + lg33.ap_per_round = 33 + Kit.check("越域 33 拒绝开会话(守卫上界 [1,32])〔架构整改 V5〕", lg33.start_session(42) == "") diff --git a/game/tests/unit/core/playtest_logger_guard_test.gd.uid b/game/tests/unit/core/playtest_logger_guard_test.gd.uid new file mode 100644 index 00000000..6feabbe6 --- /dev/null +++ b/game/tests/unit/core/playtest_logger_guard_test.gd.uid @@ -0,0 +1 @@ +uid://cyaq5yjgwr0ng diff --git a/game/tests/unit/core/treasure_effects_test.gd b/game/tests/unit/core/treasure_effects_test.gd index c2b0d23e..6a6c34cf 100644 --- a/game/tests/unit/core/treasure_effects_test.gd +++ b/game/tests/unit/core/treasure_effects_test.gd @@ -318,7 +318,7 @@ func _init() -> void: # enc 带 feats 键+ledger,专长触发经 _judge_seal 真实封印判定管线) var enc_feats = {"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": ["seal_smith"], "tree_rules": [], "battle_index": 1, + "feats": ["seal_smith"], "tree_rules": {}, "battle_index": 1, "bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle", "adv_bucket": {}} var rng15 = load("res://core/rng_service.gd").new(); rng15.set_master_seed(341) diff --git a/game/tests/unit/core/tree_rules_v11_test.gd b/game/tests/unit/core/tree_rules_v11_test.gd index 8ecd5030..8c791eb7 100644 --- a/game/tests/unit/core/tree_rules_v11_test.gd +++ b/game/tests/unit/core/tree_rules_v11_test.gd @@ -14,34 +14,45 @@ extends SceneTree func _init() -> void: var eng = load("res://core/effect_engine.gd") var tree_tbl = load("res://data/schemas/meta_tree_table.gd") + var db = load("res://core/config_db.gd").new() + db.load_tables("res://data/tables", false) var errs: Array = [] + # 规则键参数字典夹具值源(R1 换代——synth 参数读 meta_tree Fork 行 effects_*,字面量退役) + var fork_params := {} + for row in db.crossrun.meta_tree.rows: + if String(row.get("kind", "")) != "fork": + continue + for skey in ["effects_a", "effects_b"]: + var fx: Dictionary = row.get(skey, {}) as Dictionary + for rk in fx.keys(): + fork_params[String(rk)] = fx[rk] # 四键合成行夹具(与 rebuild 树规则段同构——逐字段对照,实现漂移在此转红) var synth := { "posture_first_strike": [{"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}}], + "do": {"kind": "grant_ap", "mode": "queue", "value": int(fork_params["posture_first_strike"]["ap_bonus"])}}], "posture_bulwark": [{"on": "damage_taken", "when": [{"fact": "battle_index", "op": "eq", "value": 1}, {"fact": "round_index", "op": "eq", "value": 1}], - "do": {"kind": "shield", "value": 1}}], + "do": {"kind": "shield", "value": int(fork_params["posture_bulwark"]["shield"])}}], "sense_sharp": [{"on": "mine_hit", - "when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}], - "do": {"kind": "damage_mult", "value": 50}}, + "when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": int(fork_params["sense_sharp"]["cap_per_level"])}], + "do": {"kind": "damage_mult", "value": int(fork_params["sense_sharp"]["halve_pct"])}}, {"on": "mine_hit", - "when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}], + "when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": int(fork_params["sense_sharp"]["cap_per_level"])}], "do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_sharp_used", "value": 1}}], "sense_guard": [{"on": "seal_missed", - "when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}], - "do": {"kind": "grant_ap", "mode": "queue", "value": 1}}, + "when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": int(fork_params["sense_guard"]["cap_per_level"])}], + "do": {"kind": "grant_ap", "mode": "queue", "value": int(fork_params["sense_guard"]["ap_refund"])}}, {"on": "seal_missed", - "when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}], + "when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": int(fork_params["sense_guard"]["cap_per_level"])}], "do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_guard_used", "value": 1}}], } var six := ["posture_first_strike", "posture_bulwark", "sense_sharp", "sense_guard", "economy_hoard", "economy_spend"] # rebuild 实装面(db=null→feat/attune 两段空转,_triggers 只含树规则合成行)——行数对照载体 var eng_inst = eng.new() - eng_inst.rebuild([], [], six) + eng_inst.rebuild([], [], fork_params) for key in six: if not tree_tbl.RULE_KEYS.has(key): errs.append("规则键出域 RULE_KEYS 词表: %s" % key) @@ -64,10 +75,58 @@ func _init() -> void: eng.validate_trigger_row("bad_kind", {}, [{"on": "round_start", "do": {"kind": "no_such_kind"}}], bad_errs) if bad_errs.is_empty(): errs.append("反例护栏失效:出域事件/出域效果类型未被 validate_trigger_row 拒绝") + errs.append_array(_test_rule_key_params(eng, tree_tbl, db)) if errs.is_empty(): - print("[tree_rules_v11] all green(四键合成行零违例 + 聚散两键无行成立 + 反例被拒——契约 5 闭集在册词表承载)") + print("[tree_rules_v11] all green(四键合成行零违例 + 聚散两键无行成立 + 反例被拒 + 六键参数域全过——契约 5 闭集在册词表承载)") quit(0) return for e in errs: printerr("[tree_rules_v11] %s" % str(e)) quit(1) + + +# ---- 规则键参数域校验(架构整改批 R1——六键参数字典):正例全过/缺参数拒/多参数拒/越域拒/非字典拒 ---- + +func _test_rule_key_params(eng, tree_tbl, db) -> Array: + var errs: Array = [] + var params_tbl: Dictionary = tree_tbl.RULE_KEY_PARAMS + # 正例:meta_tree.tres 现行 Fork 行逐键过 validate(加载期已验——此处显式复核) + var probe_ok := {"node_id": "probe_rule", "effects": {}, "effects_a": {}, "effects_b": {}} + for row in db.crossrun.meta_tree.rows: + if String(row.get("kind", "")) != "fork": + continue + for skey in ["effects_a", "effects_b"]: + for rk in (row.get(skey, {}) as Dictionary).keys(): + probe_ok[skey][String(rk)] = (row.get(skey, {}) as Dictionary)[rk] + errs.append_array(db.crossrun.meta_tree._validate_rule_key_params(probe_ok, "probe_ok")) + # 负例构造:取 sense_sharp 行(双参数键——缺/多/越域/非字典四形态) + var base_params: Dictionary = {"halve_pct": 50, "cap_per_level": 2} + var mk := func(mod: Dictionary) -> Dictionary: + var row := {"node_id": "probe_neg", "effects_a": {"sense_sharp": base_params.duplicate(true)}, "effects_b": {}} + for k in mod.keys(): + row["effects_a"]["sense_sharp"] = mod[k] + return row + var cases := [ + {"name": "缺参数", "row": {"node_id": "probe_neg", "effects_a": {"sense_sharp": {"halve_pct": 50}}, "effects_b": {}}}, + {"name": "多参数", "row": {"node_id": "probe_neg", "effects_a": {"sense_sharp": {"halve_pct": 50, "cap_per_level": 2, "ghost": 1}}, "effects_b": {}}}, + {"name": "越域", "row": {"node_id": "probe_neg", "effects_a": {"sense_sharp": {"halve_pct": 50, "cap_per_level": 99}}, "effects_b": {}}}, + {"name": "非字典", "row": {"node_id": "probe_neg", "effects_a": {"sense_sharp": 1}, "effects_b": {}}}, + ] + for c in cases: + var got: Array = db.crossrun.meta_tree._validate_rule_key_params(c["row"], "probe_neg") + if got.is_empty(): + errs.append("参数域负例未被拒(%s)" % String(c["name"])) + # 越域判别词在 message 内(断言定位面) + var oor: Array = db.crossrun.meta_tree._validate_rule_key_params(cases[2]["row"], "probe_neg") + var oor_hit := false + for e in oor: + if str(e).contains("越域"): + oor_hit = true + if not oor_hit: + errs.append("越域 message 缺判别词(断言定位面违约)") + # R1 对照表落点:_tree_rule_dict 注入构造面最小断言(sim 单点) + var mc = load("res://sim/adventure_monte_carlo.gd") + var td: Dictionary = mc._tree_rule_dict(db, ["posture_first_strike"]) + if not (td.size() == 1 and (td.get("posture_first_strike", {}) as Dictionary).get("ap_bonus", -1) == 1): + errs.append("_tree_rule_dict 注入构造面失配(应={posture_first_strike:{ap_bonus:1}}): %s" % str(td)) + return errs diff --git a/game/tests/unit/data_config/effect_kind_retired_test.gd b/game/tests/unit/data_config/effect_kind_retired_test.gd new file mode 100644 index 00000000..fe9819cb --- /dev/null +++ b/game/tests/unit/data_config/effect_kind_retired_test.gd @@ -0,0 +1,69 @@ +# 致命类用例(独立进程,期望退出码 1) +# 甲案词表退役〔架构整改 V3〕——overflow/attack_segments 两死效果通道摘出 EFFECT_KINDS: +# 向 feats.tres 注入 do.kind="overflow" 与 do.kind="attack_segments" 探针行 → validate_trigger_row +# 须以 EFFECT_KINDS 出域拒绝(词表闭面收窄——误写即加载致命)。任一违规被放行即回归(退出码 2)。 +# 运行:godot --headless --path game --script res://tests/unit/data_config/effect_kind_retired_test.gd +extends SceneTree + +const ConfigDBScript := preload("res://core/config_db.gd") +const Kit := preload("res://tests/unit/test_kit.gd") + +const SRC_DIR := "res://data/tables" +const BAD_OVERFLOW_DIR := "user://effect_kind_fixture_overflow" +const BAD_SEGMENTS_DIR := "user://effect_kind_fixture_segments" + +# 注入探针:死通道 do.kind(甲案摘名两族——各一探针行) +const PROBE_OVERFLOW := ' {\n "feat_id": "kind_overflow_probe",\n "display_name": "通道探针·溢出",\n "rarity": "common",\n "triggers": [ { "on": "dig_reveal", "when": [], "do": { "kind": "overflow", "value": 50 } } ],\n "desc": "夹具探针——甲案退役词 overflow"\n},\n' +const PROBE_SEGMENTS := ' {\n "feat_id": "kind_segments_probe",\n "display_name": "通道探针·连珠",\n "rarity": "common",\n "triggers": [ { "on": "dig_reveal", "when": [], "do": { "kind": "attack_segments", "cap": 2, "divisor": 3, "value": 0 } } ],\n "desc": "夹具探针——甲案退役词 attack_segments"\n},\n' + + +func _inject(dir: String, probe: String) -> bool: + var path := dir + "/feats.tres" + var f := FileAccess.open(path, FileAccess.READ) + if f == null: + push_error("注入失败:读不到 %s" % path) + return false + var s := f.get_as_text() + f.close() + var anchor := "rows = [" + var idx := s.find(anchor) + if idx < 0: + push_error("注入失败:锚点 '%s' 未找到(len=%d)" % [anchor, s.length()]) + return false + var cut := idx + anchor.length() + s = s.substr(0, cut) + "\n" + probe + s.substr(cut) + var w := FileAccess.open(path, FileAccess.WRITE) + if w == null: + push_error("注入失败:写不开 %s" % path) + return false + w.store_string(s) + w.close() + return true + + +func _assert_rejected(bad_dir: String, probe: String, label: String) -> bool: + # 返回 true=正确拒绝;false=放行或夹具失败(调用方据此退出 2) + if not Kit.copy_tables(SRC_DIR, bad_dir) or not _inject(bad_dir, probe): + push_error("[%s] 夹具目录准备失败" % label) + return false + var db = ConfigDBScript.new() + var ok: bool = db.load_tables(bad_dir, false) + if ok: + push_error("[%s] 违规表被放行——EFFECT_KINDS 出域校验失守(回归)" % label) + return false + if db.diagnostics_count("error") < 1: + push_error("[%s] 加载被拒但零 error 诊断——校验通道静默化(回归)" % label) + return false + print("[%s] 正确拒绝(error 诊断在案——EFFECT_KINDS 出域)" % label) + return true + + +func _init() -> void: + if not _assert_rejected(BAD_OVERFLOW_DIR, PROBE_OVERFLOW, "effect_kind_retired_test/①overflow 出域"): + quit(2) + return + if not _assert_rejected(BAD_SEGMENTS_DIR, PROBE_SEGMENTS, "effect_kind_retired_test/②attack_segments 出域"): + quit(2) + return + print("[effect_kind_retired_test] 双探针均正确拒绝(甲案词表退役——死通道误写加载致命)〔架构整改 V3〕") + quit(1) diff --git a/game/tests/unit/data_config/effect_kind_retired_test.gd.uid b/game/tests/unit/data_config/effect_kind_retired_test.gd.uid new file mode 100644 index 00000000..bb97b0c6 --- /dev/null +++ b/game/tests/unit/data_config/effect_kind_retired_test.gd.uid @@ -0,0 +1 @@ +uid://pgo3ndugo2w1 diff --git a/game/tests/unit/data_config/forge_table_test.gd b/game/tests/unit/data_config/forge_table_test.gd index 370bc4b5..226a5114 100644 --- a/game/tests/unit/data_config/forge_table_test.gd +++ b/game/tests/unit/data_config/forge_table_test.gd @@ -97,6 +97,21 @@ func _test_price_ladder(db) -> void: prices == [20, 30, 45, 67, 101]) Kit.check("已满级(owned=5)再购价照算 151(cap 闸归写口,价格函数纯几何)〔锻造工坊批 V2〕", ForgeTableScript.level_price(row, 5) == 151) + # 六路线全量梯(断言补齐批 V11——floor 公式按表值推算不写死表值、逐级严格递增、满级档价=上限档推算值) + var all_ladder_ok := true + for frow in db.crossrun.forge.routes: + var base: int = int(frow["base_price"]) + var ratio: int = int(frow["price_ratio_pct"]) + var cap: int = int(frow["cap"]) + var prev := 0 + for owned in range(cap + 1): + var expect: int = int(floor(float(base) * pow(float(ratio) / 100.0, float(owned)))) + if ForgeTableScript.level_price(frow, owned) != expect: + all_ladder_ok = false + if owned > 0 and expect <= prev: + all_ladder_ok = false # 逐级严格递增(ratio>100 几何保证) + prev = expect + Kit.check("六路线全量梯:floor 公式表值推算逐级严格递增、满级档价=上限档推算值〔断言补齐批 V11〕", all_ladder_ok) # ---- 负向:出域行逐类构造各一行全部被拒(V1)---- @@ -199,7 +214,7 @@ func _test_negative_cases() -> void: # 现行 31;本套件零扩签,钉现行数(将来任何加词即转红提示复核)。 func _test_effect_kinds_closed() -> void: - Kit.check("效果引擎效果类型闭集 34 种(迭代 46 换图系扩签 grant_reserve_ap/apply_discount 换代)〔锻造工坊批 V11〕", - EffectEngineScript.EFFECT_KINDS.size() == 34) + Kit.check("效果引擎效果类型闭集 32 种(甲案词表退役——overflow/attack_segments 摘名)〔锻造工坊批 V11〕", + EffectEngineScript.EFFECT_KINDS.size() == 32) Kit.check("效果引擎事件词表在册 22 种(迭代 46 换图系扩签 refresh_request/board_refreshed 换代)〔锻造工坊批 V11〕", EffectEngineScript.EVENTS.size() == 22) 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 28ac1aba..116cee43 100644 --- a/game/tests/unit/data_config/level_gen_params_test.gd +++ b/game/tests/unit/data_config/level_gen_params_test.gd @@ -26,7 +26,8 @@ func _row(L: int, layers: int, bd: float, ratios: Array, entry: int, exit_c: int return {"level_index": L, "layers": layers, "branch_density": bd, "battle_ratio": ratios[0], "reward_ratio": ratios[1], "rest_ratio": ratios[2], "shop_ratio": ratios[3], "entry_count": entry, "exit_count": exit_c, "battle_floor": floor_n, - "fork_ways_min": 2, "fork_ways_max": 3} + "fork_ways_min": 2, "fork_ways_max": 3, + "fork_third_way_pct": 35, "fill_rest_first_pct": 50, "width_max": 4} func _table(rows: Array) -> Variant: 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 ff0d7f8b..f1d5bfeb 100644 --- a/game/tests/unit/data_config/table_structure_guard_test.gd +++ b/game/tests/unit/data_config/table_structure_guard_test.gd @@ -42,6 +42,9 @@ func _init() -> void: _smoke_ac_swap_guard() _smoke_enemy_bands() _smoke_variation_framework() + _test_level_gen_new_fields() + _test_variation_header_domains() + _test_feat_cap_required() _smoke_difficulty_table() _smoke_nine_types_gp_stream() _smoke_adventure_table() @@ -529,3 +532,87 @@ func _conversion_row_of(rows: Array, attr: String) -> Dictionary: if String(r.get("attr", "")) == attr: return r return {} + + +# ---- V7 节点图三新键域校验(架构整改——缺新键行拒/width_max 越域拒/两 pct 越域拒/表值全过)---- + +func _test_level_gen_new_fields() -> void: + var tbl = _db.run.level_gen_params + var mk := func(mod: Dictionary) -> Dictionary: + var row := {"level_index": 1, "layers": 6, "branch_density": 0.3, "battle_ratio": 0.5, "reward_ratio": 0.2, + "rest_ratio": 0.2, "shop_ratio": 0.1, "entry_count": 1, "exit_count": 1, "battle_floor": 4, + "fork_ways_min": 2, "fork_ways_max": 3, "fork_third_way_pct": 35, "fill_rest_first_pct": 50, "width_max": 4} + for k in mod.keys(): + if bool(mod[k]): + row.erase(k) + else: + row[k] = mod[k] + return row + var inst = load("res://data/schemas/level_gen_params.gd").new() + # 表值 35/50/4 全过 + inst.rows.append(mk.call({})) + Kit.check("V7 表值行全过(35/50/4)〔架构整改 V7〕", inst.validate().is_empty()) + # 缺新键行拒(structural 键集闭集) + var missing: Dictionary = mk.call({"fork_third_way_pct": true, "fill_rest_first_pct": true, "width_max": true}) + inst.rows = [] as Array[Dictionary] + inst.rows.append(missing) + Kit.check("V7 缺新键行拒(ROW_KEYS 闭集)〔架构整改 V7〕", not inst.validate().is_empty()) + # width_max 越域拒 + inst.rows = [] as Array[Dictionary] + inst.rows.append(mk.call({"width_max": 7})) + Kit.check("V7 width_max=7 越域拒〔架构整改 V7〕", not inst.validate().is_empty()) + # 两 pct 越域拒 + inst.rows = [] as Array[Dictionary] + inst.rows.append(mk.call({"fork_third_way_pct": 101})) + Kit.check("V7 fork_third_way_pct=101 越域拒〔架构整改 V7〕", not inst.validate().is_empty()) + inst.rows = [] as Array[Dictionary] + inst.rows.append(mk.call({"fill_rest_first_pct": -1})) + Kit.check("V7 fill_rest_first_pct=-1 越域拒〔架构整改 V7〕", not inst.validate().is_empty()) + + +# ---- 变异表头域校验 / feat cap 必填(架构整改——丁案三件的数据面守卫)---- + +func _test_variation_header_domains() -> void: + var inst = load("res://data/schemas/variation_table.gd").new() + inst.percent_of_cells = 5 + inst.per_level_bonus = 0 + inst.pack_max = 4 + inst.pack_min = 2 + Kit.check("V11 表值头全过(5/0/4/2)〔架构整改 V11〕", inst.validate().is_empty()) + inst.pack_min = 5 # > pack_max=4 → 拒 + Kit.check("V11 pack_min>pack_max 拒〔架构整改 V11〕", not inst.validate().is_empty()) + inst.pack_min = 2 + inst.percent_of_cells = 51 # > 50 → 拒 + Kit.check("V11 percent_of_cells=51 拒〔架构整改 V11〕", not inst.validate().is_empty()) + inst.percent_of_cells = 5 + inst.pack_max = 10 # > 9 → 拒 + Kit.check("V11 pack_max=10 拒〔架构整改 V11〕", not inst.validate().is_empty()) + + +func _test_feat_cap_required() -> void: + var inst = load("res://data/schemas/feat_table.gd").new() + # 生产表全行 cap 在场(加载期已验——此处显式复核 37 行) + var all_cap := true + for row in _db.crossrun.feats.rows: + if not row.has("cap"): + all_cap = false + elif int(row["cap"]) < 1 or int(row["cap"]) > 99: + all_cap = false + Kit.check("V12 生产 feats 表全行 cap 在场且在域 [1,99]〔架构整改 V12〕", all_cap and _db.crossrun.feats.rows.size() > 0) + # 缺 cap 行拒 + var mk := {"feat_id": "probe_cap", "display_name": "探针", "rarity": "common", "triggers": []} + var no_cap := mk.duplicate(true) + inst.rows.append(no_cap) + Kit.check("V12 缺 cap 行拒(必填——零兜底)〔架构整改 V12〕", not inst.validate().is_empty()) + # 越域拒 + var oor := mk.duplicate(true) + oor["cap"] = 100 + inst.rows = [] as Array[Dictionary] + inst.rows.append(oor) + Kit.check("V12 cap=100 越域拒〔架构整改 V12〕", not inst.validate().is_empty()) + # cap_ok 直索引零兜底:在场行读 cap(relic 面真实入口) + var deck = load("res://core/curio_deck.gd").new(_db, load("res://core/rng_service.gd").new()) + var some_feat: Dictionary = _db.crossrun.feats.rows[0] + var cap_v: int = int(some_feat["cap"]) + Kit.check("V12 cap_ok 在场行直索引读 cap(首行 %s cap=%d——零兜底路径)〔架构整改 V12〕" % [String(some_feat["feat_id"]), cap_v], + deck.cap_ok("relic", String(some_feat["feat_id"])) == (int(deck.taken.get("relic:" + String(some_feat["feat_id"]), 0)) < cap_v)) diff --git a/game/tests/unit/presentation/battle_narrator_test.gd b/game/tests/unit/presentation/battle_narrator_test.gd index 13362dd7..494ef192 100644 --- a/game/tests/unit/presentation/battle_narrator_test.gd +++ b/game/tests/unit/presentation/battle_narrator_test.gd @@ -23,6 +23,7 @@ func _init() -> void: _test_phase_text_nonempty() _test_boss_name_uses_boss_rowset() _test_level_clear_wording() + _test_snapshot_effects_per_unit() quit(Kit.finish("battle_narrator_test")) @@ -99,3 +100,39 @@ func _test_level_clear_wording() -> void: {"cleared": false, "result": "failed", "levels_cleared": 1, "stopped_at": 2, "keystone_total": 4, "master_seed": 42}) Kit.check("冒险摘要完成关数行(实际「%s」)〔口径换代 V3〕" % summary, summary.contains("完成 1 关 · 止步第 2 层") and not summary.contains("通关")) + + +# ---- 呈现各读各的(增益状态 v1.5——敌方状态个体化批)---- +# 真实入口=呈现状态快照函数 presentation_snapshot:A 有削弱 B 无 → 两卡投影各现各的(零共享引用) + +func _test_snapshot_effects_per_unit() -> void: + var db = load("res://core/config_db.gd").new() + Kit.check("V8 前置:表加载零 error", db.load_tables("res://data/tables", true) and db.diagnostics_count("error") == 0) + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(20261103) + var settlement = load("res://core/battle_orchestrator.gd").new(db, rng) + # depth 3 = hobgoblin 行集 2-3 只——两卡对照面 + var open_ok: bool = settlement.begin_battle({"level_index": 1, "node_depth": 3, "hero_hp": int(db.combat.hero.max_hp)}) + Kit.check("V8 前置:开局(L1 depth3 双敌行集)", open_ok) + if not open_ok: + return + var enemies: Array = settlement._enemy_system.all_enemies() + var dual_ok: bool = enemies.size() >= 2 + Kit.check("V8 前置:≥2 敌(两卡对照面)", dual_ok) + if not dual_ok: + return + var sc = load("res://core/status_container.gd") + var src := {"source_class": "seal_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0} + Kit.check("V8 前置:A 敌受理削弱(B 敌不授)", settlement._status_effects.apply_effect("weaken", sc.owner_key_for(enemies[0]), src)) + var snap: Dictionary = settlement.presentation_snapshot() + var e1_effects: Array = snap.enemies[0].effects + var e2_effects: Array = snap.enemies[1].effects + var e1_has := false + for e in e1_effects: + if String(e.effect_id) == "weaken": + e1_has = true + var e2_has := false + for e in e2_effects: + if String(e.effect_id) == "weaken": + e2_has = true + Kit.check("A 有削弱 B 无 → 两卡投影各现各的(A 卡现削弱、B 卡无同款)〔个体化批 V8〕", e1_has and not e2_has) diff --git a/game/tests/unit/presentation/player_copy_sweep_test.gd b/game/tests/unit/presentation/player_copy_sweep_test.gd index 40fa7d12..b0b5db89 100644 --- a/game/tests/unit/presentation/player_copy_sweep_test.gd +++ b/game/tests/unit/presentation/player_copy_sweep_test.gd @@ -36,6 +36,7 @@ func _init() -> void: _sweep_source_names() _sweep_board_glyph() _sweep_token_scans() + _sweep_refresh_toast_no_literals() quit(Kit.finish("player_copy_sweep_test")) @@ -156,6 +157,27 @@ func _sweep_token_scans() -> void: orch_hits == 0) +# 刷新两 toast 文案零数字字面量(架构整改 R4——格式串零数字、插值位与表字段一一对应; +# 静态判据+评审注记观测面局限:真机文案随表值由实机目检兜——T10 清单两项) +func _sweep_refresh_toast_no_literals() -> void: + var main_src := FileAccess.get_file_as_string("res://presentation/main.gd") + var rap_toast := "" + var cap_toast := "" + for line in main_src.split(" +"): + if "弃图刷新需" in line: + rap_toast = line + if "本轮刷新次数已用完" in line: + cap_toast = line + Kit.check("V4 前置:两 toast 行在场(源锚)", rap_toast != "" and cap_toast != "") + # RAP 不足 toast:格式串零数字字面量(「需 %d」插值读 rap_refresh_cost 表值) + var rap_fmt_bad := "(弃图刷新需 8)" in rap_toast or "%d 点(弃图刷新需 %d)" not in rap_toast + Kit.check("RAP 不足 toast:格式串零数字字面量、插值位=cost 表值拼接〔架构整改 V4〕", not rap_fmt_bad) + # 轮上限 toast:文案零数字字面量(「每轮 %d 次」插值读 refresh_cap_per_round 表值) + Kit.check("轮上限 toast:零数字字面量、插值位=refresh_cap_per_round 表值拼接〔架构整改 V4〕", + "(每轮 2 次)" not in cap_toast and "每轮 %d 次" in cap_toast) + + func _first_label(node: Node) -> Label: for child in node.get_children(): if child is Label: diff --git a/game/tests/unit/presentation/settings_store_test.gd b/game/tests/unit/presentation/settings_store_test.gd new file mode 100644 index 00000000..91a7e4a0 --- /dev/null +++ b/game/tests/unit/presentation/settings_store_test.gd @@ -0,0 +1,63 @@ +# tests/unit/presentation/settings_store_test.gd — 设置存储单元契约(回读/缺省/应用链) +# 断言面 = SettingsStore 公开方法:save_settings/load_settings 持久化回读、缺文件出厂默认、 +# apply() 引擎档应用(UiKit.motion_scale 双态)。隔离卫生:一律经 _init path 注入指向临时 cfg, +# 禁碰真实 user://settings.cfg(ui_click_probe 设置面段同款纪律)。 +# 运行:godot --headless --path game --script res://tests/unit/presentation/settings_store_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const StoreScript := preload("res://presentation/settings_store.gd") + +const ISO_PATH := "user://settings_store_unit_test.cfg" # 隔离注路径(临时件——测毕清理) + + +func _init() -> void: + _test_settings_roundtrip() + _test_settings_default() + _test_settings_apply_chain() + _cleanup() + quit(Kit.finish("settings_store_test")) + + +func _cleanup() -> void: + DirAccess.remove_absolute(ProjectSettings.globalize_path(ISO_PATH)) + + +# ---- 持久化回读(写 true→存→新实例回读 true;节/键 motion/reduced)---- + +func _test_settings_roundtrip() -> void: + var w = StoreScript.new(ISO_PATH) + w.reduced_motion = true + Kit.check("前置:save_settings 落盘成功", w.save_settings()) + var r = StoreScript.new(ISO_PATH) + r.load_settings() + Kit.check("持久化回读:新实例回读 reduced_motion=true(motion/reduced 节键)〔断言补齐批 V6〕", r.reduced_motion == true) + w.reduced_motion = false + Kit.check("回写往返:改 false 再存再读得 false〔断言补齐批 V6〕", w.save_settings() and (func(): + var r2 = StoreScript.new(ISO_PATH) + r2.load_settings() + return r2.reduced_motion == false).call()) + + +# ---- 缺省值(无文件缺省 false)---- + +func _test_settings_default() -> void: + var absent_path := "user://settings_store_unit_absent_%d.cfg" % (Time.get_ticks_msec()) + DirAccess.remove_absolute(ProjectSettings.globalize_path(absent_path)) # 确保无文件 + var d = StoreScript.new(absent_path) + d.load_settings() + Kit.check("无文件缺省:reduced_motion=false(缺文件=出厂默认合法态)〔断言补齐批 V7〕", d.reduced_motion == false) + + +# ---- 引擎档应用链(reduced→apply()→UiKit.motion_scale==0.5,false→1.0;测毕还原静态初值)---- + +func _test_settings_apply_chain() -> void: + var saved_scale: float = UiKit.motion_scale # 静态初值留底——测毕还原 + var a = StoreScript.new(ISO_PATH) + a.reduced_motion = true + a.apply() + Kit.check("应用链:reduced=true → UiKit.motion_scale==0.5〔断言补齐批 V8〕", UiKit.motion_scale == 0.5) + a.reduced_motion = false + a.apply() + Kit.check("应用链:reduced=false → UiKit.motion_scale==1.0〔断言补齐批 V8〕", UiKit.motion_scale == 1.0) + UiKit.motion_scale = saved_scale # 还原(静态共享面——不渗入其他套件) diff --git a/game/tests/unit/presentation/settings_store_test.gd.uid b/game/tests/unit/presentation/settings_store_test.gd.uid new file mode 100644 index 00000000..32168909 --- /dev/null +++ b/game/tests/unit/presentation/settings_store_test.gd.uid @@ -0,0 +1 @@ +uid://bqscskbjj026u diff --git a/reports/iter50-反哺点-2026-11-03.md b/reports/iter50-反哺点-2026-11-03.md new file mode 100644 index 00000000..aad33097 --- /dev/null +++ b/reports/iter50-反哺点-2026-11-03.md @@ -0,0 +1,28 @@ +# 迭代 50 反哺点清单(2026-11-03) + +> 实现批禁写 design/ 与根规则文件——以下各条待用户裁决后由文档批落文,落文后本清单条目由用户在会话里清掉。 + +## 甲、design/ 文档落文义务 + +| # | 文档 | 落点 | 内容 | 依据 | +|---|---|---|---|---| +| 1 | design/gdd/systems/战斗循环/增益状态.md v1.5 | 〈目标规则与个体归属〉节「实装随该批」规矩行 | 删除该行(个体化已实装——合入前行为仍为旧共享态的注记过时) | 卡①关联同步面 | +| 2 | design/engineering/architecture/architecture.md | 件表/API 面 | ①新增 `MinesweeperRules.preview_reveal` 公开只读口(core 公开 API 净增 1);②戊案 `SPAWN_AREA_CELLS` 常量退役(如有登记);③庚案 battle_driver.drive_round 通用签名(act_provider/on_action/max_rejects);④R1 `MetaProgression.tree_rule_keys()` 改名 `tree_rules()` 返参数字典;⑤R1 shop.spend_discount 表字段退役(折价读规则键参数) | 卡③第 6 节同步面 | +| 3 | design/gdd/systems/冒险循环/奇物.md | cap 字段清单行(若有「可缺省/缺省按 1」类句) | 订正为「cap 必填(缺行加载致命),域 [1,99]」 | 卡③丁③(处置记录明示随卡申报反哺) | +| 4 | design/gdd/systems/跨局循环/天赋树.md v0.12 | — | 零反哺确认行(规则键参数表已是本批字段权威,实现与表逐字一致) | 卡③第 6 节 | +| 5 | design/gdd/systems/冒险循环/关卡节点图生成.md v1.4 | — | 零反哺确认行(三参数调参表行已落,实现照落;唯一注记=节点 depth 字段=最长路径深、区域序号可观测形态=d0−r——如需可入该文档注记) | 卡③第 6 节 | +| 6 | design/gdd/systems/冒险循环/冒险结构.md v1.4 | — | 零反哺确认行(货架位数行已落) | 卡③第 6 节 | +| 7 | design/gdd/systems/排雷循环/地形变异.md v1.2 | — | 零反哺确认行(pack_min 行已落;注记=per_level_bonus 现行表值 0 在域 [0,9] 内合法) | 卡③第 6 节 | +| 8 | design/gdd/systems/排雷循环/棋盘生成.md v1.2 | — | 零反哺确认行(出生区边长行已落) | 卡③第 6 节 | +| 9 | 术语表 | — | 零新增术语确认行(沿用处置记录既定用词:参数字典/只读口/驱动循环单点/个体归属) | 卡③第 6 节 | + +## 乙、game/tests/README.md(能力清单/覆盖域表) + +| # | 落点 | 内容 | +|---|---|---| +| 10 | 覆盖域表 :211「减动效开关存储契约」行 | 状态列「部分覆盖」→「已覆盖」(存储契约三断言〔断言补齐批 V6-V8〕落地;多语言切换面仍 P1 未建)——实现批禁写,归文档批翻转 | + +## 丙、过程注记(不落文,查实现报告) + +- 卡①现状考证五处行号/路径漂移、卡③取证表漂移与两处卡面-实况偏差(R8 注入通道/R10 观测面)、R1 结构还债——全数记入 `reports/iter50-实现报告-2026-11-03.md` 第六节实现决定记录 #1-#9。 +- MC 锚漂移预期与重跑义务见实现报告第八节(一档只能由用户发起)。 diff --git a/reports/iter50-实现报告-2026-11-03.md b/reports/iter50-实现报告-2026-11-03.md new file mode 100644 index 00000000..2ffc040e --- /dev/null +++ b/reports/iter50-实现报告-2026-11-03.md @@ -0,0 +1,114 @@ +# 迭代 50 实现报告(2026-11-03) + +> 三卡合批:①敌方状态个体化批 ②断言补齐批 ③架构审查 3.1+3.2 整改批。工作区 `../g-plan2-iter50`(分支 iter50,基点 6261b36e)。全部验收断言当轮真实跑批执行并通过;单元套 77 套件全绿(含 2 负面件);冒烟 15 项 0 失败;gdstyle 0 新增 0 恶化。 + +## 一、各卡状态 + +| 卡 | T 段 | 状态 | 关键提交 | +|---|---|---|---| +| 卡①敌方状态个体化 | T0/T1/T2 全收口 | 完成(V1-V8 落地) | 8d535b6f / 58c5294b / 93f25fcb | +| 卡②断言补齐 | T0-T3 全落地(T4 产出物并入本报告) | 完成(V1-V16 落地) | 卡B 提交(73 套件绿节点) | +| 卡③架构审查整改 | T0-T10 全收口 | 完成(乙丙戊丁甲/A类①/庚己/组C组D 全落) | T0→T10 逐卡提交 | + +## 二、卡①实际对照表(改动—验收,全绿) + +| 需求点 | 改动 | 验收测试(尾缀〔个体化批 Vn〕) | 层级 | 真实入口 | 判据绑定面 | 结果 | +|---|---|---|---|---|---|---| +| 实例按个体归属 | status_container.gd 桶键换代(owner_key_for/OWNER_HERO 单点,敌人="e<生成序>"沿 handle_id) | system_contracts_test.gd:`_test_status_individual_isolation`〔V1〕 | 单元 | StatusContainer.apply_effect()/query_active() | 各体投影读数互不影响+owner_key 出户 | 绿 | +| 全体授予逐体发 | reward_grant.gd `_apply_percent_row` 循环存活敌逐只发 | system_contracts_test.gd:`_test_status_grant_all_per_unit`〔V2〕 | 单元 | 容器 apply_effect(授予路径同构模拟)+reward_grant 循环经 V5/回归承载 | 逐体实例数与各自衰减 | 绿 | +| 个体死亡即清 | damage_controller `_on_unit_died`→`purge_owner` | system_contracts_test.gd:`_test_status_death_clears`〔V3〕 | 单元 | apply_effect+purge_owner() | 死体桶清零、他体不变、侧汇总只含存活 | 绿 | +| 同体合并照旧 | 合并臂 hit 提取(累加/叠加同体内) | system_contracts_test.gd:`_test_status_merge_per_unit`〔V4〕 | 单元 | apply_effect() | 累加时长倍增/满层恰拒/异体受理 | 绿 | +| 增益技归施放者自己 | combat_segment 落点=owner_key_for(u)(debuff→主角) | round_flow_skill_pool_test.gd:`_smoke_enemy_buff_targets_self`〔V5〕 | 单元 | 战斗结算编排链(技能施放——begin_battle+settle_batch 推进) | 施放者 output 池=empower 幅度、他敌恒 0(种子扫描恰一次 cast=charge) | 绿 | +| 乘区池按个体 | damage_controller.clamped_pool 带 owner_key(:375 hero攻击打target/:478 敌技source_unit) | damage_controller_test.gd:`_test_pool_consumption_per_unit`〔V6〕 | 单元 | DamageController 兑付(clamped_pool 公有算术面) | e1 池含 weaken 值、e2 恒 0 | 绿 | +| AC 合流按个体 | defender_ac 敌分支只读被打者名下 ac 型 | damage_controller_test.gd:`_test_defender_ac_per_unit`〔V7〕 | 单元 | defender_ac() | e1=基数+alert、e2=自身基数 | 绿 | +| 呈现各读各的 | presentation_snapshot enemies[].effects 逐卡读自己桶(删同引用)+standee_body 注释换代 | battle_narrator_test.gd:`_test_snapshot_effects_per_unit`〔V8〕 | 单元 | presentation_snapshot()(真编排器) | A 卡现削弱、B 卡无同款 | 绿 | +| 申报换代两件 | armor_roll V1-V3 重写(个体键 "e1" 读数,累加/覆盖/同轮语义保留);衰减相位断言 :413-421/:449 核对=decay 行相位与桶结构无关,零改动即等价换代 | roll_multiplier_test〔状态累加 V1/V2/V3〕+round_flow 衰减段 | 单元 | 原真实入口 | 原判据个体化 | 绿 | + +主角侧回归:attack_up/ac_up 全套既有断言绿(roll_multiplier 42 checks)+冒烟 15 项 0 失败。 + +## 三、卡②实际对照表(全绿,尾缀〔断言补齐批 Vn〕) + +| 需求点 | 验收测试 | 结果 | +|---|---|---| +| ①衰减逐值/到期移除/战斗结束全清 | system_contracts_test:`_test_status_decay_and_clear`〔V1/V2〕(notify_battle_end 全测试目录首次落断言) | 绿 | +| ①满层拒绝 | `_test_status_stack_full_reject`〔V3〕 | 绿 | +| ①独立并存+首授快照 | `_test_status_independent_branch`〔V4,试验行注入测毕还原〕/`_test_status_snapshot_first_grant`〔V5〕 | 绿 | +| ②存储回读/缺省/应用链 | settings_store_test.gd(新件)三函数〔V6/V7/V8〕——_init path 注入隔离,禁碰真实 user://settings.cfg | 绿 | +| ⑤每区域恰一 Boss 终点 | node_map_generator_test:`_test_boss_exit_per_region`〔V9〕——节点 depth=最长路径深(生成器校验轴),区域序号可观测形态=d0−r(区域段 L−r 的 1 步差) | 绿 | +| ⑤Boss 节点开战必选 Boss 行集 | boss_gate_test.gd(新件)`_test_boss_node_spawns_boss_rowset`〔V10〕——行类路由+begin_battle 端到端(实例化敌人全部出自 boss 行集) | 绿 | +| ⑥全路线价格梯+多级购买 | forge_table_test:`_test_price_ladder`(扩)〔V11〕——floor 公式表值推算六路线逐级严格递增;meta_progression_forge_test 长剑链至 3 级〔V12〕 | 绿 | +| ⑥拒购零副作用 | `_test_forge_buy_reject`〔V13〕 | 绿 | +| ⑥祝福件零泄漏 | `_test_blessing_no_forge_leak`〔V14,LevelRunner 真实装配链 buy_product〕 | 绿 | +| ⑥默认值解耦+甲型双轴 | `_test_default_axes_pre_key`〔V15〕/`_test_armor_switch_dual_axes`〔V16——切回后新 runner 读新选中(快照冻结契约)〕 | 绿 | + +注:卡② T3-2 卡面写「扩展 `_meta_pricing_and_buy`」,实况长剑购买链在 `_smoke_forge_axes`(:411-420 考证即该函数)——V12 落该函数购买链段(主题归属正确),函数名指认漂移记实现决定记录 #1。 + +## 四、卡③实际对照表(R1-R15 全绿,尾缀〔架构整改 Vn〕) + +| R | 改动 | 验收测试 | 结果 | +|---|---|---|---| +| R1 六键参数字典 | meta_tree.tres 三 Fork 行参数字典+RULE_KEY_PARAMS 域表+四形态校验;effect_engine 四分支全读参零字面量;battle_fx_host/orchestrator/shop_session/meta_progression(tree_rules())/adventure_runner/level_runner/main 全链 Dictionary 换代;sim _tree_rule_dict 注入单点+透传守卫 | tree_rules_v11_test:`_test_rule_key_params`(六键域正反例+_tree_rule_dict 最小断言)+既有六键行为段回归 | 绿 | +| R2 散折价收回 | shop.spend_discount 字段与域校验退役;_spend_discounted 读 discount_pct/100 | level_shop_economy_test:`_test_spend_discount_from_rule_key`〔V2〕+迁移断言删(申报 7) | 绿 | +| R3 死通道退役 | EFFECT_KINDS 摘两名(34→32);_kill_effects 改名 overflow 分支删;attack_segments 消费段删 | effect_kind_retired_test(新负面件)双探针加载致命〔V3〕+round_flow 段内序回归 | 绿 | +| R4 文案读表 | main.gd 两 toast 插值读 rap_refresh_cost/refresh_cap_per_round | player_copy_sweep_test:`_sweep_refresh_toast_no_literals`〔V4〕静态+目检局限注记 | 绿 | +| R5 必填注入 | playtest_logger 删默认+守卫 [1,32];action_economy_table 删 schema 默认;level_autoplay 注入序前置×2 | playtest_logger_guard_test(新件)〔V5〕 | 绿 | +| R6 clamp 读单点 | resolver 读 MULT_MIN/MULT_MAX;失实「GDD Edge Case」注释订正两处 | config_db_resolver_test 回归(值逐位同) | 绿 | +| R7 节点图参数入表+域并单点 | ROW_KEYS+三键+域校验;_cp_domain 整函数退役;MAX_WIDTH 删(三处消费全读表——:274 宽度校验为卡面漏列同款消费,记 #2) | node_map_generator_test:`_test_gen_params_table_driven`〔V6〕+table_structure_guard:`_test_level_gen_new_fields`〔V7〕 | 绿 | +| R8 出生区入表 | spawn_edge 表头零哨兵+[3,5]奇数域;board_data 常量退役+几何导出;board_generator edge² 公式+剃雷域导出;calibrate 一行透传 | board_generator_test:`_test_spawn_edge_from_table`〔V8〕+board_gen_reward_test 推导换代(cap 压线 9 语义不减) | 绿 | +| R9 货架位数入表 | shelf_size 零哨兵+[1,6];shop_session 读表 | level_shop_economy_test:`_test_shelf_size_from_table`〔V9,夹具=5 货架五件〕 | 绿 | +| R10 成包下界入表+表头域校验 | pack_min 零哨兵+三域校验单点;board_installer 下界读表 | variant_placement_test:`_test_pack_min_from_table`〔V10——观测面局限申报见六-#4〕+table_structure_guard:`_test_variation_header_domains`〔V11〕 | 绿 | +| R11 feat cap 必填 | 缺 cap 拒+域 [1,99];feats.tres 37 行全 cap(加载零诊断);curio_deck 死变量删+悬空报错 | table_structure_guard:`_test_feat_cap_required`〔V12〕 | 绿 | +| R12 驱动循环收敛 | battle_driver 通用签名(缺省两参逐位);level_autoplay 内层委托+抄本删除 | battle_driver_drive_test(新件)`_smoke_drive_round_contract`〔V13——缺省确定性/provider 三形态/max_rejects/合步〕 | 绿 | +| R13 档位排序读表 | _pick_token 改读 TIER_RANK(未知档 0 同语义) | adventure_feats 316-f 既有断言回归+静态(源零 rank 字面量——match 表删实核) | 绿 | +| R14 只读预览口 | preview_reveal 只读口(零写入零事件) | minesweeper_rules_test:`_test_preview_reveal_readonly`〔V14——封印两形态零写入+实挖差集 F2 同口径〕 | 绿 | +| R15 0 格连锁算子 | _deduce 补算子经 preview_reveal 真实闭包 | auto_player_deduce_test(新件)〔V15——远端 safe 形态格+零盲挖〕 | 绿 | + +零行为改动面(不设对照表行):组 D(experiment_density 删除+引用面清理——全库 grep 零命中,board_gen.tres 出处指针合法);组 C(damage_probe 零改动——run_all/闸脚本零引用+头注「诊断工具·非验收件」在位)。证据=T9/T10 步骤读数(本报告第五节)。 + +## 五、S13 对账与基线读数 + +- **改前底账**(T0 grep):overflow(damage_controller :361/:396/:402/:403/:407/:411-414+effect_engine :29/:464+adventure_feats_test :743)、attack_segments(effect_engine :29+combat_segment :62/:65)、fx_streak_segments(combat_segment :66+round_flow :443)、experiment_density(README :53+board_generator :23+board_gen.tres :414)。 +- **冒烟基线**:15 项 0 失败(T0 起点;终态同绿)。 +- **S13 对账**:现场读数(T0)→逐卡改后核对——终态 `gdstyle_gate: ok`(0 新增 0 恶化);途中三条新红(apply_effect 参数/返回/嵌套+presentation_snapshot 函数长)与两条(_validate_rule_key_params 嵌套/feat_table.validate 分支)全部就地拆段还债(单出口化+hit 提取+私有 helper 四件),无基线改动。 +- **T0 复核**:取证表 18 处现址——子代理 10 处+本会话补核 #7/#16 全部一致;漂移处(fx_apply_status :451→:427、_kill_overflow 体至 :418、combat_segment :63、卡② V12 函数名、卡③ #16 _cp_domain :76 起)按 L2 备案(第五节决定记录)。 + +## 六、实现决定记录(代拍板白名单外备案) + +1. **卡② V12 函数名指认漂移**:卡面写扩展 `_meta_pricing_and_buy`,实况长剑购买链在 `_smoke_forge_axes`(考证 :411-420 即该段)——V12 落购买链段;`_meta_pricing_and_buy` 是树购买面与锻造无关。覆盖实质不受损。 +2. **卡③ R8 MAX_WIDTH 第三消费点**:卡面列 :382 一处,实况 :274(_vo_width_rules 校验段)与 :383 两处同引——同需求点同款读表换代一并落(常量删除的编译级连带)。 +3. **卡③ R8 spawn_edge 注入通道**:卡面「resolve 注入保证在场」与实况不符(resolve_board_gen 返回行字典不含表头)——改 generate 内 `db.combat.board_gen.spawn_edge` 直索引(fail-fast 同语义),FakeDB/_StubBoardGen 两桩补表头成员(夹具形态迁移)。 +4. **卡③ R10 pack_min 观测面局限申报**(test-conventions §8.4):pack_min 消费面=randi_range 下界(软目标——8 邻贴接禁令下生长早停为合法态〔board_installer 注释明示〕,L2 候选池碎片化使「产物零 审计体=未参与实现子代理(test-conventions §8.7 默认通道,迭代 loop 内启动)。两轮:首轮(重塑尖 a2886a53)判**不通过**(缺漏三条)→实现侧整改笔 6cb1cf5e→复验判**通过**。终判=**通过**(遗留一项 L2 记录面订正已随本文件落盘闭合)。 + +--- + +# 迭代 50 独立验收审计报告(2026-11-03) + +审计体:未参与实现的子代理,只读核对+自跑最窄测试抽查。对象:`D:\code\g-plan2-iter50`(分支 iter50,尖 a2886a53,diff 基点 ab5d75a2)。三卡+实现报告+diff 全量过目;测试抽查 18 件次全部自跑。 + +## 一、总体判定:不通过 + +结论行:卡①卡②对照表全部闭合、卡③ 15 行中 13 行闭合,但卡③两行验收测试从未落地——R14 的 V14 断言全仓不存在而实现报告报「绿」,R2 的 V2 具名函数不存在(仅由申报 8 的既有断言改造承载同值判据);按卡③第 8 节末「未过不合并」口径,本批不得合并。 + +### 缺漏清单 + +| # | 缺漏 | 依据 | +|---|---|---| +| 1 | **R14/V14 验收测试不存在(重)** | `minesweeper_rules_test.gd:_test_preview_reveal_readonly` 在全仓零命中(git log -S 证实该名只出现在写卡提交 a05fd7b8 的卡片文本里);`git diff ab5d75a2..iter50 -- game/tests/unit/core/minesweeper_rules_test.gd` 为空——该文件本批零触碰(末次改动停在迭代 29 提交 d091c981)。行为面已落地(`core/minesweeper_rules.gd:373` preview_reveal 在码),但对照表声明的判据(零状态写入/与 settle_batch 实挖差集 F2 同口径/封印两形态/cell_revealed 零发射)无任何断言承载;唯一触及 preview_reveal 的测试是 V15(auto_player_deduce_test),只把它当依赖用,不绑只读契约。实现报告第四节 R14 行写「`_test_preview_reveal_readonly`〔V14——封印两形态零写入+实挖差集 F2 同口径〕绿」=**虚假绿记录** | +| 2 | **R2/V2 具名验收函数不存在(中)** | `level_shop_economy_test.gd` 中无 `_test_spend_discount_from_rule_key`(git log -S 同样只命中写卡提交);该文件本批仅新增 V9(货架位)+删迁移断言(申报 7)。等价行为判据实际由 `meta_progression_forge_test.gd` `_smoke_tree_rules` 内既有「散激活:商品购买价 ×80%」断言经申报 8 改造承载(真实入口同为 LevelRunner 装配链→current_price,期望算式已改 discount_pct 推导,本轮实跑绿)——行为覆盖在,但对照表行与 tests/README.md:101 指认的具名载体不存在,尾缀〔架构整改 V2〕全仓测试代码零命中 | +| 3 | **实现报告第九节名实核对结论失实** | 报告称「代表用例名与实际落地函数名一致(V13/V5 两处实现侧改名对齐)」——V2/V14 两处名实断裂未核对出、未呈报;卡③ T10 批末核对义务(「名实一致,改名则回表」)未完成 | + +## 二、逐卡对照表逐行核对 + +### 卡①(敌方状态个体化批)——9 行全部通过 + +| 行 | 函数存在 | 尾缀可 grep | 真实入口未绕行 | 本轮实跑 | +|---|---|---|---|---| +| V1 个体隔离 | system_contracts_test.gd:138 | 〔个体化批 V1〕×3 | StatusContainer 公开 API(apply_effect/query_active) | 绿(50 checks) | +| V2 全体逐体发 | :159 | ×3 | 容器公开 API(卡面既定入口;reward_grant 循环本体按卡面「同上」由容器同构模拟承载——卡既定设计,非实现绕行,见观察 1) | 绿 | +| V3 死亡即清 | :185 | ×4 | apply_effect+purge_owner+query_active_side 公开面 | 绿 | +| V4 同体合并 | :198 | ×3 | apply_effect 公开面 | 绿 | +| V5 增益技自体落点 | round_flow_skill_pool_test.gd:1097 | ×1 | **真实编排链** begin_battle+settle_batch 推进(无裸 EffectEngine 绕行);种子扫描恰一次 cast=charge | 绿(round_flow 136 checks) | +| V6 乘区池按个体 | damage_controller_test.gd:412 | ×1 | DamageController 公有算术面 clamped_pool+公开 owner_key_for | 绿 | +| V7 AC 按个体 | :431 | ×1 | defender_ac() 公开面 | 绿 | +| V8 呈现各读各的 | battle_narrator_test.gd:108 | ×1 | presentation_snapshot() 公开出户(判据钉两卡投影差;夹具布线经既有直查惯例) | 绿(22 checks) | +| 申报换代两件 | roll_multiplier 重写在 diff 实证("e1" 键读数,累加/覆盖/同轮三断言原样,尾缀〔状态累加 V1/V2/V3〕保留) | ✓ | 原入口 | 绿;衰减相位断言按报告零改动——diff 证实该区零删行,衰减改逐桶后相位断言语义不受影响,等价成立 | + +观察 1(不判缺漏):V2 的改动点 reward_grant.gd 循环授予无直接驱动测试,判据由容器侧同构模拟+V5/既有回归承载——这是卡面对照表自己声明的入口设计(「真实入口=同上」),实现照卡落,风险登记为对照表既定弱绑定,不属实现绕行。 + +### 卡②(断言补齐批)——V1-V16 全部通过 + +| 行 | 落点实证 | 本轮实跑 | +|---|---|---| +| V1/V2 衰减与全清 | system_contracts_test.gd:220(notify_battle_end 首次落断言,尾缀×7) | 绿 | +| V3 满层拒绝 | :240 | 绿 | +| V4/V5 独立并存/首授快照 | :257/:281(试验行注入测毕还原) | 绿 | +| V6/V7/V8 设置存储 | settings_store_test.gd(新件,manifest 已登记):隔离经构造注入临时 cfg+测毕清理,未碰真实 user://settings.cfg——卡面写 retarget(),实现改 _init 注入,隔离卫生实质达成(白名单级夹具细节,报告第三节已披露) | 绿(6 checks) | +| V9 每区域恰一 Boss 终点 | node_map_generator_test.gd:310,:32 挂入 _init | 绿(43 checks) | +| V10 Boss 行集端到端 | boss_gate_test.gd(新件):行类路由+begin_battle(node_type="boss") 真链,无绕行 | 绿(4 checks) | +| V11 全路线价格梯 | forge_table_test.gd:91(floor 公式表值推算,尾缀+注释在案) | 绿(42 checks) | +| V12 购买链至 3 级 | 落在 `_smoke_forge_axes`(:425-430)——卡面写 `_meta_pricing_and_buy`,实况该函数在 :134 且属树购买面;报告决定记录 #1 已备案指认漂移,判据面(45 档扣款余额递减)不动 | 绿(151 checks) | +| V13 拒购零副作用 | :779 | 绿 | +| V14 祝福件零泄漏 | :793(LevelRunner 真实装配链 buy_product) | 绿 | +| V15/V16 默认轴/甲型双轴 | :811/:819 | 绿 | + +### 卡③(架构整改批)——15 行中 13 行通过,R2/R14 缺漏(见第一节) + +通过行实证:V1(tree_rules_v11_test.gd:90 `_test_rule_key_params`,六键正反例+`_tree_rule_dict` 最小断言+越域判别词检——本轮绿);V3(effect_kind_retired_test 负面件双探针,真实退出码 1,manifest expect=1 negative=true 在位);V4(player_copy_sweep 两 toast 零字面量,静态判据+目检局限注记照卡);V5(logger 守卫五态);V6/V7(三参数表驱动产物可观测+ROW_KEYS 闭集越域拒);V8(edge=5/3 几何与真路径);V9(货架五件);V10(静态+同种子互异,与决定记录 #4 申报的可达判据逐字一致);V11/V12(表头三域+cap 必填 37 行在场);V13(驱动契约 10 checks:缺省确定性/provider 三形态/max_rejects/合步弃 dig);V15(远端 safe 形态+零盲挖)。R6/R13 静态判据复核:resolver 源读 MULT_MIN/MULT_MAX、_pick_token 源零 rank 字面量(match 表已删,grep 实证)。代码面抽查:EFFECT_KINDS 两名零残留、_kill_effects 改名无转发壳、_cp_domain 整函数退役、MAX_WIDTH 常量删净三消费全读表(决定记录 #2 实证)、spawn_edge 经 generate 直索引(决定记录 #3 实证)、main.gd 两 toast 读表插值(:633/:636 实证)。 + +V1 尾缀注记(不判红):〔架构整改 V1/V2/V14〕在测试代码内零命中,仅在 tests/README.md 能力行——卡③无卡①②式「尾缀全仓可 grep」红线,能力行即其定位面;但 V14 能力行指向不存在的函数,随缺漏 1 一并判红。 + +## 三、抽查跑批记录 + +命令形态:`"D:/code/godot/Godot_v4.7.2-stable_win64_console.exe" --headless --path game --script res://tests/unit/<路径>.gd`(退出码经无管道捕获)。 + +| 件 | 退出码 | 关键输出 | +|---|---|---| +| contracts/system_contracts_test | 0 | all green (50 checks);「个体隔离:A/B 各持一条〔个体化批 V1〕」「死亡即清:e1 桶清零〔V3〕」「首授快照〔断言补齐批 V5〕」均 PASS | +| battle_settlement/damage_controller_test | 0 | all green (50 checks);「B 敌的削弱不进 A 敌的账〔个体化批 V6〕」「alert 各算各的〔V7〕」PASS | +| battle_settlement/round_flow_skill_pool_test | 0 | all green (136 checks);「敌方增益技自体落点:施放者 output 池=蓄力幅度、他敌恒 0〔个体化批 V5〕」PASS | +| presentation/battle_narrator_test | 0 | all green (22 checks);「A 有削弱 B 无 → 两卡投影各现各的〔个体化批 V8〕」PASS | +| presentation/settings_store_test | 0 | all green (6 checks)(V6/V7/V8) | +| battle_settlement/boss_gate_test | 0 | all green (4 checks)(V10) | +| core/meta_progression_forge_test | 0 | all green (151 checks);「长剑三级购〔断言补齐批 V12〕」「散激活:商品购买价 ×80%」「聚:战斗掉落乘算 ×1.5」PASS | +| data_config/forge_table_test | 0 | all green (42 checks)(V11+闭集 32 钉数) | +| core/node_map_generator_test | 0 | all green (43 checks);「表值驱动:同种子产图逐位复现〔架构整改 V6〕」等 PASS | +| data_config/table_structure_guard_test | 0 | all green (79 checks);「V7 表值行全过(35/50/4)」等 PASS | +| core/level_shop_economy_test | 0 | all green (192 checks);「夹具 shelf_size=5 → 货架五件〔架构整改 V9〕」PASS | +| battle_settlement/variant_placement_test | 0 | all green (51 checks);V10 静态+互异两判据 PASS | +| core/board_generator_test | 0 | all green (16 checks);「V8 几何:edge=3 → spawn_cells 9 格(与退役常量同值——逐位回归)」PASS | +| presentation/player_copy_sweep_test | 0 | all green (25 checks);两 toast 零字面量 PASS | +| core/minesweeper_rules_test | 0 | all green (12 checks)——**12 项全为旧集,零 preview 断言执行**(缺漏 1 的运行时实证) | +| core/battle_driver_drive_test | 0 | all green (10 checks);「缺省两参:真编排器同种子两跑事件流逐位〔架构整改 V13〕」等 PASS | +| core/auto_player_deduce_test | 0 | all green (4 checks);「远端格入 safe 集(桥接 0 区域传播)〔架构整改 V15〕」PASS | +| data_config/effect_kind_retired_test | **1**(负面件,符合 expect=1) | 「双探针均正确拒绝(甲案词表退役)〔架构整改 V3〕」 | +| core/playtest_logger_guard_test | 0 | all green (5 checks);未注入空串/零文件、注入 8 放行、越域 33 拒 PASS | +| core/tree_rules_v11_test | 0 | 「四键合成行零违例+聚散两键无行成立+反例被拒+六键参数域全过」 | +| core/feat_vouchers_test | 0 | all green (49 checks)(R1 注入面回归) | + +抽查密度:卡① 8 断言函数、卡② 14 函数、卡③ 12 段——均超每卡 3 条要求。run_all.gd manifest 本批 +6 行(卡③ 4 件+卡② 2 件,负面件 expect=1 negative=true 形态正确)。 + +## 四、测试削弱核查结论 + +**实质零削弱,申报面两处程序性缺项。** 删行全量对配结果(`git diff ab5d75a2..iter50 -- game/tests` 删行 17 条断言类逐条核对): + +| 删改 | 归属 | 结论 | +|---|---|---| +| roll_multiplier armor_roll 三断言换代("e1" 键) | 卡①申报 1 | 通过——语义逐字保留,尾缀随原批 | +| round_flow 衰减相位断言 | 卡①申报 2 | 通过——零改动即等价(diff 证实该区无删行) | +| round_flow 排除表删 fx_streak_segments+注释删连珠矢 | 卡③申报 5 | 通过——段内序判据不动 | +| adventure_feats :743 载荷删 overflow 键 | 卡③申报 4 | 通过——「斩首学者击杀揭示 1 格」逐字保留 | +| board_gen_reward SPAWN_AREA_CELLS→spawn_edge² 三处 | 卡③申报 2 | 通过——cap 压线 9 语义不减(真表值 3²=9 推导) | +| board_generator from_expr→edge² | 卡③申报 3 | 通过 | +| level_shop :264 spend_discount==0.8 删 | 卡③申报 7 | 通过——但声明的承接件 V2 未落地(缺漏 2;实际承接=申报 8 改造断言) | +| meta_forge :739-740 期望算式改 discount_pct 推导 | 卡③申报 8 | 通过——折后价期望逐位同值 | +| adventure_feats rebuild []→{} ×30+meta_forge 3 处 | 卡③申报 9 | 通过 | +| tree_rules_v11 夹具字面量→参数字典 | 卡③申报 1 | 通过——四键行数对照+validate 零违例结构不变 | +| node_map_growth 夹具补三新键 | 卡③申报 6 | 通过——结构拦截与 fatal 断言零松动 | +| **forge_table 闭集钉数 34→32**(`_test_effect_kinds_closed`) | **申报外** | **例外**——R3 摘名的必然连带(钉数断言按其自身注释「钉现行数」必须随词表换代),强度不减(仍捕获任何增删),但**未列入卡③申报 9 条**,实现报告仅作改动事实记录(第四节「EFFECT_KINDS 摘两名(34→32)」)未按削弱申报程序申报 | +| system_contracts EC1/EC2/受理、damage_controller 池四断言、seal_polarity 侧汇总等签名适配 | 卡①②申报表外 | **例外(备案核对通过)**——apply_effect 增加 owner_key 必填参的编译级连带,删行全部原内容重挂(EC1/EC2/池值/ac 不入池/满层拒绝逐字保留,仅加 OWNER_HERO 显式参),实现报告决定记录 #7 事后备案「断言内容零改动」——对照卡①红线「静默放宽=判红」,此批无放宽,判例外不判红 | + +## 五、内部字段直查受控例外核查 + +判据权威=test-conventions §8(直查仅限无公开可观测面可钉,对照表行须注明理由)。结论:**新增断言的判据面全部钉公开可观测出口,零「以直查代判据」;卡①「零直查行」声明在判据面意义上成立。** 逐点: + +- 卡① V1-V4:全经容器公开 API;V6/V7 经 DamageController 公有算术面;V8 判据钉 presentation_snapshot() 公开出户(:657 成文出口),`_status_effects`/`_enemy_system` 仅作夹具布线(授予/取敌),沿两文件既有直查惯例(round_flow 基点已有 8 处 _enemy_system)。 +- 卡② V10 读实例化敌走 `oc._enemy_system`——实例化敌清单无公开面可钉,卡面判据绑定面文本「实例化敌人行键投影」即该理由的成文位置(判例边缘,登记不判红)。 +- 卡③对照表具名私有面(`_deduce`、`_tree_rule_dict`、`_validate_rule_key_params`)均为卡面点名的被测交付件本身,非绕行;V15 真板经 begin_battle+drive_round 真实装配(卡面括注 perfect_action 装配链,实现取板路径稍异——夹具布线白名单级)。 +- 两处次要注记:playtest_logger_guard 断言 `_path` 留空(内部读,与公开空串返回+盘上零文件两公开判据并存,非承重);meta_forge 夹具 `_add_keystone`(基点已有 10 处同款惯例,本批 +2)。 + +## 六、零行为改动面读数(卡③组 D/组 C) + +- `grep -rn experiment_density game/ tools/`:唯一命中=`game/data/tables/board_gen.tres:415` 出处指针注释(卡面合法指针形态;行号 :415 对卡面 :414 漂 1 行,L2 级);`game/sim/experiment_density.gd` 及 .uid 确已删除;game/README.md 工具行零残留。**通过**。 +- `grep damage_probe game/tests/run_all.gd tools/run_all_gates.sh`:零命中;`game/sim/damage_probe.gd:2` 头注「诊断工具·非验收件——不出入任何闸」在位。**通过**。 +- gdstyle:`python tools/gdstyle_gate.py` → 「扫描面 158 个 .gd;命中 5 条,基线内 5 条,基线外新违规 0 条,较登记值恶化 0 条 / ok」,退出码 0。**通过**。 +- 实现报告第六节 9 条决定记录逐条过目:#1-#9 全数与代码实况一致(#9 明细行 pool_value 改动面在 diff 在案,未独立复跑验证,风险低)。 + +## 七、判定依据汇总 + +不通过的唯一直接依据=第一节缺漏清单三条,全部可由仓库证据复现:缺漏 1/2 的函数名在 `git log -S` 全史仅出现于写卡提交 a05fd7b8(卡片文本),实现三笔提交(e6584927/cd133932/a2886a53)均未落地对应测试件;缺漏 3 为报告第九节对同一事实的失实陈述。卡③第 8 节末自我约束「独立验收审计未过不合并」,本报告判不通过即触发该条:R14 需补 `_test_preview_reveal_readonly`(判据按对照表行:F2 同口径差集/零状态写入/零事件/封印两形态);R2 需补具名承接件或由写卡段改表(实现批禁写 README,现状属名实断裂应按 L3 呈报回表);补齐后重跑受影响域闸方可合并。其余各面(卡①②全部、卡③ 13/15 行、削弱实质、直查纪律、零行为面、gdstyle)独立核验为通过。 + +--- + +# 迭代 50 复验报告(2026-11-03,整改笔 6cb1cf5e) + +## 1. 缺漏 1(R14/V14)——通过 + +| 核对点 | 实证 | +|---|---| +| 函数名+尾缀在位 | `tests/unit/core/minesweeper_rules_test.gd`:`func _test_preview_reveal_readonly()` :118、`_init` 挂载 :43;〔架构整改 V14〕尾缀 4 条 :127/:134/:147/:153,grep 实证 | +| 自跑全绿 | 退出码 0,`[minesweeper_rules_test] all green (17 checks)`——12 旧+5 新,与整改申报一致 | +| 五断言与卡③对照表判据对齐 | 形态A 零状态写入(revealed/seal 计数前后零变化)/形态B preview(封印格) 零状态写入(隐式退役只入模拟集、真实封印计数不动)/F2 同口径逐位(`str(prev_c) == str(after_diff)`——真实双入口输出比对,非硬编码)/cell_revealed 零发射(三次 preview 前后增量 0)/形态A 返回集形态 1 条 | + +注记两条(登记不判红):①F2 同口径对照的实跑实例为退化形态(输出「0 vs 0 格」——夹具首格取自开局扩张区外的数字格,实挖零新增格),多格连锁传播的同口径对照未在本件实例化;该面由 V15 承载(auto_player_deduce 远端 safe 形态格只能经 preview_reveal 闭包传播产生,已实跑绿),且 V14 断言本身比对真实输出、非空转恒真,判据绑定成立。②五断言中「形态A:preview 返回集」一条无尾缀且判据弱(`size >= 0` 恒真),承重面在四条带尾缀件。 + +## 2. 缺漏 2(R2/V2)——通过 + +| 核对点 | 实证 | +|---|---| +| 函数名+尾缀在位 | `tests/unit/core/level_shop_economy_test.gd`:`func _test_spend_discount_from_rule_key()` :1583、`_init` 挂载 :255;〔架构整改 V2〕尾缀 3 条 :1585/:1595/:1596,grep 实证 | +| 自跑全绿 | 退出码 0,`[level_shop_economy_test] all green (195 checks)`——192 旧+3 新,与整改申报一致 | +| 三断言与卡③对照表 R2 判据对齐 | shop.tres 加载零诊断+`get("spend_discount") == null`(字段真删);折后价 = (基价+关卡加成)×discount_pct/100 floor 逐位(实测 80 vs 期望 80);未激活原价对照(100)。真实入口=LevelRunner 装配链 `lr.shop.current_price()`+economy_spend 注入,与对照表 R2「真实入口」格逐字一致 | + +## 3. 缺漏 3(报告订正)——通过,附一项新发现的小失实 + +- 第九节订正属实:原文「代表用例名与实际落地函数名一致」改为如实记录「首版核对漏检两处名实断裂(V14/V2 函数未落地即报一致),经独立验收审计判缺漏后补齐」——与首轮审计发现逐字吻合。 +- 第十节新增属实:首轮「不通过」判定、三条缺漏定性(含虚假绿根因=实现期 patch 脚本漏写落盘调用,归因实现侧)与整改记录,均与本轮复验实况一致。 +- 决定记录 #10 属实:forge_table 闭集钉数 34→32 补账,定性(R3 摘名必然连带、强度不减、属形态迁移同申报 1-9 族、未在卡面申报清单内)与首轮审计例外判定一致。 +- **新发现小项(L2 记录面)**:第十节写「报告全文落 `reports/iter50-验收审计-2026-11-03.md`」——该文件不存在(reports/ 目录无此件,git 全史无新增记录)。首轮审计按子代理指令以文本返回、未落盘;该路径引用应订正,或由发起会话把审计文本落到该路径使引用成立。不属对照表行、不属行为面、不属测试削弱。 + +整改笔触碰面收敛:`git show 6cb1cf5e --stat` = 仅两测试件+实现报告三文件(+72/−2),零越界改动、零既有断言触碰。 + +## 最终判定 + +**通过**——首轮缺漏三条全部闭合(V14/V2 具名断言落地且实跑绿、报告订正与实况一致),整改笔触碰面收敛;遗留一项 L2 记录面订正(第十节引用的审计报告归档路径 `reports/iter50-验收审计-2026-11-03.md` 未落盘,落盘或改引用由发起会话裁处),不阻塞合并。 + +--- + +## 终判(发起会话归档注记) + +**通过**。复验遗留 L2 项(第十节引用的审计报告归档路径未落盘)以本文件落盘闭合——引用自此成立。随批合并(合回后 post-merge 钩子自动跑 CI 全量域闸,红则修复追绿)。 + +--- + +## 终判(发起会话归档注记) + +**通过**。复验遗留 L2 项(首轮审计报告归档路径未落盘)以本文件落盘闭合——引用自此成立。随批合并(合回后 post-merge 钩子自动跑 CI 全量域闸,红则修复追绿)。