diff --git a/game/core/action_economy.gd b/game/core/action_economy.gd index 5a49f05b..8fc13919 100644 --- a/game/core/action_economy.gd +++ b/game/core/action_economy.gd @@ -93,11 +93,29 @@ func request_dig(chain_size: int, clears_map: bool) -> bool: _reject("phase") return false var cost := _cost("dig") - # dig_paid 发射+施加(付费开掘发起、动作经济闸收对价前: + # dig_paid 发射+施加(付费开掘发起、动作经济闸收对价前——施加单点 _apply_dig_paid_hits): # counter_spend 命中=尝试加收对价并积计数;资源不足走 fallback/耗尽即止=只付常规基础对价。 - # counter_add〔双管镐 付费挖格计数〕/ grant_ap〔每第 2 次返 1 AP——对价减免, - # mod_eq 闸在行 when〕同面施加) + var apply_res := _apply_dig_paid_hits(cost) + cost = int(apply_res["cost"]) + if _ap < cost: + _reject("resource_face") + return false # 拒绝轮零入池(矿脉储能只在扣费受理后兑付——C2 成功才登记同款纪律) + if int(apply_res["refund"]) > 0: + cost = maxi(0, cost - int(apply_res["refund"])) # 返还=对价减免(净费不低于 0——免费开格不产生负对价) + var accepted := _spend_cost_and_arbitrate(cost, clears_map) + if accepted and int(apply_res["rap_gain"]) > 0: + grant_reserve_ap(int(apply_res["rap_gain"])) # 每次受理的付费挖格产 RAP(一次性入池——每动作恰一挖,连锁不二次) + return accepted + + +# dig_paid 命中施加单点(迭代 46 卡A T3 自 request_dig 迁出——分支本体逐字保留: +# counter_spend 加收对价并积计数/counter_add 施加〔双管镐 付费挖格计数〕/ +# grant_ap 对价减免〔每第 2 次返 1 AP——mod_eq 闸在行 when〕/grant_reserve_ap 矿脉储能累计; +# 命中序=声明序不变。返回 {cost, refund, rap_gain} 交受理面消费) +func _apply_dig_paid_hits(base_cost: int) -> Dictionary: + var cost := base_cost var dig_refund := 0 + var rap_gain := 0 if _fx != null: for h in _fx.dispatch(_ctx, "dig_paid", {"ap_cost": cost, "ap_surplus": _ap - cost}): if String(h.get("kind", "")) == "counter_spend": @@ -110,12 +128,9 @@ func request_dig(chain_size: int, clears_map: bool) -> bool: EffectEngine.counter_add(_ctx, String(h.get("counter_id", "")), int(h.get("value", 0)) if set_v == null else int(set_v), set_v != null) elif String(h.get("kind", "")) == "grant_ap": dig_refund += int(h.get("value", 0)) - if _ap < cost: - _reject("resource_face") - return false - if dig_refund > 0: - cost = maxi(0, cost - dig_refund) # 返还=对价减免(净费不低于 0——免费开格不产生负对价) - return _spend_cost_and_arbitrate(cost, clears_map) + elif String(h.get("kind", "")) == "grant_reserve_ap": + rap_gain += int(h.get("value", 0)) + return {"cost": cost, "refund": dig_refund, "rap_gain": rap_gain} func _spend_cost_and_arbitrate(cost: int, clears_map: bool) -> bool: @@ -196,13 +211,15 @@ func finish_round_stop(transfer: bool, transfer_pct: int = 100) -> void: # 轮内刷新:唯一入口 = # Active 相位——仅扣 RAP,相位与 AP 均不动。刷新不打断轮次、不触发结算;换图编排归 战斗结算 # (retire + install 立即执行,玩家同轮继续挖新图)。决策主体 = 玩家(经 HUD框架发起)纪律不变。 -func request_refresh() -> bool: +# cost_override=折后价入参(迭代 46 卡A T3——BattleRefreshFlow 折价读数传入;缺省 -1=表基线逐位不变) +func request_refresh(cost_override: int = -1) -> bool: if _phase != Phase.ACTIVE: _reject("phase") return false - if _rap < db.run.action_economy.rap_refresh_cost: + var cost := int(db.run.action_economy.rap_refresh_cost) if cost_override < 0 else cost_override + if _rap < cost: return false # EC13 资源不足拒绝——合法相位内资源拒绝,非编排错误 - _rap -= db.run.action_economy.rap_refresh_cost + _rap -= cost return true diff --git a/game/core/battle_orchestrator.gd b/game/core/battle_orchestrator.gd index 94b29869..026066a9 100644 --- a/game/core/battle_orchestrator.gd +++ b/game/core/battle_orchestrator.gd @@ -31,8 +31,9 @@ signal settlement_snapshot(snap: Dictionary) ## 每结算末尾的全量状态 const SOURCE_EXEMPT_TYPES := ["decay", "verdict"] # 行来源断言豁免集合(来源类别层)——事实来源是单位 / 清图而非棋盘格; # fx_reveal=专长揭示掷格(落空行无真实格); -# fx_grant_gp=专长·GP 入账(钱包面而非棋盘面,行无格子可指) -const SOURCE_EXEMPT_CLASSES := ["hero_attack", "enemy_action", "clear_reward", "fx_reveal", "fx_grant_gp"] +# fx_grant_gp=专长·GP 入账(钱包面而非棋盘面,行无格子可指); +# fx_board_refresh=成功刷新事实族(迭代 46 卡A——崩壁冲击群伤行源=换图动作而非棋盘格) +const SOURCE_EXEMPT_CLASSES := ["hero_attack", "enemy_action", "clear_reward", "fx_reveal", "fx_grant_gp", "fx_board_refresh"] var _facts: BattleFacts # 战斗事实件(battle_facts.gd——事实族 15 成员单点) var _rounds: RoundCycle # 轮循环驱动件(settlement/round_cycle.gd——五段持有/段序/重入闸/摇号) @@ -596,26 +597,17 @@ func _exit_distribution() -> void: round_started.emit(round_index) -# 呈现层查询面(HUD 刷新按钮可用性)——挖掘期唯一通道 +# 呈现层查询面(HUD 刷新按钮可用性)——挖掘期唯一通道(折价成本同源:判据归 BattleRefreshFlow) func can_refresh() -> bool: - return battle_result == "" \ - and _facts.refresh_used < int(db.run.action_economy.refresh_cap_per_round) \ - and action_economy.phase == action_economy.PHASE_ACTIVE \ - and action_economy.rap >= int(db.run.action_economy.rap_refresh_cost) + return BattleRefreshFlow.can_refresh(self) # 玩家经 HUD框架发起、战斗结算 代理调用(禁止 RAP 满自动刷)。 -# 挖掘期唯一通道:Active 相位立即换图 -# 继续挖(RAP -8;AP 保留、相位不动、不触发结算);非挖掘期请求一律拒绝——非清图轮恒同图。 +# 挖掘期唯一通道:Active 相位立即换图继续挖(RAP 按折后价扣;AP 保留、相位不动、不触发结算); +# 非挖掘期请求一律拒绝——非清图轮恒同图。刷新门/折价扣费/换图/成功事实归 BattleRefreshFlow +# (迭代 46 卡A T3 拆出——编排器入口与唯一通道不变,C10 结构预算只降不升)。 func player_request_refresh() -> bool: - if not can_refresh(): - return false - if not action_economy.request_refresh(): - return false - _facts.refresh_used += 1 - _reward_economy.retire_mapping() # 旧图批次退役(地图战斗资源 Ready→retired;E6 新批重置前提) - minesweeper.retire_board(true) # 旧板退役(轮内通道) - return _install_and_reset(true) # 当场换图——失败即编排违例 fail-fast + return BattleRefreshFlow.execute(self) func _battle_over(result: String) -> void: diff --git a/game/core/damage_controller.gd b/game/core/damage_controller.gd index 8d72cb9b..a27469fb 100644 --- a/game/core/damage_controller.gd +++ b/game/core/damage_controller.gd @@ -460,8 +460,8 @@ func _hero_attack_deliver(t: Dictionary, ctx: Dictionary, redemption: Dictionary # 敌方伤害技:掷点 → 折扣/暴击 → 克制点数(attack_type vs 主角护甲类型—— # 无类型恒中性 delta 0,写法同 hero_attack 的 _counter_delta 消费位)→ hero_defense×enemy_output # 池乘区 → 下限 → +rider(后置必中附加)→ 交付 → commit_action(作废单不 commit——死亡即不动手)。 -# 掷点算术归 _enemy_roll;damage_taken 施加面归 _enemy_shield_apply(发射在本函数—— -# 与减伤落快照的先后序固定)。 +# 掷点算术归 _enemy_roll;damage_taken 统一发射+消费归 _emit_hero_damage_taken(发射在本函数—— +# 与减伤落快照的先后序固定;「掷点后、交付前」消费时点不变)。 func _resolve_enemy_skill(t: Dictionary) -> void: var hero: Dictionary[Variant, Variant] = units.hero_unit() var roll := _enemy_roll(t) @@ -469,9 +469,8 @@ func _resolve_enemy_skill(t: Dictionary) -> void: # damage_taken 发射(kind=enemy_skill;铁壁整备减伤订阅面:shield 加法族合计 value×per(charge_layers), # 稳健拓荒树规则同面(rebuild 合成订阅);减后过下限保底。damage_reduce〔背水 hp_ratio when〕/ # counter_add〔保险丝/石肤〕/ac_bonus〔石肤〕同面施加;hp_ratio fact 由 _fx_fact 补充事实面回落提供) - var dt_hits := _fx_hits("damage_taken", {"kind": "enemy_skill", "amount": total, - "source": String(t.context.get("skill_id", "")), "round": int(t.round_index)}) - total = int(_enemy_shield_apply(dt_hits, total)["total"]) + var dt_res := _emit_hero_damage_taken(total, "enemy_skill", String(t.context.get("skill_id", "")), t.origin_cell, int(t.round_index)) + total = int(dt_res["total"]) units.apply_damage(hero, total) var payload: Dictionary[Variant, Variant] = {"target": "hero", "skill_id": String(t.context.get("skill_id", "")), "baseline": int(t.context.get("baseline", 0)), "roll": int(roll["roll"]), "ac": int(roll["threshold"]), "tier": int(roll["discount"]), "attack_type": String(t.attack_type)} @@ -502,9 +501,22 @@ func _enemy_roll(t: Dictionary) -> Dictionary: return {"roll": int(roll_read.roll), "threshold": threshold, "crit": crit, "discount": discount, "total": total} -# damage_taken 施加面(shield 加法族合计;damage_reduce 逐条减量〔背水〕;ac_bonus 转编排器单点 -# 〔石肤 AC 累积归 set_hero_ac_override 消费面〕;counter_add 施加)。返回 {total, shield}。 -func _enemy_shield_apply(hits: Array, total: int) -> Dictionary: +# 全局受伤发射单点(迭代 46 卡A T2——所有主角承伤路径唯一发射口:敌方攻击/地形/危险格/错误封印 +# 资源直伤(后三者经 board_flat_hero 单据进入)都经此 dispatch 一次 damage_taken 并消费一次; +# 同一伤害单据重复发射由 context.hero_damage_consumed 标记闸拦(地形路径在开单点标记)。 +# payload 必含 kind/amount/source/cell/round/hp_ratio;返回 {total, hits} 交调用侧按减后值开单/交付)。 +func _emit_hero_damage_taken(amount: int, damage_kind: String, source: String, cell: Vector2i, ri: int) -> Dictionary: + var hu: Dictionary[Variant, Variant] = units.hero_unit() + var payload: Dictionary[Variant, Variant] = {"kind": damage_kind, "amount": amount, "source": source, + "cell": cell, "round": ri, "hp_ratio": int(round(100.0 * float(int(hu.get("hp", 0))) / float(maxi(1, int(hu.get("max_hp", 1))))))} + var hits := _fx_hits("damage_taken", payload) + return {"total": int(_apply_hero_damage_effects(hits, amount)["total"]), "hits": hits} + + +# damage_taken 唯一消费族(组合次序冻结:shield 加法族合计→damage_reduce 逐条减量〔背水/石层缓冲〕→ +# ac_bonus 转编排器单点〔石肤 AC 累积归 set_hero_ac_override 消费面〕→counter_add 施加〔保险丝/石肤/ +# 换图系临时计数夹具〕。返回 {total, shield})。 +func _apply_hero_damage_effects(hits: Array, total: int) -> Dictionary: var shield := 0 for h in hits: match String(h.get("kind", "")): @@ -565,9 +577,15 @@ func _resolve_board_blast(t: Dictionary) -> void: _outcomes[int(t.ticket_id)] = "redeemed" -# 棋盘主角直伤(反噬/治疗包负向/地形变异):无类型必中,下限保底;context 键并入行载荷 +# 棋盘主角直伤(反噬/治疗包负向/危险格/错误封印资源直伤):无类型必中,下限保底;context 键并入行载荷。 +# damage_taken 全局发射点(迭代 46 卡A T2——单据未标 hero_damage_consumed 时在下限前发射并消费一次; +# 地形路径已在授予段开单点发射并标记,此处跳过防重复) func _resolve_board_flat_hero(t: Dictionary) -> void: - var dealt: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), int(t.base_value)) # 伤害下限全来源 + var dealt: int = int(t.base_value) + if not bool((t.get("context", {}) as Dictionary).get("hero_damage_consumed", false)): + var res_flat := _emit_hero_damage_taken(dealt, "board_flat_hero", String(t.get("source_class", "")), t.origin_cell, int(t.round_index)) + dealt = int(res_flat["total"]) + dealt = maxi(int(db.combat.settlement.damage_roll.damage_floor), dealt) # 伤害下限全来源(减后过下限) units.apply_damage(units.hero_unit(), dealt) var extra: Dictionary[Variant, Variant] = {"target": "hero"} for k in t.context.keys(): diff --git a/game/core/effect_engine.gd b/game/core/effect_engine.gd index 4033eb64..c2d3a331 100644 --- a/game/core/effect_engine.gd +++ b/game/core/effect_engine.gd @@ -12,11 +12,15 @@ extends RefCounted const EVENTS := ["dig_reveal", "dig_paid", "seal_hit", "mine_hit", "attack_commit", "clamp_check", "armor_roll", "kill", "damage_taken", "enemy_hp_threshold", "round_start", "seal_settle", "round_end", "reward_roll", "treasure_used", "charge_full", "dig_stop", "battle_won", "seal_missed", - "board_installed"] + "board_installed", + # 换图系双事件(refresh_request=玩家请求刷新〔省力周转 apply_discount 订阅面——折扣只改门槛 + # 与扣费不改拒绝判据〕;board_refreshed=成功刷新事实〔换图系七件订阅主挂点——装新板后才发射〕) + "refresh_request", "board_refreshed"] ## 扫雷侧事件集(🔴 流派扫雷触发铁律新形态:每流派 ≥1 行订阅本集事件——schema 加载校验。 -## 原 MINE_HOOKS 的同义迁移:挖掘/封印/轮末结算面事件) -const MINE_EVENTS := ["dig_reveal", "dig_paid", "seal_hit", "mine_hit", "seal_settle", "round_end", "reward_roll"] +## 原 MINE_HOOKS 的同义迁移:挖掘/封印/轮末结算面事件+轮内刷新面〔换图系〕) +const MINE_EVENTS := ["dig_reveal", "dig_paid", "seal_hit", "mine_hit", "seal_settle", "round_end", "reward_roll", + "refresh_request", "board_refreshed"] ## 效果类型词汇(解释器 switch 闭集——词表实数以 EFFECT_KINDS 常量为准,含共享行拆型: ## grant_gp/bias_wallet、counter_add/counter_spend、reveal_tiles/remove_mines/refresh_pick、 @@ -40,7 +44,11 @@ const EFFECT_KINDS := ["add_ticket_rider", "modify_ticket", "damage_mult", "open "mine_count_preview", # forgive_seal_miss=完美保险(本场首次封印落空不计入完美 # 封印判定——订阅 seal_missed 的豁免效果,消费点在授予段;feats.tres 首例 domain=battle 行) - "forgive_seal_miss"] + "forgive_seal_miss", + # 换图系两效果(grant_reserve_ap=矿脉储能·付费挖格产 RAP〔消费点在行动经济 dig_paid + # 受理后一次性入池〕;apply_discount=省力周转·刷新成本折扣〔mode=refresh cost_mult 百分比, + # floor 取整——只改门槛与扣费、不改拒绝判据〕) + "grant_reserve_ap", "apply_discount"] ## fact 条件词汇(封闭枚举——payload 同名键直读面〔fact 取值序 payload ## 优先——effect_engine._fact_value 既有机制〕。new_resource_type=首开资源类型〔地质学家〕; diff --git a/game/core/reward_grant.gd b/game/core/reward_grant.gd index 3267c2ac..ae265754 100644 --- a/game/core/reward_grant.gd +++ b/game/core/reward_grant.gd @@ -365,6 +365,12 @@ func outcome_of(ticket_id: int) -> String: return "redeemed" if ticket_id < 0 else damage.outcome(ticket_id) +# 延迟单据入队(迭代 46 卡A T4——成功刷新的崩壁冲击群伤单走案C 同队列:授予段闸外唯一入口, +# 战斗段末 _board_deferred_settle 按开单序补提交统一结账;类变量零增员——复用 defer.queue) +func defer_ticket(t: Dictionary) -> void: + (defer.queue as Array).append(t) + + # 主角直伤单据(反噬/治疗包负向——无类型必中,下限保底在控制器管线内) func _submit_flat_hero(display: String, power: int, att: Dictionary, sync: bool) -> String: var t := damage.open_ticket(DamageController.KIND_BOARD_FLAT_HERO, String(att.get("source_class", "")), display, diff --git a/game/core/settlement/battle_refresh_flow.gd b/game/core/settlement/battle_refresh_flow.gd new file mode 100644 index 00000000..0f6a30e8 --- /dev/null +++ b/game/core/settlement/battle_refresh_flow.gd @@ -0,0 +1,43 @@ +class_name BattleRefreshFlow +extends RefCounted +## 换图刷新流程件(迭代 46 卡A T3/T4 自编排器拆出):刷新可用性门、折价扣费、换图执行与成功事实登记。 +## 拆分边界(需求卡片 C13):本件只负责可用性、扣费、换图与成功事实登记——七件效果的算法留在 +## 效果引擎、授予段与伤害控制器。调用路径=呈现层→编排器单行转发→本件→行动经济/装板壳/授予段。 +## 反自动刷新(C11):只经玩家入口发起,禁 RAP 满自动刷——编排器公开面单行转发,无自动调用点。 +## 成功序钉死(T4):折价扣 RAP→refresh_used+1→旧资源退役→旧板退役→装新板→授予段成功事实发射; +## 装板失败返回 false 且零效果——成功事实只在新板就位后发射。 + + +## 折价成本读数(refresh_request 命中省力周转 apply_discount——只认 kind==apply_discount 且 +## mode==refresh,取声明序第一命中,floor(base×cost_mult/100);无命中=表基线逐位不变。 +## can_refresh 与 execute 同源取数〔V3——can 与执行同源判断〕;折扣只改变门槛与扣费, +## 不改变拒绝判据〔C2——每轮上限与相位判据照旧〕) +static func _effective_refresh_cost(oc: BattleOrchestrator) -> int: + var base := int(oc.db.run.action_economy.rap_refresh_cost) + for h in oc.fx_host().fx_emit("refresh_request", {"mode": "refresh", "base": base}): + if String(h.get("kind", "")) == "apply_discount" and String(h.get("mode", "")) == "refresh": + return int(floor(float(base) * float(int(h.get("cost_mult", 100))) / 100.0)) + return base + + +## 可用性门(HUD 刷新按钮同源判据):战斗未终局、每轮上限未满、Active 相位、RAP 抵折后价。 +static func can_refresh(oc: BattleOrchestrator) -> bool: + return oc.battle_result == "" \ + and oc._facts.refresh_used < int(oc.db.run.action_economy.refresh_cap_per_round) \ + and oc.action_economy.phase == oc.action_economy.PHASE_ACTIVE \ + and oc.action_economy.rap >= _effective_refresh_cost(oc) + + +## 执行(玩家请求刷新唯一通道体):扣费受理后继续;扣费拒绝/门拒零效果零事实。 +static func execute(oc: BattleOrchestrator) -> bool: + if not can_refresh(oc): + return false + if not oc.action_economy.request_refresh(_effective_refresh_cost(oc)): + return false + oc._facts.refresh_used += 1 + oc._reward_economy.retire_mapping() # 旧图批次退役(地图战斗资源 Ready→retired;E6 新批重置前提) + oc.minesweeper.retire_board(true) # 旧板退役(轮内通道) + if not oc._install_and_reset(true): # 当场换图——失败即编排违例 fail-fast(零成功事实) + return false + oc._rounds._grant_seg.apply_board_refreshed(oc.round_index) # 成功事实:新板就位后一次发射 + return true diff --git a/game/core/settlement/battle_refresh_flow.gd.uid b/game/core/settlement/battle_refresh_flow.gd.uid new file mode 100644 index 00000000..30075b1b --- /dev/null +++ b/game/core/settlement/battle_refresh_flow.gd.uid @@ -0,0 +1 @@ +uid://d2kjxlt5b2eqv diff --git a/game/core/settlement/grant_segment.gd b/game/core/settlement/grant_segment.gd index 1c396e51..6cdec2ab 100644 --- a/game/core/settlement/grant_segment.gd +++ b/game/core/settlement/grant_segment.gd @@ -6,7 +6,8 @@ extends RefCounted # 效果施加归 BattleFxHost(段持三引用直调,零转发壳)—— # 本段只搬运时序,零算法改动(🔴 零行为变更契约:同种子完整局输出逐位一致)。 # run 编排骨架+批私有族(_reveal_batch/_terrain_batch/_seal_batch/_round_end_apply,段内施加面 -# 再拆 _reveal_safe_apply/_reveal_mine_apply/_terrain_damage_reduce/_seal_hit_effects 等单面函数); +# 再拆 _reveal_safe_apply/_reveal_mine_apply/_seal_hit_effects 等单面函数);地形 damage_taken +# 消费随迭代 46 卡A T2 并入伤害控制器全局消费族(本段零本地减伤函数); # kind 分派单点化(P1-1 同批):counter_add 五位三行组 / grant_material 三位兑付块 # 收 _apply_counter_hit / _apply_material_hit 两助手(分支本体逐字保留在助手内)。 @@ -32,6 +33,27 @@ func run(ri: int) -> void: _round_end_apply(ri) +# 成功刷新事实发射+施加(迭代 46 卡A T4——BattleRefreshFlow 装新板后单点调用,每成功刷新恰一次; +# 拒绝刷新零事实〔C2〕。命中消费三面:counter_add→既有单点落账〔蓄力周转/石层缓冲/坑道小憩 计数〕; +# grant_gp→战斗内缓冲 escrow 入账+fx_grant_gp 行〔矿道红利——不绑清图奖励,C7〕; +# open_ticket channel=blast→崩壁冲击群伤单延迟入队〔案C 同队列,战斗段末按存活目标结账〕) +func apply_board_refreshed(ri: int) -> void: + var br_hits := fx_host.fx_emit("board_refreshed", {"round": ri}) + for h in br_hits: + if String(h.get("kind", "")) == "counter_add": + _apply_counter_hit(h) + for h in br_hits: + if String(h.get("kind", "")) == "grant_gp": + reward_grant.escrow += int(h.get("value", 0)) + oc._emit_row({"type": "effect", "source_class": "fx_grant_gp", "origin_cell": Vector2i(-1, -1)}, ri, "专长·GP 入账 +%d" % int(h.get("value", 0)), int(h.get("value", 0)), {"effect_id": "fx_grant_gp", "escrow": reward_grant.escrow}) + for h in br_hits: + if String(h.get("kind", "")) == "open_ticket" and String(h.get("channel", "")) == "blast": + var bb_t := oc._damage.open_ticket(DamageController.KIND_BOARD_BLAST, "fx_board_refresh", "崩壁冲击", + {"channel": "board", "base_value": int(h.get("value", 0)), "target_rule": DamageController.TARGET_ALL_ALIVE, + "ri": ri, "origin_cell": Vector2i(-1, -1)}) + reward_grant.defer_ticket(bb_t) + + # 开格/踩雷兑付批(轮 facts_reveals 资源事件序:safe 正向乘区兑付 / mine_hit 减半负向兑付) func _reveal_batch(ri: int) -> void: for evt in oc._facts.reveals: @@ -167,37 +189,22 @@ func _terrain_batch(ri: int) -> void: if String(vrow.get("effect", "")) != "flat_damage": continue # 触发词表按 effect 路由:flat_damage=on_open 兑现;hide_number/on_open 外组合 = 数据行新词表未接线(显式忽略面) # 地形变异伤害单据——无类型必中,下限保底在控制器管线内 - # damage_taken 发射在单据开立前:damage_reduce〔背水——hp_ratio when - # 面〕先收集再按减后值开单;counter_add〔保险丝/石肤〕+ ac_bonus〔石肤〕照常施加; - # grant_material〔荆棘/地脉〕移到单据提交后(兑付值序一致——收益不因减伤变化) + # damage_taken 统一发射(迭代 46 卡A T2——地形路径并入全局消费族 _emit_hero_damage_taken: + # 发射在单据开立前、减后值开单,组合消费含 shield〔全局受伤语义〕; + # hero_damage_consumed 标记入单据 context——战斗段末 _resolve_board_flat_hero 跳过重复发射; + # grant_material〔荆棘/地脉〕移到单据提交后(兑付值序一致——收益不因减伤变化)) var dt_amount := int(vrow.get("effect_value", 0)) - var dt_hits := fx_host.fx_emit("damage_taken", {"kind": "terrain", "amount": dt_amount, - "source": String(vid), "cell": evt.cell, "round": ri, "terrain_kind": String(vid), - "hp_ratio": int(round(100.0 * float(int(oc._enemy_system.hero_unit().get("hp", 0))) / float(maxi(1, int(oc._enemy_system.hero_unit().get("max_hp", 1))))))}) - dt_amount = _terrain_damage_reduce(dt_hits, dt_amount) + var dt_res := oc._damage._emit_hero_damage_taken(dt_amount, "terrain", String(vid), evt.cell, ri) + dt_amount = int(dt_res["total"]) var vt := oc._damage.open_ticket(DamageController.KIND_BOARD_FLAT_HERO, "地形变异", String(vrow.get("display_name", vid)), {"channel": "board", "base_value": dt_amount, "target_rule": DamageController.TARGET_HERO, - "ri": ri, "origin_cell": evt.cell, "context": {"variant_id": String(vid)}}) + "ri": ri, "origin_cell": evt.cell, "context": {"variant_id": String(vid), "hero_damage_consumed": true}}) reward_grant.submit(vt, false) - for h in dt_hits: + for h in (dt_res["hits"] as Array): if String(h.get("kind", "")) == "grant_material": _apply_material_hit(h, evt.cell, ri) -# damage_taken 施加面(damage_reduce 减量收集〔背水——定值〕/ac_bonus〔石肤〕/counter_add〔保险丝/石肤〕; -# 返回减后 amount 交开单消费) -func _terrain_damage_reduce(dt_hits: Array, dt_amount: int) -> int: - for h in dt_hits: - match String(h.get("kind", "")): - "damage_reduce": - dt_amount = maxi(0, dt_amount - int(h.get("value", 0))) - "ac_bonus": - fx_host.apply_ac_bonus(h) - "counter_add": - _apply_counter_hit(h) - return dt_amount - - # 封印判定批+战封印军规开单段(封印流:seal_judged 批后统一开单:轮末每面封印命中对最前存活敌 # 结算 board_typed 必中伤害〔复用零新配方;「封印所在巢穴敌」简化为 default_target 最前存活敌—— # 台账披露待追认〕;完美布防=本轮封印批全中(hit>0 且 miss==0)时封印伤 ×mult——全对推理奖励)。 @@ -259,6 +266,7 @@ func _seal_strike_value(fs_fx: Array, fs_hits: int, fs_misses: int) -> Dictionar func _round_end_apply(ri: int) -> void: var re_fx := fx_host.fx_emit("round_end", {"round": ri}) _round_end_blast_tickets(re_fx, ri) + _round_end_heal_value_counter(re_fx, ri) # 迭代 46 卡A T5——坑道小憩恢复兑付(先于清零行消费层值,声明序=heal 行在 counter set 行前) for h in re_fx: if String(h.get("kind", "")) == "counter_add": _apply_counter_hit(h) @@ -278,6 +286,24 @@ func _round_end_apply(ri: int) -> void: oc._damage.flush() # 授予段界结账(案C 后本段伤害单据全数暂存,此处对空队 no-op——保留作防御性段界) +# round_end heal value_counter 兑付(迭代 46 卡A T5——坑道小憩:层值=本轮成功刷新累计恢复点, +# units.heal 封顶、满血发未生效行;层清零由同事件 counter_add set 行承载〔声明序在后〕——本面 +# 先于 counter_add 遍历消费层值即保序。行源=成功刷新事实族(无棋盘格——fx_board_refresh 豁免类)) +func _round_end_heal_value_counter(re_fx: Array, ri: int) -> void: + for h in re_fx: + if String(h.get("kind", "")) != "heal" or String(h.get("value_counter", "")) == "": + continue + var layers := EffectEngine.counter(fx_host.counters_ctx, String(h.get("value_counter", ""))) + if layers <= 0: + continue + var hu: Dictionary[Variant, Variant] = oc._enemy_system.hero_unit() + if int(hu.get("hp", 0)) >= int(hu.get("max_hp", 1)): + oc._emit_row({"type": "reward_ineffective", "source_class": "fx_board_refresh", "origin_cell": Vector2i(-1, -1)}, ri, "坑道小憩 未生效(满血)", 0, {"batch_key": String(h.get("value_counter", ""))}) + continue + var healed: int = oc._enemy_system.heal(hu, layers) + oc._emit_row({"type": "heal", "source_class": "fx_board_refresh", "origin_cell": Vector2i(-1, -1)}, ri, "坑道小憩 回复", healed, {"target": "hero"}) + + # round_end blast 通道反打单(per-counter 积层读数×value=反打总量;source_class 用中性名, # per-counter 反打通道保留泛用面;案C 暂存战斗段末兑付) func _round_end_blast_tickets(re_fx: Array, ri: int) -> void: diff --git a/game/data/tables/feats.tres b/game/data/tables/feats.tres index 4cb5cd5c..6bb2bf5c 100644 --- a/game/data/tables/feats.tres +++ b/game/data/tables/feats.tres @@ -869,5 +869,215 @@ rows = [ } } ] + }, + { + "feat_id": "vein_reserve", + "display_name": "矿脉储能", + "rarity": "excellent", + "cap": 1, + "desc": "挖格储能:每次受理的付费挖格 +1 储备点(RAP)", + "triggers": [ + { + "on": "dig_paid", + "do": { + "kind": "grant_reserve_ap", + "value": 1 + } + } + ] + }, + { + "feat_id": "easy_swap", + "display_name": "省力周转", + "rarity": "excellent", + "cap": 1, + "desc": "刷新半价:弃图刷新成本为基线 50%", + "triggers": [ + { + "on": "refresh_request", + "do": { + "kind": "apply_discount", + "mode": "refresh", + "cost_mult": 50 + } + } + ] + }, + { + "feat_id": "wall_blast", + "display_name": "崩壁冲击", + "rarity": "excellent", + "cap": 1, + "desc": "刷后群伤:成功刷新后全体存活敌各受 2 点", + "triggers": [ + { + "on": "board_refreshed", + "do": { + "kind": "open_ticket", + "channel": "blast", + "value": 2 + } + } + ] + }, + { + "feat_id": "mine_profit", + "display_name": "矿道红利", + "rarity": "rare", + "cap": 1, + "desc": "刷后GP:每次成功刷新固定 +1 GP", + "triggers": [ + { + "on": "board_refreshed", + "do": { + "kind": "grant_gp", + "value": 1 + } + } + ] + }, + { + "feat_id": "stone_mitigation", + "display_name": "石层缓冲", + "rarity": "common", + "cap": 1, + "desc": "当轮减伤:本轮成功刷新后,每次受伤固定 −2(多刷不叠加)", + "triggers": [ + { + "on": "board_refreshed", + "do": { + "kind": "counter_add", + "counter_id": "round_swap_guard", + "value": 1 + } + }, + { + "on": "damage_taken", + "when": [ + { + "fact": "counter", + "id": "round_swap_guard", + "op": "gte", + "value": 1 + } + ], + "do": { + "kind": "damage_reduce", + "value": 2 + } + } + ] + }, + { + "feat_id": "charge_rotation", + "display_name": "蓄力周转", + "rarity": "excellent", + "cap": 1, + "desc": "刷后蓄力:每次刷新 +1 层(上限 3),下一次攻击消费全部层数", + "triggers": [ + { + "on": "board_refreshed", + "when": [ + { + "fact": "counter", + "id": "swap_charge_layers", + "op": "lt", + "value": 3 + } + ], + "do": { + "kind": "counter_add", + "counter_id": "swap_charge_layers", + "value": 1 + } + }, + { + "on": "attack_commit", + "when": [ + { + "fact": "counter", + "id": "swap_charge_layers", + "op": "gte", + "value": 1 + } + ], + "do": { + "kind": "attack_bonus", + "value_counter": "swap_charge_layers" + } + }, + { + "on": "attack_commit", + "when": [ + { + "fact": "counter", + "id": "swap_charge_layers", + "op": "gte", + "value": 1 + } + ], + "do": { + "kind": "counter_add", + "counter_id": "swap_charge_layers", + "set": 0 + } + } + ] + }, + { + "feat_id": "tunnel_rest", + "display_name": "坑道小憩", + "rarity": "common", + "cap": 1, + "desc": "当轮恢复:每次刷新累计 1 点(上限 3),本轮轮末合计回复", + "triggers": [ + { + "on": "board_refreshed", + "when": [ + { + "fact": "counter", + "id": "round_swap_rest", + "op": "lt", + "value": 3 + } + ], + "do": { + "kind": "counter_add", + "counter_id": "round_swap_rest", + "value": 1 + } + }, + { + "on": "round_end", + "when": [ + { + "fact": "counter", + "id": "round_swap_rest", + "op": "gte", + "value": 1 + } + ], + "do": { + "kind": "heal", + "value_counter": "round_swap_rest" + } + }, + { + "on": "round_end", + "when": [ + { + "fact": "counter", + "id": "round_swap_rest", + "op": "gte", + "value": 1 + } + ], + "do": { + "kind": "counter_add", + "counter_id": "round_swap_rest", + "set": 0 + } + } + ] } ] diff --git a/game/tests/static_scan.sh b/game/tests/static_scan.sh index f906fbce..8bf27ac4 100644 --- a/game/tests/static_scan.sh +++ b/game/tests/static_scan.sh @@ -314,7 +314,7 @@ fi # get_tree());排除注释行(行首 #/##)与字符串字面量(双引号包裹段内的 await 非代码), # 口径仿 S1/S2 的 grep 排除手法。 # 自检:清单内文件缺位(路径写错/被改名)即判红——防扫面为空静默绿。 -S8_FILES="game/core/battle_orchestrator.gd game/core/battle_fx_host.gd game/core/reward_grant.gd game/core/board_installer.gd game/core/settlement/aura_segment.gd game/core/settlement/combat_segment.gd game/core/settlement/decay_segment.gd game/core/settlement/grant_segment.gd game/core/settlement/verdict_segment.gd game/core/settlement/round_cycle.gd game/core/damage_controller.gd" +S8_FILES="game/core/battle_orchestrator.gd game/core/battle_fx_host.gd game/core/reward_grant.gd game/core/board_installer.gd game/core/settlement/aura_segment.gd game/core/settlement/combat_segment.gd game/core/settlement/decay_segment.gd game/core/settlement/grant_segment.gd game/core/settlement/verdict_segment.gd game/core/settlement/round_cycle.gd game/core/damage_controller.gd game/core/settlement/battle_refresh_flow.gd" S8_MISS=0 for f in $S8_FILES; do if [ ! -f "$f" ]; then @@ -356,7 +356,9 @@ fi # 自检:扫描面为空即判红(require_scan_face);B1 文件集与三族自家文件缺位即判红 # (文件被改名=豁免名单失效,宁可红不可静默放行)。 if require_scan_face "S7" "$DIR_CORE"; then -S7_B1_SET="game/core/battle_orchestrator.gd game/core/battle_fx_host.gd game/core/reward_grant.gd game/core/board_installer.gd game/core/settlement/aura_segment.gd game/core/settlement/combat_segment.gd game/core/settlement/decay_segment.gd game/core/settlement/grant_segment.gd game/core/settlement/verdict_segment.gd game/core/settlement/round_cycle.gd game/core/damage_controller.gd" +# B1 域文件集(迭代 46 卡A 增补 battle_refresh_flow——换图刷新流程件=C13 拆分边界的 B1 域拆件, +# 承载旧资源退役/装板编排调用;architecture.md「模块归属」BattleRefreshFlow 行随反哺点落文) +S7_B1_SET="game/core/battle_orchestrator.gd game/core/battle_fx_host.gd game/core/reward_grant.gd game/core/board_installer.gd game/core/settlement/aura_segment.gd game/core/settlement/combat_segment.gd game/core/settlement/decay_segment.gd game/core/settlement/grant_segment.gd game/core/settlement/verdict_segment.gd game/core/settlement/round_cycle.gd game/core/damage_controller.gd game/core/settlement/battle_refresh_flow.gd" S7_MISS=0 for f in $S7_B1_SET game/core/unit_container.gd game/core/status_container.gd game/core/reward_economy.gd; do if [ ! -f "$f" ]; then diff --git a/game/tests/unit/battle_settlement/refresh_feats_test.gd b/game/tests/unit/battle_settlement/refresh_feats_test.gd new file mode 100644 index 00000000..2c8850c5 --- /dev/null +++ b/game/tests/unit/battle_settlement/refresh_feats_test.gd @@ -0,0 +1,650 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 换图系双基座七件专项宿主(迭代 46 卡A——对照表 V1–V12 按轮落地:V5=T2 全局受伤链路、 +# V2/V3=T3 两基座、V4/V10=T4 成功事实与 GP、V6–V9=T5 五件后续、V11=T6 数据与池; +# V12 无专长基线回归归 round_flow/seal_polarity 既有断言承载)。 +# 临时承伤计数专长经现行 ConfigDB 注入(表行追加+持有集传 id——正常数据配置路径,卡片 §12); +# 真实入口驱动:AutoPlayer 盲挖循环+停手/封印动作;判据绑可观测提交面(damage_taken 命中计数器 +# 与 target=hero 明细行)。 +# 运行:godot --headless --path game --script res://tests/unit/battle_settlement/refresh_feats_test.gd(expect 0) +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript = preload("res://core/config_db.gd") +const EffectEngineScript = preload("res://core/effect_engine.gd") +const AutoPlayer := preload("res://sim/auto_player.gd") + +# 直接伤型资源类型(负向极性兑付=主角直伤单据的三型——reward 表 pierce/magic/heal 负向行) +const DIRECT_TYPES := ["pierce", "magic", "heal"] + +var _db + + +func _init() -> void: + _db = ConfigDBScript.new() + if not _db.load_tables("res://data/tables", true): + print("[refresh_feats_test] FAIL — 数值表加载失败") + quit(1) + return + _install_dt_probe() + test_all_hero_damage_paths_emit_once() + test_no_duplicate_damage_taken_event() + test_reserve_on_accepted_dig() + test_refresh_discount_gate_and_charge() + test_double_base_stack() + test_no_feats_baseline() + test_board_refresh_fact_only_on_success() + test_mine_profit_gp() + test_stone_mitigation_all_damage() + test_rest_rotation_cap_and_clear() + test_charge_layers_consumed_same_round() + test_blast_defers_to_combat_segment() + test_seven_feats_validate_and_pool() + quit(Kit.finish("refresh_feats_test")) + + +# ---- 夹具族(真实入口驱动——settle_batch/stop/封印动作;直构只用于编排器受理)---- + +# 临时承伤计数专长(damage_taken → counter_add battle 桶——每张主角承伤单据恰 +1) +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", + "desc": "测试夹具:每次主角承伤计数 +1", + "triggers": [{"on": "damage_taken", "do": {"kind": "counter_add", "counter_id": "dt_probe_hits", "value": 1}}], + }) + + +func _orch(seed_v: int): + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_v) + return load("res://core/battle_orchestrator.gd").new(_db, rng) + + +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": [], + "battle_index": 1, "voucher_rap": voucher_rap, "voucher_time_sand": 0, "node_type": "boss", "adv_bucket": {}} + + +func _probe_count(orch) -> int: + return EffectEngineScript.counter(orch.fx_host().counters_ctx, "dt_probe_hits") + + +# 主角承伤明细行收集(type=damage 且 target=hero——敌方技与棋盘主角直伤两类单据的行面) +func _collect_hero_rows(orch) -> Array: + var rows: Array = [] + orch.step_row.connect(func(row): + if String(row.get("type", "")) == "damage" and String(row.get("target", "")) == "hero": + rows.append(row)) + return rows + + +# 按来源类分桶计数(rows 收集器的只读后处理) +func _count_by_source(rows: Array, source: String) -> int: + var n := 0 + for r_v in rows: + if String((r_v as Dictionary).get("source_class", "")) == source: + n += 1 + return n + + +# AutoPlayer 盲挖循环直到 cell_revealed 谓词命中(真实挖掘入口;返回 {"cell","evt"} 或空) +func _dig_until(orch, pred: Callable) -> Dictionary: + var ms = orch.minesweeper + var hit := {} + ms.cell_revealed.connect(func(evt): + if hit.is_empty() and bool(pred.call(evt)): + hit["cell"] = evt.cell + hit["evt"] = evt) + var guard := 40 + while hit.is_empty() and guard > 0: + guard -= 1 + var plan = AutoPlayer.choose_dig(ms) + if plan.is_empty(): + break + if plan.hero_cell != ms.hero_pos(): + ms.settle_batch([{"action": "move", "cell": plan.hero_cell}]) + ms.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) + return hit + + +# 直接伤型快照判定(未揭示格可查——reward_economy 装板期摇定身份,示警烟火同源读面) +func _is_direct_type(orch, c: Vector2i) -> bool: + var snap_v = orch.reward_grant().reward_economy.query_full_snapshot(c) + return snap_v is Dictionary and DIRECT_TYPES.has(String((snap_v as Dictionary).get("cell_type", ""))) + + +# 可封印的直接伤型安全格扫描(未揭示+非雷+直接伤型+邻接可达 revealed 格——封印动作真实落点位) +func _find_sealable_direct(orch) -> Vector2i: + var ms = orch.minesweeper + var b = ms.current_board() + var revealed := {} + for c_v in ms.revealed_cells(): + revealed[c_v] = true + for y in b.height: + for x in b.width: + var c := Vector2i(x, y) + if ms.is_revealed(c) or b.is_mine(c) or ms.has_seal(c) or not _is_direct_type(orch, c): + continue + for n_v in b.neighbors8(c): + var n := Vector2i(n_v) + if revealed.has(n) and (ms.compute_move_path(n).size() > 0 or n == ms.hero_pos()): + return c + return Vector2i(-1, -1) + + +# 盲挖扩板直至出现可封印直接伤型安全格(有界;返回格位或 (-1,-1)) +func _seal_direct_with_expansion(orch) -> Vector2i: + var c := _find_sealable_direct(orch) + var guard := 12 + while c.x < 0 and guard > 0: + guard -= 1 + var plan = AutoPlayer.choose_dig(orch.minesweeper) + if plan.is_empty(): + break + if plan.hero_cell != orch.minesweeper.hero_pos(): + orch.minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) + orch.minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) + c = _find_sealable_direct(orch) + return c + + +# ---- V5 全局受伤链路(迭代 46 卡A T2)---- + +# 四条真实路径(敌方攻击/危险格/错误封印/地形)逐路驱动:每条至少一张主角承伤单据在案, +# 且承伤计数器 == 主角承伤单据行数(每单据恰发射一次的全量判据——重复发射即计数器>行数)。 +func test_all_hero_damage_paths_emit_once() -> void: + print("--- V5 全局 damage_taken:四路径逐路恰一次 ---") + # ① 敌方攻击路径(Boss 战零挖掘连停三轮——干净样本:零地形零资源单据;多轮保证伤害技在案) + var orch1 = _orch(46001) + var rows1 := _collect_hero_rows(orch1) + Kit.check("V5① 前置:战斗受理", orch1.begin_battle(_enc(["probe_dt_counter"]))) + for _i in 3: + orch1.minesweeper.settle_batch([{"action": "stop"}]) + var enemy_rows := _count_by_source(rows1, "enemy_action") + Kit.check("V5① 敌方攻击路径:承伤单据在案且逐单恰一次(行 %d=计数 %d)〔换图系批 V5〕" % [enemy_rows, _probe_count(orch1)], + enemy_rows >= 1 and _probe_count(orch1) == enemy_rows) + # ② 地形路径(AutoPlayer 真挖至伤害型变异格开出——发射在授予段开单点、单据标 hero_damage_consumed) + var orch2 = _orch(46002) + var rows2 := _collect_hero_rows(orch2) + Kit.check("V5② 前置:战斗受理", orch2.begin_battle(_enc(["probe_dt_counter"]))) + var terr := _dig_until(orch2, func(evt): + for vid_v in (evt.get("variant_ids", []) as Array): + if String(_db.combat.variation.row_by_id(String(vid_v)).get("effect", "")) == "flat_damage": + return true + return false) + Kit.check("V5② 前置:伤害型变异格已挖开(%s)" % str(terr.get("cell", Vector2i(-1, -1))), not terr.is_empty()) + orch2.minesweeper.settle_batch([{"action": "stop"}]) + var terrain_rows := _count_by_source(rows2, "地形变异") + Kit.check("V5② 地形路径:地形单据在案且全单据逐张恰一次(行 %d=计数 %d——盲挖途经单据一并计入)〔换图系批 V5〕" % [rows2.size(), _probe_count(orch2)], + terrain_rows >= 1 and _probe_count(orch2) == rows2.size()) + # ③ 危险格路径(AutoPlayer 真挖直接伤型危险格——负向直伤单据) + var orch3 = _orch(46003) + var rows3 := _collect_hero_rows(orch3) + Kit.check("V5③ 前置:战斗受理", orch3.begin_battle(_enc(["probe_dt_counter"]))) + var mine_hit := _dig_until(orch3, func(evt): + return String(evt.get("kind", "")) == "mine" and _is_direct_type(orch3, evt.cell)) + Kit.check("V5③ 前置:直接伤型危险格已挖开(%s)" % str(mine_hit.get("cell", Vector2i(-1, -1))), not mine_hit.is_empty()) + orch3.minesweeper.settle_batch([{"action": "stop"}]) + var danger_rows := _count_by_source(rows3, "mine_hit") + Kit.check("V5③ 危险格路径:直伤单据在案且全单据逐张恰一次(行 %d=计数 %d)〔换图系批 V5〕" % [rows3.size(), _probe_count(orch3)], + danger_rows >= 1 and _probe_count(orch3) == rows3.size()) + # ④ 错误封印路径(扩板→封直接伤型安全格不挖→停手轮末判标错——负向直伤单据) + var orch4 = _orch(46004) + var rows4 := _collect_hero_rows(orch4) + Kit.check("V5④ 前置:战斗受理", orch4.begin_battle(_enc(["probe_dt_counter"]))) + var scell := _seal_direct_with_expansion(orch4) + Kit.check("V5④ 前置:可封直接伤型安全格在位(%s)" % str(scell), scell.x >= 0) + if scell.x >= 0: + var plan4 = AutoPlayer.choose_dig(orch4.minesweeper) + # 封印动作真实落点:先就位到目标邻接可达格(封印距离同挖掘距离) + var seal_plan := _plan_seal(orch4, scell) + if not seal_plan.is_empty(): + if seal_plan["hero_cell"] != orch4.minesweeper.hero_pos(): + orch4.minesweeper.settle_batch([{"action": "move", "cell": seal_plan["hero_cell"]}]) + var seal_out: Array = orch4.minesweeper.settle_batch([{"action": "seal_place", "cell": scell}]) + Kit.check("V5④ 前置:封印受理", not seal_out.is_empty() and bool((seal_out[0] as Dictionary).get("accepted", false))) + orch4.minesweeper.settle_batch([{"action": "stop"}]) + var sealwrong_rows := _count_by_source(rows4, "seal_miss") + Kit.check("V5④ 错误封印路径:直伤单据在案且全单据逐张恰一次(行 %d=计数 %d)〔换图系批 V5〕" % [rows4.size(), _probe_count(orch4)], + sealwrong_rows >= 1 and _probe_count(orch4) == rows4.size()) + + +# 封印落点计划(目标格邻接可达 revealed 位——AutoPlayer._plan_on 同构最小私有形) +func _plan_seal(orch, target: Vector2i) -> Dictionary: + var ms = orch.minesweeper + for n_v in ms.current_board().neighbors8(target): + var n := Vector2i(n_v) + if ms.is_revealed(n) and (ms.compute_move_path(n).size() > 0 or n == ms.hero_pos()): + return {"hero_cell": n} + return {} + + +# 同一伤害不得出现两次消费(重复发射=计数器>行数):敌方路径与地形路径两样本逐位核对—— +# 地形单据带 hero_damage_consumed 标记,战斗段末 _resolve_board_flat_hero 跳过二次发射由此钉住。 +func test_no_duplicate_damage_taken_event() -> void: + print("--- V5 全局 damage_taken:无重复消费 ---") + var orch1 = _orch(46011) + var rows1 := _collect_hero_rows(orch1) + Kit.check("ND 前置:战斗受理", orch1.begin_battle(_enc(["probe_dt_counter"]))) + for _i in 2: + orch1.minesweeper.settle_batch([{"action": "stop"}]) + Kit.check("ND 敌方路径零重复(行 %d=计数 %d——重复消费即计数>行)〔换图系批 V5〕" % [rows1.size(), _probe_count(orch1)], + rows1.size() >= 1 and _probe_count(orch1) == rows1.size()) + var orch2 = _orch(46012) + var rows2 := _collect_hero_rows(orch2) + Kit.check("ND 前置:战斗受理", orch2.begin_battle(_enc(["probe_dt_counter"]))) + var terr2 := _dig_until(orch2, func(evt): + for vid_v in (evt.get("variant_ids", []) as Array): + if String(_db.combat.variation.row_by_id(String(vid_v)).get("effect", "")) == "flat_damage": + return true + return false) + Kit.check("ND 前置:伤害型变异格已挖开(%s)" % str(terr2.get("cell", Vector2i(-1, -1))), not terr2.is_empty()) + orch2.minesweeper.settle_batch([{"action": "stop"}]) + Kit.check("ND 地形路径零重复(行 %d=计数 %d——context 标记闸失效即计数翻倍)〔换图系批 V5〕" % [rows2.size(), _probe_count(orch2)], + rows2.size() >= 1 and _probe_count(orch2) == rows2.size()) + + +# ---- V2/V3 两件基座(迭代 46 卡A T3)---- + +# 表驱动停手攒 RAP(每轮 1:1 转存——无转存率订阅缺省) +func _bank_rap(orch, rounds_n: int) -> void: + for _i in rounds_n: + orch.minesweeper.settle_batch([{"action": "stop"}]) + + +# AutoPlayer 单次付费挖格(真实挖掘入口——移位+dig 一动作) +func _auto_dig(orch) -> void: + var plan = AutoPlayer.choose_dig(orch.minesweeper) + if not plan.is_empty(): + if plan.hero_cell != orch.minesweeper.hero_pos(): + orch.minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) + orch.minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) + + +# V2 矿脉储能:受理的付费挖格 RAP 恰 +1、AP 按表扣、板资源不变 +func test_reserve_on_accepted_dig() -> void: + print("--- V2 矿脉储能:付费挖格产 RAP ---") + var orch = _orch(46021) + Kit.check("V2 前置:战斗受理", orch.begin_battle(_enc(["vein_reserve"]))) + _bank_rap(orch, 1) + var rap_before: int = orch.action_economy.rap + var ap_before: int = orch.action_economy.ap + _auto_dig(orch) + var dig_cost: int = int(_db.run.action_economy.dig_ap_cost) + Kit.check("V2 受理付费挖格 RAP 恰 +1(%d→%d)〔换图系批 V2〕" % [rap_before, orch.action_economy.rap], + orch.action_economy.rap == rap_before + 1) + Kit.check("V2 AP 按表扣(%d→%d,dig 价 %d)〔换图系批 V2〕" % [ap_before, orch.action_economy.ap, dig_cost], + orch.action_economy.ap == ap_before - dig_cost) + Kit.check("V2 受理记账闭合:AP 耗尽轮 RAP 增量恰等于受理挖数(AP 消耗数——每受理恰 +1,未受理零入池)〔换图系批 V2〕", + _drain_round_rap_accounting(orch)) + + +# V2 辅证:挖到 AP 耗尽——RAP 增量 == 受理挖数(每受理恰 +1;移位耗 AP 不入账故按挖计数) +func _drain_round_rap_accounting(orch) -> bool: + var rap0: int = orch.action_economy.rap + var digs := 0 + var guard := 24 + while orch.action_economy.phase == "Active" and guard > 0: + guard -= 1 + var plan = AutoPlayer.choose_dig(orch.minesweeper) + if plan.is_empty(): + break + if plan.hero_cell != orch.minesweeper.hero_pos(): + orch.minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) + if orch.action_economy.phase != "Active": + break # 移位耗尽 AP 收轮——本轮不再有受理挖 + orch.minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) + digs += 1 + return digs > 0 and orch.action_economy.rap - rap0 == digs + + +# V3 省力周转:折价门槛与扣费(can 与执行同源;base 8→扣 4) +func test_refresh_discount_gate_and_charge() -> void: + print("--- V3 省力周转:折价门槛与扣费 ---") + var orch = _orch(46022) + Kit.check("V3 前置:战斗受理", orch.begin_battle(_enc(["easy_swap"]))) + _bank_rap(orch, 1) + var base_cost: int = int(_db.run.action_economy.rap_refresh_cost) + var discounted: int = int(floor(float(base_cost) * 50.0 / 100.0)) + var rap0: int = orch.action_economy.rap + Kit.check("V3 门同源:can_refresh 与折后价同源(RAP %d ≥ 折后 %d)〔换图系批 V3〕" % [rap0, discounted], + orch.can_refresh()) + Kit.check("V3 第 1 次刷新受理", orch.player_request_refresh()) + Kit.check("V3 折后单次扣费(%d→%d,−%d)〔换图系批 V3〕" % [rap0, orch.action_economy.rap, discounted], + orch.action_economy.rap == rap0 - discounted) + Kit.check("V3 第 2 次刷新受理(RAP=%d)" % orch.action_economy.rap, orch.player_request_refresh()) + Kit.check("V3 两次折后扣费累计(RAP=%d=%d−2×%d)〔换图系批 V3〕" % [orch.action_economy.rap, rap0, discounted], + orch.action_economy.rap == rap0 - 2 * discounted) + Kit.check("V3 门槛拒:RAP 不足折后价零受理(can=false 且执行拒)〔换图系批 V3〕", + orch.action_economy.rap < discounted and not orch.can_refresh() and not orch.player_request_refresh()) + + +# V3 双基座叠加:挖格产 RAP 与刷新折价同场生效 +func test_double_base_stack() -> void: + print("--- V3 双基座叠加 ---") + var orch = _orch(46023) + Kit.check("V3 前置:战斗受理", orch.begin_battle(_enc(["vein_reserve", "easy_swap"]))) + _bank_rap(orch, 1) + var rap_before: int = orch.action_economy.rap + var base_cost: int = int(_db.run.action_economy.rap_refresh_cost) + _auto_dig(orch) + Kit.check("V3 双基座:挖格产 RAP +1(%d→%d)〔换图系批 V3〕" % [rap_before, orch.action_economy.rap], + orch.action_economy.rap == rap_before + 1) + var rap_after_dig: int = orch.action_economy.rap + Kit.check("V3 双基座:刷新按折后价扣(−%d)〔换图系批 V3〕" % int(base_cost / 2), + orch.player_request_refresh() and orch.action_economy.rap == rap_after_dig - int(base_cost / 2)) + + +# V12(refresh_feats 侧):无专长基线——挖格零产 RAP、刷新按表基线扣、AP 保留、上限照旧 +func test_no_feats_baseline() -> void: + print("--- V12 无专长基线(refresh_feats 侧) ---") + var orch = _orch(46024) + Kit.check("V12 前置:战斗受理", orch.begin_battle(_enc([]))) + _bank_rap(orch, 3) + var rap_before: int = orch.action_economy.rap + var ap_before: int = orch.action_economy.ap + var base_cost: int = int(_db.run.action_economy.rap_refresh_cost) + _auto_dig(orch) + Kit.check("V12 无基座:挖格零产 RAP(%d 不变)〔换图系批 V12〕" % rap_before, orch.action_economy.rap == rap_before) + Kit.check("V12 刷新受理并按表基线扣(−%d)〔换图系批 V12〕" % base_cost, + orch.player_request_refresh() and orch.action_economy.rap == rap_before - base_cost) + Kit.check("V12 AP 保留、相位 Active(基线刷新不重置)〔换图系批 V12〕", + orch.action_economy.ap == ap_before - int(_db.run.action_economy.dig_ap_cost) and orch.action_economy.phase == "Active") + Kit.check("V12 每轮上限照旧:第 3 次拒(RAP 足额仍拒)〔换图系批 V12〕", + orch.player_request_refresh() and not orch.player_request_refresh()) + + +# ---- V4/V10 成功刷新事实与命中施加(迭代 46 卡A T4)---- + +# 成功刷新事实探针专长安装(board_refreshed → counter_add——成功事实的可观测计数面) +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", + "desc": "测试夹具:每次成功刷新计数 +1", + "triggers": [{"on": "board_refreshed", "do": {"kind": "counter_add", "counter_id": "br_probe_hits", "value": 1}}], + }) + + +func _br_count(orch) -> int: + return EffectEngineScript.counter(orch.fx_host().counters_ctx, "br_probe_hits") + + +# V4 成功事实只在新板就位后发射:成功恰一次;非 Active/每轮上限/RAP 不足三拒面全零事实零换图 +func test_board_refresh_fact_only_on_success() -> void: + print("--- V4 成功刷新事实:拒绝零事实 ---") + _install_br_probe() + var orch = _orch(46031) + var swaps := {"n": 0} + var waiting_rejected := {"v": false} + orch.board_changed.connect(func(): swaps["n"] += 1) + # 非 Active 探针(round_flow 同款:借衰减行在结算段内同步探测一次——Waiting 相位刷新必拒) + orch.step_row.connect(func(row): + if String(row.get("type", "")) == "decay" and not bool(waiting_rejected["v"]): + waiting_rejected["v"] = not orch.player_request_refresh()) + Kit.check("V4 前置:战斗受理", orch.begin_battle(_enc(["probe_br_counter"]))) + _bank_rap(orch, 3) # RAP=3×AP;首轮结算衰减行触发 Waiting 探针 + var swaps0: int = swaps["n"] + Kit.check("V4 非 Active 拒零事实(结算段内探测拒绝)〔换图系批 V4〕", + bool(waiting_rejected["v"]) and _br_count(orch) == 0 and int(swaps["n"]) == swaps0) + # 成功 ×2:事实计数与换图信号逐次 +1 + Kit.check("V4 第 1 次成功(事实 +1 换图 +1)〔换图系批 V4〕", + orch.player_request_refresh() and _br_count(orch) == 1 and int(swaps["n"]) == swaps0 + 1) + Kit.check("V4 第 2 次成功(事实 +1 换图 +1)〔换图系批 V4〕", + orch.player_request_refresh() and _br_count(orch) == 2 and int(swaps["n"]) == swaps0 + 2) + # 每轮上限拒:used=2 满门、RAP 足额——零事实零换图 + Kit.check("V4 上限拒零事实(RAP %d 足额仍拒)〔换图系批 V4〕" % orch.action_economy.rap, + not orch.can_refresh() and not orch.player_request_refresh() and _br_count(orch) == 2 and int(swaps["n"]) == swaps0 + 2) + # RAP 不足拒:停手进新轮(used 重置、停手再入 RAP)→ 消费至 RAP 0 → 拒(零事实零换图; + # 纯 RAP 不足拒绝面已由 V3 门槛拒单测钉住,此处并拦形态) + orch.minesweeper.settle_batch([{"action": "stop"}]) + Kit.check("V4 新轮受理两次(used 重置、RAP 复入)〔换图系批 V4〕", + orch.action_economy.phase == "Active" and orch.player_request_refresh() and orch.player_request_refresh() and _br_count(orch) == 4) + Kit.check("V4 RAP 不足拒零事实(RAP %d < 价 %d)〔换图系批 V4〕" % [orch.action_economy.rap, int(_db.run.action_economy.rap_refresh_cost)], + orch.action_economy.rap < int(_db.run.action_economy.rap_refresh_cost) and not orch.can_refresh() and not orch.player_request_refresh() and _br_count(orch) == 4 and int(swaps["n"]) == swaps0 + 4) + + +# V10 矿道红利:每次成功刷新 escrow 恰 +1 且 fx_grant_gp 行在案;拒绝零变化 +func test_mine_profit_gp() -> void: + print("--- V10 矿道红利:刷新固定 GP ---") + var orch = _orch(46032) + var gp_rows := {"n": 0} + orch.step_row.connect(func(row): + if String(row.get("type", "")) == "effect" and String(row.get("effect_id", "")) == "fx_grant_gp": + gp_rows["n"] += 1) + Kit.check("V10 前置:战斗受理", orch.begin_battle(_enc(["mine_profit"]))) + _bank_rap(orch, 2) + var escrow0: int = orch.reward_grant().escrow + Kit.check("V10 第 1 次成功 escrow +1(%d→%d)〔换图系批 V10〕" % [escrow0, orch.reward_grant().escrow], + orch.player_request_refresh() and orch.reward_grant().escrow == escrow0 + 1) + Kit.check("V10 fx_grant_gp 行在案(%d 行)〔换图系批 V10〕" % int(gp_rows["n"]), int(gp_rows["n"]) == 1) + Kit.check("V10 第 2 次成功 escrow +1(%d→%d)〔换图系批 V10〕" % [orch.reward_grant().escrow, orch.reward_grant().escrow + 1], + orch.player_request_refresh() and orch.reward_grant().escrow == escrow0 + 2) + # 拒绝零变化:RAP 不足 + Kit.check("V10 拒绝零变化(escrow=%d)〔换图系批 V10〕" % orch.reward_grant().escrow, + not orch.player_request_refresh() and orch.reward_grant().escrow == escrow0 + 2 and int(gp_rows["n"]) == 2) + + +# ---- V6/V8/V9/V7 五件后续效果(迭代 46 卡A T5)---- + +func _counter(orch, counter_id: String) -> int: + return EffectEngineScript.counter(orch.fx_host().counters_ctx, counter_id) + + +# 伤害明细行泛收集(type=damage 全量——按来源类与值断言) +func _collect_damage_rows(orch) -> Array: + var rows: Array = [] + orch.step_row.connect(func(row): + if String(row.get("type", "")) == "damage": + rows.append(row)) + return rows + + +func _damage_rows_by_source(rows: Array, source: String) -> Array: + var out: Array = [] + for r_v in rows: + var r := r_v as Dictionary + if String(r.get("source_class", "")) == source: + out.append(r) + return out + + +# V6 石层缓冲:全局固定减伤(危险格 pierce 8→6 两档钉死:单刷减 2·同轮多刷不叠加·未刷新轮不减; +# 地形路径减后值=max(下限, effect_value−2)——含下限保持;敌方路径照常单次发射) +func test_stone_mitigation_all_damage() -> void: + print("--- V6 石层缓冲:全局固定减伤 ---") + # S1 同轮双刷 → 危险格 pierce 8 减 2=6(叠加即 4——多刷不增钉死) + var orch1 = _orch(46041) + var rows1 := _collect_damage_rows(orch1) + Kit.check("V6 S1 前置:战斗受理", orch1.begin_battle(_enc(["stone_mitigation"]))) + _bank_rap(orch1, 3) + Kit.check("V6 S1 同轮双刷受理", orch1.player_request_refresh() and orch1.player_request_refresh()) + Kit.check("V6 S1 guard 累计 2(同轮双刷)", _counter(orch1, "round_swap_guard") == 2) + var mine1 := _dig_until(orch1, func(evt): + return String(evt.get("kind", "")) == "mine" and _is_direct_type(orch1, evt.cell)) + Kit.check("V6 S1 前置:直接伤型危险格已挖开(%s)" % str(mine1.get("cell", Vector2i(-1, -1))), not mine1.is_empty()) + orch1.minesweeper.settle_batch([{"action": "stop"}]) + var pierce_rows1 := _damage_rows_by_source(rows1, "mine_hit") + Kit.check("V6 S1 危险格减伤恰 2 且多刷不叠加(行值 %s∈[6]——叠加态应为 4)〔换图系批 V6〕" % str(pierce_rows1.map(func(r): return int(r.value))), + pierce_rows1.size() >= 1 and int((pierce_rows1[0] as Dictionary).get("value", -1)) == 6) + # S2 未刷新轮:guard 随 round_start 清空 → 同型危险格不减(pierce/magic 表幂 8——heal 型幂 6 不同锚勿混) + var mine2 := _dig_until(orch1, func(evt): + var snap_v2 = orch1.reward_grant().reward_economy.query_full_snapshot(evt.cell) + return String(evt.get("kind", "")) == "mine" and snap_v2 is Dictionary \ + and ["pierce", "magic"].has(String((snap_v2 as Dictionary).get("cell_type", "")))) + Kit.check("V6 S2 前置:第二直接伤型危险格已挖开(%s)" % str(mine2.get("cell", Vector2i(-1, -1))), not mine2.is_empty()) + orch1.minesweeper.settle_batch([{"action": "stop"}]) + var pierce_rows2 := _damage_rows_by_source(rows1, "mine_hit") + Kit.check("V6 S2 未刷新轮不减伤(新轮行值 %s 含 8——round 桶轮始清空)〔换图系批 V6〕" % str(pierce_rows2.map(func(r): return int(r.value))), + pierce_rows2.size() >= 2 and int((pierce_rows2[pierce_rows2.size() - 1] as Dictionary).get("value", -1)) == 8) + # S3 地形路径:减后值 = max(下限, effect_value−2)——下限保持与定值减一并钉死 + # (种子 46002=V5② 已证含 flat_damage 变异板;刷新换板后变异重布—— digging guard 内可达) + var orch3 = _orch(46002) + var rows3 := _collect_damage_rows(orch3) + Kit.check("V6 S3 前置:战斗受理", orch3.begin_battle(_enc(["stone_mitigation"]))) + _bank_rap(orch3, 2) + Kit.check("V6 S3 单刷受理", orch3.player_request_refresh()) + var terr3 := _dig_until(orch3, func(evt): + for vid_v in (evt.get("variant_ids", []) as Array): + if String(_db.combat.variation.row_by_id(String(vid_v)).get("effect", "")) == "flat_damage": + return true + return false) + Kit.check("V6 S3 前置:伤害型变异格已挖开(%s)" % str(terr3.get("cell", Vector2i(-1, -1))), not terr3.is_empty()) + orch3.minesweeper.settle_batch([{"action": "stop"}]) + var terrain_rows := _damage_rows_by_source(rows3, "地形变异") + var floor_v: int = int(_db.combat.settlement.damage_roll.damage_floor) + var ev_ok := true + for tr_v in terrain_rows: + var tr := tr_v as Dictionary + var vid := String(tr.get("variant_id", "")) + var ev: int = int(_db.combat.variation.row_by_id(vid).get("effect_value", 0)) + if int(tr.get("value", -1)) != maxi(floor_v, ev - 2): + ev_ok = false + Kit.check("V6 S3 地形减伤=max(下限,effect_value−2) 逐单闭合(%d 单)〔换图系批 V6〕" % terrain_rows.size(), + terrain_rows.size() >= 1 and ev_ok) + + +# V9 坑道小憩:轮末合计回复(白盒预置承载上限 3——计数器直改属数据面注入; +# 自然累计 2 与满血未生效两形态分战场钉死) +func test_rest_rotation_cap_and_clear() -> void: + print("--- V9 坑道小憩:轮末恢复 ---") + # R1 白盒预置 3 层 + 刷新闸(when<3 拒第 4 层)→ 停手回复 min(3, 缺血)——护甲降档保缺血 ≥3 + var orch1 = _orch(46051) + var rows1: Array = [] + orch1.step_row.connect(func(row): rows1.append(row)) + Kit.check("V9 R1 前置:战斗受理", orch1.begin_battle(_enc(["tunnel_rest"], 0, 10, 100))) + _bank_rap(orch1, 1) # 开局血差 100(hero_hp 100/max 200)——缺血 ≥3 恒成立,回复面确定 + var missing: int = 200 - int(orch1._enemy_system.hero_unit().get("hp", 0)) + EffectEngineScript.counter_add(orch1.fx_host().counters_ctx, "round_swap_rest", 3) + Kit.check("V9 R1 预置 3 层后刷新被闸(when<3——上限 3)", orch1.player_request_refresh() and _counter(orch1, "round_swap_rest") == 3) + orch1.minesweeper.settle_batch([{"action": "stop"}]) + var heal_rows1: Array = [] + var idle_rows1 := 0 + for r_v in rows1: + var r := r_v as Dictionary + if String(r.get("type", "")) == "heal" and String(r.get("source_class", "")) == "fx_board_refresh": + heal_rows1.append(r) + if String(r.get("type", "")) == "reward_ineffective" and String(r.get("batch_key", "")) == "round_swap_rest": + idle_rows1 += 1 + Kit.check("V9 R1 轮末回复=min(3,缺血%d) 且层清 0(heal 行值 %s)〔换图系批 V9〕" % [missing, str(heal_rows1.map(func(r): return int(r.value)))], + missing >= 3 and heal_rows1.size() == 1 and int((heal_rows1[0] as Dictionary).get("value", -1)) == 3 and _counter(orch1, "round_swap_rest") == 0) + # R2 满血未生效:首轮刷新后停手——授予段 heal 时点主角仍满血(combat 段还手在其后) + var orch2 = _orch(46052) + var rows2: Array = [] + orch2.step_row.connect(func(row): rows2.append(row)) + Kit.check("V9 R2 前置:战斗受理", orch2.begin_battle(_enc(["tunnel_rest"], 8))) + Kit.check("V9 R2 首轮刷新受理(voucher_rap 入池)", orch2.player_request_refresh()) + orch2.minesweeper.settle_batch([{"action": "stop"}]) + var heal_rows2 := 0 + var idle2 := 0 + for r2_v in rows2: + var r2 := r2_v as Dictionary + if String(r2.get("type", "")) == "heal" and String(r2.get("source_class", "")) == "fx_board_refresh": + heal_rows2 += 1 + if String(r2.get("type", "")) == "reward_ineffective" and String(r2.get("batch_key", "")) == "round_swap_rest": + idle2 += 1 + Kit.check("V9 R2 满血未生效行在案且零 heal 行(未生效 %d·heal %d)〔换图系批 V9〕" % [idle2, heal_rows2], + idle2 == 1 and heal_rows2 == 0 and _counter(orch2, "round_swap_rest") == 0) + # R3 自然累计:同轮双刷 → 回复 2(上限 3 的闸面由 R1 白盒钉住,此处证自然路径) + var orch3 = _orch(46053) + var rows3: Array = [] + orch3.step_row.connect(func(row): rows3.append(row)) + Kit.check("V9 R3 前置:战斗受理", orch3.begin_battle(_enc(["tunnel_rest"]))) + _bank_rap(orch3, 2) + Kit.check("V9 R3 同轮双刷受理", orch3.player_request_refresh() and orch3.player_request_refresh()) + Kit.check("V9 R3 自然累计 2 层", _counter(orch3, "round_swap_rest") == 2) + orch3.minesweeper.settle_batch([{"action": "stop"}]) + var heal3 := 0 + for r3_v in rows3: + if String((r3_v as Dictionary).get("type", "")) == "heal" and String((r3_v as Dictionary).get("source_class", "")) == "fx_board_refresh": + heal3 += 1 + Kit.check("V9 R3 轮末 heal 行值 2(自然累计路径)〔换图系批 V9〕", + heal3 == 1 and _counter(orch3, "round_swap_rest") == 0) + + +# V8 蓄力周转:当轮攻击消费全部层数(上限 3 闸=白盒预置;战斗新建自然清空) +func test_charge_layers_consumed_same_round() -> void: + print("--- V8 蓄力周转:攻击消费层 ---") + var orch1 = _orch(46061) + var rows1 := _collect_damage_rows(orch1) + Kit.check("V8 前置:战斗受理", orch1.begin_battle(_enc(["charge_rotation"], 16))) + # 上限 3 闸:白盒预置 3 层后刷新被拒层(when<3) + EffectEngineScript.counter_add(orch1.fx_host().counters_ctx, "swap_charge_layers", 3) + Kit.check("V8 上限 3 闸(预置 3 刷新不加层)", orch1.player_request_refresh() and _counter(orch1, "swap_charge_layers") == 3) + # 当轮攻击消费:停手 → 主角先手攻击 → 层数全消费清零 + orch1.minesweeper.settle_batch([{"action": "stop"}]) + var attack_rows := 0 + for r_v in rows1: + if String((r_v as Dictionary).get("source_class", "")) == "hero_attack": + attack_rows += 1 + Kit.check("V8 当轮攻击消费全部层(攻击行在案 %d·层清 0)〔换图系批 V8〕" % attack_rows, + attack_rows >= 1 and _counter(orch1, "swap_charge_layers") == 0) + # 自然累计:双刷 2 层(voucher_rap 16 入池)→ 停手消费归零 + var orch2 = _orch(46062) + Kit.check("V8 前置:战斗受理", orch2.begin_battle(_enc(["charge_rotation"], 16))) + Kit.check("V8 战斗新建层 0(引擎重建自然清空)", _counter(orch2, "swap_charge_layers") == 0) + Kit.check("V8 双刷累计 2 层", orch2.player_request_refresh() and orch2.player_request_refresh() and _counter(orch2, "swap_charge_layers") == 2) + orch2.minesweeper.settle_batch([{"action": "stop"}]) + Kit.check("V8 攻击后层清 0(消费面闭合)〔换图系批 V8〕", _counter(orch2, "swap_charge_layers") == 0) + + +# V7 崩壁冲击:延迟群伤单(刷新时点零伤害行——战斗段末按存活目标结账) +func test_blast_defers_to_combat_segment() -> void: + print("--- V7 崩壁冲击:延迟群伤 ---") + var orch = _orch(46071) + var rows := _collect_damage_rows(orch) + Kit.check("V7 前置:战斗受理", orch.begin_battle(_enc(["wall_blast"], 8))) + Kit.check("V7 刷新受理(voucher_rap 入池)", orch.player_request_refresh()) + Kit.check("V7 刷新时点零伤害行(授予段不结账——案C 延迟)〔换图系批 V7〕", + _damage_rows_by_source(rows, "fx_board_refresh").is_empty()) + orch.minesweeper.settle_batch([{"action": "stop"}]) + var blast_rows := _damage_rows_by_source(rows, "fx_board_refresh") + Kit.check("V7 战斗段末群伤单恰一单(全体存活敌 ×2——行值 2 count≥1)〔换图系批 V7〕", + blast_rows.size() == 1 and int((blast_rows[0] as Dictionary).get("value", -1)) == 2 + and int((blast_rows[0] as Dictionary).get("count", 0)) >= 1) + + +# ---- V11 数据与池(迭代 46 卡A T6)---- + +# 七件 validate/在册/稀有度 cap 对表/跨表计数器引用闭合/desc 禁词 +func test_seven_feats_validate_and_pool() -> void: + print("--- V11 七件数据与池 ---") + var seven := {"vein_reserve": "excellent", "easy_swap": "excellent", "wall_blast": "excellent", + "charge_rotation": "excellent", "stone_mitigation": "common", "tunnel_rest": "common", "mine_profit": "rare"} + Kit.check("V11 feats.validate 零违例(七行全过词表/行键白名单/前缀校验)〔换图系批 V11〕", + _db.crossrun.feats.validate().is_empty()) + var all_in := true + var desc_bad := 0 + for fid in seven.keys(): + var row: Dictionary = _db.crossrun.feats.row_by_id(String(fid)) + if row.is_empty() or String(row.get("rarity", "")) != String(seven[fid]) or int(row.get("cap", 0)) != 1: + all_in = false + if String(row.get("desc", "")).contains("雷") or String(row.get("desc", "")).contains("旗"): + desc_bad += 1 + Kit.check("V11 七行在册且稀有度/cap 逐件对表〔换图系批 V11〕", all_in) + Kit.check("V11 七件 desc 禁词零命中(雷/旗字面)〔换图系批 V11〕", desc_bad == 0) + # 跨表计数器引用闭合(判据域=换图系七件自身:value_counter/when-id 读取全部有 counter_add 写入方; + # 预存行的消费点记账形态〔如完美保险 insurance_used 由授予段落账〕不在此判据域) + var written := {} + for row_v in (_db.crossrun.feats.rows as Array): + for tv in ((row_v as Dictionary).get("triggers", []) as Array): + var do_d: Dictionary = (tv as Dictionary).get("do", {}) + if String(do_d.get("kind", "")) == "counter_add": + written[String(do_d.get("counter_id", ""))] = true + var refs_ok := true + for fid2 in seven.keys(): + var row2: Dictionary = _db.crossrun.feats.row_by_id(String(fid2)) + for tv2 in ((row2.get("triggers", []) as Array)): + var tr2 := tv2 as Dictionary + var do2: Dictionary = tr2.get("do", {}) + for reader in [String(do2.get("value_counter", "")), String(do2.get("value_from", ""))]: + if reader != "" and not written.has(reader): + refs_ok = false + for c_v in (tr2.get("when", []) as Array): + var cid := String((c_v as Dictionary).get("id", "")) + if (c_v as Dictionary).get("fact", "") == "counter" and cid != "" and not written.has(cid): + refs_ok = false + Kit.check("V11 七件跨表计数器引用闭合(value_counter/when-id 全部有 counter_add 写入方)〔换图系批 V11〕", refs_ok) + Kit.check("V11 换图系三计数器在写入集(swap_charge_layers/round_swap_guard/round_swap_rest)〔换图系批 V11〕", + written.has("swap_charge_layers") and written.has("round_swap_guard") and written.has("round_swap_rest")) diff --git a/game/tests/unit/battle_settlement/refresh_feats_test.gd.uid b/game/tests/unit/battle_settlement/refresh_feats_test.gd.uid new file mode 100644 index 00000000..ce2831df --- /dev/null +++ b/game/tests/unit/battle_settlement/refresh_feats_test.gd.uid @@ -0,0 +1 @@ +uid://bmsh0idfmpw3j diff --git a/game/tests/unit/core/adventure_feats_test.gd b/game/tests/unit/core/adventure_feats_test.gd index ddbb73b3..e362e441 100644 --- a/game/tests/unit/core/adventure_feats_test.gd +++ b/game/tests/unit/core/adventure_feats_test.gd @@ -461,8 +461,8 @@ func _smoke_adventure() -> void: func _smoke_feats() -> void: print("--- 专长重设计实装(feat_table 四级 · 专长带池接线 · 订阅面逐件)---") - # ① 现行表牌库构成:30 件全量入池——102 数值令牌+30 relic 令牌(四级 rarity 直映射) - Kit.check("feat_table 已加载(30 件=基础 27+迁入 2+卡② 完美保险 1——迭代 31 卡② 换代)", _db.crossrun.feats != null and (_db.crossrun.feats.rows as Array).size() == 30) + # ① 现行表牌库构成:37 件全量入池——102 数值令牌+37 relic 令牌(四级 rarity 直映射) + Kit.check("feat_table 已加载(37 件=基础 27+迁入 2+完美保险 1+迭代 46 换图系七件——换代)", _db.crossrun.feats != null and (_db.crossrun.feats.rows as Array).size() == 37) Kit.check("feat_table.validate 零违例(四级 rarity/cap 域/trigger 词表/行键白名单全过)", _db.crossrun.feats.validate().is_empty()) var a_r0 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(71)) Kit.check("专长带场景:冒险启动(seed 71)", a_r0.start_adventure(71)) @@ -473,7 +473,7 @@ func _smoke_feats() -> void: feat_tokens += 1 var tk := String((t as Dictionary).get("tier", "")) tier_counts[tk] = int(tier_counts.get(tk, 0)) + 1 - Kit.check("专长带入池:30 件四级直映射(普通 12/优秀 9/稀有 9/传说 0)——牌库 102+30=132〔专长批 V2+卡② 完美保险迁入〕", feat_tokens == 30 and int(tier_counts.get("common", 0)) == 12 and int(tier_counts.get("excellent", 0)) == 9 and int(tier_counts.get("rare", 0)) == 9 and a_r0.deck().deck.size() == 132) + Kit.check("专长带入池:37 件四级直映射(普通 14/优秀 13/稀有 10/传说 0)——牌库 102+37=139〔专长批 V2+迭代 46 换图系七件换代〕", feat_tokens == 37 and int(tier_counts.get("common", 0)) == 14 and int(tier_counts.get("excellent", 0)) == 13 and int(tier_counts.get("rare", 0)) == 10 and a_r0.deck().deck.size() == 139) # ② 旧 27 件零残留钉面(V2——arcane_ember 系旧件表首行;全仓 grep 零残留归静态核查) Kit.check("旧 27 件已退役:arcane_ember/magma_body 表内零残留〔专长批 V2〕", _db.crossrun.feats.row_by_id("arcane_ember").is_empty() and _db.crossrun.feats.row_by_id("magma_body").is_empty()) Kit.check("迁出收尾:treasure_items 无 venom_edge/seal_burst 行(同调目录自然收窄)〔专长批 V13〕", _db.crossrun.treasure_items.row_by_id("venom_edge").is_empty() and _db.crossrun.treasure_items.row_by_id("seal_burst").is_empty()) diff --git a/game/tests/unit/core/effect_words_t1_test.gd b/game/tests/unit/core/effect_words_t1_test.gd index 3f5d44f1..12a17964 100644 --- a/game/tests/unit/core/effect_words_t1_test.gd +++ b/game/tests/unit/core/effect_words_t1_test.gd @@ -14,6 +14,11 @@ func _init() -> void: # ① 词表在册断言(扩签面:2 事件+8 事实+7 效果类型+1 算子) Kit.check_quiet(EffectEngineScript.EVENTS.has("dig_stop") and EffectEngineScript.EVENTS.has("battle_won"), "新事件在册:dig_stop/battle_won〔专长批 V1〕") + # ①-a 换图系两事件两效果在册(迭代 46 扩签——旧出域负例全部保留) + Kit.check_quiet(EffectEngineScript.EVENTS.has("refresh_request") and EffectEngineScript.EVENTS.has("board_refreshed"), + "换图系新事件在册:refresh_request/board_refreshed〔换图系批 V1〕") + Kit.check_quiet(EffectEngineScript.EFFECT_KINDS.has("grant_reserve_ap") and EffectEngineScript.EFFECT_KINDS.has("apply_discount"), + "换图系新效果在册:grant_reserve_ap/apply_discount〔换图系批 V1〕") Kit.check_quiet(EffectEngineScript.FACTS.has("hp_ratio") and EffectEngineScript.FACTS.has("is_edge_cell") and EffectEngineScript.FACTS.has("new_resource_type") and EffectEngineScript.FACTS.has("dug_under_seal") and EffectEngineScript.FACTS.has("ap_exhausted_last") and EffectEngineScript.FACTS.has("ap_left") diff --git a/game/tests/unit/data_config/forge_table_test.gd b/game/tests/unit/data_config/forge_table_test.gd index c1a75a4e..370bc4b5 100644 --- a/game/tests/unit/data_config/forge_table_test.gd +++ b/game/tests/unit/data_config/forge_table_test.gd @@ -199,7 +199,7 @@ func _test_negative_cases() -> void: # 现行 31;本套件零扩签,钉现行数(将来任何加词即转红提示复核)。 func _test_effect_kinds_closed() -> void: - Kit.check("效果引擎效果类型闭集 32 种(锻造工坊批零新增口径随迭代 31 卡② 扩签 forgive_seal_miss 换代)〔锻造工坊批 V11〕", - EffectEngineScript.EFFECT_KINDS.size() == 32) - Kit.check("效果引擎事件词表在册 20 种(本批零扩签参照面)〔锻造工坊批 V11〕", - EffectEngineScript.EVENTS.size() == 20) + Kit.check("效果引擎效果类型闭集 34 种(迭代 46 换图系扩签 grant_reserve_ap/apply_discount 换代)〔锻造工坊批 V11〕", + EffectEngineScript.EFFECT_KINDS.size() == 34) + Kit.check("效果引擎事件词表在册 22 种(迭代 46 换图系扩签 refresh_request/board_refreshed 换代)〔锻造工坊批 V11〕", + EffectEngineScript.EVENTS.size() == 22)