Files
gplan-2/game/core/battle_orchestrator.gd
T
DKChan 96fc61c701 feat(tree): 迭代21天赋树实装批 T1-T6 主体——12节点换代+六键接线+营地四柜台(2026-10-12 U0)
T1 schema 换代:MetaTreeTable FORK_GROUPS 4→3(fg_mastery 退役)/RULE_KEYS 2→6(sense/economy 四键扩签)/
  fork 单行双态(effects_a/b+name_a/b、每组恰一校验)/row 字段退役(行门槛主语=节点等级 level_gate)/
  requires 只留点亮前置(R1 裁撤案执行);meta_tree.tres 33→12 行重排(脊柱2/锻造2含彩蛋/钥石2/同调6,
  att_socket1 改根节点无前置,easter_ley 摘 atk/armor_class 分量=契约9)
T2 购买与彩蛋面:tree_can_buy easter 拒购+行门槛换轴;tree_buy_fork=解锁默认A;tree_toggle_fork 零币切态;
  tree_rule_keys 按激活态 effects_a/b 投影;_check_easter_egg 特化判定(两线拉满+socket全解锁);营地树行板双态钮
T3 六键接线:EVENTS+flag_missed/FACTS+battle_index(L2 扩签两处);rebuild 六键合成订阅
  (posture 窗=battle_index&&round_index 双闸/sense 两组=counter 闸+记账双行 domain:adventure 限次2次 provisional);
  level_runner battle_index 注入(visited battle 型计数);_judge_flag 去重闸后 flag_missed 发射(契约12);
  聚=_roll_gp_drop 入账前×1.5 provisional(escrow 不乘);散=_spend_discounted ×80%(current_price/pool_price
  双计价点,刷新价不折=§11②定义边界);_start_current_level 限次按关清零(_reset_tree_limited_counters 可测口)
T4 注入换代:hero_override=树淬炼flat+FORGE_ROUTE_BONUS预留0(命中不注入=契约6);Attunement.slots 判定源切
  树点亮数(谓词表退役);tree_buy(ks_refresh) 同账路由 buy_attune_refresh(V15 双入口)
T5 旧档废弃与删除:save_manager tree_struct=2 结构版本标记+_normalize 白名单重建+树域废弃重置(禁迁移折算);
  migrate_tree/buy_forge/forge_flat/forge_level/forge_price/meta_forge_row/forge_products 行族/
  attunement_growth 表+访问器/生涯页三投影/career forge_total 全删;treasure_item_table UNLOCK_COUNTER_KEYS 内联;
  verifier_batch 花费策略换代树节点;MC slots 画像常量3
T6 营地统一:四柜台并排横滚(树淬炼主界面/锻造工坊钥匙门+零交互占位面板/秘宝匣钥匙门+内容迁入/图鉴常开);
  暂停层锻造段连带删;camp data forge 投影删
存量红修复:_flush_pending_reveals 三处空 source_class 补 fx_reveal+类级豁免(a57489e9 落地件,冒烟51行)
测试随批:smoke 树段全重写(V1/V2/V3/V8/V9/V16)+_smoke_tree_rules V10 行为段(窗/限次/守侧五场景编排rig/聚散
  同种子对拍)+单元 tree_rules_v11_test(六键合成行闭集校验)+run_all 22 套;ui_probe/ui_click_probe 换代树点击路径
轻闸全绿:冒烟886/0+run_all 22套+probe --click 全绿
2026-10-12 07:54:38 +08:00

