Files
gplan-2/game/core/battle_orchestrator.gd
T
DKChan 6c2dbc37c1 docs: ADR 体系废除 + architecture.md 确立为架构唯一事实源(2026-10-15 用户令:决策记录归 git commit log,不另存决策文档)
- 删除 design/dev/architecture/adr-0001~0007 共 7 份文件(内容留 git 历史可查)
- architecture.md:删「ADR 审计」「ADR 立项清单」两节;清除全文 ADR-000X 注记、QQ 表/S 表 ADR 引用;未决表关闭已落定项(QQ-01/02/03),立项清单在途事项转登记 QQ-11~14;补 A1 生成姿态事实、autoload 四件清单、board_generator 性能姿态;文档状态立「事实源地位」条
- CLAUDE.md:恢复 RNG 单点硬性红线(去 ADR-0007 引用);新增「架构事实唯一源」红线;规范导航架构行标注唯一事实源
- doc-conventions:§5 增「架构事实唯一源」行;§11 活文档清单补架构文档
- registry:architecture.yaml 删 18 处 adr 字段、autoload_set 补 SaveManager/MetaProgression、board_rending 更新为 Node2D 已落地事实;entities.yaml 12 处引用清除
- game/ 代码注释约 60 处清 ADR-000X 引用(事实与日期保留);豁免标记 ADR-0007-exempt → rng-seed-exempt(main.gd/static_scan.sh/architecture.md/06/iter27 五处同步)
- GDD/qa 实现报告/research 卡片/roadmap/tests README 引用全清;死链校验 1206 链接 0 死链、gdd_lint 0 不通过
- 代码中 adr/adr5 等为 adventure_runner 既有局部变量名(非 ADR 引用),未动
2026-10-15 23:52:30 +08:00