1621 lines
101 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# 战斗结算编排中枢:轮次生命周期唯一 owner · 五段结算(授予→增益/减益→战斗→衰减→判定——2026-09-15
# 格面资源批,道具窗口/道具段随可选类删除下线)· 授予段极性路由(「动作 × 格型」矩阵:开安全格/标中旗
# = 正向,开雷格/标错旗 = 负向;结算队列 + 抵消过滤——标旗后被撤旗以轮末在场旗为基线不判)·
# 10 类行协议可回放步骤流(零 await;随机消费三处皆封闭流——开战遭遇骰〔初始化随机〕+ 技能摇号
# behavior_roll〔回合内、开轮时点、步骤流之外——敌人技能池批 2026-09-16〕+ 护甲等级掷点〔回合内、
# 步骤流之内〕;同种子同输入逐位复现)· 高光双落(完美排雷 ×2 / 标中旗 highlight)·
# Waiting 相位结算触发(不依赖事件到达序)。
# GP+商店房实施批(2026-09-21):授予段极性路由增 GP 型兑付(effect_id gp_gain/gp_loss → 战斗内缓冲
# escrow 记账,不入钱包不入增益容器);战斗结束分支掷 gp_drop 封闭流(每战斗末恰一 scope)出
# gp_dropped = 掷定 + level_gp_bonus + escrow 随 battle_finished 上抛——战斗域零 GP 状态持久。
# 伤害管线单据化批(2026-09-24 用户三裁决,06 台账 148):伤害兑付全部收口 伤害控制器(DamageController
# 单点管线)——本类六调用点(主角先手/敌方伤害技/棋盘克制直伤/群伤/反噬/地形变异)退化为开单点
# (流程决定何时开单给谁开单;单据怎么算归控制器)。结账时机开关在控制器 policy:IMMEDIATE(A=开单
# 即结,逐位等价迁移前行为——回切位)/ SEGMENT(B=攒批到段界 flush:战斗段尾统一兑现)。
# 地图伤害账殿后批(案C 2026-10-06 用户裁——「主角先手」歧义质询触发):授予段不再当场兑付任何伤害——
# 全部伤害形态单据经 _submit 暂存闸入 _deferred_board(保授予段事件序),战斗段「主角先手→逐敌还手」后
# 补提交统一结账;主角死于敌方还手 → 暂存单整体不兑,逐单留「未生效(主角已倒下)」行(forfeit=hero_down)。
# 死亡事件 = 单位容器 unit_died(单位自行宣布)→ 控制器作废该敌未结单据〔死亡即不动手〕/ 主角死终止兑付。
# 切片登记:① 事件总线载体 = 直连信号(实现期 ADR 项,provisional)② Waiting 相位刷新通道 2026-09-15 用户拍板整体
# 拆除(仅 Active 挖掘期受理)③ 效果行协议 = 授予段逐 grant 行 + 增益/减益段续存实例组合行。
# 实现 GDD:design/gdd/systems/战斗循环/战斗结算.md(v0.7 格面资源批——registry 追溯锚)
class_name BattleOrchestrator
extends RefCounted
signal step_row(row: Dictionary)
signal battle_finished(payload: Dictionary) # {result, hero_hp, gp_dropped}——hero 在 despawn 前捕获(呈现层销毁后读状态的时序防护);gp_dropped = 战斗掉落合计(GP+商店房实施批 2026-09-21)
signal round_started(round_index: int)
signal board_changed()
signal settlement_snapshot(snap: Dictionary) # 每结算末尾的全量状态快照(playtest_logger 消费——JSONL 数值分析)
# 明细流水行型协议(战斗结算 Core Rule;9 类——2026-09-20 伏击单轮上限退役,溢出未生效行 overflow 随之下线;
# 2026-09-15 格面资源批由 15 类收拢,清单改动须同步 battle_log_view 的 _format_row 与本文档头注):
# 效果行 effect · 伤害行 damage · 治疗行 heal ·
# 奖励未生效行 reward_ineffective · 标旗未中行 flag_miss · 施放行 cast(敌人技能池批由蓄力行 charge 改型)· 衰减行 decay ·
# 判定行 verdict · 高光行 highlight
# 行来源断言豁免集合(行型层)——判定段 / 衰减段本就无棋盘来源,勿一刀切误炸
const SOURCE_EXEMPT_TYPES := ["decay", "verdict"]
# 行来源断言豁免集合(来源类别层)——事实来源是单位 / 清图而非棋盘格;
# fx_reveal=专长揭示掷格(落空行无真实格〔迭代 21 存量红修复——a57489e9 落地件三处空来源
# 触发行来源断言,51 行/冒烟,两轮确定性复现〕)
const SOURCE_EXEMPT_CLASSES := ["hero_attack", "enemy_action", "clear_reward", "fx_reveal"]
var db: ConfigDb
var rng: RngServiceCore
# E1 封装收口(2026-09-13):协作对象 board_generator/enemy_system/status_effects/reward_economy 私有化——跨层读取一律经 presentation_snapshot()/
# diagnostic_snapshot() 成文出口(呈现层链式穿透 settlement._enemy_system.hero_unit() 的根因收口)。
# minesweeper/action_economy 保持公有 = 架构文档 API Boundaries 的 HUD框架消费面(意图提交 settle_batch + compute_move_path
# 只读;ap/rap/相位读取)——非本文档登记的契约面,勿增新用途。
var _board_generator: BoardGenerator
var minesweeper: MinesweeperRules
var action_economy: ActionEconomy
var _enemy_system: UnitContainer
var _status_effects: StatusContainer
var _reward_economy: RewardEconomy
var treasure_inventory: TreasureItems # 迭代 7 T3——LevelRunner 注入的关卡域库存引用;
# 每战新建本类,库存不归本类所有——注入面
var _damage: DamageController # 伤害控制器(伤害管线单据化批 2026-09-24——单据唯一兑付管线)
var encounter: Dictionary[String, Variant] = {}
var round_index := 0
var battle_result := "" # "" / "victory" / "defeat" / "fatal_error"
var escrow := 0 # 战斗内 GP 缓冲(战斗域中间态——begin_battle 清零;gp_gain/gp_loss 兑付累记;随 gp_dropped 上抛即弃,不入钱包)
var _facts_reveals: Array[Dictionary] = []
var _facts_flags: Array[Dictionary] = []
var _facts_path := ""
var _settling := false
var _defer_board_damage := false # 案C 暂存闸(2026-10-06 用户裁:地图奖励伤害兑付迁战斗段末——怪物还手后)
var _deferred_board: Array[Dictionary] = [] # 授予段暂存伤害单据(保授予段事件序;战斗段怪物还手后按序补提交统一结账)
var _refresh_used_this_round := 0 # 每轮刷新计数(Active 相位合计,兜底上限 = ActionEconomyTable.refresh_cap_per_round)
var _board_seq := 0 # 板序号(本战斗第几张板——快照归因用)
var _board_from_midround_refresh := false # 装板 provenance:轮内刷新换来的图 = true(清图奖励守卫——刷新图不发清图奖励)
var _flag_consumed := {} # 兑付资格以雷格实例为键(板级;换图重置)
# 同调装配运行态(迭代 11 第①批 T5——begin_battle 经 enc.attunement 注入;off=空集全部效果零扰动)
var attunement: Array = [] # 装备中同调件 item_id 集
var _attune_charges_ref: Dictionary = {} # 充能账本共享引用(迭代 11 第①批 T6——use_treasure 消费直改)
var _attune_safe_seen := 0 # 掘进蓄能 battle 域安全开格计数(进度呈现取数面;积点账本=效果引擎计数器 dig_charge_bank——行 triggers counter_spend 订阅同账本)
# ---- 效果引擎扩签运行态(迭代 20 专长实装批 T1——新效果类型施加面;空订阅零扰动)----
var _fx_hero_ac_base := -1 # 主角 AC 基线快照(enc.hero_armor_class;-1=表值兜底未解析)
var _fx_ac_round := 0 # ac_bonus 本轮累积器(封印桩形态——round_start 清零)
var _fx_ac_battle := 0 # ac_bonus 本战累积器(石肤形态——cap 封顶,begin_battle 清零)
var _fx_adventure_counters_ref: Dictionary = {} # adventure 域计数器共享引用(T6——attune_charges 先例:begin_battle 回灌 rebuild、battle 末 counters_export 回写)
var _fx_bonus_ref: Dictionary = {} # 冒险加算区共享引用(bonus_delta 战末汇入——血税双向账)
var _fx_bonus_delta := {"atk": 0, "max_hp": 0, "armor_class": 0, "ap": 0} # 本战 bonus_delta 累积(战末并入 _fx_bonus_ref 后清零)
var _fx_new_resource_types := {} # 本战已见资源类型集(地质学家 fact new_resource_type 判定——battle 域)
var _fx_pending_reveals: Array[Dictionary] = [] # 揭示效果待兑队列(见血开窍/斩首学者/地质学家/跳格采掘——授予段末统一兑付,防轮 facts 迭代中追加重入)
var _fx_revealing := false # fx 揭示兑付中标记(_on_cell_revealed 缓冲旁路——fx 揭示的奖励由兑付点直接入账,防二次缓冲二次兑付)
var _fx_insurance := 0 # 完美保险余量(迭代 20 T4——本战标错豁免次数;消费于 flag_miss 首见判定)
# 特性运行态(内容数据驱动扩展批 T3——relic 持有集经 enc.feats 注入:效果行为=数据行 triggers 经
# 效果引擎分发;魔矢每战首次计数 battle 域。空集=零扰动)
var _feat_first_dig_used := false
# 效果引擎(内容数据驱动扩展批 T1——发布订阅解释器;begin_battle 从持有集/装备集重建订阅。
# T3 起施加面全量切换:发射点 dispatch 返回命中,发射点按组合规则施加——旧 feat_flags 旗注入消亡)
var _fx: EffectEngine
# 事件发射薄面(18 发射点统一入口——订阅空=零循环零扰动;确定性同步分发零 RNG)
func _fx_emit(event: String, payload: Dictionary) -> Array:
if _fx == null:
return []
return _fx.dispatch(event, payload, Callable(self, "_fx_fact"))
# round_start 发射+施加(内容数据驱动扩展批 T3——grant_ap mode:queue=当轮入队,行动经济轮切换
# 即兑现;计数器 round_start 重置点在 dispatch 内生效。调用点两处:begin_battle 首轮 / 轮始段续轮。
# 迭代 20 T1——payload 扩 ap_exhausted_last〔一滴不剩 when 面〕+ac_bonus 本轮累积器清零)
func _emit_round_start() -> void:
_fx_ac_round = 0
_sync_hero_ac()
for h in _fx_emit("round_start", {"round": round_index,
"ap_exhausted_last": action_economy.ap_exhausted_last_round()}):
if String(h.get("kind", "")) == "grant_ap" and String(h.get("mode", "")) == "queue":
action_economy.queue_ap_delta(int(h.get("value", 0)))
# ac_bonus 累积后同步主角 AC(石肤/封印桩消费面——defender_ac 经 set_hero_ac_override 单点;
# 基线 = enc 口径,-1(表值兜底态)时解析为表值后叠加,守恒口径与 begin_battle 注入一致)
func _sync_hero_ac() -> void:
if _fx_hero_ac_base < 0:
_fx_hero_ac_base = int(db.hero.armor_class)
_damage.set_hero_ac_override(_fx_hero_ac_base + _fx_ac_round + _fx_ac_battle)
# ac_bonus 施加(迭代 20 T1——mode=round 本轮累积器〔封印桩,round_start 清零〕/
# mode=battle 本战累积器〔石肤,cap 封顶;缺省 battle〕;cap=0/缺省=无上限)
func _apply_ac_bonus(h: Dictionary) -> void:
var mode := String(h.get("mode", "battle"))
var cap := int(h.get("cap", 0))
if mode == "round":
_fx_ac_round += int(h.get("value", 0))
else:
_fx_ac_battle = _fx_ac_battle + int(h.get("value", 0)) if cap <= 0 else mini(cap, _fx_ac_battle + int(h.get("value", 0)))
_sync_hero_ac()
# 揭示效果入队(fx_reveal 掷格兑付延后到授予段末——防轮 facts 迭代中追加重入;见血开窍/
# 斩首学者〔经伤害控制器 fx_reveal_request 回调〕/地质学家/跳格采掘共用)
func _fx_enqueue_reveal() -> void:
_fx_pending_reveals.append({"round": round_index})
# 揭示效果兑付(授予段末统一——fx_reveal 封闭流从未揭示安全格掷 1 格全揭示;
# 信息红线=只揭真格〔雷格不入候选〕;空候选=零兑付留归因行;揭示奖励经 _grant_routed
# 正向路由直接入账——_fx_revealing 旁路 _on_cell_revealed 缓冲防二次兑付)
func _flush_pending_reveals(ri: int) -> void:
if _fx_pending_reveals.is_empty():
return
var board: BoardData = minesweeper.current_board()
while not _fx_pending_reveals.is_empty():
_fx_pending_reveals.pop_back()
var cands: Array = []
for c in board.all_cells():
if not minesweeper.is_revealed(c) and not board.is_mine(c):
cands.append(c)
if cands.is_empty():
_emit_row("fx_reveal", "fx_reveal", Vector2i(-1, -1), ri, "揭示落空(无未揭示安全格)", 0, {}, "")
continue
var rg: RandomNumberGenerator = rng.open_stream("fx_reveal")
var cell: Vector2i = cands[rg.randi_range(0, cands.size() - 1)]
_fx_revealing = true
var ok: bool = minesweeper.fx_reveal(cell)
_fx_revealing = false
if not ok:
_emit_row("fx_reveal", "fx_reveal", Vector2i(-1, -1), ri, "揭示落空(格子不可揭)", 0, {}, "")
continue
var snap = _reward_economy.query_full_snapshot(cell)
if snap is Dictionary:
_grant_routed(snap, "positive", "fx_reveal", cell, ri, "fx_reveal")
_emit_row("fx_reveal", "fx_reveal", cell, ri, "专长揭示", 0, {}, "fx_reveal")
# 补充事实面(fact 取值第二优先源——payload 未携带的运行态读数)
func _fx_fact(fact: String):
match fact:
"charge_layers":
return action_economy.charge_layers
"nest_count":
var n := 0
for eff in _status_effects.query_active("enemy"):
if String(eff.effect_id) == "weaken":
n += 1
return n
"round_index":
return round_index
"battle_index":
return int(encounter.get("battle_index", 1)) # 关卡域战斗序(1 基——level_runner enc 携带;开局姿态窗条件面)
"flags_count":
var hits_n := 0
for fevt in _facts_flags:
if bool(fevt.get("hit", false)):
hits_n += 1
return hits_n
"hp_ratio":
var hu: Dictionary = _enemy_system.hero_unit()
return int(round(100.0 * float(int(hu.get("hp", 0))) / float(maxi(1, int(hu.get("max_hp", 1)))))) # 百分比 int(0-100——when lte 算子 int 截断比较口径)
return null
func _attune_has(item_id: String) -> bool:
return attunement.has(item_id)
# 装配集只读出口(迭代 19 卡④——呈现桥消费面:组合根每战装配链判「装配集含 fog_lantern」→
# board_view.set_fog_lantern。_attune_has 契约化出口,save v6 只读访问器先例;private 面内部调用
# 零改动。只读零副作用——呈现侧判定专用,非结算写入口)
func attune_has(item_id: String) -> bool:
return _attune_has(item_id)
# 装备件表值读取(charge_n 等——TreasureItemTable 行;未装配不读表。magnitude 读数面随 T4
# 秘宝订阅迁移退役——行值经 value_from 在 rebuild 定影,零运行期按件取值)
func _attune_row(item_id: String) -> Dictionary:
return db.treasure_item_row(item_id) if _attune_has(item_id) else {}
# 特性求值(内容数据驱动扩展批 T3——_feat_flags_for 挂点求值退役:效果行为=数据行 triggers 订阅,
# 效果引擎 dispatch 分发;旧 23 hook 枚举与旗注入消亡。流派档位质变=FeatsEngine 纯函数求值
# (档位机制本身系引擎基建非内容效果),两运行态注伤害控制器:档一 rider+1 / 档二 rider 可暴击)
var _flag_miss_seen := {} # 标旗未中行去重(每安全格实例一次)
var _perfect_hit_true := 0 # 板级完美排雷计数(install 重置)
var _perfect_hit_false := 0
var _hero_atk := 0 # 主角攻击基值快照(encounter.hero_atk;E4 收口——表值运行期不可变)
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
db = db_ref
rng = rng_ref
_fx = EffectEngine.new(db_ref)
_board_generator = BoardGenerator.new()
action_economy = ActionEconomy.new(db)
minesweeper = MinesweeperRules.new(db, action_economy)
_enemy_system = UnitContainer.new(db)
_status_effects = StatusContainer.new(db)
_reward_economy = RewardEconomy.new(db, rng)
_damage = DamageController.new(db, rng, _enemy_system, _status_effects)
_damage.emit_row_cb = _emit_row # 行协议归属本类(9 类行协议 + 来源断言不外泄)
_damage.set_fx(_fx) # 效果引擎注入(attack_commit/clamp_check/armor_roll/kill/damage_taken 发射面)
action_economy.set_fx(_fx) # dig_paid/charge_full 发射面
_reward_economy.set_fx(_fx) # reward_roll 发射面
minesweeper.cell_revealed.connect(_on_cell_revealed)
minesweeper.flag_judged.connect(_on_flag_judged)
minesweeper.round_end_board.connect(_on_round_end)
action_economy.round_end_stopped.connect(_on_round_end)
# ---- 战斗开始序列(编排专章①:门 → 增益状态 → 敌人系统 → install+roll → 行动经济)----
func begin_battle(enc: Dictionary) -> bool:
if round_index != 0:
push_warning("[战斗结算] begin_battle 重复受理")
return false
encounter.assign(enc)
escrow = 0 # 战斗域零 GP 持久(GP+商店房实施批——每场战斗缓冲从零起算)
_damage.set_hero_ac_override(int(enc.get("hero_armor_class", -1))) # 主角护甲合流入参(伤害控制器 defender_ac 消费——口径同原 _defender_ac)
db.set_reload_gate(true)
if not _status_effects.notify_battle_start():
_fatal("增益状态 开域被拒(编排违例)")
return false
# 遭遇骰(难度曲线与Boss GDD Core Rule 8——2026-09-12 用户拍板):行有 unit_count_max 时按 [unit_count, max] 均匀骰;
# 2026-09-14 敌方攻击区间批扩展:行有 atk_min 时按 [atk_min, atk_max] 骰 spawn 期整战持久值
# (每拍抖动否决——数字可读性锚;中点 = 行值)。encounter 封闭流恰一 scope(数量骰先、atk 骰后);
# 挖掘决策域之外——P1 合规;敌人系统 零随机纪律保持(收 override 即确定值)。
# 遭遇骰(难度曲线与Boss GDD Core Rule 8——2026-09-12 用户拍板):行有 unit_count_max 时按 [unit_count, max] 均匀骰;
# 2026-09-14 敌方攻击区间批扩展:行有 atk_min 时按 [atk_min, atk_max] 骰 spawn 期整战持久值
# (每拍抖动否决——数字可读性锚;中点 = 行值)。encounter 封闭流恰一 scope(数量骰先、atk 骰后);
# 挖掘决策域之外——P1 合规;敌人系统 零随机纪律保持(收 override 即确定值)。
# 逐行骰换代(2026-10-07 行集多行批 Q8 用户裁 + P1 补充):混编行集**每行各掷自己的区间骰**——
# 先按行序逐行数量骰、再按行序逐行 atk 骰(单行行集 = 既有一次数量骰 + 一次 atk 骰,判定条件、
# 开流条件、消费序、消费次数逐位一致——零内容期随机流零变化;区间宽度为零的行不掷 = 「定值不骰」
# 既有口径;atk 区间逐行同构 = 行值语义完整,代拍板随批留痕:属「每行一骰」预批准语义内的
# 行字段区间骰逐行化,非新增随机源形态)。
var erowset_pre: Array = db.resolve_enemy_rowset(int(enc.level_index), int(enc.node_depth))
if erowset_pre.is_empty():
_fatal("敌人表 行集解析为空(%d,%d)" % [int(enc.level_index), int(enc.node_depth)])
return false
var count_overrides: Array = []
var atk_overrides: Array = []
var needs_count := false
var needs_atk := false
for r in erowset_pre:
if int(r.get("unit_count_max", int(r.get("unit_count", 1)))) > int(r.get("unit_count", 1)):
needs_count = true
if r.has("atk_min"):
needs_atk = true
if needs_count or needs_atk:
var estream: RandomNumberGenerator = rng.open_stream("encounter")
if needs_count:
for r in erowset_pre:
var lo: int = int(r.get("unit_count", 1))
var hi: int = int(r.get("unit_count_max", lo))
count_overrides.append((lo + estream.randi_range(0, hi - lo)) if hi > lo else lo)
if needs_atk:
for r in erowset_pre:
if r.has("atk_min"):
atk_overrides.append(int(r.atk_min) + estream.randi_range(0, int(r.atk_max) - int(r.atk_min)))
else:
atk_overrides.append(-1)
# E4 收口:加成经 encounter 显式携带(hero_atk/hero_max_hp;缺省 = 表值——冒烟/工具直呼路径)
_hero_atk = int(enc.get("hero_atk", int(db.hero.atk)))
if not _enemy_system.spawn(int(enc.level_index), int(enc.node_depth), int(enc.hero_hp), count_overrides,
_hero_atk, int(enc.get("hero_max_hp", -1)), atk_overrides):
_fatal("敌人系统 spawn 被拒")
return false
if not _install_new_board():
return false
_reset_round_facts() # 首板 install 的初始展开事件不入轮 facts(resource 恒 false 的双保险)
# 同调装配运行态注入(迭代 11 第①批 T5——enc.attunement 携带;off=空集零扰动。淬毒 N 注入
# 伤害控制器、偏运 N 注入地图战斗资源——均须先于首板 install)
attunement = (enc.get("attunement", []) as Array).duplicate()
_attune_charges_ref = enc.get("attune_charges", {}) # 共享 dict 引用(level/adventure 同账本——use_treasure 扣减直改)
_attune_safe_seen = 0
# 淬毒/偏运注入拆除(内容数据驱动扩展批 T4——两件被动改 triggers 订阅行:淬毒=clamp_check
# modify 窗口、偏运=reward_roll bias_rewards;value 行字段 value_from 定影,零 begin_battle 注入)
# 特性运行态(内容数据驱动扩展批 T3——enc.feats 携带持有集:效果行为=数据行 triggers 经效果
# 引擎分发;流派档位=FeatsEngine 纯函数求值两运行态注伤害控制器。魔矢每战首次开格计数同步归零)
# 迭代 20 T1——扩签运行态就位:AC 基线快照、累积器清零、adventure 计数器/加算区共享引用接收
_fx_hero_ac_base = int(enc.get("hero_armor_class", -1))
_fx_ac_round = 0
_fx_ac_battle = 0
_fx_new_resource_types = {}
_fx_pending_reveals = []
_fx_bonus_delta = {"atk": 0, "max_hp": 0, "armor_class": 0, "ap": 0}
var bonus_ref = enc.get("bonus_ref", null)
_fx_bonus_ref = bonus_ref if bonus_ref is Dictionary else {}
var counters_ref = enc.get("adventure_counters", null)
_fx_adventure_counters_ref = counters_ref if counters_ref is Dictionary else {}
_damage.fx_reveal_request = Callable(self, "_fx_enqueue_reveal")
_damage.fx_ac_request = Callable(self, "_apply_ac_bonus")
var feat_ids: Array = enc.get("feats", []) as Array
# 迭代 20 T3 换代(V14 旧实现摘除)——流派档位质变随重设计退役(奇物.md v1「不设流派标签、
# 不设档位机制」):FeatsEngine 求值与 rider_tier 注入拆除,散件单体效果=唯一形态
_damage.rider_tier_bonus = 0
_damage.rider_tier_crit = false
_feat_first_dig_used = false
# 树 rule 键消费(迭代 12 T10 → 内容数据驱动扩展批 T3 换代:Fork 激活侧效果=效果引擎合成
# 订阅(rebuild 注入——round_start 首轮 AP+1 / damage_taken 减伤+1),旧直调拆除。其余 rule 键
# =stat 面投影不入战斗(T8 表内容 provisional 口径披露))
var tree_rules: Array = enc.get("tree_rules", []) as Array
# 效果引擎订阅重建(内容数据驱动扩展批 T1——每战从持有集/装备集重建,中途获得下一战生效;
# 树规则合成订阅同面装载。装载序=feats 表序→treasure 表序→树规则,写死保种子复放。
# 迭代 20 T6——adventure 域计数器从共享引用回灌〔跨战持久〕)
_fx.rebuild(feat_ids, attunement, tree_rules)
_fx.counters_seed(_fx_adventure_counters_ref)
# 迭代 10 奖励重构批——跨冒险行动点加成轴(统一冒险加算区第四轴):经排队修正通道兑现
# 「每场战斗开始首轮 AP +加成值」(queue_ap_delta = 下轮开轮基线加算、notify_round_start 兑现后
# 清零——首轮即消费,后续轮次不重复兑现;语义锚 = 行动经济 GDD「跨冒险行动点加成轴」节)
var ap_bonus: int = int(enc.get("hero_ap_bonus", 0)) + int(enc.get("voucher_time_sand", 0)) # 迭代 20 T4——时沙同通道(首轮池增量,不改上限)
if ap_bonus > 0:
action_economy.queue_ap_delta(ap_bonus)
# 迭代 20 T4——储备金袋(节点级 RAP 注入)与完美保险(标错豁免次数)接收
var voucher_rap: int = int(enc.get("voucher_rap", 0))
if voucher_rap > 0:
action_economy.grant_reserve_ap(voucher_rap)
_fx_insurance = int(enc.get("voucher_insurance", 0))
# 工兵晨令直调拆除(内容数据驱动扩展批 T4——round_start grant_ap(mode:queue) 订阅行承载,
# when round_index ≤ effective_rounds 行字段 value_from 定影;发射位先于轮切换=当轮兑现)
# round_start 发射+施加(内容数据驱动扩展批 T3——发射位先于行动经济轮切换:树规则 grant_ap
# mode:queue 当轮入队、notify_round_start 即兑现(先手压制=首轮 AP+1 语义锚,与迁移前
# begin_battle 直调 queue_ap_delta 逐位一致);计数器 round_start 重置点同点生效)
round_index = 1
_emit_round_start()
if not action_economy.notify_round_start():
_fatal("行动经济 首轮 notify_round_start 被拒")
return false
_roll_round_actions() # 开轮摇号(敌人技能池批——首轮同样摇;先于 round_started,挖掘期动画位可读)
round_started.emit(round_index)
return true
func _install_new_board(during_round := false) -> bool:
var stream: RandomNumberGenerator = rng.open_stream("board_gen")
var board: BoardData = _board_generator.generate(int(encounter.level_index), int(encounter.node_depth), stream, db)
if board == null:
return false
# 全局作用域变异决议(首发批 2026-10-04——装板序内前置于 roll_board:图级偏斜旋钮须在资源
# 摇号前定型;全局门控与板无关〔节点身份+unlock+board_types〕,确定性取用零取号)。
# boss/elite 图命中全局型 → 写 board_variants(_place_variants 见非空即跳 region=替换制);
# 全局池空 → 板级字段留空(_place_variants 原路走 region=兜底回落合法态)。
_resolve_global_variant(board)
var res: Dictionary = minesweeper.install_board(board, during_round)
if not res.accepted:
_fatal("排雷玩法 install 拒绝(不变式违例): %s" % str(res))
return false
# 地图战斗资源 义务⑥:roll_board 恰一次(install 后、挖掘前);资格格 = 全部非封锁格(安全格 + 雷格,雷安同池)
var r0 := {}
for c in board.compute_r0():
r0[c] = true
var eligible: Array[Dictionary] = []
for c in board.safe_cells():
if not r0.has(c):
eligible.append({"cell": c})
for c in board.mines.keys():
if not r0.has(c):
eligible.append({"cell": c})
if not _reward_economy.roll_board(eligible):
_fatal("地图战斗资源 roll_board 被拒")
return false
# 地形变异 布置段(2026-09-19 用户拍板棋盘设置顺序第 4 步:生成棋盘 → 打开状态初始化 →
# 战斗资源格设置 → 变异格设置)——布置在初始展开与资源格之后,变异天然不落已开区
_place_variants(board)
_flag_consumed.clear() # 换图新雷 = 新实例,资格自然重置(行动经济 Rule 8)
_flag_miss_seen.clear()
_perfect_hit_true = 0 # 板级完美计数随 install 重置
_perfect_hit_false = 0
_board_from_midround_refresh = during_round # 轮内刷新装板 = 刷新图(清图奖励不发);常规换图/开战装板 = false
_board_seq += 1
board_changed.emit()
if treasure_inventory != null:
treasure_inventory.retire_overlays() # 迭代 7 T3——叠加层随图消亡(换图/刷新图装板点;已花道具不返还)
return true
# ---- 全局作用域变异 决议(首发批 2026-10-04——装板序内 roll_board 之前)----
# boss/elite 图命中全局型:写板级 board_variants(恰一行)+ 注入资源面偏斜旋钮(图级投影在
# RewardEconomy.roll_board 消费);不消费 variant_place 流(全局取用确定性——零取号合法态)。
# 全局池空(含 L1 Boss 免疫/unlock 门控未达):字段留空、旋钮归零——_place_variants 原路走
# region 布置 = 兜底回落(保守向后兼容,防配置缺行静默裸奔——provisional)。
func _resolve_global_variant(board: BoardData) -> void:
board.board_variants.clear()
_reward_economy.board_skew = 0
var row: Dictionary = db.global_variant_row(int(encounter.level_index), String(board.board_type),
String(encounter.get("node_type", "")))
if row.is_empty():
return
board.board_variants = [String(row.variant_id)]
_reward_economy.board_skew = int(row.get("effect_value", 0))
# ---- 地形变异 布置段(首批类型定稿批 2026-09-19——棋盘设置顺序第 4 步)----
# 候选池门控(unlock_level ≤ 关卡序号 + board_types 与本地形交集,∅ 通配)经 config_db.variant_pool;
# 池空 = 合法态,静默零布置(框架 P0 口径不变)。零特判:本段只写格子变异数据快照,不改扫雷规则本体。
# 候选格域 = 安全格 − 初始展开区(伤害型 placement=closed_only 的结构保障——布置发生在初始展开之后,
# 已开格天然排除);雷格不吃变异(挖雷一层惩罚 + 变异伤害双层叠罚污染逐行归因,首版排除——provisional)。
# 每图至多一种类型(候选池内均匀选一,混型布置归因复杂留待后续批);数量 = round(候选池格数×percent) +
# (关卡序号−1)×per_level_bonus——基数为候选池而非总格数(2026-09-21 用户拍板基数修正批:初始展开
# 占全板 56–67%,总格数基数使有效密度膨胀 4–6 倍、L1 小板极端全池覆盖;percent 语义 = 占可挖格比例,
# 关卡递增由 per_level_bonus 绝对格数承载);连通片生长(8 邻),单片 ≤ pack_max(2×2 / 1×4 / 1×3+1 等形)。
# 替换制(全局作用域变异首发批 2026-10-04):板级 board_variants 非空 = 本图已带全局型,跳过 region
# 布置(每图一型的板级推广:普通图=region,Boss/精英图=global);空 = 原路径(含 Boss 图全局池空回落)。
func _place_variants(board: BoardData) -> void:
if not board.board_variants.is_empty():
return
var pool: Array = db.variant_pool(int(encounter.level_index), String(board.board_type))
if pool.is_empty():
return
var r0 := {}
for c in board.compute_r0():
r0[c] = true
var candidates: Array[Vector2i] = []
for c in board.safe_cells():
if not r0.has(c):
candidates.append(c)
if candidates.is_empty():
return
var stream: RandomNumberGenerator = rng.open_stream("variant_place") # 一板恰一 scope(换图/刷新图各取号一次)
var vrow: Dictionary = pool[stream.randi_range(0, pool.size() - 1)]
var total: int = int(round(float(candidates.size()) * float(db.variation.percent_of_cells) / 100.0)) \
+ (int(encounter.level_index) - 1) * int(db.variation.per_level_bonus)
total = mini(total, candidates.size())
var placed := {}
while placed.size() < total:
var pack_size: int = mini(total - placed.size(), stream.randi_range(2, int(db.variation.pack_max)))
var free: Array[Vector2i] = []
for c in candidates:
# 新片种子/生长格禁与其他已布片 8 邻贴接——否则两片粘连成一个超 pack_max 的连通分量
# (R3 随机流下实测暴露:独立生长的相邻片合流击穿单片上限断言)
if placed.has(c) or _adjacent_to_packs(board, placed, c):
continue
free.append(c)
if free.is_empty():
break
var seed_c: Vector2i = free[stream.randi_range(0, free.size() - 1)]
placed[seed_c] = true
var pack: Array = [seed_c]
var pack_set := {seed_c: true}
while pack.size() < pack_size:
var fringe: Array[Vector2i] = []
for c in pack:
for n in board.neighbors8(c):
if candidates.has(n) and not placed.has(n) and not fringe.has(n) \
and not _adjacent_to_packs(board, placed, n, pack_set):
fringe.append(n)
if fringe.is_empty():
break # 生长域耗尽——该片就地收束(数量不足总目标为合法态,候选域碎片化时触发)
var nxt: Vector2i = fringe[stream.randi_range(0, fringe.size() - 1)]
placed[nxt] = true
pack_set[nxt] = true
pack.append(nxt)
for c in placed.keys():
board.variants[c] = [String(vrow.variant_id)]
# 候选格是否与其他已布片(own_pack 之外的 placed 格)8 邻贴接——片间隔离保证连通分量 = 单片 ≤ pack_max
func _adjacent_to_packs(board: BoardData, placed: Dictionary, c: Vector2i, own_pack: Dictionary = {}) -> bool:
for m in board.neighbors8(c):
if placed.has(m) and not own_pack.has(m):
return true
return false
# ---- 事件缓冲(轮 facts 收集;结算触发 = Waiting 相位编排点,不依赖到达序)----
func _on_cell_revealed(evt: Dictionary) -> void:
# 轮 facts 缓冲不设相位守卫——轮末最后一挖的揭示事件在 request_dig 翻相之后才到(事件序:
# cell_revealed 批 → flag_judged 批 → round_end),phase==Active 守卫会丢最后一挖的事实;
# initial 通道事件(install 初始展开——封锁格)resource=false,进入授予段被资格检查自然跳过。
# fx_reveal 通道事件旁路缓冲(迭代 20 T1——专长揭示的奖励由 _flush_pending_reveals 兑付点
# 直接入账,此处缓冲会导致二次兑付)。
if battle_result == "" and not _fx_revealing:
if bool(evt.get("resource", false)):
# 身份快照随缓冲捕获(批次隔离——轮内刷新后 mapping 已换批,授予必须用缓冲期身份,
# 防旧图已挖格在轮末错拿新图同坐标身份)。雷格同样携带(雷安同池——负向兑付的来源格)
var snap = _reward_economy.query_full_snapshot(evt.cell)
if snap != null:
evt = evt.duplicate()
evt["reward_snapshot"] = snap
_facts_reveals.append(evt)
func _on_flag_judged(evt: Dictionary) -> void:
_facts_flags.append(evt)
func _on_round_end(_payload: Dictionary) -> void:
_facts_path = String(_payload.get("path_tag", ""))
_maybe_settle()
func _maybe_settle() -> void:
if _settling or battle_result != "":
return
if action_economy.phase != action_economy.PHASE_WAITING:
return
_settle()
# ---- 每轮结算序列(五段——段序骨架冻结于 SettlementTable.segment_order)----
func _settle() -> void:
_settling = true
_defer_board_damage = true # 案C:授予段伤害单据只暂存不结账(2026-10-06 用户裁——兑付时点=战斗段末)
var ri := round_index
# ① 授予段:结算队列(开格/旗判定/清图)→ 极性路由逐批兑付(伤害形态单据暂存——见 _submit 案C 闸)。
# 判定基线 = 轮末在场旗(发射面已过滤——被挖开的旗已随挖掘擦除零兑付,插旗不可逆批 2026-09-19;
# 开格事实不可撤)。
for evt in _facts_reveals:
if not bool(evt.get("resource", false)):
continue # 封锁格(initial 通道)恒无资源——不入账(战斗结算 义务②)
var snap = evt.get("reward_snapshot")
if not (snap is Dictionary):
continue
if evt.kind == "safe":
if bool(evt.get("dug_under_flag", false)):
# 插旗不可逆四情况④(2026-09-19 用户拍板):插安全格再挖开 = 零惩罚零奖励——正向兑付
# 作废(归因行在案);标空判定已随旗擦除自然缺席(被挖的旗不在轮末在场基线)。
_emit_row("reward_ineffective", "safe_cell", evt.cell, ri, "奖励作废(插旗格直接挖开——零惩罚零奖励)", 0, {}, "")
continue
# dig_reveal 发射+施加(内容数据驱动扩展批 T3——桶序固定承载等价:
# 乘区收集 → 正向兑付 → 计数器 → 追加产出(深挖)→ 奥术矢。
# 迭代 20 T1——payload 扩三事实:is_edge_cell〔边陲矿工〕/is_blank_zero〔跳格采掘〕/
# new_resource_type〔地质学家——快照 cell_type 首见判定,见后集登记〕)
var board_b: BoardData = minesweeper.current_board()
var snap_pre = evt.get("reward_snapshot")
var ctype := String((snap_pre as Dictionary).get("cell_type", "")) if snap_pre is Dictionary else ""
var is_new_type := ctype != "" and not _fx_new_resource_types.has(ctype)
if is_new_type:
_fx_new_resource_types[ctype] = true
var dr_hits := _fx_emit("dig_reveal", {"cell": evt.cell, "round": ri,
"is_first_dig_of_battle": not _feat_first_dig_used,
"terrain_kind": _harmful_variants(evt.get("variant_ids", []) as Array),
"is_edge_cell": evt.cell.x == 0 or evt.cell.y == 0
or evt.cell.x == board_b.width - 1 or evt.cell.y == board_b.height - 1,
"is_blank_zero": board_b.digit_of(evt.cell) == 0,
"new_resource_type": is_new_type})
var pos_m := 0
var neg_m := 0
var combo_cap := -1
for h in dr_hits:
if String(h.get("kind", "")) != "yield_mult":
continue
pos_m += int(h.get("mult", 0))
neg_m += int(h.get("cost_mult", 0))
if int(h.get("cap", 0)) > 0:
combo_cap = int(h.cap) # 矿脉直觉连击上限
_grant_routed(_feat_yield_adjust(snap, true, pos_m, neg_m, combo_cap), "positive", "safe_cell", evt.cell, ri, "")
# 计数器执行(连击计数——加法族,乘区收集后执行=本格兑付用旧值,与迁移前逐位一致)
for h in dr_hits:
if String(h.get("kind", "")) == "counter_add":
var set_v = h.get("set", null)
_fx.counter_add(String(h.get("counter_id", "")), int(h.get("value", 0)) if set_v == null else int(set_v), set_v != null)
# 揭示效果入队(迭代 20 T1——地质学家/跳格采掘 reveal_tiles 订阅面;授予段末
# _flush_pending_reveals 统一掷格兑付)
if not _hits_of(dr_hits, "reveal_tiles").is_empty():
_fx_enqueue_reveal()
# 追加产出(深挖钻头 v2——本格双份制:grant_material 命中=配额内追加第二份;标签随行)
for h in dr_hits:
if String(h.get("kind", "")) == "grant_material":
var dd_snap = _reward_economy.grant(evt.cell)
if dd_snap is Dictionary:
_grant_routed(dd_snap, "positive", "safe_cell", evt.cell, ri, "", String(h.get("label", "")))
# 奥术矢(魔能共鸣——每战首次开安全格追加魔法矢;任意真族单次生效)
var spell_hits: Array = _hits_of(dr_hits, "spawn_spell")
if not spell_hits.is_empty() and not _feat_first_dig_used:
_feat_first_dig_used = true
var spell_t := _damage.open_ticket(DamageController.KIND_BOARD_TYPED, "feat_first_dig_spell",
"奥术矢", "board", int((spell_hits[0] as Dictionary).get("value", 0)),
DamageController.TARGET_FRONT_ALIVE, ri, evt.cell, "magic")
_submit(spell_t, false)
# 掘进蓄能计数(迭代 11 同调批 T6 → 内容数据驱动扩展批 T4:积点账本迁效果引擎计数器
# dig_charge_bank——行 triggers counter_spend(treasure_used) 受理闸同账本。模判定留编排器侧:
# 「每 N 格积 1 点」在发射点施加架构下无法行内表达先增后判(when 于 dispatch 预评估),
# 逐位等价优先——词汇表边界注记随 commit 留痕;charge_n 读行零字面量)
if _attune_has("dig_charge"):
_attune_safe_seen += 1
var cn_b: int = int(db.treasure_item_row("dig_charge").get("charge_n", 999))
if cn_b > 0 and _attune_safe_seen % cn_b == 0:
_fx.counter_add("dig_charge_bank", 1)
elif evt.kind == "mine":
# 插旗不可逆四情况②:插雷格再挖开 = 只受挖雷一层惩罚(mine_hit 照常兑付——惊动巢穴),
# 标中不发(旗已随挖掘擦除、不入轮末判定基线)。
# mine_hit 发射+施加(内容数据驱动扩展批 T3——桶序固定承载等价:
# 减半收集 → 负向兑付〔带减半系数〕→ 反打开单 → 连击清零计数器。
# 迭代 20 T1——发射前移+payload 扩 dug_under_flag〔破封一击〕;新增施加面:
# damage_reduce〔安全帽耗层减伤——value_counter 层值,一次清零〕/ heal〔血蜜〕/
# blast_scaled〔陷阱爆发——雷伤×倍率全体敌〕/ bonus_delta〔血税〕/ reveal_tiles〔见血开窍〕)
var mine_power := int((snap as Dictionary).get("power", 0))
var mh_hits := _fx_emit("mine_hit", {"cell": evt.cell, "round": ri,
"damage": mine_power, "dug_under_flag": bool(evt.get("dug_under_flag", false)),
"hp_ratio": int(round(100.0 * float(int(_enemy_system.hero_unit().get("hp", 0))) / float(maxi(1, int(_enemy_system.hero_unit().get("max_hp", 1))))))})
var halve_pct := 0
var mine_reduce := 0
for h in mh_hits:
match String(h.get("kind", "")):
"damage_mult":
halve_pct += int(h.get("value", 0))
"damage_reduce":
if h.has("value_counter") and String(h.get("value_counter", "")) != "":
mine_reduce += _fx.counter(String(h.get("value_counter", ""))) # 安全帽——层值减免
else:
mine_reduce += int(h.get("value", 0)) # 背水——定值减免
"heal":
var hu_heal: Dictionary = _enemy_system.hero_unit()
hu_heal["hp"] = mini(int(hu_heal.get("hp", 0)) + int(h.get("value", 0)), maxi(1, int(hu_heal.get("max_hp", 1))))
"bonus_delta":
var axis := String(h.get("axis", ""))
if _fx_bonus_delta.has(axis):
_fx_bonus_delta[axis] += int(h.get("delta", 0))
"blast_scaled":
var blast_v := maxi(int(db.settlement.damage_floor), int(float(mine_power) * float(h.get("mult", 1))))
var bs_t := _damage.open_ticket(DamageController.KIND_BOARD_BLAST, "feat_blast_scaled", "陷阱爆发", "board",
blast_v, DamageController.TARGET_ALL_ALIVE, ri, evt.cell, "", 0, "", {})
_submit(bs_t, false)
if mine_reduce > 0:
snap["power"] = maxi(0, mine_power - mine_reduce) # 减伤落快照(负向兑付按减后值——安全帽每层减 1)
for h in mh_hits:
if String(h.get("kind", "")) == "counter_add":
var set_v = h.get("set", null)
_fx.counter_add(String(h.get("counter_id", "")), int(h.get("value", 0)) if set_v == null else int(set_v), set_v != null)
if not _hits_of(mh_hits, "reveal_tiles").is_empty():
_fx_enqueue_reveal() # 见血开窍——每颗雷一次揭示(授予段末统一兑付)
_grant_routed(_feat_yield_adjust(snap, false, 0, 0, -1), "negative", "mine_hit", evt.cell, ri, "", "", halve_pct)
for h in mh_hits:
if String(h.get("kind", "")) == "open_ticket" and String(h.get("channel", "")) == "reflect":
var nct := _damage.open_ticket(DamageController.KIND_BOARD_BLAST, "attune_nest_counter", "惊巢反制·反弹", "board",
int(h.get("value", 0)), DamageController.TARGET_ALL_ALIVE, ri, evt.cell, "", 0, "", {})
_submit(nct, false)
# 地形变异 兑付段(授予段尾——触发枚举 on_open:挖开即伤、chain 通道同口径;伤害单据案C 暂存到
# 战斗段末兑付〔2026-10-06 用户裁〕;信息型 hide_number 不进结算——呈现层读取,见 board_view 数字遮蔽;
# 雷格恒无变异——布置段排除,双层叠罚不存在;插旗格直挖照吃变异伤害——地形后果同 mine_hit 口径,
# 非奖励不作废;吞噬流资源兑付〔荆棘编织/地脉补偿〕中伤害形态行同样经案C闸暂存,非伤害形态照常当场)
for evt in _facts_reveals:
var vids: Array = evt.get("variant_ids", [])
for vid in vids:
var vrow: Dictionary = db.variation.row_by_id(String(vid))
if String(vrow.get("effect", "")) != "flat_damage":
continue # 触发词表按 effect 路由:flat_damage=on_open 兑现;hide_number/on_open 外组合 = 数据行新词表未接线(显式忽略面)
# 地形变异伤害单据(伤害管线单据化批 2026-09-24)——无类型必中,下限保底在控制器管线内
# 迭代 20 T1——damage_taken 发射前移(单据开立前):damage_reduce〔背水——hp_ratio when
# 面〕先收集再按减后值开单;counter_add〔保险丝/石肤〕+ ac_bonus〔石肤〕照常施加;
# grant_material〔荆棘/地脉〕移到单据提交后(与迁移前兑付值序一致——收益不因减伤变化)
var dt_amount := int(vrow.get("effect_value", 0))
var dt_hits := _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(_enemy_system.hero_unit().get("hp", 0))) / float(maxi(1, int(_enemy_system.hero_unit().get("max_hp", 1))))))})
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":
_apply_ac_bonus(h)
"counter_add":
var set_v = h.get("set", null)
_fx.counter_add(String(h.get("counter_id", "")), int(h.get("value", 0)) if set_v == null else int(set_v), set_v != null)
var vt := _damage.open_ticket(DamageController.KIND_BOARD_FLAT_HERO, "地形变异", String(vrow.get("display_name", vid)), "board", \
dt_amount, DamageController.TARGET_HERO, ri, evt.cell, "", 0, "", {"variant_id": String(vid)})
_submit(vt, false)
for h in dt_hits:
if String(h.get("kind", "")) == "grant_material":
var tw_snap = _reward_economy.grant(evt.cell)
if tw_snap is Dictionary:
_grant_routed(tw_snap, "positive", "safe_cell", evt.cell, ri, "", String(h.get("label", "")))
for evt in _facts_flags:
_judge_flag(evt, ri)
# 战旗军规开单段(迭代 12 T3 标旗流——flag_judged 批后统一开单:轮末每面标中旗对最前存活敌
# 结算 board_typed 必中伤害〔复用零新配方;「旗所在巢穴敌」简化为 default_target 最前存活敌——
# 台账披露待追认〕;完美布防=本轮旗批全中(hit>0 且 miss==0)时旗伤 ×mult——全对推理奖励)。
# 伤害单据案C 暂存战斗段末兑付。
# flag_settle 发射+施加(内容数据驱动扩展批 T3——战旗 open_ticket + 完美布防 modify_ticket
# 改写窗口:旗伤基值×完美倍率后逐旗开单,与迁移前算序一致)
var fs_hits := 0
var fs_misses := 0
for fevt in _facts_flags:
if bool(fevt.get("hit", false)):
fs_hits += 1
else:
fs_misses += 1
var fs_fx := _fx_emit("flag_settle", {"flags_hit_all": fs_hits > 0 and fs_misses == 0,
"flags_count": fs_hits, "round": ri})
var banner_hits: Array = []
for h in fs_fx:
if String(h.get("kind", "")) == "open_ticket" and String(h.get("channel", "")) == "attack":
banner_hits.append(h)
if not banner_hits.is_empty():
var strike_value := int((banner_hits[0] as Dictionary).get("value", 0))
var flag_mult := 1
for h in fs_fx:
if String(h.get("kind", "")) == "modify_ticket" and String(h.get("mode", "")) == "flag_mult":
flag_mult *= maxi(1, int(h.get("value", 1)))
if fs_misses == 0 and fs_hits > 0 and flag_mult > 1:
strike_value *= flag_mult # 完美布防:同轮全中旗伤倍增(全对推理奖励)
for fevt in _facts_flags:
if not bool(fevt.get("hit", false)):
continue
var st_t := _damage.open_ticket(DamageController.KIND_BOARD_TYPED, "feat_flag_strike",
"战旗军规", "board", strike_value, DamageController.TARGET_FRONT_ALIVE, ri,
fevt.cell, "normal")
_submit(st_t, false)
# round_end 发射+施加(内容数据驱动扩展批 T3——每轮结算收口:熔火之躯反打单开立〔open_ticket
# 即时执行〕+积层清零〔counter_add set〕;整备转蓄力由 finish_round_stop 承载——停手侧 AP 已在
# request_stop 记账,转存决策=grant_ap(mode:transfer) 订阅定夺,无订阅走 RAP 转存缺省)
var re_fx := _fx_emit("round_end", {"round": ri})
for h in re_fx:
if String(h.get("kind", "")) == "open_ticket" and String(h.get("channel", "")) == "blast":
var mg_layers := _fx.counter(String(h.get("per", "")))
var mg_total := int(h.get("value", 0)) * mg_layers
if mg_total > 0:
# 迭代 20 T9——source_class 中性名(旧件名 feat_magma_body 随熔火之躯退役;per-counter 反打通道保留泛用面)
var mg_t := _damage.open_ticket(DamageController.KIND_BOARD_BLAST, "fx_blast_counter",
"积层反噬 ×%d" % mg_layers, "board", mg_total,
DamageController.TARGET_ALL_ALIVE, ri, minesweeper.hero_pos(), "", 0, "", {})
_submit(mg_t, false)
for h in re_fx:
if String(h.get("kind", "")) == "counter_add":
var set_v = h.get("set", null)
_fx.counter_add(String(h.get("counter_id", "")), int(h.get("value", 0)) if set_v == null else int(set_v), set_v != null)
# 迭代 20 T1——round_end 新施加面:grant_gp〔爆破会计/收盘——escrow 战斗域缓冲,随 gp_dropped 上抛〕/
# grant_ap mode:queue〔封印回响——返 1 AP 下轮,queue_ap_delta 通道与轮始加成同面〕
for h in re_fx:
if String(h.get("kind", "")) == "grant_gp":
escrow += int(h.get("value", 0))
_emit_row("effect", "fx_grant_gp", Vector2i(-1, -1), ri, "专长·GP 入账 +%d" % int(h.get("value", 0)), int(h.get("value", 0)), {"effect_id": "fx_grant_gp", "escrow": escrow}, "")
elif String(h.get("kind", "")) == "grant_ap" and String(h.get("mode", "")) == "queue":
action_economy.queue_ap_delta(int(h.get("value", 0)))
action_economy.finish_round_stop(_fx != null and _fx.has_effect_on("round_end", "grant_ap", "transfer"))
if _facts_path == "cleared":
_grant_clear_reward(ri)
_flush_pending_reveals(ri) # 迭代 20 T1——揭示效果兑付(授予段末统一掷格,防轮 facts 迭代重入)
_damage.flush() # 授予段界结账(案C 后本段伤害单据全数暂存,此处对空队 no-op——保留作防御性段界)
# ② 增益/减益段(续存实例组合行;本结算新授予的已在授予段逐 grant 出行——增益状态 义务⑦)
_emit_composition_effects("hero", ri)
_emit_composition_effects("enemy", ri)
# ③ 战斗段(调用序:主角先手 → 逐敌还手 → 地图伤害账殿后〔案C 2026-10-06 用户裁〕;hero hp=0 短路)
_combat_segment(ri)
# ④ 衰减段(恰一次恒执行含末轮——先兑付后衰减)
var k: int = _status_effects.decay_round()
_emit_row("decay", "", Vector2i(-1, -1), ri, "状态衰减", k, {}, "")
# ⑤ 判定段(防御性优先级 hero 先判——双死结构不可达的纵深防御)
var verdict := "continue"
if int(_enemy_system.hero_unit().get("hp", 0)) <= 0:
verdict = "defeat"
elif _enemy_system.query_alive("enemy").is_empty():
verdict = "victory"
_emit_settlement_snapshot(verdict) # 全量状态快照(despawn 前组装——终局含 result)
if verdict == "defeat":
_emit_row("verdict", "", Vector2i(-1, -1), ri, "战斗失败", 0, {}, "")
_battle_over("defeat")
return
if verdict == "victory":
_emit_row("verdict", "", Vector2i(-1, -1), ri, "战斗胜利", 1, {}, "")
_battle_over("victory")
return
_emit_row("verdict", "", Vector2i(-1, -1), ri, "下一轮次", 0, {}, "")
_exit_distribution()
_settling = false
# ---- 授予段 helpers(极性路由)----
# 开格产出乘区(内容数据驱动扩展批 T3 参数化——贪婪矿灯/矿脉直觉/赌徒的血签事件收集器注入):
# positive=power ×(1+静态 mult% + 矿脉直觉连击加成 min(cap,combo)×10%);negative=power ×(1+cost%)。
# 全零收集器=原快照原样返回(零扰动)。combo 读数=效果引擎 dig_combo 计数器(safe+1/mine 清零,
# 跨轮累计——与迁移前 _feat_combo 逐位一致)。
func _feat_yield_adjust(snap: Dictionary, positive: bool, pos_m: int, neg_m: int, combo_cap: int) -> Dictionary:
var combo_val := _fx.counter("dig_combo") if _fx != null else 0
if positive and pos_m <= 0 and combo_cap <= 0:
return snap
if not positive and neg_m <= 0:
return snap
var out: Dictionary = snap.duplicate()
var power := int(out.get("power", int(_reward_economy.polarity_row(String(out.get("cell_type", "")), "positive" if positive else "negative").power)))
var pct := pos_m if positive else neg_m
if positive and combo_cap > 0:
pct += mini(combo_cap, combo_val) * 10
out["power"] = maxi(1, int(round(float(power) * (1.0 + float(pct) / 100.0))))
return out
# 伤害型变异过滤(terrain_kind fact 取数面——variation 表 effect=flat_damage 判据;
# 🔴 R4 甲永不对冲边界=仅限伤害型,信息型(hide_number 族)永不出现在本集)
func _harmful_variants(vids: Array) -> Array:
var out: Array = []
for vid_v in vids:
var vid := String(vid_v)
var vrow: Dictionary = db.variation.row_by_id(vid)
if String(vrow.get("effect", "")) == "flat_damage":
out.append(vid)
return out
# 邻接伤害型变异收集(terrain_kind fact 取数面——flag_hit 发射点用:标中旗相邻八格的伤害型变异集;
# 🔴 R4 甲永不对冲边界=仅限伤害型,信息型〔hide_number 族〕不入集)
func _harmful_adjacent(cell: Vector2i) -> Array:
var out: Array = []
var board: BoardData = minesweeper.current_board()
if board == null:
return out
for n in board.neighbors8(cell):
out.append_array(_harmful_variants(board.variant_ids(n)))
return out
# 效果命中过滤(按效果类型取命中子集——发射点分桶施加面)
func _hits_of(hits: Array, kind: String) -> Array:
var out: Array = []
for h in hits:
if String((h as Dictionary).get("kind", "")) == kind:
out.append(h)
return out
# 「动作 × 格型」矩阵的执行面:snap = 缓冲期冻结身份快照(cell_type = 该格装板期摇定的类型身份),
# polarity 由动作决定(开安全格/标中旗 = positive,开雷格/标错旗 = negative),
# 兑付行 = 奖励字典(类型, 极性)行。返回 true = percent 行实际生效(flat 行恒 false——兑付结果由行协议承载)。
# ---- 秘宝(use_treasure 四步受理编排保留给同调主动件复用——迭代 11 第①批 T7:过渡授予路由
# _grant_treasure 随掉落/停点渠道退役拆除;能力件注入归 LevelRunner.set_attune_charges 骑库存面)----
# use_treasure 四步受理(registry use_orchestration 规范序——唯一调用权归本类;序即原子:
# ① 相位 gate(Active——行动经济 phase getter)→ ② validate_use(目标校验读 排雷玩法 只读面,零消耗)→
# ③ 逐 kind 应用(顺序钉死:空间先、ap_grant 后——effect_kinds 列表序非运行时语义)→ ④ consume。
# 拒绝全部发生在 ①②(后两步合法输入下无失败路径);使用零 AP 成本(不经 try_spend)。
# 结算明细无道具行(GDD——效果即时可见:overlay 呈现+AP 条刷新;AP 过账本 grant_ap 走 _reject 面)。
func use_treasure(handle: int, target: Vector2i) -> Dictionary:
if treasure_inventory == null:
return {"ok": false, "reason": "库存未接线"}
if action_economy.phase != action_economy.PHASE_ACTIVE:
return {"ok": false, "reason": "非挖掘期(使用窗口=Active)"}
var board: MinesweeperRules = minesweeper # 恒等包装退役(2026-10-02 修复批——use_treasure 直引公有成员)
var v: Dictionary = treasure_inventory.validate_use(handle, target,
Vector2(board.width, board.height),
func(c: Vector2i): return not board.is_revealed(c))
if not bool(v.ok):
return v # ② 拒绝零消耗
# treasure_used 发射+施加(内容数据驱动扩展批 T4——使用主动秘宝受理面:counter_spend 订阅=受理闸
# (掘进蓄能积点账本),消耗成功才受理;积点不足拒绝零消耗。按 item_id 特判随本批拆除)
var tu_hits := _fx_emit("treasure_used", {"item_id": String(v.item_id), "target": target})
for h in tu_hits:
if String(h.get("kind", "")) == "counter_spend":
if not _fx.counter_spend(String(h.get("counter_id", "")), int(h.get("value", 1))):
var cn_info: int = int(db.treasure_item_row("dig_charge").get("charge_n", 0))
return {"ok": false, "reason": "掘进蓄能:安全格积点未达(%d/%d)" % [_attune_safe_seen % cn_info if cn_info > 0 else 0, cn_info]}
var kinds: Array = v.kinds
if kinds.has("mine_reveal"):
treasure_inventory.apply_overlay("mine_reveal", v.resolved)
if kinds.has("reward_preview"):
treasure_inventory.apply_overlay("reward_preview", v.resolved)
if kinds.has("ap_grant"):
if not action_economy.grant_ap(int(v.magnitude)):
return {"ok": false, "reason": "行动点加量被拒(相位防御)"}
if kinds.has("ap_charge_safe"):
# 掘进蓄能兑付(迭代 11 同调批 T6——临时 AP 当轮生效,grant_ap 同 sapper_order 通道)
if not action_economy.grant_ap(int(v.magnitude)):
return {"ok": false, "reason": "行动点加量被拒(相位防御)"}
if kinds.has("cell_removal"):
# 爆破工兵包兑付(迭代 13 T8/D6——中立移除雷格:未揭示/无旗/是雷过滤归排雷玩法;
# P1 甲邻接数字冻结零重算、清图分母零涉(雷格不入分母);空集拒绝零消耗——
# consume 之前 return(拒绝面注记扩「③内零消耗分支」),拒绝 reason 可呈现)
var blasted: Array = board.remove_mines_in(v.resolved)
if blasted.is_empty():
return {"ok": false, "reason": "爆破工兵包:3×3 区域内无可移除雷格(须未揭示且未插旗)"}
var rec: Dictionary = treasure_inventory.consume(handle)
# 同调主动件充能扣减(迭代 11 第①批 T6——共享账本直改:adventure/level/settlement 同一字典引用)
if not rec.is_empty() and String(rec.get("source_class", "")) == "attunement":
var used_iid: String = String(rec.get("item_id", ""))
if _attune_charges_ref.has(used_iid):
_attune_charges_ref[used_iid] = maxi(0, int(_attune_charges_ref[used_iid]) - 1)
return {"ok": true, "item_id": v.item_id, "resolved": v.resolved, "record": rec}
# aux 解锁门控转发(迭代 7 T5——LevelRunner 注入;E1/E3 封装:外部经公有面,禁直读 _reward_economy)
func set_treasure_unlocked(ids: Dictionary) -> void:
_reward_economy.set_treasure_unlocked(ids)
func _grant_routed(snap: Dictionary, polarity: String, source_class: String, cell: Vector2i, ri: int, highlight: String = "", label_suffix: String = "", halve_pct: int = 0) -> bool:
# 迭代 11 第①批 T7——aux 授予分支随掉落/停点渠道退役拆除(reward 词表第十型 treasure_item 保留但零投放:
# 无 aux 行入池、CURIO_KINDS 词表已除名);剩余类型走极性字典路由。
var row: Dictionary = _reward_economy.polarity_row(String(snap.get("cell_type", "")), polarity)
if row.is_empty():
_fatal("极性字典缺行: %s %s" % [String(snap.get("cell_type", "")), polarity])
return false
if polarity == "positive":
row["power"] = int(snap.get("power", int(row.power))) # 区间行快照 power(身份批 roll 值)覆盖表值——授予期零随机口径(E14)
# 标中兑付溢价(迭代14②判式换代批 T4——条款3 条件二旗溢价形态 a,2026-10-04 用户裁 P3):
# δ 读 settlement.flag_hit_premium(percent 域 125=×1.25 provisional);100=无溢价直通零扰动。
# 兑付公式一处=本块:只乘标中旗正向(flat power 乘后入单据/治疗/排队;percent 幅度经
# premium_pct 贯穿 _apply_percent_row 应用与呈现同乘)——标空/挖格/清图不乘,雷安同池冻结不动。
# 取整=四舍五入(与伤害管线 round_direction=floor 分域——registry clause3_inequality 随批登记;
# 1 点量纲行 ac_up/action 取整后不变=整数粒度地板)。
if source_class == "flag_hit" and int(db.settlement.flag_hit_premium) != 100:
var prem_pct: int = int(db.settlement.flag_hit_premium)
row = row.duplicate()
row["power"] = int(round(int(row.power) * prem_pct / 100.0))
row["premium_pct"] = prem_pct
# 惊巢反制(迭代 11 同调批 T5 → 内容数据驱动扩展批 T4:按 id 特判拆除——减半系数=mine_hit
# damage_mult 订阅命中(行 value 50=减 50%,取整 ceil 与迁移前逐位一致);反弹单=mine_hit
# open_ticket channel reflect 订阅施加(编排器 mine_hit 施加段);duplicate 隔离——减半不回写极性字典)
if halve_pct > 0 and polarity == "negative" and source_class == "mine_hit":
row = row.duplicate()
var half: int = int(ceil(float(int(row.power)) * float(100 - halve_pct) / 100.0))
_emit_row("effect", source_class, cell, ri, "惊巢反制——挖雷惩罚减半(%d→%d)" % [int(row.power), half], 0, {"effect_id": "nest_counter"}, highlight)
row.power = half
return _dispatch_row(row, source_class, cell, ri, highlight, label_suffix)
# 兑付形态分派:effect_id 非空 = percent 行(进增益状态容器);空 = flat 行(当场兑付)
func _dispatch_row(row: Dictionary, source_class: String, cell: Vector2i, ri: int, highlight: String = "", label_suffix: String = "") -> bool:
var eff := String(row.get("effect_id", ""))
if eff != "":
return _apply_percent_row(row, source_class, cell, ri, highlight, label_suffix)
_apply_flat_row(row, source_class, cell, ri, highlight, label_suffix)
return false
# percent 行兑付:GP 型行走战斗内缓冲记账(GP+商店房实施批);其余 apply_effect 入容器;
# 满层拒收 = "奖励未生效"行(零静默)
func _apply_percent_row(row: Dictionary, source_class: String, cell: Vector2i, ri: int, highlight: String = "", label_suffix: String = "") -> bool:
var eff := String(row.effect_id)
if eff == "gp_gain" or eff == "gp_loss":
return _apply_gp_row(row, source_class, cell, ri, highlight, label_suffix)
var srow: Dictionary = db.status_effect_row(eff)
# 旗溢价贯穿(迭代14② T4——仅标中旗正向行携带 premium_pct;其余授予路径缺省 100 零扰动):
# 应用幅度与呈现行值同乘,容器经 magnitude_override 拿同一快照(抗表重载口径保持)。
var prem_pct: int = int(row.get("premium_pct", 100))
var eff_mag: int = int(srow.magnitude)
var src := {"source_class": source_class, "origin_cell": cell, "round_index": ri}
if prem_pct != 100:
eff_mag = int(round(eff_mag * prem_pct / 100.0))
src["magnitude_override"] = eff_mag
var ok: bool = _status_effects.apply_effect(eff, src)
if ok:
_emit_row("effect", source_class, cell, ri, String(srow.display_name) + label_suffix, eff_mag, {"effect_id": eff, "pool_value": _pool_effective(eff)}, highlight)
return true
_emit_row("reward_ineffective", source_class, cell, ri, "奖励未生效(满层)%s" % label_suffix, 0, {"batch_key": eff}, highlight)
return false
# GP 型行兑付(GP+商店房实施批 2026-09-21):战斗内缓冲 escrow 记账——不入钱包不入增益容器。
# 正向(gp_gain)= 挖开金币安全格/标中旗;负向(gp_loss)= 挖雷格/标错旗。兑付行经既有 effect 行
# 通道外发(escrow 随行——归因可读),战斗结束随 gp_dropped 一次上抛后即弃。
func _apply_gp_row(row: Dictionary, source_class: String, cell: Vector2i, ri: int, highlight: String = "", label_suffix: String = "") -> bool:
var eff := String(row.effect_id)
var delta: int = int(row.power) if eff == "gp_gain" else -int(row.power)
escrow += delta
_emit_row("effect", source_class, cell, ri, "%s %+d%s" % [String(row.display_name), delta, label_suffix], delta, {"effect_id": eff, "escrow": escrow}, highlight)
return true
# flat 行兑付(负向恒即时,无库存语义):伤害型分支全部改开单据(伤害管线单据化批 2026-09-24——
# 管线/下限/目标解析归 伤害控制器,本函数只保留非伤害分支与单据装配):
# action = 行动点排队(非伤害);blast 正向 = 群伤单据(无类型恒定表值打全体存活敌;负向 = 警觉 percent 行走 _apply_percent_row);
# heal 正向 = 主角回复 clamp(满血 → 奖励未生效行);heal 负向 = 主角直伤单据(不经增益状态);
# pierce/magic 正向 = 单目标克制直伤单据(attack_type 查克制矩阵,必中全额不掷点);负向 = 主角直伤单据。
# 返回 "redeemed" = 实际有效兑付 / "zero" = 零兑付(行已自记)——读数 = 控制器结账 outcome(A 模式提交即结)。
# sync = 同步开单(清图批——流程依赖完美第二份/高光份数读数,无视结账政策当场结账)。
func _apply_flat_row(row: Dictionary, source_class: String, cell: Vector2i, ri: int, highlight: String = "", label_suffix: String = "", sync := false) -> String:
var ctype := String(row.cell_type)
var power := int(row.power)
var display := String(row.display_name) + label_suffix
if ctype == "action":
var delta: int = power if String(row.get("polarity", "positive")) == "positive" else -power
action_economy.queue_ap_delta(delta)
_emit_row("effect", source_class, cell, ri, "%s(下轮行动点 %+d)" % [display, delta], delta, {"effect_id": "ap_delta", "pool_value": action_economy.pending_ap_delta()}, highlight)
return "redeemed"
if ctype == "blast":
var t := _damage.open_ticket(DamageController.KIND_BOARD_BLAST, source_class, display, "board", power, \
DamageController.TARGET_ALL_ALIVE, ri, cell, "", 0, highlight, {"batch_key": String(row.reward_id)})
return _outcome_of(_submit(t, sync))
if ctype == "pierce" or ctype == "magic":
# 伤害道具批(2026-09-18)语义不变:正向 = 单目标克制直伤(必中全额不掷点——棋盘来源伤害既有口径);
# 负向 = 主角直伤(复用反噬通道)。
if String(row.get("polarity", "positive")) == "negative":
return _submit_flat_hero(source_class, display, power, cell, ri, highlight, sync)
var atk_type: String = String(row.get("attack_type", ctype))
var t2 := _damage.open_ticket(DamageController.KIND_BOARD_TYPED, source_class, display, "board", power, \
DamageController.TARGET_FRONT_ALIVE, ri, cell, atk_type, 0, highlight, {"batch_key": String(row.reward_id)})
return _outcome_of(_submit(t2, sync))
if ctype == "heal":
if String(row.get("polarity", "positive")) == "negative":
return _submit_flat_hero(source_class, display, power, cell, ri, highlight, sync)
if int(_enemy_system.hero_unit().get("hp", 0)) <= 0:
# 主角已倒下(同轮更早的负向直伤致死)——治疗不再合法作用,按"奖励未生效"留痕
_emit_row("reward_ineffective", source_class, cell, ri, "%s 未生效(主角已倒下)" % display, 0, {"batch_key": String(row.reward_id)}, highlight)
return "zero"
if int(_enemy_system.hero_unit().hp) >= int(_enemy_system.hero_unit().max_hp):
_emit_row("reward_ineffective", source_class, cell, ri, "%s 未生效(满血)" % display, 0, {"batch_key": String(row.reward_id)}, highlight)
return "zero"
var healed: int = _enemy_system.heal(_enemy_system.hero_unit(), power) # clamp 归 战斗结算
_emit_row("heal", source_class, cell, ri, "%s 回复" % display, healed, {"target": "hero"}, highlight)
return "redeemed"
_fatal("flat 行未知类型: %s" % ctype)
return "zero"
# 开单分流:sync(清图批)走控制器同步开单——先按序结清段内排队单据、再当场兑现本单;
# 常规开单按结账时机政策(A 即结 / B 入队)。
func _submit(t: Dictionary, sync: bool) -> int:
if _defer_board_damage:
_deferred_board.append(t) # 案C:授予段伤害单据暂存——战斗段怪物还手后按事件序补提交(sync 同闸,清图批一致延后)
return -1
return _damage.flush_submit(t) if sync else _damage.submit(t)
# 案C 结账读数:暂存单(_submit 返回 -1)以「将兑付」回报——授予段兑付决策(清图完美第二份等)只能
# 依据开单时点事实(该时点敌人存活有保证);战斗段末实际结账若目标已灭,控制器自记未生效行。
func _outcome_of(ticket_id: int) -> String:
return "redeemed" if ticket_id < 0 else _damage.outcome(ticket_id)
# 主角直伤单据(反噬/治疗包负向——无类型必中,下限保底在控制器管线内)
func _submit_flat_hero(source_class: String, display: String, power: int, cell: Vector2i, ri: int, highlight: String, sync := false) -> String:
var t := _damage.open_ticket(DamageController.KIND_BOARD_FLAT_HERO, source_class, display, "board", power, \
DamageController.TARGET_HERO, ri, cell, "", 0, highlight, {})
return _outcome_of(_submit(t, sync))
# 旗判定批:标中 = 该雷格资源的正向兑付(每雷实例一次;高光 = 强制检查点);
# 标空 = "标旗未中"归因行 + 该安全格资源的负向兑付(同键去重——每格实例一次)。
func _judge_flag(evt: Dictionary, ri: int) -> void:
var cell: Vector2i = evt.cell
if evt.hit:
if _flag_consumed.has(cell):
return # 去重跳过(资格以雷格实例为键——排雷玩法 诚实重复上报的对侧);零明细行
_flag_consumed[cell] = true
_perfect_hit_true += 1
# flag_hit 发射+施加(内容数据驱动扩展批 T3——桶序固定承载等价:殉爆〔T4 秘宝面〕→
# 净化改写 → 图谱额外资源 → 基线兑付)
var fh_hits := _fx_emit("flag_hit", {"cell": cell, "round": ri,
"terrain_kind": _harmful_adjacent(cell)})
# 标中殉爆(迭代 11 同调批 T5 → 内容数据驱动扩展批 T4:订阅施加——行 triggers open_ticket
# channel blast value_from magnitude;每标中一面旗全体存活敌吃 N 群伤,无类型走 blast 通道。
# 迭代 20 T1——封印爆裂〔迁入专长〕同面消费:行 triggers open_ticket channel blast)
for h in _hits_of(fh_hits, "open_ticket"):
if String(h.get("channel", "")) != "blast":
continue
var fbt := _damage.open_ticket(DamageController.KIND_BOARD_BLAST, "feat_flag_burst", "封印爆裂", "board",
int(h.get("value", 0)), DamageController.TARGET_ALL_ALIVE, ri, cell, "", 0, "highlight", {})
_submit(fbt, false)
# 迭代 20 T1——标中新施加面:counter_add〔血税 标中+1 攻——bonus_delta atk 轴〕/
# ac_bonus〔封印桩 本轮 AC+N〕/ preview_area〔示警烟火 3×3 资源类型预告〕/
# remove_mines〔封印匠 每第 3 次该危险格中立移除——mod_eq 闸在行 when〕。
# (净化工兵 rewrite_board 分支随老 27 件退役摘除——效果类型同批除名,T1 落法 commit 留痕)
for h in fh_hits:
match String(h.get("kind", "")):
"counter_add":
var set_v = h.get("set", null)
_fx.counter_add(String(h.get("counter_id", "")), int(h.get("value", 0)) if set_v == null else int(set_v), set_v != null)
"ac_bonus":
_apply_ac_bonus(h)
"bonus_delta":
var axis_f := String(h.get("axis", ""))
if _fx_bonus_delta.has(axis_f):
_fx_bonus_delta[axis_f] += int(h.get("delta", 0))
"remove_mines":
var removed: Array = minesweeper.remove_mines_in([cell])
if not removed.is_empty():
_emit_row("mine_neutralized", "fx_seal_smith", cell, ri, "封印匠·危险格中立移除", 0, {}, "")
"preview_area":
var board_p: BoardData = minesweeper.current_board()
var types: Array = []
for n in board_p.neighbors8(cell):
var n_snap = _reward_economy.query_full_snapshot(n)
if n_snap is Dictionary and not types.has(String((n_snap as Dictionary).get("cell_type", ""))):
types.append(String((n_snap as Dictionary).get("cell_type", "")))
if treasure_inventory != null and not types.is_empty():
treasure_inventory.apply_overlay("reward_preview", board_p.neighbors8(cell))
_emit_row("fx_preview_area", "", cell, ri, "示警烟火·周围资源类型", 0, {"types": types}, "")
# 雷区图谱(迭代 12 T3 标旗流——标中雷额外获得一份格面资源:插旗开始产出对标开格;
# 复用 grant 单点,额外兑付走同一 _grant_routed 正向路由)
for h in fh_hits:
if String(h.get("kind", "")) == "grant_material":
var extra_snap = _reward_economy.grant(cell)
if extra_snap is Dictionary:
_grant_routed(extra_snap, "positive", "flag_hit", cell, ri, "", String(h.get("label", "")))
var snap = _reward_economy.grant(cell)
if not (snap is Dictionary):
return
_grant_routed(snap, "positive", "flag_hit", cell, ri, "flag_hit")
else:
_perfect_hit_false += 1
if _flag_miss_seen.has(cell):
return # 每格实例一次去重(归因行与负向兑付同键——防逐轮惩罚轰炸)
_flag_miss_seen[cell] = true
# 完美保险(迭代 20 T4——本战下一次标错不破坏完美资格:_perfect_hit_false 回退=本次标错
# 对完美谓词无痕;只保一次/件,节点级用完即止)
if _fx_insurance > 0:
_fx_insurance -= 1
_perfect_hit_false -= 1
_emit_row("insurance_used", "", cell, ri, "完美保险·标错豁免", 0, {"left": _fx_insurance}, "")
var hl := ""
if _perfect_hit_false == 1:
hl = "perfect_broken" # 本板完美谓词已破的归因锚
_emit_row("flag_miss", "flag_miss", cell, ri, "标旗未中", 0, {}, hl)
# 危险直觉·守挂点(迭代 21 T3——flag_missed 发射:契约 12 触发主语=「当回合发生的标错
# 动作」——去重闸=新旧标错判别器〔后续轮重判旧旗被闸挡回零触发〕;双条件②「结算时旗仍在
# 场」由发射面既有过滤承载〔被挖开的旗已随挖掘擦除零兑付〕;grant_ap mode:queue 下一轮
# 开轮兑现——末轮判出无下一轮=合法作废自动成立〔编排器每战新建,队列不跨战〕;限次=合成行
# adventure 域计数器闸)
for h in _fx_emit("flag_missed", {"cell": cell, "round": ri}):
if String(h.get("kind", "")) == "grant_ap" and String(h.get("mode", "")) == "queue":
action_economy.queue_ap_delta(int(h.get("value", 0)))
var snap = _reward_economy.grant(cell)
if not (snap is Dictionary):
return
_grant_routed(snap, "negative", "flag_miss", cell, ri, "")
# 清图批:清图奖励对象恰好发一次;完美排雷追加第二份同身份兑付(grant_clear 本身恰一次)。
# 高光行 value = 实际有效兑付份数(禁记名义值;首份零兑付 → 降级不发)。
func _grant_clear_reward(ri: int) -> void:
if _board_from_midround_refresh:
return # 轮内刷新换来的新图不发清图奖励(行动经济/战斗结算 明文——刷新图不是清图挣来的)
var snap = _reward_economy.grant_clear()
if not (snap is Dictionary):
return
var perfect: bool = _perfect_hit_false == 0 and _perfect_hit_true >= 1
var first_ok := _grant_clear_identity(snap, ri, "(清图奖励)")
var second_ok := false
if perfect and first_ok:
second_ok = _grant_clear_identity(snap, ri, "(完美第二份)")
if perfect and first_ok:
_emit_row("highlight", "clear_reward", Vector2i(-1, -1), ri, "完美排雷!清图奖励 ×2", (2 if second_ok else 1), {}, "perfect_sweep")
# 清图奖励 = 板级独立对象(不属于任何一格):兑付形态按其 cell_type 的正向字典形态走,
# 行值全部取快照(身份批 roll 值)。返回实际有效兑付。
# 同步开单(sync)——完美第二份与高光份数的流程依赖需要同步读数(开单权归流程 用户裁决③);
# 同步路径内先按序结清段内排队单据(B 模式保序),随后本批无视结账政策当场兑现。
func _grant_clear_identity(snap: Dictionary, ri: int, suffix: String) -> bool:
var row := {
"reward_id": String(snap.get("reward_id", "")),
"cell_type": String(snap.get("cell_type", "")),
"polarity": "positive",
"display_name": String(snap.get("display_name", "")),
"power": int(snap.get("power", 0)),
"effect_id": String(snap.get("effect_id", "")),
"stacks": int(snap.get("stacks", 1)),
}
var eff := String(row.effect_id)
if eff != "":
return _apply_percent_row(row, "clear_reward", Vector2i(-1, -1), ri, "clear_reward_h2", suffix)
return _apply_flat_row(row, "clear_reward", Vector2i(-1, -1), ri, "clear_reward_h2", suffix, true) == "redeemed"
# ---- 开轮摇号(敌人技能池批——共识点 B:技能选择前置到轮次开始,数值计算留结算段)----
# 每实例:保底判定(连续执行普攻 ≥ M → 必出特殊技、特殊技内按权重、不受冷却限制)→ 排除冷却中技能
# (中签率 = 权重 ÷ 可摇权重合计——份额归一)→ 按行序逐敌从统一流顺序取号摇定本拍动作。
# 随机消费只在换算层(本方法 + SkillLottery);单位容器收确定值(零随机纪律——armor_roll 先例同构)。
# 并流批(ADR-0007 裁丙 2026-09-27):behavior_roll 子流派生向量(spawn_index×STRIDE+序)退役——
# 「单敌轨迹独立复放」前提随之移除(位置交错,单点定位由事件日志承载)。摇定后、兑现前死亡 → 本拍技能作废。
func _roll_round_actions() -> void:
var pity_m: int = int(db.skill.pity_threshold)
for u in _enemy_system.query_alive("enemy"):
# Boss 半血解锁闩(迭代14④ T4——开轮摇号前逐敌检查;一次过门单向闩;解锁出一次归因明细行
# 「XX 解锁了 YY」带 skill_unlock 高光键〔type=highlight 既有行型零新增〕,呈现层弹一次性
# 技能名提示〔轻量 toast 非演出——D8 呈现面〕。门=确定性谓词,机制面零随机,摇号随机
# 消费仍在 behavior_roll 既有流——同种子逐位复现不破坏)
for sid in _enemy_system.check_skill_unlocks(u):
var srow_u: Dictionary = db.skill_row(sid)
_emit_row("highlight", "enemy_action", Vector2i(-1, -1), round_index,
"%s 解锁了 %s" % [String(u.display_name), String(srow_u.get("display_name", sid))],
0, {"skill_id": sid}, "skill_unlock")
# enemy_hp_threshold 发射点(内容数据驱动扩展批 T1——血量阈值沿;对所有带该字段敌人逐个发射,
# 非 Boss 专属——Boss 语义留给订阅者 when 判定。pct=本次解锁条目的 unlock_hp_pct 表值)
var pct_v := 0
for pe in (u.get("pool", []) as Array):
if String((pe as Dictionary).get("skill_id", "")) == sid and (pe as Dictionary).has("unlock_hp_pct"):
pct_v = int((pe as Dictionary).unlock_hp_pct)
break
# 迭代 20 T1——target_attack_bonus 施加面(处刑台:敌血量阈值沿挂单位字典标记,
# 主角攻击面 _resolve_hero_attack 通用消费——fx_atk_bonus 键加算进 base_value)
var eht_hits := _fx_emit("enemy_hp_threshold", {"unit": String(u.enemy_id), "pct": pct_v,
"is_boss": String(encounter.get("node_type", "")) == "boss"})
for h in eht_hits:
if String(h.get("kind", "")) == "target_attack_bonus":
u["fx_atk_bonus"] = int(u.get("fx_atk_bonus", 0)) + int(h.get("value", 0))
var entries: Array = _enemy_system.rollable_skills(u)
if _enemy_system.basic_streak(u) >= pity_m:
var specials: Array = _enemy_system.special_skills(u)
if not specials.is_empty():
entries = specials # 保底命中——普攻恒可摇 → 非保底路径池恒非空,此为唯一替代集合
var stream: RandomNumberGenerator = rng.open_stream("behavior_roll")
_enemy_system.set_pending_action(u, SkillLottery.draw(entries, stream))
_enemy_system.tick_cooldowns() # 递减在摇号读值之后——"施放后连续 N 拍不可摇中"的等价实现(单位容器 注记)
# ---- 战斗段 ----
# 伤害管线单据化批(2026-09-24):本段只开单——主角先手单 + 敌方伤害技单;掷点/折扣/暴击/克制/
# 池乘区/下限/交付/commit 全部收口 伤害控制器(A 模式提交即结=与迁移前逐位一致;B 模式段尾 flush)。
# 开单守卫归流程(用户裁决③——流程决定给谁开单):主角存活+目标存在才开先手单;hero hp=0 立即
# 停开(B 模式下若 flush 中主角倒下,由控制器死亡事件终止余单——两模式同一结果)。
func _combat_segment(ri: int) -> void:
_defer_board_damage = false # 案C 闸复位——战斗段起开单即入队(主角/敌方/补提交的地图单按开单序统一结账)
# 本轮扫雷事实推导(迭代 12 T4——执拗 streak/连珠矢段数共用取数面):本轮 safe 事件数=
# streak;踩雷即清零(mine_hit 监听同口——执拗「踩雷清零」承载)。攻击在战斗段=授予段后
# 同一次 _settle 内,facts 已定。
var round_safe := 0
var round_mine := false
for evt in _facts_reveals:
if evt.kind == "safe":
round_safe += 1
elif evt.kind == "mine":
round_mine = true
_damage.dig_streak = 0 if round_mine else round_safe
# 蓄力层注入(内容数据驱动扩展批 T3——整备军备「每层下轮首段 +1」/厚积薄发满层 spike/铁壁整备
# 减伤——三件 fact charge_layers 同读 action_economy;battle 域每轮刷新)
_damage.charge_layers = action_economy.charge_layers
# ⓪ 主角先手攻击(基础攻击恒存在——2026-09-11 用户裁决;先于敌方行动。案C 后授予段不再产出伤害,
# 主角进入战斗段时恒存活——先手必兑,除非场上无敌)。
# 单分量伤害构成(2026-09-19 充能退役批·用户拍板):主角攻击 = 唯一基础分量(atk,吃掷点含暴击;
# 类型 = 主角 attack_type 表值——2026-09-18 营地扩展前置批:量与类型抽象分离;查甲效矩阵对应行取克制修正)。
# 原"基础 + 穿刺/魔法充能三分量"模型随充能系统整体退役——克制策略归冒险前配置(营地换型 / 未来冒险前参数面),
# 局内不再有挖掘攒弹药的第二伤害供给。attack_up 增益经 hero_offense 池乘攻击合计(见池乘区注记)。
# 护甲等级掷点批(2026-09-16):基础分量吃掷点判定(含暴击);棋盘来源伤害不掷不折;
# 任何伤害落地 ≥ damage_floor(全局 max(1,·)——2026-09-20 用户拍板扩全来源,下限在控制器管线单点收口)。
var hero_alive: bool = int(_enemy_system.hero_unit().get("hp", 0)) > 0
var hero_target = _enemy_system.default_target()
if hero_alive and hero_target != null:
var ht := _damage.open_ticket(DamageController.KIND_HERO_ATTACK, "hero_attack", "主角攻击", "combat", _hero_atk, \
DamageController.TARGET_FRONT_ALIVE, ri, Vector2i(-1, -1), String(_enemy_system.hero_unit().get("attack_type", "normal")))
_damage.submit(ht)
# 连珠矢(内容数据驱动扩展批 T3——attack_commit 订阅施加面:开格节奏变攻击段数,本轮每开
# ~3 安全格 +1 段穿刺附加(上限 cap;段值 provisional 固定值不乘 atk——台账披露)。施加位
# 紧随主角攻击单开立=迁移前开单位(先于敌方行动单入队——flush 兑现序逐位一致)
for h in _hits_of(ht.context.get("fx_attack_hits", []) as Array, "attack_segments"):
var segments := mini(int(h.get("cap", 2)), _damage.dig_streak / maxi(1, int(h.get("divisor", 3))))
for s in segments:
# 迭代 20 T9——source_class 中性名(旧件名 feat_chain_arrows 随连珠矢退役;attack_segments 兑付通道保留泛用面)
var ct := _damage.open_ticket(DamageController.KIND_BOARD_TYPED, "fx_streak_segments",
"连击分段", "board", int(h.get("value", 0)), DamageController.TARGET_FRONT_ALIVE, ri,
minesweeper.hero_pos(), "pierce")
_damage.submit(ct)
# 敌方行动(敌人技能池批——本拍动作开轮已摇定,此处按技能行兑现;行序 = 生成序)。
# 死亡即不动手:摇定后、兑现前死亡的敌不在存活序中——本拍技能作废,不 commit、不进冷却。
for u in _enemy_system.query_alive("enemy"):
if int(_enemy_system.hero_unit().hp) <= 0:
break # hero hp=0 立即短路(死尸挨打行是归因噪音——用户拍板)
var skill_id := _enemy_system.pending_action(u)
var srow: Dictionary = db.skill_row(skill_id)
if srow.is_empty():
_fatal("敌实例待发动作悬空: %s" % skill_id)
continue
if String(srow.type) == "buff" or String(srow.type) == "debuff":
# 增益/妨害技(新效应词汇批 2026-09-19)= 0 伤害施放行:不掷不折;状态授予即数值兑现,技能冷却与增益持续两计时器分账。
# (原"本轮喂的伏击逢施放拍不兑现"读怪奖励随伏击退役一并作废——2026-09-20 八型批)
# 落点由状态行 target_side 路由(buff → 敌方侧 / debuff → 主角侧——容器零改动,proposal-status-vocabulary §1)。
var dur: int = int(srow.get("buff_duration", 1))
_status_effects.apply_effect(String(srow.buff_ref), {"source_class": "enemy_action", "origin_cell": Vector2i(-1, -1), "round_index": ri}, dur)
_emit_row("cast", "enemy_action", 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})
_enemy_system.commit_action(u)
continue
# 伤害技(含普攻——倍率恒 1)= 敌方伤害技单据:基线 = round(atk×倍率)、rider 预计算归开单侧
# (表值算术零随机),掷点/克制恒中性/hero_defense×enemy_output 池乘区/下限/rider 后置/commit
# 全归控制器管线(兑现序 = 开单序;来源敌已死在兑现时作废——死亡即不动手)。
var baseline: int = int(round(float(u.atk) * float(srow.get("damage_mult", 1.0))))
var rider := 0
if srow.has("rider_mult"):
rider = maxi(int(db.settlement.damage_floor), int(floor(float(u.atk) * float(srow.rider_mult))))
var et := _damage.open_ticket(DamageController.KIND_ENEMY_SKILL, "enemy_action", "%s %s" % [u.display_name, String(srow.display_name)], "combat", \
baseline, DamageController.TARGET_HERO, ri, Vector2i(-1, -1), "", rider)
et["source_unit"] = u
et["context"] = {"skill_id": skill_id, "baseline": baseline}
_damage.submit(et)
# 地图伤害兑付(案C 2026-10-06 用户裁——战斗段末:怪物还手后按授予段事件序补提交统一结账;
# 主角死于怪物 → 地图账不兑,逐单留「未生效」行不作废静默——没生效也要留痕口径;
# 补提交后若地图直伤反杀主角,控制器 _halted 作废余单——战斗已败,胜负归判定段)
if int(_enemy_system.hero_unit().get("hp", 0)) <= 0:
for t in _deferred_board:
_emit_row("reward_ineffective", String(t.get("source_class", "")), t.get("origin_cell", Vector2i(-1, -1)), ri,
"%s 未生效(主角已倒下)" % String(t.get("display", "")), 0, {"forfeit": "hero_down"}, "")
else:
for t in _deferred_board:
_damage.submit(t)
_deferred_board.clear()
_damage.flush() # 战斗段界结账(B 模式——主角单+敌方单+地图单按开单序统一兑现;A 模式地图单补提交时即结)
# ---- 算术读数(伤害管线单据化批 2026-09-24 迁移注记)----
# 掷点/折扣/克制点数/池乘区/攻击行文本已全部迁入 伤害控制器(damage_controller.gd 管线单点);
# 本类仅保留一处薄委托——_defender_ac(快照面消费)。_clamped_pool 委托壳已随 2026-10-02 修复批删除
# (冒烟白盒断言消费面改直引 settlement._damage.clamped_pool——并行卡同批改 smoke_all)。
# 防守方护甲等级读数——实现归 伤害控制器 defender_ac(本委托供结算快照/呈现快照)
func _defender_ac(unit: Dictionary) -> int:
return _damage.defender_ac(unit)
func _pool_effective(effect_id: String) -> int:
var side := "hero"
var row: Dictionary = db.status_effect_row(effect_id)
if not row.is_empty():
side = String(row.target_side)
for eff in _status_effects.query_active(side):
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):
if eff.round_index < ri:
# 续存实例(前轮授予)——每结算兑付一次的可见面(合并实例只持首授归因——增益状态 义务⑦)
_emit_row("effect", eff.source_class, eff.origin_cell, ri, eff.display_name, eff.snap_magnitude, {"effect_id": eff.effect_id, "pool_value": eff.effective_value})
# ---- 结算快照(playtest 数值分析数据面——每次结算全量状态;运行时零决策读取)----
func _emit_settlement_snapshot(verdict: String) -> void:
var enemies: Array[Dictionary] = []
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})
var mines_hit := 0
var safe_n := 0
for evt in _facts_reveals:
if evt.kind == "mine":
mines_hit += 1
elif evt.kind == "safe":
safe_n += 1
var board_stats := {"seq": _board_seq}
if minesweeper.current_board() != null:
board_stats["w"] = minesweeper.current_board().width
board_stats["h"] = minesweeper.current_board().height
board_stats["revealed"] = minesweeper.revealed_cells().size()
board_stats["flags"] = minesweeper.flag_count()
settlement_snapshot.emit({
"kind": "settlement",
"level": int(encounter.get("level_index", 0)),
"depth": int(encounter.get("node_depth", 0)),
"round": round_index,
"verdict": verdict,
"unit_count": enemies.size(),
"spawn_count": _enemy_system.all_enemies().size(), # 本战斗实例化总数(含死亡残位)——unit_count=0 时区分「首轮全灭」vs「空场」(playtest-2026-09-12 报告 Bug#1 误读教训)
"hero": {"hp": int(_enemy_system.hero_unit().get("hp", 0)), "max_hp": int(_enemy_system.hero_unit().get("max_hp", 0)), "atk": int(_enemy_system.hero_unit().get("atk", 0)), "armor_class": _defender_ac(_enemy_system.hero_unit())},
"economy": {"phase": String(action_economy.phase), "ap": int(action_economy.ap), "rap": int(action_economy.rap), "refresh_used": _refresh_used_this_round},
"board": board_stats,
"enemies": enemies,
"buffs": buffs,
"round_facts": {"path": _facts_path, "safe": safe_n, "mines": mines_hit, "flag_events": _facts_flags.size(), },
})
# ---- 出口分发与战斗结束序列 ----
func _exit_distribution() -> void:
var mode := "same_board"
if _facts_path == "cleared":
mode = "new_board" # cleared 轮恒走 new_board(契约义务);非清图轮恒同图(等待期刷新通道已移除)
if mode == "new_board":
_reward_economy.retire_mapping()
minesweeper.retire_board()
if not _install_new_board():
_fatal("换图编排失败")
return
# 工兵晨令续队直调拆除(内容数据驱动扩展批 T4——round_start 订阅行承载,见 begin_battle 注)
action_economy.notify_settlement_done(mode)
# round_start 发射先于行动经济轮切换(queue 型 grant_ap 当轮入队当轮兑现——与首轮同口径)
round_index += 1
_emit_round_start()
action_economy.notify_round_start()
_reset_round_facts()
_refresh_used_this_round = 0
_roll_round_actions() # 开轮摇号——每轮恰摇一次(轮内刷新换图不重摇:refresh 不走本通道)
round_started.emit(round_index)
# 呈现层查询面(HUD 刷新按钮可用性)——挖掘期唯一通道
func can_refresh() -> bool:
return battle_result == "" \
and _refresh_used_this_round < int(db.action_economy.refresh_cap_per_round) \
and action_economy.phase == action_economy.PHASE_ACTIVE \
and action_economy.rap >= int(db.action_economy.rap_refresh_cost)
# 玩家经 HUD框架发起、战斗结算 代理调用(禁止 RAP 满自动刷)。
# 挖掘期唯一通道(2026-09-12 方案 A 拍板 + 2026-09-15 等待期通道整体移除):Active 相位立即换图
# 继续挖(RAP -8;AP 保留、相位不动、不触发结算);非挖掘期请求一律拒绝——非清图轮恒同图。
func player_request_refresh() -> bool:
if not can_refresh():
return false
if not action_economy.request_refresh():
return false
_refresh_used_this_round += 1
_reward_economy.retire_mapping() # 旧图批次退役(地图战斗资源 Ready→retired;E6 新批重置前提)
minesweeper.retire_board(true) # 旧板退役(轮内通道)
return _install_new_board(true) # 当场换图——失败即编排违例 fail-fast
func _battle_over(result: String) -> void:
battle_result = result
# battle_won 发射+施加(迭代 20 T1——战地包扎 heal 订阅面;败北不发射。heal=英雄单位回血
# 〔封顶单位上限〕,战后生命经 hero_hp_left 回写冒险账本——时点在捕获前=回血计入带入下一层)
if result == "victory":
for h in _fx_emit("battle_won", {"round": round_index}):
if String(h.get("kind", "")) == "heal":
var hu_w: Dictionary = _enemy_system.hero_unit()
hu_w["hp"] = mini(int(hu_w.get("hp", 0)) + int(h.get("value", 0)), maxi(1, int(hu_w.get("max_hp", 1))))
# bonus_delta 战末汇入(迭代 20 T1——血税双向账:本战累积并入冒险加算区共享引用后清零)+
# adventure 域计数器导出回写(T6——共享引用覆写,attune_charges 先例)
if _fx_bonus_ref != null and not _fx_bonus_ref.is_empty():
for axis in _fx_bonus_delta.keys():
if _fx_bonus_ref.has(axis):
_fx_bonus_ref[axis] = int(_fx_bonus_ref.get(axis, 0)) + int(_fx_bonus_delta[axis])
_fx_bonus_delta = {"atk": 0, "max_hp": 0, "armor_class": 0, "ap": 0}
if _fx != null and not _fx_adventure_counters_ref.is_empty():
var exported: Dictionary = _fx.counters_export()
for cid in exported.keys():
_fx_adventure_counters_ref[cid] = int(exported[cid])
# 映射随战斗结束退役(地图战斗资源 义务④——下一场战斗 = 新板新 scope 批,roll_board 相位守卫的前提)
var hero_hp_left := int(_enemy_system.hero_unit().get("hp", 0)) # despawn 前捕获(信号载荷供呈现层"HP 带入下一层")
var gp_dropped := _roll_gp_drop() # 战斗掉落(GP+商店房实施批——escrow 随此值上抛即弃)
_reward_economy.retire_mapping()
_enemy_system.despawn_all()
_status_effects.notify_battle_end()
if treasure_inventory != null:
treasure_inventory.retire_overlays() # 迭代 7 T3——叠加层随战斗结束清空(库存不动——关卡域跨战斗)
action_economy.notify_combat_end() # → Idle,RAP 清零(2026-10-02 修复批:原被 tab 事故并进上一行行尾注释从不执行——每战新建实例掩盖了症状;P0-3)
db.set_reload_gate(false)
_reset_round_facts()
battle_finished.emit({"result": result, "hero_hp": hero_hp_left, "gp_dropped": gp_dropped})
# 战斗掉落掷定(GP+商店房实施批 2026-09-21):tier = Boss 节点 ? "boss" : "normal"(elite 留分支位);
# gp_dropped = gp_drop 封闭流掷定(tier 行的 roll_min..roll_max 均匀整数——每战斗末恰一 scope)
# + level_gp_bonus(level_index) + escrow。掷定行缺位 = 数据面缺行(fail 报错按 0 兑付——不哑掉)。
func _roll_gp_drop() -> int:
var tier := "boss" if String(encounter.get("node_type", "")) == "boss" else "normal"
var drops: Dictionary = db.shop_gp_drop_row(tier)
var roll := 0
if drops.is_empty():
push_error("[战斗结算] gp_drops 缺行 tier=%s——掉落掷定按 0 兑付" % tier)
else:
var stream: RandomNumberGenerator = rng.open_stream("gp_drop")
roll = stream.randi_range(int(drops.get("roll_min", 0)), int(drops.get("roll_max", 0)))
var drop := roll + db.shop_level_gp_bonus(int(encounter.get("level_index", 0)))
# 财富之道·聚(迭代 21 T3——战斗掉落 GP 乘算·入账前:只乘战斗掉落两成分〔掷定+关卡加成〕,
# 店内 escrow 不乘〔范围=仅战斗掉落渠道,卡片§11②裁决〕;本函数返回值=结算框「GP 增量」行
# 显示源,落在入账前=显示即实得;乘数 provisional 150%,值归 M4 数值批)
if (encounter.get("tree_rules", []) as Array).has("economy_hoard"):
drop = int(round(float(drop) * 1.5))
return drop + escrow
# ---- 呈现状态面(E1 封装收口 2026-09-13):呈现层 / headless 自动玩家的唯一读取出口——
# 协作对象(_enemy_system/_status_effects/_reward_economy)私有化后,跨层读取一律经本快照或下列命名面(diagnostic_snapshot 同款模式,
# 禁再链式穿透 settlement.enemy_system.hero 一类路径)。载荷 schema 成文(code-conventions 2.1):
# round / battle_result / phase / ap / rap / can_refresh / hero{hp,max_hp,atk,alive,armor_class} /
# enemies[{order,display_name,hp,max_hp,enemy_id,armor_type,armor_class,pending_skill,pending_type,pending_name,effects[],alive}] /
# (敌人技能池批批 2 定稿:beat/ring_size/ring_index 拆除 → pending_* 三字段 + 敌方增益 effects 投影;池与权重不公开;
# 八型批 2026-09-20:ambush_layers 拆除——伏击退役,警觉并入 effects 投影)hero_effects[](增益状态 query_active 投影)
func presentation_snapshot() -> Dictionary:
var enemies: Array[Dictionary] = []
var all: Array = _enemy_system.all_enemies()
# 敌方增益投影(side-wide 状态——蓄力/硬化/削弱/暴走 percent 行 + 警觉 ac 行,八型批 2026-09-20)
var enemy_effects: Array[Dictionary] = []
for eff in _status_effects.query_active("enemy"):
var erow: Dictionary = db.status_effect_row(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 = all[i]
var alive: bool = bool(u.get("alive", false))
var pending: String = String(u.get("pending_skill", "")) if alive else ""
var pending_row: Dictionary = db.skill_row(pending)
enemies.append({
"order": i + 1,
"display_name": String(u.display_name),
"hp": int(u.hp), "max_hp": int(u.max_hp),
"enemy_id": String(u.enemy_id),
"armor_type": String(u.get("armor_type", "light")),
"armor_class": int(u.get("armor_class", 0)),
# 敌人技能池批(批 2 定稿):摇定动作三字段——普通攻击零表示 / 特殊技类型级动画占位。
# 池与权重不公开(proposal D4/D5)——实例池的权重/冷却不投影,实例池可见面 = 摇定结果本身。
"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,
"alive": alive,
})
var hero := {}
var h: Dictionary = _enemy_system.hero_unit()
if not h.is_empty():
hero = {"hp": int(h.hp), "max_hp": int(h.max_hp), "atk": int(h.atk), "alive": bool(h.get("alive", false)), "armor_class": _defender_ac(h)}
return {
"round": round_index,
"battle_result": battle_result,
"phase": String(action_economy.phase),
"ap": int(action_economy.ap), "rap": int(action_economy.rap),
"can_refresh": can_refresh(),
"hero": hero,
"enemies": enemies,
"hero_effects": _status_effects.query_active("hero").duplicate(true), # 值拷贝出户(code-review 2026-09-29 P2-3——快照消费方直改不渗入状态容器)
# 板级变异投影(全局作用域变异首发批 2026-10-04——呈现横幅/常驻角标唯一读取面;
# 空数组 = 无全局型合法态:普通图与全局池空回落图恒空)
"board_variants": (minesweeper.current_board().board_variant_ids().duplicate()
if minesweeper.current_board() != null else ([] as Array[String])),
}
# 战斗进行中谓词(hero 在场且存活)——组合根高频相位判断的低成本面
func is_battle_live() -> bool:
return battle_result == "" and not _enemy_system.hero_unit().is_empty()
# 奖励身份解析(组合根注入 BoardView 徽章回调的只读代理——Ready 相位守卫归 地图战斗资源)
func query_reward_identity(cell: Vector2i):
return _reward_economy.query_identity(cell)
# 诊断观测面(组合根诊断转储 / 行为探针消费):私有编排状态的成文只读出口——
# 消费方禁止再直读本类下划线字段(code-review 2026-09-12 封装收口批)
func diagnostic_snapshot() -> Dictionary:
return {
"battle_result": battle_result,
"round": round_index,
"settling": _settling,
"refresh_used_round": _refresh_used_this_round,
"board_seq": _board_seq,
"facts_path": _facts_path,
"facts_reveals": _facts_reveals.size(),
"facts_flags": _facts_flags.size(),
"action_economy": {"phase": String(action_economy.phase), "ap": action_economy.ap, "rap": action_economy.rap},
"minesweeper_board_installed": minesweeper.current_board() != null,
"minesweeper_revealed": minesweeper.revealed_cells().size() if minesweeper.current_board() != null else 0,
"minesweeper_flags": minesweeper.flag_count() if minesweeper.current_board() != null else 0,
"reward_economy_phase": _reward_economy.phase(),
"enemies_alive": _enemy_system.query_alive("enemy").size(),
"hero_hp": int(_enemy_system.hero_unit().get("hp", -1)) if not _enemy_system.hero_unit().is_empty() else -1,
}
func _reset_round_facts() -> void:
_facts_reveals.clear()
_facts_flags.clear()
_facts_path = ""
# ---- 行协议(Core Rule:公共字段 + 行类型特有字段)----
func _emit_row(type: String, source_class: String, origin_cell: Vector2i, ri: int, display: String, value: int, extra: Dictionary, highlight: String = "") -> void:
_assert_row_source(type, source_class, origin_cell)
var row := {
"type": type,
"source_class": source_class,
"origin_cell": [origin_cell.x, origin_cell.y],
"round_index": ri,
"display_name": display,
"value": value,
}
if highlight != "":
row["highlight"] = highlight
for k in extra.keys():
row[k] = extra[k]
step_row.emit(row)
# 行来源断言(战斗结算 明细流水:"任何一行找不到来源即断言失败——宁可报错"):
# 来源字段 = source_class(公共字段)+ origin_cell(棋盘来源行的真实格)。
# 豁免按两层成文(见 SOURCE_EXEMPT_TYPES / SOURCE_EXEMPT_CLASSES 常量注释),其余行必须带真实棋盘格。
func _assert_row_source(type: String, source_class: String, origin_cell: Vector2i) -> void:
if SOURCE_EXEMPT_TYPES.has(type):
return
assert(source_class != "", "[战斗结算] 明细行缺来源类别(type=%s)——任何一行找不到来源即断言失败" % type)
if SOURCE_EXEMPT_CLASSES.has(source_class):
return
assert(origin_cell.x >= 0 and origin_cell.y >= 0,
"[战斗结算] 明细行来源格越界(type=%s source=%s cell=%s)——棋盘来源行必须指回真实格" % [type, source_class, str(origin_cell)])
func _fatal(msg: String) -> void:
push_error("[战斗结算/fatal] " + msg)
battle_result = "fatal_error" # release 构建剥离 assert 后的唯一阻断面——受理/结算/刷新入口全查 battle_result == ""
assert(false, msg)