1310 lines
81 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 · 五段结算(授予→增益/减益→战斗→衰减→判定)·
# 授予段极性路由(「动作 × 格型」矩阵:开安全格/标中旗
# = 正向,开雷格/标错旗 = 负向;结算队列 + 抵消过滤——标旗后被撤旗以轮末在场旗为基线不判)·
# 10 类行协议可回放步骤流(零 await;随机消费三处皆封闭流——开战遭遇骰〔初始化随机〕+ 技能摇号
# behavior_roll〔回合内、开轮时点、步骤流之外〕+ 护甲等级掷点〔回合内、
# 步骤流之内〕;同种子同输入逐位复现)· 高光双落(完美排雷 ×2 / 标中旗 highlight)·
# Waiting 相位结算触发(不依赖事件到达序)。
# GP 型兑付:授予段极性路由含 GP(effect_id gp_gain/gp_loss → 战斗内缓冲 escrow 记账,不入钱包
# 不入增益容器);战斗结束分支掷 gp_drop 封闭流(每战斗末恰一 scope)出
# gp_dropped = 掷定 + level_gp_bonus + escrow 随 battle_finished 上抛——战斗域零 GP 状态持久。
# 伤害管线:伤害兑付全部收口 DamageController 单点管线——本类六调用点(主角先手/敌方伤害技/
# 棋盘克制直伤/群伤/反噬/地形变异)为开单点(流程决定何时开单给谁开单;单据怎么算归控制器)。
# 结账时机开关在控制器 policy:IMMEDIATE(开单即结)/ SEGMENT(攒批到段界 flush:战斗段尾统一兑现)。
# 地图伤害殿后:授予段不当场兑付任何伤害——全部伤害形态单据经 _submit 暂存闸入 _deferred_board
# (保授予段事件序),战斗段「主角先手→逐敌还手」后补提交统一结账;主角死于敌方还手 → 暂存单
# 整体不兑,逐单留「未生效(主角已倒下)」行(forfeit=hero_down);
# 死亡事件 = 单位容器 unit_died(单位自行宣布)→ 控制器作废该敌未结单据〔死亡即不动手〕/ 主角死终止兑付。
# 切片登记:① 事件总线载体 = 直连信号(已定)② Waiting 相位无刷新通道
# (仅 Active 挖掘期受理)③ 效果行协议 = 授予段逐 grant 行 + 增益/减益段续存实例组合行。
# 实现 GDD:design/gdd/systems/战斗循环/战斗结算.md(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] = [] # 授予段暂存伤害单据(保授予段事件序;战斗段怪物还手后按序补提交统一结账)
# 结算五段 Segment(迭代 27 T2——P1-1 拆段;段边界=审计五段〔裁点③甲〕,段序骨架冻结于
# SettlementTable.segment_order 语义:授予→增益/减益→战斗→衰减→判定。本类退为段序编排+
# 行发射/开单原语单点——段体逻辑归 game/core/settlement/ 五类)
var _grant_seg = null # GrantSegment(①授予段——含旗判与 counter/grant 助手单点)
var _aura_seg = null # AuraSegment(②增益/减益段)
var _combat_seg = null # CombatSegment(③战斗段——原 _combat_segment 体)
var _decay_seg = null # DecaySegment(④衰减段)
var _verdict_seg = null # VerdictSegment(⑤判定段——返回裁定,终局早退语义归本类)
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[Variant] = [] # 装备中同调件 item_id 集
var _ledger: RunLedger = null # 共享四本账(迭代 27 T3——begin_battle 经 enc["ledger"] 持引用;缺省新账=调试面)
var _attune_safe_seen := 0 # 积点制件 battle 域安全开格计数(迭代 22 T3 通用化——装配集 ap_charge_safe 行扫描;反哺批⑤ 2026-10-12 用户裁:满 charge_n 自动兑付当轮 AP+1,无玩家发起入口)
# ---- 效果引擎扩签运行态(迭代 20 专长实装批 T1——新效果类型施加面;空订阅零扰动)----
var _fx_hero_ac_base := -1 # 主角 AC 基线快照(enc.hero_armor_class;-1=表值兜底未解析)
var _fx_ac_round := 0 # ac_bonus 本轮累积器(封印桩形态——round_start 清零)
var nest_preview_count := -1 # 巢穴预告读数口(迭代 22 秘宝批 T3——board_installed 命中落值;-1=未装配/未发射;呈现桥消费)
var _fx_ac_battle := 0 # ac_bonus 本战累积器(石肤形态——cap 封顶,begin_battle 清零)
var _fx_bonus_delta := {"atk": 0, "max_hp": 0, "armor_class": 0, "ap": 0} # 本战 bonus_delta 累积(战末经 _ledger.bonus_add_axis 汇入后清零)
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, _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[Variant] = []
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[Variant, Variant] = _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 卡④立——呈现桥消费面现归 attune_param_sum〔迭代 22 T4 换代〕。
# _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 定影,零运行期按件取值)
# 装配集参数合计(迭代 22 秘宝批 T3——常时参数面 kind 的装配集 Σmagnitude 读数口;
# kind 分发查询先例=_attune_refresh_x_total bonus_refresh。零装配=0)
func attune_param_sum(kind: String) -> int:
var total := 0
for iid_v in attunement:
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v))
if (arow.get("effect_kinds", []) as Array).has(kind):
total += int(arow.get("magnitude", 0))
return total
# 装配集内 ap_charge_safe 行读取(迭代 22 秘宝批 T3——掘进蓄能 id 特判通用化取数源;
# 首行语义=装配集内至多一件积点制〔新目录恰试炼镐〕)
func _attune_charge_safe_row() -> Dictionary:
for iid_v in attunement:
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v))
if (arow.get("effect_kinds", []) as Array).has("ap_charge_safe"):
return arow
return {}
# 充能重置(迭代 22 秘宝批 T3——按周期回满:账本回 charges_per_adventure+骑库存差额补注入
# (used handle 不回收=本轮已耗充能作废,补新 handle=「回满」;差额注入防双份=level_runner
# set_attune_charges 同构)。reset ∈ {battle, level}——adventure 周期归冒险开始种子面)
func _reseed_attune_charges(reset: String) -> void:
for iid_v in attunement:
var iid := String(iid_v)
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(iid)
if String(arow.get("form", "")) != "active_charged":
continue
if String(arow.get("charge_reset", "adventure")) != reset:
continue
var want := int(arow.get("charges_per_adventure", 0))
if want < 1:
continue # charges=0=自动兑付件(试炼镐——不走骑库存面,兑付=_on_cell_revealed 积点段自动 grant_ap)
_ledger.attune_charge_set(iid, want)
if treasure_inventory != null:
var existing := 0
for r in treasure_inventory.snapshot_inventory():
if String(r.get("item_id", "")) == iid and String(r.get("source_class", "")) == "attunement" and not bool(r.get("used", false)):
existing += 1
for i in range(maxi(0, want - existing)):
treasure_inventory.add_item(iid, "attunement", Vector2i(-1, -1), 0)
# 积点制件进度读数(迭代 22 反哺批⑤——自动兑付无玩家发起入口,读数口=C 区秘宝槽悬停浮窗进度行:
# assembled=装配在册/points=本战安全格进度(charge_n 取余)/need=charge_n/item_id=装配行 id。
# 未装配=assembled false)
func attune_convert_state() -> Dictionary:
var row := _attune_charge_safe_row()
if row.is_empty() or String(row.get("form", "")) != "active_charged":
return {"assembled": false, "points": 0, "need": 0, "display_name": "", "item_id": ""}
var cn := int(row.get("charge_n", 0))
return {"assembled": true, "points": _attune_safe_seen % cn if cn > 0 else 0, "need": cn,
"display_name": String(row.get("display_name", "")), "item_id": String(row.get("item_id", ""))}
# 特性求值(内容数据驱动扩展批 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)
# 结算五段接线(迭代 27 T2——段持宿主引用;信号连接纪律不涉段〔段零信号连接〕)
_grant_seg = GrantSegment.new(self)
_aura_seg = AuraSegment.new(self)
_combat_seg = CombatSegment.new(self)
_decay_seg = DecaySegment.new(self)
_verdict_seg = VerdictSegment.new(self)
# ---- 战斗开始序列(编排专章①:门 → 增益状态 → 敌人系统 → 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[Variant] = 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[Variant] = []
var atk_overrides: Array[Variant] = []
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,
String(enc.get("attack_type", "")), String(enc.get("armor_type", ""))): # 迭代 23 T4——两根类型轴(空串=锻造配置默认值回退,行为连续)
_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_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 ledger_ref = enc.get("ledger", null)
_ledger = ledger_ref if ledger_ref is RunLedger else RunLedger.new() # 缺省新账=调试/单场零扰动面
_damage.fx_reveal_request = _fx_enqueue_reveal
_damage.fx_ac_request = _apply_ac_bonus
var feat_ids: Array[Variant] = 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[Variant] = enc.get("tree_rules", []) as Array
# 效果引擎订阅重建(内容数据驱动扩展批 T1——每战从持有集/装备集重建,中途获得下一战生效;
# 树规则合成订阅同面装载。装载序=feats 表序→treasure 表序→树规则,写死保种子复放。
# 迭代 20 T6——adventure 域计数器从共享引用回灌〔跨战持久〕)
_fx.rebuild(feat_ids, attunement, tree_rules)
_fx.counters_seed(_ledger.counters_seed_snapshot())
# 秘宝常时参数注入(迭代 22 秘宝批 T3——装配集 kind 分发:远程镐挖距放宽/信标网络旗可达豁免;
# 缺省 0/false=零装配零扰动。注入先于首板 install 已不成立〔板在早段〕——本批板生成不读两参数,
# 参数只影响挖掘/插棋动作判据,时序安全)
minesweeper.dig_range_bonus = attune_param_sum("dig_range_bonus")
minesweeper.flag_range_free = attune_param_sum("flag_range_free") > 0
# 充能重置周期(迭代 22 秘宝批 T3——battle=每战回满/level=每关首战回满〔battle_index==1〕;
# adventure 周期维持冒险开始 _seed_attune_charges 单次)
_reseed_attune_charges("battle")
if int(enc.get("battle_index", 1)) == 1:
_reseed_attune_charges("level")
# board_installed 发射+施加(迭代 22 秘宝批 T3——巢穴预告:装板后一次报本板危险格数量;
# 只报数量不标位置=信息红线,不碰分布=棋盘事实不可变契约 3。命中=读数口落值,呈现归
# 呈现桥消费(无格来源不进结算明细行——行来源纪律);板已就绪〔早段 install〕、订阅行已在 rebuild 装载)
nest_preview_count = -1
var bi_hits := _fx_emit("board_installed", {})
for h in bi_hits:
if String(h.get("kind", "")) == "mine_count_preview":
nest_preview_count = minesweeper.mine_total()
# 迭代 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[Variant, Variant] = 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[Variant, Variant] = 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[Variant] = 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[Variant, Variant] = pool[stream.randi_range(0, pool.size() - 1)]
var total: int = int(round(float(candidates.size()) * float(db.variation.percent_of_cells) / 100.0)) \
+ (int(encounter.level_index) - 1) * int(db.variation.per_level_bonus)
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[Variant] = [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:
# 积点自动兑付(迭代 11 同调批 T6 → 迭代 22 秘宝批 T3 通用化 → 反哺批⑤ 2026-10-12 用户裁:
# 满 charge_n 直接当轮 grant_ap,无玩家发起入口、无积点账中转〔账即时清空,battle 域重置
# 自然无感〕。落位=揭示缓冲点——事件序 cell_revealed 批先于本格扣费,AP 归零恰逢积点满时
# 先兑再扣,轮次不因本格结束;授予段已无积点段(本段唯一计数点)。fx_reveal 旁路不计数=
# 沿现役缓冲路径口径〔天眼开格不计积点〕。grant_ap 相位防御在 ACTIVE 挖格链内恒过)
if String(evt.get("kind", "")) == "safe":
var cs_row: Dictionary[Variant, Variant] = _attune_charge_safe_row()
if not cs_row.is_empty():
_attune_safe_seen += 1
var cn_b: int = int(cs_row.get("charge_n", 999))
if cn_b > 0 and _attune_safe_seen % cn_b == 0:
action_economy.grant_ap(int(cs_row.get("magnitude", 1)))
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
var ri := round_index
# 段序编排(迭代 27 T2——五段 Segment;段体归 game/core/settlement/,本类退为段序+
# 行发射/开单原语单点。旧 259 行巨函数体已逐字迁五段类,git 史留痕)
_grant_seg.run(ri) # ① 授予段(案C 闸随段装载)
_aura_seg.run(ri) # ② 增益/减益段(续存实例组合行)
_combat_seg.run(ri) # ③ 战斗段(主角先手 → 逐敌还手 → 地图伤害账殿后〔案C〕)
_decay_seg.run(ri) # ④ 衰减段(恰一次恒执行含末轮——先兑付后衰减)
var verdict: String = _verdict_seg.run(ri) # ⑤ 判定段(防御性优先级 hero 先判)
if verdict != "continue":
return # 终局:旧早退语义逐位一致(_settling 不回置——_battle_over 已接管)
_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[Variant, Variant] = 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[Variant] = []
for vid_v in vids:
var vid := String(vid_v)
var vrow: Dictionary[Variant, Variant] = 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[Variant] = []
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[Variant] = []
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 直引公有成员)
# 板维度读数(迭代 22 T3 修——Rules 无 width 代理〔潜伏缺陷:旧目录期零直呼覆盖掩盖〕,读 BoardData 本体)
var board_b: BoardData = board.current_board()
var v: Dictionary[Variant, Variant] = treasure_inventory.validate_use(handle, target,
Vector2(board_b.width, board_b.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 vrow: Dictionary[Variant, Variant] = db.treasure_item_row(String(v.item_id))
var cn_info: int = int(vrow.get("charge_n", 0)) # 迭代 22 T3——读受理行自身(旧 dig_charge 硬编码随行退役)
return {"ok": false, "reason": "%s:安全格积点未达(%d/%d)" % [String(vrow.get("display_name", "")), _attune_safe_seen % cn_info if cn_info > 0 else 0, cn_info]}
var kinds: Array[Variant] = v.kinds
if kinds.has("mine_reveal") or kinds.has("reveal_tiles"):
# 真视写入(迭代 22 T3——reveal_tiles 新真视 kind 与 mine_reveal 同叠加层通道:
# 危险格显示=信息呈现零回写,棋盘事实不可变契约 3)
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("safe_dig"):
# 天眼安全格直开(迭代 22 秘宝批 T3——C5 裁全额兑付:fx_reveal 通道复用=免 AP 零相位、
# 连锁与格面资源与普通挖格同链;危险格仅真视显示不移除=契约 3;有旗格跳过=旗=玩家承诺不代开)
var opened_any := false
for c_v in v.resolved:
var c := Vector2i(c_v)
if board.has_flag(c):
continue
if board.fx_reveal(c):
opened_any = true
if not opened_any:
return {"ok": false, "reason": "天眼:窗口内安全格均已开(无可开格)"}
if kinds.has("cell_removal"):
# 爆破工兵包兑付(迭代 13 T8/D6——中立移除雷格:未揭示/无旗/是雷过滤归排雷玩法;
# P1 甲邻接数字冻结零重算、清图分母零涉(雷格不入分母);空集拒绝零消耗——
# consume 之前 return(拒绝面注记扩「③内零消耗分支」),拒绝 reason 可呈现)
var blasted: Array[Variant] = board.remove_mines_in(v.resolved)
if blasted.is_empty():
return {"ok": false, "reason": "爆破工兵包:3×3 区域内无可移除雷格(须未揭示且未插旗)"}
var rec: Dictionary[Variant, Variant] = treasure_inventory.consume(handle)
# 同调主动件充能扣减(迭代 11 第①批 T6——共享账本直改:adventure/level/settlement 同一字典引用)
if not rec.is_empty() and String(rec.get("source_class", "")) == "attunement":
_ledger.attune_charge_consume(String(rec.get("item_id", "")))
# 真视类工具使用计数(迭代 22 秘宝批 T3——信标网络解锁行为条件:受理成功恰计一次;
# 共享 dict 引用下传〔attune_charges 同款〕,冒险收束聚合入档 save.add_vision_used)
if kinds.has("reveal_tiles") or kinds.has("safe_dig") or kinds.has("mine_reveal"):
if not _ledger.vision_counter.is_empty():
_ledger.vision_count_add(1) # 迭代 22 秘宝批 T3——受理成功恰计一次(缺省空账=调试不计)
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[Variant, Variant] = _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[Variant, Variant] = 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))
# 旗判定批:标中 = 该雷格资源的正向兑付(每雷实例一次;高光 = 强制检查点);
# 标空 = "标旗未中"归因行 + 该安全格资源的负向兑付(同键去重——每格实例一次)。
# 清图批:清图奖励对象恰好发一次;完美排雷追加第二份同身份兑付(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 先例同构)。
# 并流批(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[Variant, Variant] = 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[Variant] = _enemy_system.rollable_skills(u)
if _enemy_system.basic_streak(u) >= pity_m:
var specials: Array[Variant] = _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 中主角倒下,由控制器死亡事件终止余单——两模式同一结果)。
# ---- 算术读数(伤害管线单据化批 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[Variant, Variant] = 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[Variant, Variant] = _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 not _ledger.bonus.is_empty():
for axis in _fx_bonus_delta.keys():
_ledger.bonus_add_axis(axis, int(_fx_bonus_delta[axis]))
_fx_bonus_delta = {"atk": 0, "max_hp": 0, "armor_class": 0, "ap": 0}
if _fx != null:
_ledger.counters_merge_export(_fx.counters_export())
# 映射随战斗结束退役(地图战斗资源 义务④——下一场战斗 = 新板新 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[Variant, Variant] = 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[Variant] = _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[Variant, Variant] = 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[Variant, Variant] = all[i]
var alive: bool = bool(u.get("alive", false))
var pending: String = String(u.get("pending_skill", "")) if alive else ""
var pending_row: Dictionary[Variant, Variant] = db.skill_row(pending)
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[Variant, Variant] = _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(),
"refresh_used": _refresh_used_this_round, # 迭代 27 T3——P1-2 刷新双账收口:受理计数入呈现成文面(HUD 余量单源读此处)
"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)