feat(slice): 格面资源批代码收尾——五段结算/10 类行协议/可选类道具下线

段序七段→五段(删道具窗口/道具段);明细 15 类行→10 类;删 RewardSupplyController、
RewardConfigTable、库存面板(inventory_panel_view)与供给扫描(supply_scan);
reward.tres 收窄为格面池五型正负行 + 清图槽;battle_finished payload 去 remaining_items。
其中 battle_orchestrator/level_runner/reward_economy/reward_table/debug_session/main/
board_view/reward_expectation/smoke_all 十文件叠有系统更名批的注释与日志字样(随本文件提交)。
验证:smoke 211 项 0 失败 + 四 UI 探针通过(2026-09-16 全量)。
This commit is contained in:
DKChan committed 2026-09-16 01:20:19 +08:00
1 parent e250279536
commit 3540fdbd31
41 files changed
+990 -1675

No files matched your search

@@ -1,32 +1,32 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# 战斗结算编排中枢:轮次生命周期唯一 owner · 段序骨架(授予→窗口→增益/减益→道具→战斗→衰减→判定)·
# 15 类行协议可回放步骤流(零随机零 await;2026-09-15 窗口锚行批 13→15)· 高光双落(完美排雷 ×2 / 标中旗 highlight)· 伏击预截流 ·
# 战斗结算编排中枢:轮次生命周期唯一 owner · 五段结算(授予→增益/减益→战斗→衰减→判定——2026-09-15
# 格面资源批,道具窗口/道具段随可选类删除下线)· 授予段极性路由(「动作 × 格型」矩阵:开安全格/标中旗
# = 正向,开雷格/标错旗 = 负向;结算队列 + 抵消过滤——标旗后被撤旗以轮末在场旗为基线不判)·
# 10 类行协议可回放步骤流(零随机零 await)· 高光双落(完美排雷 ×2 / 标中旗 highlight)·
# Waiting 相位结算触发(不依赖事件到达序)。
# 切片登记:① 事件总线载体 = 直连信号(实现期 ADR 项,provisional)② Waiting 相位刷新通道 2026-09-15 用户拍板整体
# 拆除(仅 Active 挖掘期受理;旧"每 Waiting 至多一次"简化登记随之作废)③ 效果行协议 = 授予段逐 grant 行 + 增益/减益段续存实例组合行。
# 实现 GDD:design/archive/gdd-2026-09-14/b1-settlement.md(系统 ID B1·战斗结算——registry 追溯锚,命名规范见 CLAUDE.md)
# 拆除(仅 Active 挖掘期受理)③ 效果行协议 = 授予段逐 grant 行 + 增益/减益段续存实例组合行。
# 实现 GDD:design/gdd/systems/战斗循环/战斗结算.md(v0.7 格面资源批——registry 追溯锚)
class_name BattleOrchestrator
extends RefCounted
signal step_row(row: Dictionary)
signal window_opened(items: Array)
signal battle_finished(payload: Dictionary) # {result, hero_hp, remaining_items}——hero 在 despawn 前捕获(呈现层销毁后读状态的时序防护)
signal battle_finished(payload: Dictionary) # {result, hero_hp}——hero 在 despawn 前捕获(呈现层销毁后读状态的时序防护)
signal round_started(round_index: int)
signal board_changed()
signal settlement_snapshot(snap: Dictionary) # 每结算末尾的全量状态快照(playtest_logger 消费——JSONL 数值分析)
# 明细流水行型协议(战斗结算 Core Rule 14;15 类——2026-09-15 窗口锚行批由 13 类扩为 15 类,
# 明细流水行型协议(战斗结算 Core Rule;10 类——2026-09-15 格面资源批由 15 类收拢,
# 清单改动须同步 battle_log_view 的 _format_row 与本文档头注):
# 效果行 effect · 伤害行 damage · 治疗行 heal · 道具行 item · 溢出未生效行 overflow ·
# 奖励未生效行 reward_ineffective · 已消耗未生效行 consumed_ineffective · 使用被拒行 use_rejected ·
# 标旗未中行 flag_miss · 蓄力行 charge · 衰减行 decay · 判定行 verdict · 高光行 highlight ·
# 窗口打开锚行 window_open · 窗口跳过锚行 window_skip(后两类 = 2026-09-15 补的窗口段锚行)
# 行来源断言豁免集合(行型层)——判定段 / 衰减段 / 窗口锚行本就无棋盘来源,勿一刀切误炸
const SOURCE_EXEMPT_TYPES := ["decay", "verdict", "window_open", "window_skip"]
# 行来源断言豁免集合(来源类别层)——事实来源是单位 / 清图 / 库存件而非棋盘格
const SOURCE_EXEMPT_CLASSES := ["hero_attack", "enemy_action", "clear_reward", "item"]
# 效果行 effect · 伤害行 damage · 治疗行 heal · 溢出未生效行 overflow ·
# 奖励未生效行 reward_ineffective · 标旗未中行 flag_miss · 蓄力行 charge · 衰减行 decay ·
# 判定行 verdict · 高光行 highlight
# 行来源断言豁免集合(行型层)——判定段 / 衰减段本就无棋盘来源,勿一刀切误炸
const SOURCE_EXEMPT_TYPES := ["decay", "verdict"]
# 行来源断言豁免集合(来源类别层)——事实来源是单位 / 清图而非棋盘格
const SOURCE_EXEMPT_CLASSES := ["hero_attack", "enemy_action", "clear_reward"]
var db
var rng
@@ -49,13 +49,11 @@ var _facts_reveals: Array = []
var _facts_flags: Array = []
var _facts_path := ""
var _settling := false
var _awaiting_window := false
var _window_items: Array = []
var _refresh_used_this_round := 0 # 每轮刷新计数(Active 相位合计,兜底上限 = ActionEconomyTable.refresh_max_per_round)
var _board_seq := 0 # 板序号(本战斗第几张板——快照归因用)
var _board_from_midround_refresh := false # 装板 provenance:轮内刷新换来的图 = true(清图奖励守卫——刷新图不发清图奖励)
var _flag_consumed := {} # 兑付资格以雷格实例为键(板级;换图重置)
var _flag_miss_seen := {} # 标旗未中行去重(每雷格实例一次)
var _flag_miss_seen := {} # 标旗未中行去重(每安全格实例一次)
var _perfect_hit_true := 0 # 板级完美排雷计数(install 重置)
var _perfect_hit_false := 0
var _enemy_cap := 0 # EnemyTable 行值快照(伏击 cap)
@@ -64,7 +62,7 @@ var _hero_atk := 0 # 主角攻击基值快照(encounte
var _charge_counts: Dictionary = {} # 本轮充能计数 {奖励触发源: 计数}——投哪个攻击类型桶由 settlement.charge_grants(奖励定义)决定,本类不认识"开格=穿刺"这类绑定
func _init(db_ref, rng_ref, shared_reward_economy = null) -> void:
func _init(db_ref, rng_ref) -> void:
db = db_ref
rng = rng_ref
_board_generator = BoardGenerator.new()
@@ -72,8 +70,7 @@ func _init(db_ref, rng_ref, shared_reward_economy = null) -> void:
minesweeper = MinesweeperRules.new(db, action_economy)
_enemy_system = UnitContainer.new(db)
_status_effects = StatusContainer.new(db)
# 库存关卡域(2026-09-11 关内延续):连续关卡模式注入共享 奖励道具经济 容器,库存跨战斗延续
_reward_economy = shared_reward_economy if shared_reward_economy != null else RewardEconomy.new(db, rng)
_reward_economy = RewardEconomy.new(db, rng)
minesweeper.cell_revealed.connect(_on_cell_revealed)
minesweeper.flag_judged.connect(_on_flag_judged)
minesweeper.round_end_board.connect(_on_round_end)
@@ -91,7 +88,6 @@ func begin_battle(enc: Dictionary) -> bool:
if not _status_effects.notify_battle_start():
_fatal("增益状态 开域被拒(编排违例)")
return false
_reward_economy.notify_battle_start() # 可选类供给控制器战斗域清零(2026-09-11 供给控制器批——首件保证按战重置)
# 遭遇骰(难度曲线与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 骰后);
@@ -120,7 +116,7 @@ func begin_battle(enc: Dictionary) -> bool:
_m_amb = int(db.status_effect_row("ambush").get("magnitude", 0))
if not _install_new_board():
return false
_reset_round_facts() # 首板 install 的初始展开事件不入轮 facts(reward_eligible 恒 false 的双保险)
_reset_round_facts() # 首板 install 的初始展开事件不入轮 facts(resource 恒 false 的双保险)
if not action_economy.notify_round_start():
_fatal("行动经济 首轮 notify_round_start 被拒")
return false
@@ -138,17 +134,19 @@ func _install_new_board(during_round := false) -> bool:
if not res.accepted:
_fatal("排雷玩法 install 拒绝(不变式违例): %s" % str(res))
return false
# 奖励道具经济 义务⑥:roll_board 恰一次(install 后、挖掘前);资格格 = S ∖ R₀
# 地图战斗资源 义务⑥:roll_board 恰一次(install 后、挖掘前);资格格 = 全部非封锁格(安全格 + 雷格,雷安同池)
var r0 := {}
for c in board.compute_r0():
r0[c] = true
var eligible: Array = []
for c in board.safe_cells():
if r0.has(c):
continue
eligible.append({"cell": c})
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 被拒")
_fatal("地图战斗资源 roll_board 被拒")
return false
_flag_consumed.clear() # 换图新雷 = 新实例,资格自然重置(行动经济 Rule 8)
_flag_miss_seen.clear()
@@ -165,11 +163,11 @@ func _install_new_board(during_round := false) -> bool:
func _on_cell_revealed(evt: Dictionary) -> void:
# 轮 facts 缓冲不设相位守卫——轮末最后一挖的揭示事件在 request_dig 翻相之后才到(事件序:
# cell_revealed 批 → flag_judged 批 → round_end),phase==Active 守卫会丢最后一挖的事实;
# initial 通道事件(install 初始展开)reward_eligible=false,进入授予段被资格检查自然跳过。
# initial 通道事件(install 初始展开——封锁格)resource=false,进入授予段被资格检查自然跳过。
if battle_result == "":
if evt.kind == "safe" and bool(evt.get("reward_eligible", false)):
if bool(evt.get("resource", false)):
# 身份快照随缓冲捕获(批次隔离——轮内刷新后 mapping 已换批,授予必须用缓冲期身份,
# 防旧图已挖格在轮末错拿新图同坐标身份)
# 防旧图已挖格在轮末错拿新图同坐标身份)。雷格同样携带(雷安同池——负向兑付的来源格)
var snap = _reward_economy.query_full_snapshot(evt.cell)
if snap != null:
evt = evt.duplicate()
@@ -194,91 +192,37 @@ func _maybe_settle() -> void:
_settle()
# ---- 每轮结算序列(段序骨架——Core Rule 4)----
# ---- 每轮结算序列(五段——段序骨架冻结于 SettlementTable.segment_order)----
func _settle() -> void:
_settling = true
var ri := round_index
# ① 授予段(含 grant_clear 兑付)
var mine_cells: Array = []
# ① 授予段:结算队列(开格/旗判定/清图)→ 极性路由逐批兑付。
# 抵消过滤 = 旗判定批以轮末在场旗为基线(被拔不判——发射面已过滤,标旗后撤旗零兑付);开格事实不可撤。
for evt in _facts_reveals:
if evt.kind == "mine":
mine_cells.append(evt.cell)
elif evt.kind == "safe" and evt.reward_eligible:
_charge_counts["safe_cell"] = int(_charge_counts.get("safe_cell", 0)) + 1 # 充能计数(开格 = 奖励触发源;只计数,投哪个桶由 charge_grants 决定);初始展开格 reward_eligible=false 不计
_grant_cell(evt.cell, ri, "safe_cell", evt.get("reward_snapshot"))
# reward_eligible=false(initial 通道)不入库——战斗结算 义务②(行动经济 EC5 委派闭环)
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":
_charge_counts["safe_cell"] = int(_charge_counts.get("safe_cell", 0)) + 1 # 充能计数(开安全格 = 奖励触发源;只计数,投哪个桶由 charge_grants 决定)
_grant_routed(snap, "positive", "safe_cell", evt.cell, ri, "")
elif evt.kind == "mine":
_grant_routed(snap, "negative", "mine_hit", evt.cell, ri, "")
for evt in _facts_flags:
_judge_flag(evt, ri)
if not mine_cells.is_empty():
_convert_ambush(mine_cells, ri)
if _facts_path == "cleared":
_grant_clear_reward(ri)
# ② 道具使用窗口(授予段后、增益/减益段前——当轮拾取当轮可用)
_window_items = _assemble_window()
if not _window_items.is_empty():
# 窗口打开锚行(战斗结算 验收:"窗口段恒在案")——零棋盘来源,行型豁免
_emit_row("window_open", "", Vector2i(-1, -1), ri, "道具窗口(%d 件可选)" % _window_items.size(), _window_items.size(), {})
_awaiting_window = true
window_opened.emit(_window_items)
_settling = false
return # 窗口选择序列 = 结算回放输入事件
# 空库存不弹窗,但窗口段仍要在案("空库存记跳过")——锚行不可省
_emit_row("window_skip", "", Vector2i(-1, -1), ri, "道具窗口跳过(空库存)", 0, {})
_finish_settlement([])
func _assemble_window() -> Array:
# 窗口数据组装 = 奖励道具经济 query_inventory 投影 + 战斗结算 侧守卫预判 usable_now(奖励道具经济 零跨系统读不破)
var out: Array = []
for inst in _reward_economy.query_inventory():
if inst.status != "InStock":
continue
var usable := true
if inst.category == "heal_pack":
usable = int(_enemy_system.hero_unit().hp) < int(_enemy_system.hero_unit().max_hp) # 满血治疗包禁选
out.append({
"index": inst.index,
"item_id": inst.item_id,
"display_name": inst.display_name,
"category": inst.category,
"power": inst.power,
"source_cell": inst.source_cell,
"round_index": inst.round_index,
"usable_now": usable,
})
return out
func submit_window_selection(selection: Array) -> void:
if not _awaiting_window:
push_warning("[战斗结算] 无窗口期的选择提交(编排违例)")
return
_awaiting_window = false
_settling = true
if selection.size() > 1:
push_warning("[战斗结算] 窗口至多选用一件(2026-09-11 用户拍板——避免结算复杂度)——超出部分忽略")
var picked: Array = selection.slice(0, 1)
if picked.is_empty():
# 零选择跳过锚行(战斗结算 验收:"零选择记跳过")——窗口段在案的第二半
_emit_row("window_skip", "", Vector2i(-1, -1), round_index, "道具窗口跳过(未选用)", 0, {})
_finish_settlement(picked)
func _finish_settlement(selection: Array) -> void:
var ri := round_index
# ③ 增益/减益段(续存实例组合行;本结算新授予的已在授予段逐 grant 出行——增益状态 义务⑦)
# ② 增益/减益段(续存实例组合行;本结算新授予的已在授予段逐 grant 出行——增益状态 义务⑦)
_emit_composition_effects("hero", ri)
_emit_composition_effects("enemy", ri)
# ④ 道具段(窗口选定序逐件生效;逐件动态守卫)
for idx in selection:
_apply_selected_item(int(idx), ri)
# ⑤ 战斗段(调用序钉死 query_attack → apply_damage → advance;hero hp=0 短路)
# ③ 战斗段(调用序钉死 query_attack → apply_damage → advance;hero hp=0 短路)
_combat_segment(ri)
# ⑥ 衰减段(恰一次恒执行含末轮——先兑付后衰减)
# ④ 衰减段(恰一次恒执行含末轮——先兑付后衰减)
var k: int = _status_effects.decay_round()
_emit_row("decay", "", Vector2i(-1, -1), ri, "状态衰减", k, {}, "")
# ⑦ 判定段(防御性优先级 hero 先判——双死结构不可达的纵深防御)
# ⑤ 判定段(防御性优先级 hero 先判——双死结构不可达的纵深防御)
var verdict := "continue"
if int(_enemy_system.hero_unit().get("hp", 0)) <= 0:
verdict = "defeat"
@@ -298,52 +242,97 @@ func _finish_settlement(selection: Array) -> void:
_settling = false
# ---- 授予段 helpers ----
# ---- 授予段 helpers(极性路由)----
# snap_override 非空 = 冻结快照授予(轮内刷新批——缓冲期身份,绕过现行 mapping/_granted)
func _grant_cell(cell: Vector2i, ri: int, source_class: String, snap_override = null) -> void:
var snap = snap_override if snap_override != null else _reward_economy.grant(cell)
if not (snap is Dictionary):
return
if snap.category == "buff":
var ok: bool = _status_effects.apply_effect(snap.effect_id, {"source_class": source_class, "origin_cell": cell, "round_index": ri})
if ok:
var row: Dictionary = db.status_effect_row(snap.effect_id)
var pool := _pool_effective(snap.effect_id)
_emit_row("effect", source_class, cell, ri, String(row.display_name), int(row.magnitude), {"effect_id": snap.effect_id, "pool_value": pool})
else:
_emit_row("reward_ineffective", source_class, cell, ri, "奖励未生效(满层)", 0, {"batch_key": String(snap.effect_id)})
elif _resolve_instant(snap, source_class, cell, ri) != "not_instant":
pass # 即时类:授予段当场兑付(兑付行/零兑付未生效行均由 _resolve_instant 自记——2026-09-11 两类奖励裁决)
else:
_reward_economy.stock(snap, cell, ri)
_emit_row("item", source_class, cell, ri, "%s 入库" % snap.display_name, int(snap.power), {})
# 「动作 × 格型」矩阵的执行面:snap = 缓冲期冻结身份快照(cell_type = 该格装板期摇定的类型身份),
# polarity 由动作决定(开安全格/标中旗 = positive,开雷格/标错旗 = negative),
# 兑付行 = 奖励字典(类型, 极性)行。返回 true = percent 行实际生效(flat 行恒 false——兑付结果由行协议承载)。
func _grant_routed(snap: Dictionary, polarity: String, source_class: String, cell: Vector2i, ri: int, highlight: String = "", label_suffix: String = "") -> bool:
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)
return _dispatch_row(row, source_class, cell, ri, highlight, label_suffix)
# 即时类当场兑付(2026-09-11 两类奖励裁决):instant_aoe = 全体存活敌各 power 点;
# instant_heal = 主角 +power(clamp,满血 → "奖励未生效"行)。
# 返回 "redeemed" = 实际有效兑付 / "zero" = 零兑付(奖励未生效行已记)/ "not_instant" = 非即时类(道具类走库存)。
# 零兑付必须可区分:完美排雷高光行只记实际有效兑付,首份即零兑付则高光行不发(战斗结算 授予段规则)。
func _resolve_instant(snap: Dictionary, source_class: String, cell: Vector2i, ri: int, suffix: String = "", highlight: String = "") -> String:
if snap.category == "instant_aoe":
# 兑付形态分派: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 行兑付: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)
var ok: bool = _status_effects.apply_effect(eff, {"source_class": source_class, "origin_cell": cell, "round_index": ri})
if ok:
var srow: Dictionary = db.status_effect_row(eff)
_emit_row("effect", source_class, cell, ri, String(srow.display_name) + label_suffix, int(srow.magnitude), {"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
# flat 行兑付(负向恒即时,无库存语义):
# blast 正向 = 全体存活敌各 power 点(无敌 → 奖励未生效行);blast 负向 = 敌方伏击 +stacks 层(cap/溢出行协议不变);
# heal 正向 = 主角回复 clamp(满血 → 奖励未生效行);heal 负向 = 主角直伤 power 点(不经增益状态;归零 → 战斗段短路/判定段判负)。
# 返回 "redeemed" = 实际有效兑付 / "zero" = 零兑付(行已自记)——清图高光只记实际有效兑付。
func _apply_flat_row(row: Dictionary, source_class: String, cell: Vector2i, ri: int, highlight: String = "", label_suffix: String = "") -> String:
var ctype := String(row.cell_type)
var power := int(row.power)
var display := String(row.display_name) + label_suffix
if ctype == "blast":
if String(row.get("polarity", "positive")) == "negative":
return _apply_ambush_grant(row, source_class, cell, ri, highlight, label_suffix)
var alive: Array = _enemy_system.query_alive("enemy")
if alive.is_empty():
_emit_row("reward_ineffective", source_class, cell, ri, "%s 未生效(无存活敌人)%s" % [snap.display_name, suffix], 0, {"batch_key": "instant_aoe"}, highlight)
_emit_row("reward_ineffective", source_class, cell, ri, "%s 未生效(无存活敌人)" % display, 0, {"batch_key": String(row.reward_id)}, highlight)
return "zero"
for u in alive:
_enemy_system.apply_damage(u, int(snap.power)) # 恒定表值,逐个;受击即死——死亡即不动手
_emit_row("damage", source_class, cell, ri, "%s → 全体敌人 ×%d%s" % [snap.display_name, alive.size(), suffix], int(snap.power), {"target": "all_enemies", "count": alive.size()}, highlight)
_enemy_system.apply_damage(u, power) # 恒定表值,逐个;受击即死——死亡即不动手
_emit_row("damage", source_class, cell, ri, "%s → 全体敌人 ×%d" % [display, alive.size()], power, {"target": "all_enemies", "count": alive.size()}, highlight)
return "redeemed"
if snap.category == "instant_heal":
if int(_enemy_system.hero_unit().hp) >= int(_enemy_system.hero_unit().max_hp):
_emit_row("reward_ineffective", source_class, cell, ri, "%s 未生效(满血)%s" % [snap.display_name, suffix], 0, {"batch_key": "instant_heal"}, highlight)
if ctype == "heal":
if String(row.get("polarity", "positive")) == "negative":
_enemy_system.apply_damage(_enemy_system.hero_unit(), power)
_emit_row("damage", source_class, cell, ri, "%s → 主角" % display, power, {"target": "hero"}, highlight)
return "redeemed"
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"
var healed: int = _enemy_system.heal(_enemy_system.hero_unit(), int(snap.power)) # clamp 归 战斗结算
_emit_row("heal", source_class, cell, ri, "%s 回复%s" % [snap.display_name, suffix], healed, {"target": "hero"}, highlight)
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"
return "not_instant"
_fatal("flat 行未知类型: %s" % ctype)
return "zero"
# blast 负向:敌方伏击 +stacks 层(伏击池 cap 与"溢出未生效"行协议不变——敌人系统 F3 逐层守恒,
# 多源共享同一池值;单结算内先到先占 cap,超出部分逐层记溢出行)
func _apply_ambush_grant(row: Dictionary, source_class: String, cell: Vector2i, ri: int, highlight: String = "", label_suffix: String = "") -> String:
var requested: int = maxi(1, int(row.get("stacks", 1)))
var remaining_cap: int = maxi(0, _enemy_cap - _status_effects.pool_layers("ambush"))
var plan: Dictionary = _enemy_system.ambush_grant_plan(requested, remaining_cap)
for i in range(int(plan.granted)):
_status_effects.apply_effect("ambush", {"source_class": source_class, "origin_cell": cell, "round_index": ri})
if int(plan.granted) > 0:
_emit_row("effect", source_class, cell, ri, "敌方伏击 +%d 层%s" % [int(plan.granted), label_suffix], int(plan.granted), {"effect_id": "ambush", "pool_value": _status_effects.pool_layers("ambush") * _m_amb}, highlight)
for i in range(int(plan.overflow)):
_emit_row("overflow", source_class, cell, ri, "伏击溢出未生效", 0, {"batch_key": "ambush_overflow"})
return "redeemed" if int(plan.granted) > 0 else "zero"
# 旗判定批:标中 = 该雷格资源的正向兑付(每雷实例一次;高光 = 强制检查点);
# 标空 = "标旗未中"归因行 + 该安全格资源的负向兑付(同键去重——每格实例一次)。
func _judge_flag(evt: Dictionary, ri: int) -> void:
var cell: Vector2i = evt.cell
if evt.hit:
@@ -351,69 +340,28 @@ func _judge_flag(evt: Dictionary, ri: int) -> void:
return # 去重跳过(资格以雷格实例为键——排雷玩法 诚实重复上报的对侧);零明细行
_flag_consumed[cell] = true
_perfect_hit_true += 1
_charge_counts["flag_hit"] = int(_charge_counts.get("flag_hit", 0)) + 1 # 充能计数(标中旗 = 奖励触发源;与 weaken 消费同键,每雷实例一次——忘拔旗不重复充能)
var ok: bool = _status_effects.apply_effect("weaken", {"source_class": "flag_hit", "origin_cell": cell, "round_index": ri})
if ok:
var row: Dictionary = db.status_effect_row("weaken")
_emit_row("effect", "flag_hit", cell, ri, String(row.display_name), int(row.magnitude), {"effect_id": "weaken", "pool_value": _pool_effective("weaken")}, "flag_hit")
else:
_emit_row("reward_ineffective", "flag_hit", cell, ri, "奖励未生效(满层)", 0, {"batch_key": "weaken"})
_charge_counts["flag_hit"] = int(_charge_counts.get("flag_hit", 0)) + 1 # 充能计数(标中旗 = 奖励触发源;每雷实例一次——忘拔旗不重复充能)
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 # 每雷格实例一次去重(防逐轮惩罚轰炸)
return # 每格实例一次去重(归因行与负向兑付同键——防逐轮惩罚轰炸)
_flag_miss_seen[cell] = true
var hl := ""
if _perfect_hit_false == 1:
hl = "perfect_broken" # 本板完美谓词已破的归因锚
_emit_row("flag_miss", "flag_miss", cell, ri, "标旗未中", 0, {}, hl)
_apply_miss_backlash(cell, ri)
# 标错惩罚(2026-09-12 用户拍板:随机给敌方一个增伤/buff,或我方 debuff;时效 1 结算——用户拍板"短点,比如 1 回合")。
# 随机载体 = 坐标×板序号确定性派生(零新流、同种子可回放;正式侧候选 = RngService 新增封闭流——待拍板,
# 见 design/research/lore-nest-metaphor-2026-09-12.md ③ 边界提醒)。三池项:enrage(敌增伤)/ ambush×2(敌 buff)/ off_guard(我方 debuff)。
func _apply_miss_backlash(cell: Vector2i, ri: int) -> void:
var kind: int = (absi(cell.x) * 7 + absi(cell.y) * 13 + _board_seq * 5) % 3
var src: Dictionary = {"source_class": "flag_miss", "origin_cell": cell, "round_index": ri}
match kind:
0:
if _status_effects.apply_effect("enrage", src):
var row: Dictionary = db.status_effect_row("enrage")
_emit_row("effect", "flag_miss", cell, ri, "反噬·%s(敌方)" % String(row.display_name), int(row.magnitude), {"effect_id": "enrage", "pool_value": _pool_effective("enrage")})
else:
_emit_row("reward_ineffective", "flag_miss", cell, ri, "反噬未生效(满层)", 0, {"batch_key": "enrage"})
1:
var granted := 0
for i in range(2):
if _status_effects.apply_effect("ambush", src):
granted += 1
if granted > 0:
_emit_row("effect", "flag_miss", cell, ri, "反噬·敌方伏击 +%d 层" % granted, granted, {"effect_id": "ambush", "pool_value": _status_effects.pool_layers("ambush") * _m_amb})
else:
_emit_row("reward_ineffective", "flag_miss", cell, ri, "反噬未生效(满层)", 0, {"batch_key": "ambush"})
2:
if _status_effects.apply_effect("off_guard", src):
var row2: Dictionary = db.status_effect_row("off_guard")
_emit_row("effect", "flag_miss", cell, ri, "反噬·%s(我方)" % String(row2.display_name), int(row2.magnitude), {"effect_id": "off_guard", "pool_value": _pool_effective("off_guard")})
else:
_emit_row("reward_ineffective", "flag_miss", cell, ri, "反噬未生效(满层)", 0, {"batch_key": "off_guard"})
func _convert_ambush(mine_cells: Array, ri: int) -> void:
# 敌人系统 义务②⑥:预截流计划面 → 前 granted 颗逐颗转化(事件规范序);每颗雷恰一次
var plan: Dictionary = _enemy_system.ambush_grant_plan(mine_cells.size(), _enemy_cap)
for i in range(plan.granted):
var cell: Vector2i = mine_cells[i]
var ok: bool = _status_effects.apply_effect("ambush", {"source_class": "mine_hit", "origin_cell": cell, "round_index": ri})
if ok:
var layers := _status_effects.pool_layers("ambush")
_emit_row("effect", "mine_hit", cell, ri, "伏击", _m_amb, {"effect_id": "ambush", "pool_value": layers * _m_amb})
for i in range(plan.granted, plan.granted + plan.overflow):
var cell: Vector2i = mine_cells[i]
_emit_row("overflow", "mine_hit", cell, ri, "伏击溢出未生效", 0, {"batch_key": "ambush_overflow"})
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 # 轮内刷新换来的新图不发清图奖励(行动经济/战斗结算 明文——刷新图不是清图挣来的)
@@ -421,99 +369,42 @@ func _grant_clear_reward(ri: int) -> void:
if not (snap is Dictionary):
return
var perfect: bool = _perfect_hit_false == 0 and _perfect_hit_true >= 1
# 首份兑付(first_ok = 实际有效兑付——零兑付/被拒均不算)
var first_ok := _grant_clear_identity(snap, ri)
# 完美排雷:真实双份交付(第二份按身份类型走真实兑付——即时类重走 _resolve_instant;
# 增益类重授容器;道具类入库。grant_clear 本身仍恰一次)
var first_ok := _grant_clear_identity(snap, ri, "(清图奖励)")
var second_ok := false
if perfect and first_ok:
if snap.category == "buff":
second_ok = _status_effects.apply_effect(snap.effect_id, {"source_class": "clear_reward", "origin_cell": Vector2i(-1, -1), "round_index": ri})
if second_ok:
var row: Dictionary = db.status_effect_row(snap.effect_id)
_emit_row("effect", "clear_reward", Vector2i(-1, -1), ri, "%s(完美第二份)" % String(row.display_name), int(row.magnitude), {"effect_id": snap.effect_id, "pool_value": _pool_effective(snap.effect_id)})
else:
_emit_row("reward_ineffective", "clear_reward", Vector2i(-1, -1), ri, "完美第二份未生效(满层)", 0, {"batch_key": String(snap.effect_id)})
else:
var res2 := _resolve_instant(snap, "clear_reward", Vector2i(-1, -1), ri, "(完美第二份)")
if res2 == "redeemed":
second_ok = true
elif res2 == "not_instant":
var s2 = _reward_economy.stock(snap, Vector2i(-1, -1), ri)
second_ok = s2 is Dictionary
if second_ok:
_emit_row("item", "clear_reward", Vector2i(-1, -1), ri, "%s 入库(完美第二份)" % snap.display_name, int(snap.power), {})
else:
_emit_row("reward_ineffective", "clear_reward", Vector2i(-1, -1), ri, "完美第二份未生效(入库被拒)", 0, {"batch_key": String(snap.reward_id)})
# res2 == "zero":零兑付的奖励未生效行已由 _resolve_instant 自记
second_ok = _grant_clear_identity(snap, ri, "(完美第二份)")
if perfect and first_ok:
# 高光行 value = 实际有效兑付份数(禁记名义值;首份满层零兑付 → 降级不发)
_emit_row("highlight", "clear_reward", Vector2i(-1, -1), ri, "完美排雷!清图奖励 ×2", (2 if second_ok else 1), {}, "perfect_sweep")
func _grant_clear_identity(snap: Dictionary, ri: int) -> bool:
if snap.category == "buff":
var ok: bool = _status_effects.apply_effect(snap.effect_id, {"source_class": "clear_reward", "origin_cell": Vector2i(-1, -1), "round_index": ri})
if ok:
var row: Dictionary = db.status_effect_row(snap.effect_id)
_emit_row("effect", "clear_reward", Vector2i(-1, -1), ri, String(row.display_name), int(row.magnitude), {"effect_id": snap.effect_id, "pool_value": _pool_effective(snap.effect_id)}, "clear_reward_h2")
return true
_emit_row("reward_ineffective", "clear_reward", Vector2i(-1, -1), ri, "清图奖励未生效(满层)", 0, {"batch_key": snap.effect_id})
return false
var res := _resolve_instant(snap, "clear_reward", Vector2i(-1, -1), ri, "(清图奖励)", "clear_reward_h2")
if res != "not_instant":
return res == "redeemed" # 即时类零兑付(无存活敌/满血)= 未生效——首份即零兑付则高光行不发
var s = _reward_economy.stock(snap, Vector2i(-1, -1), ri)
if not (s is Dictionary):
_emit_row("reward_ineffective", "clear_reward", Vector2i(-1, -1), ri, "清图奖励未生效(入库被拒)", 0, {"batch_key": String(snap.reward_id)})
return false
_emit_row("item", "clear_reward", Vector2i(-1, -1), ri, "%s 入库(清图奖励)" % snap.display_name, int(snap.power), {}, "clear_reward_h2")
return true
# ---- 窗口 / 道具段 ----
func _apply_selected_item(window_index: int, ri: int) -> void:
if window_index < 0 or window_index >= _window_items.size():
return
var item: Dictionary = _window_items[window_index]
if item.category == "heal_pack":
# 逐件动态守卫:前件推满后本件拦截(不烧包——战斗结算 不调 use)
if int(_enemy_system.hero_unit().hp) >= int(_enemy_system.hero_unit().max_hp):
_emit_row("use_rejected", "item", item.source_cell, ri, "%s 被拒(满血)" % item.display_name, 0, {})
return
var receipt = _reward_economy.use(item.index)
if not (receipt is Dictionary):
return
var healed: int = _enemy_system.heal(_enemy_system.hero_unit(), receipt.power) # clamp 归 战斗结算
_emit_row("heal", "item", receipt.source_cell, ri, "%s 回复" % item.display_name, healed, {"target": "hero"})
else:
# 伤害道具:恒定表值不吃修正;零效果判定读 敌人系统 存活集(照常消耗——E10)
if _enemy_system.query_alive("enemy").is_empty():
var receipt0 = _reward_economy.use(item.index)
if not (receipt0 is Dictionary):
return
_emit_row("consumed_ineffective", "item", receipt0.source_cell, ri, "已消耗未生效(无存活敌人)", 0, {})
return
var receipt = _reward_economy.use(item.index)
if not (receipt is Dictionary):
return
var target = _enemy_system.default_target()
var killed_before: bool = not target.alive
_enemy_system.apply_damage(target, receipt.power)
var killed: bool = not killed_before and not target.alive
_emit_row("damage", "item", receipt.source_cell, ri, "%s 命中" % item.display_name, receipt.power, {"target": target.display_name, "killed": killed})
# 清图奖励 = 板级独立对象(不属于任何一格):兑付形态按其 cell_type 的正向字典形态走,
# 行值全部取快照(身份批 roll 值)。返回实际有效兑付。
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) == "redeemed"
# ---- 战斗段 ----
func _combat_segment(ri: int) -> void:
var L := _status_effects.pool_layers("ambush") # 授予段后的当轮值;池共享不消耗
# ⓪ 主角先手攻击(基础攻击恒存在——2026-09-11 用户裁决;先于敌方行动)。
# ⓪ 主角先手攻击(基础攻击恒存在——2026-09-11 用户裁决;先于敌方行动;授予段负向直伤致死 → 致电短路不先手)。
# 三段伤害构成(2026-09-12 用户拍板;2026-09-15 命名统一批 + 攻击类型/奖励抽象分离批):普通(atk×(1+attack_up),不吃克制)
# + 穿刺/魔法(由充能供给——充能按攻击类型分桶,谁能充/充哪个桶归奖励定义 charge_grants)。攻击类型只管查甲效矩阵哪一行,与来源无关。
var hero_alive: bool = int(_enemy_system.hero_unit().get("hp", 0)) > 0
var hero_target = _enemy_system.default_target()
if hero_target != null:
if hero_alive and hero_target != null:
var armor: String = String(hero_target.get("armor_type", "light"))
var up := clampi(_pool_effective("attack_up"), int(db.settlement.pool_clamp_lo), int(db.settlement.pool_clamp_hi))
var points := _charge_points() # {攻击类型: 充能点数}——奖励定义投递的结果(本段不认识"开格/标雷")
@@ -658,7 +549,6 @@ func _emit_settlement_snapshot(verdict: String) -> void:
"enemies": enemies,
"buffs": buffs,
"ambush_layers": _status_effects.pool_layers("ambush"),
"inventory_in_stock": _reward_economy.in_stock_count(),
"round_facts": {"path": _facts_path, "safe": safe_n, "mines": mines_hit, "flag_events": _facts_flags.size(), "charge_counts": _charge_counts.duplicate(), "charge_points": _charge_points()},
})
@@ -704,15 +594,14 @@ func player_request_refresh() -> bool:
if not action_economy.request_refresh_active():
return false
_refresh_used_this_round += 1
_reward_economy.retire_mapping() # 旧图批次退役(奖励道具经济 Ready→retired;E6 新批重置前提)
_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
# 库存关卡域(2026-09-11 关内延续):battle_over 不再清库存——跨战斗延续,关卡末经 level_clear() 清空;
# 映射随战斗结束退役(下一场战斗 = 新板新 scope 批,roll_board 相位守卫的前提)
# 映射随战斗结束退役(地图战斗资源 义务④——下一场战斗 = 新板新 scope 批,roll_board 相位守卫的前提)
var hero_hp_left := int(_enemy_system.hero_unit().get("hp", 0)) # despawn 前捕获(信号载荷供呈现层"HP 带入下一层")
_reward_economy.retire_mapping()
_enemy_system.despawn_all()
@@ -720,7 +609,7 @@ func _battle_over(result: String) -> void:
action_economy.notify_combat_end() # → Idle,RAP 清零
db.set_reload_gate(false)
_reset_round_facts()
battle_finished.emit({"result": result, "hero_hp": hero_hp_left, "remaining_items": _reward_economy.in_stock_count()})
battle_finished.emit({"result": result, "hero_hp": hero_hp_left})
# ---- 呈现状态面(E1 封装收口 2026-09-13):呈现层 / headless 自动玩家的唯一读取出口——
@@ -728,7 +617,7 @@ func _battle_over(result: String) -> void:
# 禁再链式穿透 settlement.enemy_system.hero 一类路径)。载荷 schema 成文(code-conventions 2.1):
# round / battle_result / phase / ap / rap / can_request_refresh / hero{hp,max_hp,atk,alive} /
# enemies[{order,display_name,hp,max_hp,enemy_id,armor_type,beat,ring_size,ring_index,ambush_layers,alive}] /
# ambush_layers / hero_effects[](增益状态 query_active 投影) / inventory[](奖励道具经济 query_inventory 投影) / in_stock
# ambush_layers / hero_effects[](增益状态 query_active 投影)
func presentation_snapshot() -> Dictionary:
var enemies: Array = []
var all: Array = _enemy_system.all_enemies()
@@ -763,8 +652,6 @@ func presentation_snapshot() -> Dictionary:
"enemies": enemies,
"ambush_layers": ambush,
"hero_effects": _status_effects.query_active("hero"),
"inventory": _reward_economy.query_inventory(),
"in_stock": _reward_economy.in_stock_count(),
}
@@ -773,12 +660,7 @@ func is_battle_live() -> bool:
return battle_result == "" and not _enemy_system.hero_unit().is_empty()
# 库存余量(层间过渡文案 / 终局清库反馈)
func stock_count() -> int:
return _reward_economy.in_stock_count()
# 奖励身份解析(组合根注入 BoardView 徽章回调的只读代理——Ready 相位守卫归 奖励道具经济)
# 奖励身份解析(组合根注入 BoardView 徽章回调的只读代理——Ready 相位守卫归 地图战斗资源)
func query_reward_identity(cell: Vector2i):
return _reward_economy.query_identity(cell)
@@ -789,7 +671,6 @@ func diagnostic_snapshot() -> Dictionary: return {
"battle_result": battle_result,
"round": round_index,
"settling": _settling,
"awaiting_window": _awaiting_window,
"refresh_used_round": _refresh_used_this_round,
"board_seq": _board_seq,
"facts_path": _facts_path,
@@ -799,20 +680,12 @@ func diagnostic_snapshot() -> Dictionary: return {
"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_stock": [_reward_economy.in_stock_count()],
"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,
}
# 关卡结束编排(关卡节点地图/冒险结构 关卡结构落位前由上层显式调用——2026-09-11 关内延续):
# 清空库存并返回快照计数("剩余道具 ×N 随关卡结束清空"反馈行 / 折算结构位的数据面)
func level_clear() -> Dictionary:
var remaining := _reward_economy.in_stock_count()
_reward_economy.clear_inventory()
return {"remaining": remaining}
func _reset_round_facts() -> void:
_facts_reveals.clear()
_facts_flags.clear()
@@ -820,7 +693,7 @@ func _reset_round_facts() -> void:
_charge_counts.clear() # 充能计数随轮 facts 重置(每轮重新攒——结算段按奖励定义折算当轮点数)
# ---- 行协议(Core Rule 14:公共字段 + 行类型特有字段)----
# ---- 行协议(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)
@@ -16,7 +16,6 @@ const TABLE_MANIFEST := {
"enemy": "enemy.tres",
"hero": "hero.tres",
"reward": "reward.tres",
"reward_config": "reward_config.tres",
"settlement": "settlement.tres",
"level": "level.tres",
"difficulty": "difficulty.tres",
@@ -29,7 +28,6 @@ var board_gen: BoardGenTable
var enemy: EnemyTable
var hero: HeroTable
var reward: RewardTable
var reward_config: RewardConfigTable
var settlement: SettlementTable
var level: LevelTable
var difficulty: DifficultyTable
@@ -99,7 +97,6 @@ func load_tables(root: String, collect_only: bool) -> bool:
enemy = loaded.get("enemy")
hero = loaded.get("hero")
reward = loaded.get("reward")
reward_config = loaded.get("reward_config")
settlement = loaded.get("settlement")
level = loaded.get("level")
difficulty = loaded.get("difficulty")
@@ -110,82 +107,27 @@ func load_tables(root: String, collect_only: bool) -> bool:
func _cross_validate(loaded: Dictionary) -> void:
var st: StatusEffectTable = loaded.get("status_effects")
var rt: RewardTable = loaded.get("reward")
var rc: RewardConfigTable = loaded.get("reward_config")
var st_tab: SettlementTable = loaded.get("settlement")
if st == null or rt == null or rc == null or st_tab == null:
if st == null or rt == null or st_tab == null:
return # 单表校验已报错,不连锁
var effect_ids := {}
for row in st.rows:
effect_ids[row.get("effect_id", "")] = true
# ① RewardTable effect_id 悬空(首个引用行已回填 → 激活)
# ① RewardTable effect_id 悬空(percent 行引用 StatusEffectTable——悬空加载致命)
for row in rt.rows:
var eff: String = row.get("effect_id", "")
if eff != "" and not effect_ids.has(eff):
push_diag("error", "dangling_effect", "RewardTable %s 引用不存在的 effect_id: %s" % [row.get("reward_id"), eff])
# ② 池空护栏:safe_cell 池须 ≥1 活行;活 buff 行集非空
var safe_rows: Array = []
var clear_rows: Array = []
for row in rt.rows:
if int(row.get("weight", 0)) <= 0:
continue
if row.get("pool_tag", "") == "safe_cell":
safe_rows.append(row)
elif row.get("pool_tag", "") == "clear_default":
clear_rows.append(row)
if safe_rows.is_empty():
push_diag("error", "pool_empty_safe", "safe_cell 池无活行")
var has_buff := false
for row in safe_rows:
if row.get("category", "") == "buff":
has_buff = true
if safe_rows.size() > 0 and not has_buff:
push_diag("error", "no_live_buff_row", "safe_cell 池零活 buff 行——tier_min_buff 无定义")
# ③ clear_default 恒正当量(行动经济 条款④——2026-09-11 供给控制器批:清图槽改即时类一次性伤害,正当量含即时类)
var clear_value_ok := false
for row in clear_rows:
if row.get("category", "") == "buff" and String(row.get("effect_id", "")) != "":
clear_value_ok = true
elif row.get("category", "") in ["damage_item", "heal_pack", "instant_aoe", "instant_heal"] and int(row.get("power", 0)) >= 1:
clear_value_ok = true
if not clear_value_ok:
push_diag("error", "clear_no_value", "clear_default 池无正当量活行(清图奖励 > 0 违例)")
# ④ floor_supply_missing(F5 护栏 i)——k_dmg ≥ 1 时才检查(供给控制器批后 k_dmg=0 休眠)
if rc.k_dmg >= 1:
var supply_ok := false
for row in safe_rows:
if row.get("category", "") == "damage_item" and int(row.get("power", 0)) >= rc.p_min:
supply_ok = true
if not supply_ok:
push_diag("error", "floor_supply_missing", "K_dmg ≥ 1 但 safe_cell 池无 power ≥ P_min 活刀行")
# ⑤ no_damage_row(F5 护栏 ii——零伤害世界后闸;2026-09-11 供给控制器批:扫描域扩至全池活行,
# 可选类退出格子池后伤害来源在 active_inject 池)
if rc.k_dmg == 0:
var any_knife := false
for row in rt.rows:
if int(row.get("weight", 0)) > 0 and row.get("category", "") == "damage_item":
any_knife = true
if not any_knife:
push_diag("error", "no_damage_row", "K_dmg = 0 且全池零活刀行——零伤害世界后闸")
# ⑨ active_inject 池护栏(可选类供给控制器批):非可选类拒绝;池须有活行(否则战斗首件保证落空)
var inject_live := 0
for row in rt.rows:
if row.get("pool_tag", "") == "active_inject":
if int(row.get("weight", 0)) > 0:
inject_live += 1
if row.get("category", "") not in ["damage_item", "heal_pack"]:
push_diag("error", "inject_pool_foreign", "active_inject 池混入非可选类行: %s" % row.get("reward_id"))
if inject_live == 0:
push_diag("error", "inject_pool_empty", "active_inject 池无活行(战斗首件保证必落空)")
# ⑥ SettlementTable.effect_pool_map 悬空
# ② SettlementTable.effect_pool_map 悬空
for eff in st_tab.effect_pool_map.keys():
if not effect_ids.has(eff):
push_diag("error", "poolmap_dangling", "SettlementTable.effect_pool_map 悬空 effect_id: %s" % eff)
# ⑦ source_label_map 键集封闭性(键 = 增益状态 source_class ∪ {item, enemy_action, flag_miss})
var required_sources := ["safe_cell", "flag_hit", "mine_hit", "clear_reward", "item", "hero_attack", "enemy_action", "flag_miss"]
# ③ source_label_map 键集封闭性(键 = 增益状态 source_class ∪ {enemy_action, hero_attack, flag_miss}——2026-09-15 格面资源批删 item 键)
var required_sources := ["safe_cell", "flag_hit", "mine_hit", "clear_reward", "hero_attack", "enemy_action", "flag_miss"]
for s in required_sources:
if not st_tab.source_label_map.has(s):
push_diag("error", "segorder_violation", "source_label_map 缺必需键: %s" % s)
# ⑧ LevelTable 节点悬空引用(battle/boss 的 (L,node_depth) 须在 board_gen 与 enemy 两表有精确行——
# ④ LevelTable 节点悬空引用(battle/boss 的 (L,node_depth) 须在 board_gen 与 enemy 两表有精确行——
# 线性推进器按精确深度行跑,floor 兜底静默降难度属调参事故,加载期直接拦)
var lv: LevelTable = loaded.get("level")
var bg: BoardGenTable = loaded.get("board_gen")
@@ -2,8 +2,10 @@
# Validation Question: 线性第一关(战×2→道具→战×2→恢复→Boss)能否承载"数值提升→通关期望改善"的元进度验证
# Date: 2026-09-11
# 关卡节点地图(provisional 线性形态——节点链来自 LevelTable,路径分支/形态定稿权归 关卡节点地图):
# 关卡域生命周期唯一编排者——共享 奖励道具经济 容器跨战斗延续(2026-09-11 关内延续裁决)、HP 跨战斗携带、
# 奖励点/恢复点节点兑付、关卡末 level_clear 清库。Core 层零 UI 依赖(headless 可跑完整关卡——数据配置 Rule 7)。
# 关卡域生命周期唯一编排者——HP 跨战斗携带、恢复点节点兑付、关卡末收束。
# 2026-09-15 格面资源批:可选类道具与库存整体删除——奖励点节点内容随功能道具渠道(P1,渠道未定)悬置为
# 直通节点(零兑付);战斗各自持有 地图战斗资源 容器(映射随战斗退役,无跨战斗状态可延续)。
# Core 层零 UI 依赖(headless 可跑完整关卡——数据配置 Rule 7)。
# 测试专用:hero_atk_pct/hero_hp_pct 数值覆盖(调整板模拟天赋树——正式侧归元进度,切片不落盘)。
# E4 收口(2026-09-13):覆盖值经 encounter 显式传入战斗(不再写 db.hero 表——表值运行期不可变,
# 双基线同步/双写叠加 6→18→54 整类隐患随表写一并移除)。
@@ -11,21 +13,19 @@
class_name LevelRunner
extends RefCounted
signal node_entered(node: Dictionary, info: Dictionary) # info 按 type:battle/boss={} · reward={snapshot} · rest={healed,hp,max_hp}
signal node_entered(node: Dictionary, info: Dictionary) # info 按 type:battle/boss={} · reward={}(内容待拍板) · rest={healed,hp,max_hp}
signal step_row(row: Dictionary) # 转发当前 战斗结算 结算行
signal window_opened(items: Array) # 转发当前 战斗结算 道具窗口
signal round_started(round_index: int)
signal board_changed()
signal level_finished(payload: Dictionary) # {result, hp, node_index, node_type, remaining_items}
signal level_finished(payload: Dictionary) # {result, hp, node_index, node_type}
var db
var rng
var level_row: Dictionary = {}
var node_index := -1
var hero_hp := 0
# 协作对象私有(E1/E3 封装收口 2026-09-13):settlement/reward_economy 读取经 current_battle()/inventory() 成文出口
# 协作对象私有(E1/E3 封装收口 2026-09-13):settlement 读取经 current_battle() 成文出口
var _settlement = null # 当前战斗编排器(关卡结束置 null)
var _reward_economy = null # 关卡域共享 奖励道具经济 容器(库存跨战斗延续)
var hero_atk_pct := 0 # 测试面板覆盖值(切片专用)
var hero_hp_pct := 0
@@ -70,7 +70,6 @@ func start_level(level_index: int, seed_val: int) -> bool:
node_index = -1
level_result = ""
hero_hp = effective_max_hp()
_reward_economy = RewardEconomy.new(db, rng)
rng.new_adventure(seed_val) # 关卡起点 = 冒险起点(切片口径;跨关接续归 冒险结构)
_running = true
return _enter_next_node()
@@ -105,11 +104,9 @@ func _enter_next_node() -> bool:
"battle", "boss":
return _start_battle(node)
"reward":
var inst = _reward_economy.grant_node_active(Vector2i(-1, -1), 0)
if not (inst is Dictionary):
push_error("[关卡节点地图] 奖励点入库被拒")
return false
node_entered.emit(node, {"snapshot": inst.snapshot})
# 奖励点内容待拍板(格面资源批 2026-09-15:可选类道具删除,功能道具渠道 P1 未定——
# 关卡节点地图定稿批裁决;节点保留占位、零兑付直通)
node_entered.emit(node, {})
return true
"rest":
var healed := _apply_rest()
@@ -120,11 +117,10 @@ func _enter_next_node() -> bool:
func _start_battle(node: Dictionary) -> bool:
_settlement = BattleOrchestrator.new(db, rng, _reward_economy) # 共享 奖励道具经济——库存跨战斗延续(关内延续裁决)
_settlement = BattleOrchestrator.new(db, rng)
# core↔core 接线只许方法 Callable(ADR-0001 静态规则 S6——lambda 捕获 self 会延长
# RefCounted 生命周期乃至成环:settlement 的信号持捕获 runner 的 lambda,runner 又持 settlement)
_settlement.step_row.connect(_forward_step_row)
_settlement.window_opened.connect(_forward_window_opened)
_settlement.round_started.connect(_forward_round_started)
_settlement.board_changed.connect(_forward_board_changed)
_settlement.battle_finished.connect(_on_battle_finished)
@@ -142,10 +138,6 @@ func _forward_step_row(row: Dictionary) -> void:
step_row.emit(row)
func _forward_window_opened(items: Array) -> void:
window_opened.emit(items)
func _forward_round_started(ri: int) -> void:
round_started.emit(ri)
@@ -161,16 +153,12 @@ func _on_battle_finished(payload: Dictionary) -> void:
hero_hp = maxi(1, int(payload.get("hero_hp", 0))) # despawn 前捕获值(载荷自包含)
if String(node.get("type", "")) == "boss" or result == "defeat":
# Boss 胜利 = 关卡通关;任意战斗败北 = 关卡失败(Roguelike——冒险即终,重开归 冒险结构/元进度)
var left: int = _settlement.level_clear()["remaining"] # 库存随关卡结束清空(关内延续裁决③)
if _reward_economy.in_stock_count() != 0:
push_error("[关卡节点地图] level_clear 后库存非空(清库违例——fail-fast)")
return
level_result = "victory" if result == "victory" else "defeat"
var hp_left := hero_hp if result == "victory" else 0
_running = false
_settlement = null
level_finished.emit({"result": level_result, "hp": hp_left, "node_index": node_index,
"node_type": String(node.get("type", "")), "remaining_items": left})
"node_type": String(node.get("type", ""))})
return
_enter_next_node()
@@ -184,16 +172,12 @@ func _apply_rest() -> int:
return actual
# ---- 只读出口(E1/E3 封装收口:呈现层/工具经此读当前战斗与关卡库存,禁直读协作对象)----
# ---- 只读出口(E1/E3 封装收口:呈现层/工具经此读当前战斗,禁直读协作对象)----
func current_battle():
return _settlement
func inventory() -> Array:
return _reward_economy.query_inventory() if _reward_economy != null else []
# ---- 玩家输入转发(呈现层只与 runner 对话)----
# 奖励/恢复点确认继续(呈现层在覆盖层上点击"继续"后调用;自动模拟在 node_entered 回调内立即调用)
@@ -204,11 +188,6 @@ func continue_after_node() -> bool:
return _enter_next_node()
func submit_window_selection(selection: Array) -> void:
if _settlement != null:
_settlement.submit_window_selection(selection)
func player_request_refresh() -> bool:
if _settlement == null:
return false
@@ -94,12 +94,12 @@ func install_board(b: BoardData, during_round := false) -> Dictionary:
_spawn = {}
for c in b.spawn_cells():
_spawn[c] = true
# 初始展开:O₀ = R₀ 恰等(免费/计入清图/无奖励/永不触雷);板级一次跨轮不重放
# 初始展开:O₀ = R₀ 恰等(免费/计入清图/无格面资源/永不触雷);板级一次跨轮不重放
for c in _initial_expansion_order(b):
_revealed[c] = {"kind": "safe", "digit": b.digit_of(c)}
cell_revealed.emit({
"cell": c, "kind": "safe", "digit": b.digit_of(c),
"variant_ids": [], "channel": "initial", "reward_eligible": false,
"variant_ids": [], "channel": "initial", "resource": false,
})
_hero = b.spawn_center
return _outcome(true, "executed", "", "")
@@ -255,17 +255,19 @@ func _do_dig(c: Vector2i) -> Dictionary:
continue # 闭包可穿过已开区——已开格零事件(cell_revealed × |O′ ∖ O|)
var is_mine := _board.is_mine(rc)
var channel := "dig" if rc == c else "chain"
# 格面资源资格(2026-09-15 格面资源批):chain/dig 恒 true——安全格与雷格一视同仁(雷格亦携带,
# 负向兑付归 战斗结算 极性路由);initial(封锁格)恒 false。资源本体与极性不在本系统。
if is_mine:
_revealed[rc] = {"kind": "mine", "digit": -1}
cell_revealed.emit({
"cell": rc, "kind": "mine", "digit": -1,
"variant_ids": [], "channel": channel, "reward_eligible": false,
"variant_ids": [], "channel": channel, "resource": true,
})
else:
_revealed[rc] = {"kind": "safe", "digit": _board.digit_of(rc)}
cell_revealed.emit({
"cell": rc, "kind": "safe", "digit": _board.digit_of(rc),
"variant_ids": _board.variant_ids(rc), "channel": channel, "reward_eligible": true,
"variant_ids": _board.variant_ids(rc), "channel": channel, "resource": true,
})
if action_economy.phase != action_economy.PHASE_ACTIVE:
_emit_round_end_flags_and_payload()
@@ -1,51 +1,42 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 可选类供给能否按"格极稀 / 板≈0.78 / 战斗期望≈1(首件保证 + 指数递减)"三尺度被精确控制
# Date: 2026-09-10
# 奖励道具经济:install 期身份预分配(roll_board 恰一次、消费 reward_roll 封闭流恰一 scope 批)·
# 授予期零随机 · 库存关卡域(battle_over 全清)· 道具伤害恒定表值 · Ready 后只读。
# 2026-09-11 可选类供给控制器批(用户拍板):格子池只剩即时类;可选类经 RewardSupplyController 注入
# (战斗首件保证 + p0×decay^(j-1) 指数递减);旧 F5 格池刀保底(k_dmg)休眠;清图槽改一次性伤害。
# 2026-09-14 伤害量值区间批(用户拍板②):区间行(power_min/max 可选配对)在身份预分配批内追加取号
# (④ 步,既有取号序零扰动);快照 E14 口径不变——快照 power = roll 值,战斗结算/呈现零改动;授予期零随机不变。
# 切片登记:取号约定 = randi_range(0, W-1)(实现期 ADR 事实,切片内恒定)。
# 实现 GDD:design/archive/gdd-2026-09-14/b4-reward-item-economy.md(系统 ID B4·奖励道具经济——registry 追溯锚,命名规范见 CLAUDE.md)
# Validation Question: 每个非封锁格是否都携带可兑的格面资源身份,且同种子同操作逐位回放
# Date: 2026-09-10(2026-09-15 格面资源批结构性重写:库存/供给轴下线,格面资源身份面纯化)
# 地图战斗资源:格面资源的身份层——install 期身份预分配(roll_board 恰一次、消费 reward_roll 封闭流
# 恰一 scope 批:格批 + 清图批尾)· 授予期零随机 · Ready 后只读、无重掷 · 换图映射消亡。
# 安全格与雷格一视同仁(同一类型池、同一权重分布——雷安同池);封锁格不入摇号清单。
# 极性不在资源内:「动作 × 格型」矩阵的字典定义归 GDD、路由执行归 战斗结算 授予段
# (本类只提供 polarity_row 的(类型, 极性)→ 字典行查询面)。
# 切片登记:取号约定 = randi_range(0, W-1)(实现期 ADR 事实,切片内恒定);
# 区间行 power roll 在身份批内追加取号(伤害量值区间批——快照 power = roll 值,授予期零随机不变)。
# 实现 GDD:design/gdd/systems/战斗循环/地图战斗资源.md(v0.4 格面资源批——registry 追溯锚)
class_name RewardEconomy
extends RefCounted
var db
var rng
var _supply: RewardSupplyController # 可选类供给状态机(组合件——战斗域计数)
var _phase := "unrolled" # unrolled → ready → retired;clear_inventory 后 _inventory_cleared
var _inventory_cleared := false
var _phase := "unrolled" # unrolled → ready → retired
var _eligible: Array = [] # [{cell}] 行主序规范序
var _mapping := {} # Vector2i → reward_id
var _power_roll := {} # Vector2i → 区间行 roll 值(伤害量值区间批——身份批内追加取号)
var _mapping := {} # Vector2i → reward_id(正向行身份——格面资源类型由 cell_type 承载)
var _power_roll := {} # Vector2i → 区间行 roll 值(身份批内追加取号)
var _clear_power := -1 # 清图槽区间行 roll 值(-1 = 定值行)
var _granted := {} # Vector2i → 快照(重复受理幂等返回)
var _clear_identity := ""
var _clear_granted := false
var _clear_snapshot := {}
var _inventory: Array = [] # 逐件实例 {item_id, snapshot, source_cell, round_index, status}
func _init(db_ref, rng_ref) -> void:
db = db_ref
rng = rng_ref
_supply = RewardSupplyController.new(db.reward_config)
# ---- 战斗开始(战斗结算.begin_battle 编排调用——奖励道具经济 唯一调用方纪律不变)----
# 供给控制器战斗域计数清零;库存(关卡域)不受影响——关内延续照旧
func notify_battle_start() -> void:
_supply.notify_battle_start()
# ---- F1/F2/F5:install 期 roll(恰一次;Ready 相位再调拒绝)----
# ---- install 期 roll(恰一次;Ready 相位再调拒绝)----
# eligible = 全部非封锁格(安全格 + 雷格,战斗结算 install 编排供给;封锁格不入清单)
func roll_board(eligible: Array) -> bool:
# 相位守卫:Ready 再调 = double-roll 拒绝(E5);Retired(换图后)= 合法新批重置(E6 roll 半)
if _phase == "ready":
push_warning("[奖励道具经济] roll_board 相位守卫拒绝(E5——double-roll 编排违约显式暴露)")
push_warning("[地图战斗资源] roll_board 相位守卫拒绝(E5——double-roll 编排违约显式暴露)")
return false
if eligible.is_empty():
_fatal("roll_board 空资格清单(E1 兜底 fail-fast——排雷玩法 不变式④前置)")
@@ -56,7 +47,7 @@ func roll_board(eligible: Array) -> bool:
for e in eligible:
var c: Vector2i = e.cell
if seen.has(c):
push_warning("[奖励道具经济] 资格清单重复格已去重: %s" % str(c))
push_warning("[地图战斗资源] 资格清单重复格已去重: %s" % str(c))
continue
seen[c] = true
list.append({"cell": c})
@@ -67,22 +58,16 @@ func roll_board(eligible: Array) -> bool:
_clear_power = -1
_clear_granted = false
_clear_snapshot = {}
var stream: RandomNumberGenerator = rng.open_stream("reward_roll") # 恰一 scope 批(身份批 + 清图槽 + 注入判定/选格 + 区间行 power roll——追加式取号,既有序零扰动)
var safe_rows: Array = _pool_rows("safe_cell")
var stream: RandomNumberGenerator = rng.open_stream("reward_roll") # 恰一 scope 批(格批 + 清图批尾 + 区间行追加取号)
var cell_rows: Array = _rollable_rows() # cell 池正向行 weight>0(类型频次)
var clear_rows: Array = _pool_rows("clear_default")
var inject_rows: Array = _pool_rows("active_inject")
# ① 身份批:每资格格恰一即时类身份(可选类已退出格子池——供给控制器批)
# ① 身份批:每非封锁格恰一类型身份(雷安同池——同一行集同一权重分布)
var draft := {}
for e in list:
draft[e.cell] = weighted_pick(safe_rows, stream.randi_range(0, _pool_weight(safe_rows) - 1))
# ② 清图槽:一次性高额伤害(当场兑付类——不入库、不占可选类供给;2026-09-11 用户拍板)
draft[e.cell] = weighted_pick(cell_rows, stream.randi_range(0, _pool_weight(cell_rows) - 1))
# ② 清图槽批尾:同流同批判尾摇定清图奖励对象身份
var clear_rid := weighted_pick(clear_rows, stream.randi_range(0, _pool_weight(clear_rows) - 1))
# ③ 可选类供给注入(替代旧 F5 刀保底):首件保证 / p0×decay^(j-1) 指数递减——判定恰一取号
if _supply.should_inject(stream):
var target = list[stream.randi_range(0, list.size() - 1)] # 均匀随机(拍板③)
draft[target.cell] = weighted_pick(inject_rows, stream.randi_range(0, _pool_weight(inject_rows) - 1))
# ④ 伤害量值区间批(2026-09-14):身份定稿后逐格 roll power(区间行恰一取号,定值行零取号);
# 授予期零随机纪律不变——快照 E14 口径,战斗结算/呈现零改动
# ③ 区间行 power roll(身份定稿后逐格恰一追加取号,定值行零取号)
for cell in draft.keys():
var row: Dictionary = _row_by_id(draft[cell])
if row.has("power_min"):
@@ -96,31 +81,17 @@ func roll_board(eligible: Array) -> bool:
return true
# ---- 关卡节点奖励(2026-09-11 关卡扩展批——线性奖励点,正式归属 奖励道具经济/关卡节点地图 修订)----
# 奖励点 = 用户拍板的关卡节点类型:在本表全部可选类活行(damage_item / heal_pack,即"主动道具")
# 中 roll 一件并直接入库。消耗 reward_roll 恰一 scope(与 board install 批同流异 scope——流集合封闭不变)。
# cell=(-1,-1)/round_index=0 = 非格子来源标记(关卡节点授予,区别于安全格拾取的呈现面)。
func grant_node_active(cell: Vector2i, round_index: int) -> Dictionary:
var pool: Array = []
for row in db.reward.rows:
if int(row.weight) > 0 and (String(row.category) == "damage_item" or String(row.category) == "heal_pack"):
pool.append(row)
if pool.is_empty():
_fatal("节点奖励池为空(damage_item/heal_pack 零活行)")
return {}
var stream: RandomNumberGenerator = rng.open_stream("reward_roll")
var rid := weighted_pick(pool, stream.randi_range(0, _pool_weight(pool) - 1))
var row: Dictionary = _row_by_id(rid)
var rolled := -1
if row.has("power_min"):
rolled = stream.randi_range(int(row.power_min), int(row.power_max)) # 区间行恰一追加取号(伤害量值区间批)
var inst = stock(_snapshot(row, rolled), cell, round_index)
if inst is Dictionary:
_supply.notify_granted() # 节点奖励 = 可选类实际发放——与 grant() 同一入账点(playtest-2026-09-12 报告 Bug#3:绕过供给计数修正)
return inst
# ---- 查询面(Ready 只读;未开格恒可查——魔法道具 桥接结构前提)----
# 相位只读出口(诊断观测面——unrolled/ready/retired;消费方禁直读 _phase 下划线字段)
func phase() -> String:
return _phase
# ---- 查询面(Ready 只读)----
# 静默映射查询(批量工具用——零 E19 诊断;语义 = query_identity 非 null 的布尔投影)
func is_mapped(cell: Vector2i) -> bool:
return _phase == "ready" and _mapping.has(cell)
func query_identity(cell: Vector2i):
if _phase != "ready":
@@ -128,14 +99,15 @@ func query_identity(cell: Vector2i):
return null
var rid: String = _mapping.get(cell, "")
if rid == "":
_diag("query_identity 清单外坐标(E19——未分配非错误态)")
_diag("query_identity 清单外坐标(E19——未分配非错误态;封锁格不入清单)")
return null
var row: Dictionary = _row_by_id(rid)
return {"reward_id": rid, "category": String(row.category), "tier": int(row.tier)}
return {"reward_id": rid, "cell_type": String(row.cell_type), "tier": int(row.tier)}
# 身份完整快照(轮内刷新批次隔离用——facts 缓冲期捕获,授予期 mapping 可能已换批):
# 返回与 grant 相同结构的快照(display_name/power/effect_id 齐备),供 战斗结算 冻结授予直通
# 返回与 grant 相同结构的快照(cell_type/display_name/power/effect_id/stacks 齐备),
# 供 战斗结算 冻结授予直通
func query_full_snapshot(cell: Vector2i):
if _phase != "ready":
_diag("query_full_snapshot 非 Ready 相位")
@@ -146,7 +118,7 @@ func query_full_snapshot(cell: Vector2i):
return _snapshot(_row_by_id(rid), int(_power_roll.get(cell, -1)))
# ---- 受理面(战斗结算 编排期调用;E6 收口 2026-09-13:失败统一返回 null——此前 false/null 混用)----
# ---- 受理面(战斗结算 编排期调用;E6 收口 2026-09-13:失败统一返回 null)----
func grant(cell: Vector2i):
if _phase != "ready":
@@ -158,10 +130,7 @@ func grant(cell: Vector2i):
if _granted.has(cell):
_diag("grant 同格重复受理(E18 幂等兜底——受理恰一次归 战斗结算)")
return _granted[cell]
var row: Dictionary = _row_by_id(_mapping[cell])
var snap := _snapshot(row, int(_power_roll.get(cell, -1)))
if String(snap.category) == "damage_item" or String(snap.category) == "heal_pack":
_supply.notify_granted() # 发放即获取——首件保证/递减计数的唯一入账点(发放侧语义,满血拒"使用"不影响)
var snap := _snapshot(_row_by_id(_mapping[cell]), int(_power_roll.get(cell, -1)))
_granted[cell] = snap
return snap
@@ -179,86 +148,6 @@ func grant_clear():
return _clear_snapshot
func stock(snapshot: Dictionary, source_cell: Vector2i, round_index: int):
if _inventory_cleared:
_diag("stock 战斗已清库(E7)")
return null
var cat: String = String(snapshot.get("category", ""))
if cat != "damage_item" and cat != "heal_pack":
_diag("stock 仅受理道具类(E20): %s" % cat)
return null
var inst := {
"item_id": String(snapshot.get("reward_id", "")),
"snapshot": snapshot,
"source_cell": source_cell,
"round_index": round_index,
"status": "InStock",
}
_inventory.append(inst) # 数量无上限;枚举序 = stock 受理序(E21)
return inst
# 窗口选用消耗:零游戏态守卫(照常消耗——E10;满血/零效果守卫归 战斗结算)
func use(index: int):
if _inventory_cleared:
_diag("use 战斗已清库")
return null
if index < 0 or index >= _inventory.size():
_diag("use 句柄越界")
return null
var inst: Dictionary = _inventory[index]
if inst.status != "InStock":
_diag("use 已消耗句柄(E9 Used 不可逆)")
return null
inst.status = "Used"
return {
"item_id": inst.item_id,
"category": String(inst.snapshot.get("category", "")),
"power": int(inst.snapshot.get("power", 0)), # 授予时快照值(E14——非表读值)
"source_cell": inst.source_cell,
"round_index": inst.round_index,
}
func query_inventory() -> Array:
var out: Array = []
for i in _inventory.size():
var inst: Dictionary = _inventory[i]
out.append({
"index": i,
"item_id": inst.item_id,
"category": String(inst.snapshot.get("category", "")),
"display_name": String(inst.snapshot.get("display_name", "")),
"power": int(inst.snapshot.get("power", 0)),
"source_cell": inst.source_cell,
"round_index": inst.round_index,
"status": inst.status,
})
return out
func in_stock_count() -> int:
var n := 0
for inst in _inventory:
if inst.status == "InStock":
n += 1
return n
# 观测面(冒烟/调参工具——本战斗已实际获取的可选类件数)
func supply_granted_count() -> int:
return _supply.granted_count()
func clear_inventory() -> bool:
if _inventory_cleared:
_diag("clear_inventory 重复调用(幂等守卫)")
return false
_inventory.clear() # battle_over 全清不跨战斗
_inventory_cleared = true
return true
func retire_mapping() -> bool:
if _phase != "ready":
_diag("retire_mapping 非 Ready 相位(幂等受理族)")
@@ -271,7 +160,19 @@ func retire_mapping() -> bool:
return true
# ---- F6 当量供给(战斗结算 条款①对照实物输入)----
# ---- 极性路由字典面(战斗结算 授予段消费):(类型, 极性)→ 格面池字典行(拷贝出口)----
func polarity_row(cell_type: String, polarity: String) -> Dictionary:
for row in db.reward.rows:
if String(row.get("pool_tag", "")) == "cell" \
and String(row.get("cell_type", "")) == cell_type \
and String(row.get("polarity", "")) == polarity:
return row.duplicate() # E6:表行值拷贝出口——消费方禁改配置源
_fatal("极性字典缺行(五型×正负齐备为加载前提): %s %s" % [cell_type, polarity])
return {}
# ---- F6 当量供给(行动经济 条款①对照实物输入——全活行投影 + tier_min_buff)----
func equivalence_supply() -> Dictionary:
var live: Array = []
@@ -280,8 +181,12 @@ func equivalence_supply() -> Dictionary:
if int(row.weight) <= 0:
continue
live.append(row.duplicate()) # E6:表行值拷贝出口——消费方禁改配置源
if row.pool_tag == "safe_cell" and row.category == "buff":
tier_min_buff = mini(tier_min_buff, int(row.tier))
# tier_min_buff = 我侧增益(hero_offense/hero_defense 池的 percent 正向行——attack_up/defense_up)最低档
if String(row.get("pool_tag", "")) == "cell" and String(row.get("polarity", "")) == "positive" \
and String(row.get("effect_id", "")) != "":
var eff_row: Dictionary = db.status_effect_row(String(row.effect_id))
if not eff_row.is_empty() and String(eff_row.get("target_side", "")) == "hero":
tier_min_buff = mini(tier_min_buff, int(row.tier))
return {"live_rows": live, "tier_min_buff": tier_min_buff}
@@ -299,6 +204,15 @@ func weighted_pick(rows: Array, r: int) -> String:
return ""
func _rollable_rows() -> Array:
# 摇号池 = cell 池正向行 weight>0(负向行纯字典不消费权重;封锁格不在本函数职责域——清单由调用方裁剪)
var out: Array = []
for row in db.reward.rows:
if String(row.get("pool_tag", "")) == "cell" and String(row.get("polarity", "")) == "positive" and int(row.weight) > 0:
out.append(row)
return out
func _pool_rows(pool_tag: String) -> Array:
var out: Array = []
for row in db.reward.rows:
@@ -324,10 +238,11 @@ func _row_by_id(rid: String) -> Dictionary:
func _snapshot(row: Dictionary, rolled_power: int = -1) -> Dictionary:
var snap := {
"reward_id": String(row.reward_id),
"category": String(row.category),
"cell_type": String(row.cell_type),
"display_name": String(row.display_name),
"power": int(row.power) if rolled_power < 0 else rolled_power, # 区间行 = 身份批 roll 值(E14 授予时快照口径)
"tier": int(row.tier),
"stacks": int(row.get("stacks", 1)),
}
if String(row.get("effect_id", "")) != "":
snap["effect_id"] = String(row.effect_id)
@@ -335,9 +250,9 @@ func _snapshot(row: Dictionary, rolled_power: int = -1) -> Dictionary:
func _diag(msg: String) -> void:
push_warning("[奖励道具经济] " + msg)
push_warning("[地图战斗资源] " + msg)
func _fatal(msg: String) -> void:
push_error("[奖励道具经济/fatal] " + msg)
push_error("[地图战斗资源/fatal] " + msg)
assert(false, msg)
@@ -1,43 +0,0 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 可选类供给能否按"格极稀 <0.01 / 板≈0.78 / 战斗期望≈1(首件保证 + 指数递减)"三尺度被精确控制
# Date: 2026-09-11
# 可选类供给控制器(2026-09-11 用户拍板批——奖励道具经济 私有组合件):
# 格子权重池不再产出可选类;本控制器在 install 期决定"本板是否注入 1 件可选类"——
# 战斗内尚未获取首件 → 本板必注入(首件保证);已获取 j 件 → 概率 p0×decay^(j-1)(指数下降、永不绝育)。
# 行值快照(p0/decay 构造期快照——抗热重载,敌人系统 同款);零自有随机数(判定取号经 奖励道具经济 传入的 reward_roll
# 封闭流实例——Rule 5);"获取"以实际发放入库为准(预分配未挖到不计数——授予侧经 奖励道具经济.grant 挂钩上报)。
# 实现 GDD:design/archive/gdd-2026-09-14/b4-reward-item-economy.md(系统 ID B4·奖励道具经济——registry 追溯锚,命名规范见 CLAUDE.md)
class_name RewardSupplyController
extends RefCounted
var _granted := 0 # 本战斗内已实际获取的可选类件数(战斗域——notify_battle_start 清零)
var _p0 := 0.4
var _decay := 0.5
func _init(reward_config) -> void:
_p0 = clampf(float(reward_config.active_p0), 0.0, 1.0)
_decay = clampf(float(reward_config.active_decay), 0.001, 1.0)
# 战斗结算.begin_battle 编排调用(奖励道具经济 转发)——战斗域计数重置;跨战斗库存延续不受影响
func notify_battle_start() -> void:
_granted = 0
# install 期注入判定(奖励道具经济.roll_board 在身份批 + 清图槽之后调用;取号 = 传入封闭流恰一次)
func should_inject(stream: RandomNumberGenerator) -> bool:
if _granted == 0:
return true # 首件保证
var p: float = _p0 * pow(_decay, float(_granted - 1))
return stream.randf() < p
# 奖励道具经济.grant 实际发放可选类时上报(发放即获取;满血拒"使用"不影响——那是消耗侧语义)
func notify_granted() -> void:
_granted += 1
# 观测面(冒烟/调参工具)
func granted_count() -> int:
return _granted
@@ -1 +0,0 @@
uid://de40p45nneaw1
@@ -4,7 +4,7 @@
# RngService autoload(数据配置)——封闭命名流单点(Rule 5)。玩法代码禁止 randomize()/自建 RandomNumberGenerator。
# 实现 GDD:design/archive/gdd-2026-09-14/d2-data-config.md(系统 ID D2·数据配置——registry 追溯锚,命名规范见 CLAUDE.md)
# R2 派生公式(黄金向量已预验证):derive = (SALT XOR wrap64(master + scope×GOLDEN)) AND 0x7FFF…
# 取号语义:全新实例首个 open_stream = scope 0(奖励道具经济 预推进术语的登记口径)。
# 取号语义:全新实例首个 open_stream = scope 0(地图战斗资源 预推进术语的登记口径)。
extends Node
const STREAM_NAMES := ["board_gen", "reward_roll", "encounter"] # 2026-09-12 增 encounter(遭遇随机化批,用户拍板——registry rng_service 条目同步)
@@ -1,27 +0,0 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# 数据配置 RewardConfigTable schema(奖励道具经济 full 评审接口漂移新增;单行全局——奖励道具经济 标量 knob 的 Rule 1 载体)。
class_name RewardConfigTable
extends Resource
@export var k_dmg: int = 0 # [0,999] 旧 F5 格池刀保底——2026-09-11 可选类供给控制器批置 0(语义由"战斗首件保证"取代)
@export var n_draft: int = 32 # [1,999] 旧 F5 草稿重 roll 上限(随 k_dmg=0 休眠——字段保留)
@export var p_min: int = 4 # [1,999] K≥1 供给闸刀力下界(随 k_dmg=0 休眠——字段保留)
@export var active_p0: float = 0.4 # [0,1] 可选类供给:战斗首件之后,每张新板的注入基础概率(2026-09-11 用户拍板批)
@export var active_decay: float = 0.5 # (0,1] 可选类供给:战斗内每多获取 1 件,注入概率乘一次衰减(指数下降、永不绝育——用户拍板④)
func validate() -> Array:
var errs: Array = []
if k_dmg < 0 or k_dmg > 999:
errs.append({"category": "domain", "message": "k_dmg 越域 [0,999]: %d" % k_dmg})
if n_draft < 1 or n_draft > 999:
errs.append({"category": "domain", "message": "n_draft 越域 [1,999]: %d" % n_draft})
if p_min < 1 or p_min > 999:
errs.append({"category": "domain", "message": "p_min 越域 [1,999]: %d" % p_min})
if active_p0 < 0.0 or active_p0 > 1.0:
errs.append({"category": "domain", "message": "active_p0 越域 [0,1]: %f" % active_p0})
if active_decay <= 0.0 or active_decay > 1.0:
errs.append({"category": "domain", "message": "active_decay 越域 (0,1]: %f" % active_decay})
return errs
@@ -1 +0,0 @@
uid://dc14jo26wutra
@@ -1,16 +1,19 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# 数据配置 RewardTable / RewardConfigTable schema(奖励道具经济 回填值)。
# RewardTable 行 = Dictionary,键 reward_id(Rule 4 豁免:定义表);跨字段校验族 + 池护栏。
# Date: 2026-09-10(2026-09-15 格面资源批结构性重写:category/use_timing/库存/供给轴下线,五型 × 极性字典取代)
# 数据配置 RewardTable schema(地图战斗资源 回填值)。
# 行 = Dictionary,键 reward_id(Rule 4 豁免:定义表);跨字段校验族 + 池护栏。
# 五型封闭枚举(attack/defense/heal/blast/weaken)× 极性(positive/negative):
# 正向行 = 摇号池(权重 = 类型频次);负向行 = 纯兑付字典(不消费权重)。
class_name RewardTable
extends Resource
@export var rows: Array = [] # Array[Dictionary]
const CATEGORIES := ["buff", "damage_item", "heal_pack", "instant_aoe", "instant_heal"] # 2026-09-11 两类奖励裁决五值:即时类 {buff, instant_aoe, instant_heal} / 可选类 {damage_item, heal_pack}
const POOL_TAGS := ["safe_cell", "clear_default", "active_inject"] # active_inject = 可选类注入池(2026-09-11 供给控制器批——可选类退出格子权重池);clear_<biome> 命名空间预留拒绝
const USE_TIMINGS := ["pre_settlement"] # MVP 单值承重
const CELL_TYPES := ["attack", "defense", "heal", "blast", "weaken"] # 五型封闭——清单外加载拒绝
const POLARITIES := ["positive", "negative"]
const POOL_TAGS := ["cell", "clear_default"] # cell = 格面资源单一池;clear_<biome> 命名空间预留拒绝
const CELL_TYPE_KEYS := ["attack", "defense", "heal", "blast", "weaken"] # 字典完整性校验轴(类型 × 极性 = 十格)
func validate() -> Array:
@@ -19,6 +22,9 @@ func validate() -> Array:
errs.append({"category": "empty_table", "message": "RewardTable 为空"})
return errs
var seen := {}
var cell_dict := {} # "type|polarity" → row(字典完整性)
var cell_positive_live := 0 # cell 池 weight>0 正向行计数(池空护栏)
var clear_value_ok := false # clear_default 恒正当量(行动经济 条款④)
for row in rows:
var rid: String = row.get("reward_id", "")
if rid == "":
@@ -27,17 +33,18 @@ func validate() -> Array:
if seen.has(rid):
errs.append({"category": "dup_key", "message": "reward_id 重复: %s" % rid})
seen[rid] = true
var cat: String = row.get("category", "")
if not CATEGORIES.has(cat):
errs.append({"category": "enum", "message": "%s category 出域/预留值拒绝: %s" % [rid, cat]})
var ctype: String = row.get("cell_type", "")
if not CELL_TYPES.has(ctype):
errs.append({"category": "enum", "message": "%s cell_type 出域(五型封闭): %s" % [rid, ctype]})
var pol: String = row.get("polarity", "")
if not POLARITIES.has(pol):
errs.append({"category": "enum", "message": "%s polarity 出域: %s" % [rid, pol]})
var pool: String = row.get("pool_tag", "")
var pool_reserved: bool = pool.begins_with("clear_") and pool != "clear_default"
if not POOL_TAGS.has(pool) and not pool_reserved:
errs.append({"category": "enum", "message": "%s pool_tag 未知: %s" % [rid, pool]})
elif pool_reserved:
errs.append({"category": "enum", "message": "%s pool_tag clear_<biome> 命名空间预留拒绝: %s" % [rid, pool]})
if not USE_TIMINGS.has(row.get("use_timing", "")):
errs.append({"category": "enum", "message": "%s use_timing 出域: %s" % [rid, row.get("use_timing")]})
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % rid})
var power: int = int(row.get("power", 0))
@@ -47,15 +54,20 @@ func validate() -> Array:
errs.append({"category": "domain", "message": "%s weight 越域 [0,999]" % rid})
if tier < 1 or tier > 8:
errs.append({"category": "domain", "message": "%s tier 越域 [1,8]" % rid})
var stacks: int = int(row.get("stacks", 1))
if stacks < 1 or stacks > 16:
errs.append({"category": "domain", "message": "%s stacks 越域 [1,16]: %d" % [rid, stacks]})
# 跨字段:effect_id 非空 = percent 行(引用悬空校验归 ConfigDB 跨表通道);空 = flat 行。
# flat 行 power ≥ 1(直付行:治疗/群伤/直伤)——例外 = blast 负向(伏击加层行,消费 stacks 不消费 power)。
var eff: String = row.get("effect_id", "")
# 跨字段:buff ⇒ effect_id 必填;非 buff ⇒ 恒空;item 类 power ≥ 1
if cat == "buff" and eff == "":
errs.append({"category": "cross_field", "message": "%s buff 行 effect_id 必填" % rid})
if cat != "buff" and eff != "":
errs.append({"category": "cross_field", "message": "%s 非 buff 行 effect_id 恒空" % rid})
if cat != "buff" and power < 1: # 2026-09-11:即时类(aoe/heal)与道具类同 consumes power ≥ 1
errs.append({"category": "cross_field", "message": "%s item 行 power ≥ 1" % rid})
# 伤害量值区间批(2026-09-14):可选配对 power_min/power_max——缺省 = 定值行向后兼容(armor_type 先例)
var is_layer_row: bool = eff == "" and ctype == "blast" and pol == "negative"
if power < 0 or power > 999:
errs.append({"category": "domain", "message": "%s power 越域 [0,999]: %d" % [rid, power]})
if eff == "" and power < 1 and not is_layer_row:
errs.append({"category": "cross_field", "message": "%s flat 行 power ≥ 1" % rid})
if is_layer_row and int(row.get("stacks", 1)) < 1:
errs.append({"category": "cross_field", "message": "%s 加层行 stacks ≥ 1" % rid})
# 区间配对(缺省 = 定值行向后兼容):必成对 · percent 行禁止 · power = 中点锚
var has_min: bool = row.has("power_min")
var has_max: bool = row.has("power_max")
if has_min != has_max:
@@ -65,9 +77,42 @@ func validate() -> Array:
var pmax: int = int(row.get("power_max", 0))
if pmin < 1 or pmax > 999 or pmin > pmax:
errs.append({"category": "domain", "message": "%s power 域违例 [1,999] 且 min≤max: %d..%d" % [rid, pmin, pmax]})
if cat == "buff":
errs.append({"category": "cross_field", "message": "%s buff 行禁区间字段(power 恒 0)" % rid})
if eff != "":
errs.append({"category": "cross_field", "message": "%s percent 行禁区间字段" % rid})
if power != int((pmin + pmax) / 2.0):
# 中点锚:power = 区间中点(行动经济 条款①期望当量锚——调区间必同步调 power)
errs.append({"category": "cross_field", "message": "%s power(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [rid, power, (pmin + pmax) / 2]})
# 字典完整性 / 池护栏累计(cell 池行)
if pool == "cell" and CELL_TYPES.has(ctype) and POLARITIES.has(pol):
cell_dict["%s|%s" % [ctype, pol]] = true
if pol == "positive" and weight > 0:
cell_positive_live += 1
if pool == "clear_default" and eff == "" and power >= 1:
clear_value_ok = true
# ① 字典完整性:五型 × 正负两行齐备(缺任一即致命)
for ctype in CELL_TYPE_KEYS:
for pol in POLARITIES:
if not cell_dict.has("%s|%s" % [ctype, pol]):
errs.append({"category": "cross_field", "message": "五型字典缺行: %s %s——(类型, 极性) 齐备为加载前提" % [ctype, pol]})
# ② 池空护栏:cell 池 ≥1 正向行 weight>0(摇号池非空)
if cell_positive_live < 1:
errs.append({"category": "cross_field", "message": "cell 池零活正向行(weight>0)——摇号池空"})
# ③ clear_default 恒正当量(行动经济 条款④——清图奖励 > 0 的行级兑现口径)
if not clear_value_ok:
errs.append({"category": "cross_field", "message": "clear_default 池无正当量活行(清图奖励 > 0 违例)"})
return errs
func rows_by_pool(pool_tag: String) -> Array:
var out: Array = []
for row in rows:
if row.get("pool_tag", "") == pool_tag:
out.append(row)
return out
func row_by_id(rid: String) -> Dictionary:
for row in rows:
if row.get("reward_id", "") == rid:
return row
return {}
@@ -25,7 +25,7 @@ extends Resource
const ARMOR_KEYS := ["light", "ward", "heavy"]
const ATTACK_TYPE_KEYS := ["normal", "pierce", "magic"]
const FROZEN_SEGMENT_ORDER := ["grant", "item_window", "buff_debuff", "item", "combat", "decay", "verdict"]
const FROZEN_SEGMENT_ORDER := ["grant", "buff_debuff", "combat", "decay", "verdict"]
const P_PERFECT_DOMAIN := [100, 200]
@@ -5,86 +5,112 @@
[resource]
script = ExtResource("1")
rows = [{
"reward_id": "defense_up_basic",
"category": "buff",
"effect_id": "defense_up",
"display_name": "石肤术",
"power": 0,
"weight": 16,
"pool_tag": "safe_cell",
"tier": 2,
"use_timing": "pre_settlement"
}, {
"reward_id": "attack_up_basic",
"category": "buff",
"reward_id": "attack_up",
"cell_type": "attack",
"polarity": "positive",
"effect_id": "attack_up",
"display_name": "增伤术",
"display_name": "增伤",
"power": 0,
"weight": 20,
"pool_tag": "safe_cell",
"tier": 2,
"use_timing": "pre_settlement"
"pool_tag": "cell",
"tier": 2
}, {
"reward_id": "defense_up",
"cell_type": "defense",
"polarity": "positive",
"effect_id": "defense_up",
"display_name": "减伤",
"power": 0,
"weight": 16,
"pool_tag": "cell",
"tier": 2
}, {
"reward_id": "spark_aoe",
"category": "instant_aoe",
"cell_type": "blast",
"polarity": "positive",
"effect_id": "",
"display_name": "爆裂粉尘",
"power": 4,
"weight": 16,
"pool_tag": "safe_cell",
"tier": 1,
"use_timing": "pre_settlement"
"pool_tag": "cell",
"tier": 1
}, {
"reward_id": "heal_minor",
"category": "instant_heal",
"cell_type": "heal",
"polarity": "positive",
"effect_id": "",
"display_name": "急救敷料",
"power": 4,
"power_min": 2,
"power_max": 6,
"weight": 15,
"pool_tag": "safe_cell",
"tier": 1,
"use_timing": "pre_settlement"
"pool_tag": "cell",
"tier": 1
}, {
"reward_id": "knife_t1",
"category": "damage_item",
"effect_id": "",
"display_name": "小刀",
"power": 8,
"power_min": 5,
"power_max": 11,
"weight": 60,
"pool_tag": "active_inject",
"tier": 1,
"use_timing": "pre_settlement"
"reward_id": "weaken",
"cell_type": "weaken",
"polarity": "positive",
"effect_id": "weaken",
"display_name": "削弱",
"power": 0,
"weight": 12,
"pool_tag": "cell",
"tier": 1
}, {
"reward_id": "knife_adv",
"category": "damage_item",
"effect_id": "",
"display_name": "精工短刀",
"power": 16,
"power_min": 10,
"power_max": 22,
"weight": 30,
"pool_tag": "active_inject",
"tier": 2,
"use_timing": "pre_settlement"
"reward_id": "enrage",
"cell_type": "attack",
"polarity": "negative",
"effect_id": "enrage",
"display_name": "敌方暴走",
"power": 0,
"weight": 0,
"pool_tag": "cell",
"tier": 2
}, {
"reward_id": "heal_pack",
"category": "heal_pack",
"reward_id": "off_guard",
"cell_type": "defense",
"polarity": "negative",
"effect_id": "off_guard",
"display_name": "我方失衡",
"power": 0,
"weight": 0,
"pool_tag": "cell",
"tier": 2
}, {
"reward_id": "heal_backlash",
"cell_type": "heal",
"polarity": "negative",
"effect_id": "",
"display_name": "治疗包",
"power": 12,
"power_min": 6,
"power_max": 18,
"weight": 10,
"pool_tag": "active_inject",
"tier": 2,
"use_timing": "pre_settlement"
"display_name": "反噬震荡",
"power": 4,
"weight": 0,
"pool_tag": "cell",
"tier": 1
}, {
"reward_id": "ambush_double",
"cell_type": "blast",
"polarity": "negative",
"effect_id": "",
"display_name": "敌方伏击",
"power": 0,
"stacks": 2,
"weight": 0,
"pool_tag": "cell",
"tier": 1
}, {
"reward_id": "sapped",
"cell_type": "weaken",
"polarity": "negative",
"effect_id": "sapped",
"display_name": "我方乏力",
"power": 0,
"weight": 0,
"pool_tag": "cell",
"tier": 1
}, {
"reward_id": "clear_blast",
"category": "instant_aoe",
"cell_type": "blast",
"polarity": "positive",
"effect_id": "",
"display_name": "清障炸药",
"power": 40,
@@ -92,21 +118,15 @@ rows = [{
"power_max": 55,
"weight": 1,
"pool_tag": "clear_default",
"tier": 2,
"use_timing": "pre_settlement"
"tier": 2
}]
; 2026-09-11 可选类供给控制器批(用户拍板):
; 可选类三行移出 safe_cell 权重池 → active_inject 注入池(小刀 60 / 精工短刀 30 / 治疗包 10——治疗资源宝贵),
; 由 RewardSupplyController 在 install 期注入(首件保证 + 指数递减),不再走格子权重 roll。
; 清图槽 clear_knife(入库刀)→ clear_blast(instant_aoe 一次性高额伤害,当场兑付全体存活敌,不入库不占可选类供给)。
; 格子池现仅即时类四行(attack_up 20 / defense_up 16 / spark_aoe 16 / heal_minor 15,合计 67)。
; 减伤墙调参批(2026-09-13,playtest-2026-09-12 报告 Q1 触发):defense_up 24 → 16——
; 石肤自 32% 降至 23.9%,攻击成为池内最高行;与 settlement.tres pool_clamp_lo=-60 同批(免伤墙双杠杆)。
; 伤害量值区间批(2026-09-14 用户拍板②——"上限掺运气"第一步,奖励侧五行:上阶/下阶区间内随机;
; 中点恒等旧定值 = A3 条款①期望当量锚不漂移;宽度 provisional 归 C3 测量迭代。验收口径 = 完美策略档
; 0 加成通关率 ≤40%〔OQ7 完美档基线 100%〕)。roll 时机 = 身份预分配批(reward_roll 恰一 scope 批内
; 追加取号,既有取号序零扰动——追加式);快照 E14 口径不变,B1/呈现零改动。区间:
; heal_minor [2,6] · knife_t1 [5,11] · heal_pack [6,18] · knife_adv [10,22] · clear_blast [25,55]。
; buff 两行(attack_up/defense_up)与 spark_aoe 不在本批(即时光效类按拍板面五行为准)。
; 格面资源批(2026-09-15 用户拍板——registry reward_table 条目为现行 schema 事实源):
; 十行格面池(五型 × 正负各一行)+ 清图槽一行。安全格与雷格同池同分布(雷安同池),
; 装板期一次摇定;摇号只消费正向行权重(类型频次 = attack 20 / defense 16 / blast 16 / heal 15 / weaken 12,合计 79);
; 负向行 weight=0 不参与摇号,纯(类型, 极性)兑付字典——heal 负向 = 主角直伤 4(不经增益状态),
; blast 负向 = ambush +2 层(stacks=2,伏击池 cap 与"溢出未生效"行协议不变)。
; 可选类三行(knife_t1/knife_adv/heal_pack)与注入池 active_inject 随可选类道具整体删除(供给控制器/保底/窗口同步下线)。
; 区间行(伤害量值区间批 2026-09-14):heal_minor [2,6] · clear_blast [25,55]——中点锚 power 恒等期望当量,
; roll 时机 = 身份预分配批内追加取号(既有取号序零扰动);spark_aoe 定值行无区间字段。
; 减伤墙调参批(2026-09-13):defense_up 权重 24→16——攻击成为池内最高行(历史注记)。
@@ -1,15 +0,0 @@
[gd_resource type="Resource" script_class="RewardConfigTable" load_steps=2 format=3]
[ext_resource type="Script" path="res://data/schemas/reward_config_table.gd" id="1"]
[resource]
script = ExtResource("1")
k_dmg = 0
n_draft = 32
p_min = 8
active_p0 = 0.4
active_decay = 0.5
; 2026-09-11 可选类供给控制器批(用户拍板):k_dmg 1→0(旧 F5 格池刀保底休眠,语义由"战斗首件保证"取代);
; active_p0 = 0.4 / active_decay = 0.5(首件之后每张新板注入概率 0.4 → 0.2 → 0.1 → …指数下降、永不绝育;
; provisional 起步值,标定目标 E[战斗获取] ≈ 1,level_monte_carlo 扫描验证后归 C3 定稿)。
@@ -6,13 +6,12 @@
script = ExtResource("1")
equivalence_ref_damage = 12
p_perfect = 200
segment_order = PackedStringArray("grant", "item_window", "buff_debuff", "item", "combat", "decay", "verdict")
segment_order = PackedStringArray("grant", "buff_debuff", "combat", "decay", "verdict")
source_label_map = {
"safe_cell": "安全格",
"flag_hit": "标中旗",
"mine_hit": "踩雷",
"clear_reward": "清图奖励",
"item": "道具",
"enemy_action": "敌方行动",
"hero_attack": "主角攻击",
"flag_miss": "标旗未中"
@@ -23,7 +22,8 @@ effect_pool_map = {
"weaken": "enemy_output",
"ambush": "flat_attach",
"enrage": "enemy_output",
"off_guard": "hero_defense"
"off_guard": "hero_defense",
"sapped": "hero_offense"
}
pool_clamp_lo = -100
pool_clamp_hi = 100
@@ -61,15 +61,14 @@ armor_effectiveness = {
; 攻击类型 × 护甲克制批(2026-09-12 用户拍板结构;2026-09-15 命名统一批 + 抽象分离批,数值 provisional 待用户/C3 调参):
; 主角攻击 = 普通(atk×(1+attack_up),对全部护甲 100%)+ 穿刺 + 魔法(后两者由充能供给),三分量各自 floor 后相加;
; 克制语义 = 克制 ×150 / 中性 ×100 / 被克 ×50(穿甲弹/高爆弹式对策三角)。
; 【两条抽象分离,勿焊死】攻击类型 {normal, pierce, magic} = 普通 / 穿刺 / 魔法(原 {base, dmg_a, dmg_b})=
; 【两条抽象分离,勿焊死】攻击类型 {normal, pierce, magic} = 普通 / 穿刺 / 魔法 =
; 伤害的属性轴,只决定查矩阵哪一行,定义里不含任何棋盘来源;护甲类型 {light, ward, heavy} = 轻甲 / 魔法护甲 / 重甲
; (原 {none, a, b})是敌单位属性。充能 = 按攻击类型分桶的附加伤害储备:谁能充、充哪个桶 = 奖励定义。
; charge_grants 现行供给(奖励侧定义,可整体替换——换桶/换量/换成增益或道具都不触动攻击类型与矩阵):
; safe_cell(本轮每开 1 资格格)→ 穿刺桶 1/格、单轮计数上限 8(0 = 不封顶);
; flag_hit(每颗首次标中雷,每雷实例一次去重——忘拔旗不重复充能)→ 魔法桶 2/雷,无上限。
; 原位置字段 damage_a_per_cell / damage_a_cap / damage_b_per_flag("类型×来源"焊死的命名)已废除。
; enrage(敌方暴走 +25% 进 enemy_output 池)/ off_guard(我方失衡 +25% 进 hero_defense 池)=
; 标错惩罚池两行(第三项 = 复用 ambush +2 层);时效均 1 结算(用户拍板"短点,比如 1 回合")。
; 减伤墙调参批(2026-09-13,playtest-2026-09-12 报告 Q1 触发):拟改 pool_clamp_lo=-60 被 schema
; 冻结域拦截([-100,+100] 结构冻结——B1 F2 乘区结构决策),杠杆改走 status_effects.tres
; defense_up magnitude -50 → -30(叠满两层 = -60 减伤帽,本表 clamp 不动);登记以 status_effects.tres 批注为准。
; 是敌单位属性。充能 = 按攻击类型分桶的附加伤害储备:谁能充、充哪个桶 = 奖励定义。
; charge_grants 现行供给(奖励侧定义,可整体替换):safe_cell(本轮每挖开 1 个安全格)→ 穿刺桶 1/格、
; 单轮计数上限 8(0 = 不封顶);flag_hit(每颗首次标中雷,每雷实例一次去重)→ 魔法桶 2/雷,无上限。
; 格面资源批(2026-09-15 用户拍板):段序七段 → 五段(item_window/item 两段随可选类道具删除下线);
; source_label_map 删 item 键;effect_pool_map 增 sapped→hero_offense(weaken 型负向——乏力)。
; enrage(敌方暴走 +25%,enemy_output 池)/ off_guard(我方失衡 +25%,hero_defense 池)/
; sapped(我方乏力 -25%,hero_offense 池)= attack/defense/weaken 三型负向字典行
; (极性路由兑付:挖雷格 / 标错安全格按该格 cell_type 取负向行;heal 负向 = 主角直伤不经状态、blast 负向 = ambush+2 层)。
; 减伤墙调参批(2026-09-13):pool_clamp 域 [-100,+100] 结构冻结不动;减伤杠杆走 status_effects.tres(批注在该表)。
@@ -58,9 +58,21 @@ rows = [{
"duration_settlements": 1,
"stack_policy": "stack",
"max_stack": 16
}, {
"effect_id": "sapped",
"display_name": "乏力",
"target_side": "hero",
"magnitude": -25,
"unit": "percent",
"duration_settlements": 1,
"stack_policy": "stack",
"max_stack": 16
}]
; 减伤墙调参批(2026-09-13,playtest-2026-09-12 报告 Q1 触发——defense_scan 实测墙轮 39%、
; 真人局 12/12 轮零承伤满血通关):defense_up magnitude -50 → -30——叠满两层 = -60 减伤帽
; (原拟 pool_clamp_lo=-60 被 schema 冻结域拦截,杠杆移至本表);石肤权重 24→16 随批(reward.tres)。
; 承伤带对 C3 F4(≤20%/普通场、≤50%/Boss 场)复验责任随批;B3 GDD "-50" 文字同步随下次 B3 修订批。
; 格面资源批(2026-09-15):新增 sapped(乏力——weaken 型负向,hero_offense 池,attack_up 镜像;
; 进 effect_pool_map 同批登记)——状态表六行→七行;enrage/off_guard 语义 = attack/defense 型负向
; (标错安全格 / 踩到雷格经极性路由兑付,不再绑"标错反噬"单一来源)。
@@ -13,9 +13,8 @@ static func node_banner(runner, node: Dictionary, t: String) -> String:
"Boss 房" if t == "boss" else "战斗", int(node.get("node_depth", 0))]
static func reward_line(snap: Dictionary) -> String:
return "[color=#d0c090]关卡奖励:%s(+%d)入库[/color]" % [
String(snap.get("display_name", "")), int(snap.get("power", 0))]
static func reward_line() -> String:
return "[color=#d0c090]关卡奖励点(节点内容待定——直通)[/color]"
static func rest_line(healed: int, hp: int, max_hp: int) -> String:
@@ -31,34 +30,32 @@ static func level_open_line(level_display: String, seed_val: int, atk: int, max_
# 调试开场行
static func debug_open_line(seed_val: int, level: int, level_mode: bool) -> String:
return "[color=#c8a860]种子 %d · 遭遇 %d 层%s[/color]" % [
seed_val, level, "(关卡模式 · 库存延续)" if level_mode else ""]
seed_val, level, "(关卡模式 · HP 延续)" if level_mode else ""]
# 关卡终局文案(result = victory/defeat;载荷 = runner level_finished 快照)
static func level_end_text(result: String, payload: Dictionary, effective_max_hp: int) -> String:
if result == "victory":
return "关卡通关!\n剩余道具 ×%d 随关卡结束清空 · 剩余 HP %d/%d" % [
int(payload.get("remaining_items", 0)), int(payload.get("hp", 0)), effective_max_hp]
return "关卡通关!\n剩余 HP %d/%d" % [
int(payload.get("hp", 0)), effective_max_hp]
return "关卡失败……探险者倒在了节点 %d(%s)。" % [
int(payload.get("node_index", 0)) + 1, String(payload.get("node_type", ""))]
# 调试战斗终局文案(level_end = 关内两层模式的关卡末;left = 清库剩余件数)
static func battle_end_text(result: String, level_end: bool, left: int) -> String:
# 调试战斗终局文案(level_end = 关内两层模式的关卡末)
static func battle_end_text(result: String, level_end: bool) -> String:
if level_end:
return "关卡通关!\n剩余道具 ×%d 随关卡结束清空" % left
return "关卡通关!"
if result == "victory":
return "战斗胜利!敌人全灭。"
return "战斗失败……探险者倒下了。"
# 相位指示文本(B 区轮次·阶段行)
static func phase_text(round_index: int, battle_result: String, window_open: bool, waiting: bool, boss_active: bool, boss_name: String) -> String:
static func phase_text(round_index: int, battle_result: String, waiting: bool, boss_active: bool, boss_name: String) -> String:
var boss_tag := " · Boss:%s" % boss_name if boss_active else ""
if battle_result != "":
return "第 %d 轮 · 战斗结束%s" % [round_index, boss_tag]
if window_open:
return "第 %d 轮 · 道具窗口%s" % [round_index, boss_tag]
if waiting:
return "第 %d 轮 · 结算中%s" % [round_index, boss_tag]
return "第 %d 轮 · 挖掘中%s" % [round_index, boss_tag]
@@ -1,13 +1,14 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-13(封装审查 O1/O2 修复批——自 main.gd 拆出,规范 1.1 审视线 / 单一实现纪律)
# 调试模式编排控制器:单场遭遇(第 1/2 层)与连续两层(库存与 HP 关内延续)的模式状态机与战斗生命周期。
# 职责边界:层序/携带 HP/共享 奖励道具经济 状态 + 战斗结算 生命周期编排(创建 / begin_battle / 关卡末 level_clear)。
# 调试模式编排控制器:单场遭遇(第 1/2 层)与连续两层(HP 关内延续)的模式状态机与战斗生命周期。
# 职责边界:层序/携带 HP + 战斗结算 生命周期编排(创建 / begin_battle)。
# 2026-09-15 格面资源批:库存与共享 地图战斗资源 随可选类道具删除下线——层间延续只剩 HP
# ("库存与 HP 关内延续"语义收窄为"HP 关内延续",O2 单一实现点随之收窄)。
# 授权先例:与 tune_sim_controller 同款(code-review 2026-09-12 批)——控制器承接模式编排,
# 组合根仍持有全部信号接线(battle_created 先于 begin_battle 发出,ADR-0001 接线唯一归组合根)
# 与终局 UI 决策;玩家意图写调用(settle_batch / 窗口提交 / 刷新)不经本类。
# 与终局 UI 决策;玩家意图写调用(settle_batch / 刷新)不经本类。
# E4 收口(2026-09-13):加成生效值经注入 Callable 供给(组合根从面板百分比现算)→ encounter
# 显式传入战斗——零表写,"双写叠加/双基线同步"整类隐患随表写移除。
# O2 收口:调试两层的"库存与 HP 延续"语义唯一实现点(此前与 main.gd 调试分支双实现漂移)。
extends RefCounted
signal battle_created(settlement, level: int, carry_hp: int) # begin_battle 前——组合根完成信号接线(ADR-0001)
@@ -22,7 +23,6 @@ var _level_mode := false
var _level_battles: Array = []
var _level_idx := 0
var _carry_hp := 0
var _shared_reward_economy = null
var settlement = null # 当前战斗编排器(组合根在 battle_created 接线、battle_ready 接手)
@@ -38,21 +38,19 @@ func is_level_mode() -> bool:
# 单场遭遇原语(2026-09-13 用户裁决:单场用户入口已删——本方法仅 ui_probe 单场段使用;
# 无库存延续、standalone 终局语义保留供探针战斗收尾)
# 无延续、standalone 终局语义保留供探针战斗收尾)
func start_single(level: int) -> bool:
_level_mode = false
_shared_reward_economy = null
return _start_battle(level, -1)
# 连续两层(调试入口——共享 奖励道具经济 + HP 携带;首层满血开局)
# 连续两层(调试入口——HP 关内延续;首层满血开局)
func start_debug_levels() -> bool:
_level_mode = true
_level_battles = [1, 2]
_level_idx = 0
_carry_hp = 0
rng.new_adventure(_seed_value.call())
_shared_reward_economy = RewardEconomy.new(db, rng)
return _start_battle(_level_battles[0], -1)
@@ -62,28 +60,21 @@ func next_layer() -> bool:
# 终局处置(组合根 battle_finished 处理器调用)→ 决策载荷:
# {outcome: "next_layer"|"level_end"|"standalone_end", layer, carry_hp, items_left, remaining}
# {outcome: "next_layer"|"level_end"|"standalone_end", layer, carry_hp}
func on_battle_finished(payload: Dictionary) -> Dictionary:
var result: String = String(payload.get("result", ""))
if result == "victory" and _level_mode and _level_idx < _level_battles.size() - 1:
_carry_hp = maxi(1, int(payload.get("hero_hp", 0))) # despawn 前捕获值
_level_idx += 1
return {"outcome": "next_layer", "layer": _level_idx, "carry_hp": _carry_hp,
"items_left": settlement.stock_count()}
var remaining := 0
return {"outcome": "next_layer", "layer": _level_idx, "carry_hp": _carry_hp}
var is_level_end := result == "victory" and _level_mode
if is_level_end:
remaining = int(settlement.level_clear()["remaining"]) # 库存随关卡结束清空(关内延续裁决③)
elif _level_mode:
settlement.level_clear() # 失败同样清库(关卡域生命周期闭合——调试模式与 runner 模式同规)
return {"outcome": "level_end" if is_level_end else "standalone_end",
"layer": _level_idx, "carry_hp": _carry_hp, "items_left": 0, "remaining": remaining}
"layer": _level_idx, "carry_hp": _carry_hp}
# 会话复位(组合根终局出口"返回开始界面"调用)
func reset() -> void:
_level_mode = false
_shared_reward_economy = null
settlement = null
@@ -91,7 +82,7 @@ func _start_battle(level: int, carry_hp: int) -> bool:
if not _level_mode or _level_idx == 0:
rng.new_adventure(_seed_value.call())
var ov: Dictionary = _hero_override.call() # E4:生效值经 encounter 传入——零表写
settlement = BattleOrchestrator.new(db, rng, _shared_reward_economy if _level_mode else null)
settlement = BattleOrchestrator.new(db, rng)
battle_created.emit(settlement, level, carry_hp)
var hp: int = carry_hp if carry_hp > 0 else int(ov.max_hp)
if not settlement.begin_battle({"level_index": level, "node_depth": 0, "hero_hp": hp,
@@ -8,7 +8,7 @@
# 敌人含死亡残位的投影由快照 enemies[] 承载,含 alive/beat/ring 字段)。
# 更新触发注记(battle.md Data Requirements):旗/拔/移动成功反馈零事件外发(排雷玩法 发射面封闭)——
# 经 ActionOutcome 返回值命令式驱动;AP/RAP 无推送信号——组合根每次调用返回后重查;
# 增益状态 纯被动容器零信号——状态栏刷新绑 战斗结算 结算编排完成时点;库存 奖励道具经济 查询面——写路径回执后重查。
# 增益状态 纯被动容器零信号——状态栏刷新绑 战斗结算 结算编排完成时点。
# Date: 2026-09-12(五区改造批;基础 = 2026-09-11 HUD 装配器;2026-09-13 快照面改造)
class_name HudPresenter
extends RefCounted
@@ -16,20 +16,19 @@ extends RefCounted
# 每次结算行/轮次/节点变化/动作返回后调用(组合根全量重查装配)
static func refresh(stage: BattleStageView, info: HeroInfoPanelView, ap_bar: ApBarView,
inv: InventoryPanelView, settlement, runner, level_active: bool, db, refresh_remaining: int) -> void:
settlement, runner, level_active: bool, db, refresh_remaining: int) -> void:
if settlement == null:
_refresh_out_of_battle(stage, info, inv, runner, level_active, db)
_refresh_out_of_battle(stage, info, runner, level_active, db)
return
var s: Dictionary = settlement.presentation_snapshot()
var hero: Dictionary = s.hero
if hero.is_empty() or not bool(hero.get("alive", false)):
_refresh_out_of_battle(stage, info, inv, runner, level_active, db)
_refresh_out_of_battle(stage, info, runner, level_active, db)
return
_refresh_hero(stage, info, s)
_refresh_economy(info, ap_bar, s, db, refresh_remaining)
stage.set_enemies(s.enemies)
info.set_status_effects(s.hero_effects)
_fill_inventory(inv, s.inventory)
# ---- B 区英雄卡 + C 区数据框 ----
@@ -73,58 +72,8 @@ static func _refresh_economy(info: HeroInfoPanelView, ap_bar: ApBarView, s: Dict
ap_bar.set_stop_enabled(bool(s.hero.get("alive", false)) and digging)
# ---- E 区库存常读列表(枚举序 = stock 受理序 E21;来源不呈现——2026-09-12 拍板) ----
static func _fill_inventory(inv: InventoryPanelView, inventory: Array) -> void:
# 窗口态占屏(D2 原位窗口激活)——常读刷新不得覆盖窗口条目(窗口关闭后由下一轮刷新恢复常读面)
if inv.is_window_open():
return
var rows: Array = []
for inst in inventory:
if inst.status != "InStock":
continue
rows.append(_inventory_row(inst.display_name, String(inst.category), int(inst.power), "可用"))
if rows.is_empty():
inv.show_empty()
else:
inv.show_inventory(rows)
# 行值快照投影派生属性简述(伤害/回复/增益一句话)——来源 {cell, round} 不呈现
static func _inventory_row(name_: String, category: String, power: int, status: String) -> Dictionary:
var desc := ""
var tooltip := ""
match category:
"damage_item":
desc = "伤害 %d · 一次性" % power
tooltip = "对最前敌造成 %d 点伤害(恒定表值,不吃修正)。触发:轮末道具窗口选用,道具段生效。" % power
"heal_pack":
desc = "回复 %d · 可用" % power
tooltip = "回复 %d 点 HP(超量截断)。触发:轮末道具窗口选用;满血时禁选(不烧包)。" % power
"buff":
desc = "增益 · 结算期生效"
tooltip = "状态效果增益。触发:轮末结算增益/减益段。"
_:
desc = "威力 %d" % power
tooltip = "触发:轮末结算。"
return {"display_name": name_, "category": category, "desc": desc, "tooltip": tooltip, "status": status}
# 窗口条目投影(战斗结算 window_opened 载荷 → E 区窗口态行;pos = 窗口条目位置——缺陷④契约)
static func window_rows(items: Array, hero_hp: int, hero_max_hp: int) -> Array:
var rows: Array = []
for pos in items.size():
var it: Dictionary = items[pos]
var row := _inventory_row(String(it.display_name), String(it.category), int(it.power), "")
row["pos"] = pos
row["usable_now"] = bool(it.usable_now)
row["reason"] = "HP 已满 · 禁选" if not bool(it.usable_now) else ""
rows.append(row)
return rows
# 战斗外(节点间隙/关卡结束)——HP 读 runner 携带值
static func _refresh_out_of_battle(stage: BattleStageView, info: HeroInfoPanelView, inv: InventoryPanelView,
static func _refresh_out_of_battle(stage: BattleStageView, info: HeroInfoPanelView,
runner, level_active: bool, db) -> void:
if not level_active:
return
@@ -134,14 +83,6 @@ static func _refresh_out_of_battle(stage: BattleStageView, info: HeroInfoPanelVi
stage.hero_card.set_data(runner.hero_hp, runner.effective_max_hp(), runner.effective_atk(), "(关卡推进中)", "")
stage.clear_enemies() # 节点间隙清残位敌卡(死亡瞬间的灰化呈现已在结算行驱动完成)
info.set_rap(0, int(db.action_economy.rap_refresh_cost)) # battle_over RAP 清零反馈(行动经济 义务)
var rows: Array = []
for inst in runner.inventory():
if inst.status == "InStock":
rows.append(_inventory_row(inst.display_name, String(inst.category), int(inst.power), "可用"))
if rows.is_empty():
inv.show_empty()
else:
inv.show_inventory(rows)
# 三源拒绝可感知(排雷玩法 reject_reason/sub_reason → 玩家文案;pattern 1 双源拒绝反馈)
@@ -1,17 +1,18 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 线性第一关(战×2→道具→战×2→恢复→Boss)能否承载"数值提升→通关期望改善"的元进度验证
# 实现 GDD:design/ux/battle.md(战斗屏五区 A/B/C/D/E + 全屏 States + 遥测)。
# 实现 GDD:design/ux/battle.md(战斗屏 A/B/C/D 四区 + 全屏 States + 遥测;E 区随可选类删除下线 2026-09-15)。
# Date: 2026-09-12(battle.md 五区改造批;基础 = 2026-09-11 HUD框架-lite 组合根;code-review 修复批重构)
# 2026-09-13(封装审查修复批):核心状态读取收口至 战斗结算/runner 成文只读面(presentation_snapshot /
# current_board / current_battle 等——禁链式穿透 settlement.enemy_system.hero 一类路径);调试模式编排抽离
# debug_session.gd(规范 1.1 审视线 + 调试语义单一实现)。
# 组合根(composition root):唯一的模式流转与 core↔view 接线处。
# 职责边界:① 构建五区视图并接线信号(节点构建归 ui_assembler.gd——规范 1.1,本类只接线)
# 职责边界:① 构建四区视图并接线信号(节点构建归 ui_assembler.gd——规范 1.1,本类只接线)
# ② 模式管理(完整关卡 关卡节点地图 runner / 调试两层——后者编排归 debug_session.gd;单场原语仅探针使用)
# ③ 核心写调用只在此处发生(minesweeper.settle_batch / settlement.submit_window_selection / 刷新编排入口 / runner 推进;
# ③ 核心写调用只在此处发生(minesweeper.settle_batch / 刷新编排入口 / runner 推进;
# E4 收口:加成值零表写——面板百分比经 runner.set_hero_override / debug_session 生效值 Callable
# 以 encounter 参数入战斗,db.hero 运行期只读)
# ④ 全屏 States 状态机(挖掘/结算/道具窗口/终局)与一次性提示去重。
# ④ 全屏 States 状态机(挖掘/结算/终局)与一次性提示去重。
# 2026-09-15 格面资源批:道具窗口与库存面板随可选类道具删除下线——E 区移除,窗口接线/选择序列删除。
# 渲染与文案归 views/,核心状态→文案装配归 hud_presenter.gd 与 battle_narrator.gd,节点构建归
# ui_assembler.gd,模态层级归 modal_stack.gd,批量模拟编排归 tune_sim_controller.gd,调试模式编排归
# debug_session.gd——本类不拼呈现字符串。
@@ -38,7 +39,7 @@ var _inter_label: Label
# 调试模式编排(单场/连续两层——状态机与战斗生命周期归 debug_session.gd,本类只接线与出 UI)
var _debug = null
# 视图(views/)——battle.md 五区
# 视图(views/)——battle.md 四区(E 区道具面板随可选类删除下线)
var strip_view: LevelStripView # A 区
var stage: BattleStageView # B 区(含 log_view 明细带)
var log_view: BattleLogView
@@ -46,7 +47,6 @@ var info_panel: HeroInfoPanelView # C 区
var board_zone: BoardZoneView # D 区(含 ap_bar/board_view/toast/fx)
var board_view: BoardView # 探针兼容别名(= board_zone.board_view)
var ap_bar: ApBarView # 探针兼容别名(= board_zone.ap_bar)
var inventory_panel: InventoryPanelView # E 区(原位窗口态)
# 模态 overlay(z 序 = 登记序;pattern 6 层级纪律)
var legend_overlay: LegendOverlay
var review_overlay: SettlementReviewOverlay
@@ -58,7 +58,6 @@ var _tune_sim = null # 调整板批量模拟控制器(tune_sim_c
var _hints_shown := {} # 一次性非模态提示去重(每 run 一次:chord/no_adjacent)
var _refresh_used_round := 0 # 本轮已受理刷新次数(遥测 round_refresh_index——每轮 round_started 归零)
var _boss_active := false # Boss 遭遇变体(阶段指示附 Boss 名)
var _window_payload: Array = [] # 窗口投影缓存(ux_window_choice 遥测读 usable_now/item_id)
func _ready() -> void:
@@ -89,7 +88,6 @@ func _build_ui() -> void:
board_zone = five.board_zone
board_view = five.board_view # 探针兼容别名(= board_zone.board_view)
ap_bar = five.ap_bar # 探针兼容别名(= board_zone.ap_bar)
inventory_panel = five.inventory_panel
log_view.review_requested.connect(_open_review_from_band)
board_zone.action_requested.connect(_on_board_action)
board_zone.stop_pressed.connect(_on_stop_pressed)
@@ -97,11 +95,8 @@ func _build_ui() -> void:
board_zone.legend_toggled.connect(_on_legend_toggled)
board_zone.hover_forbidden.connect(_on_hover_forbidden)
board_zone.chord_attempted.connect(_on_chord_attempted)
inventory_panel.item_selected.connect(_on_window_item_selected)
inventory_panel.skipped.connect(_on_window_skipped)
inventory_panel.reject_reason.connect(_on_inventory_reject)
# 奖励徽章数据源:奖励道具经济 身份解析只读回调(已开格身份 icon——挖掘期 Active 相位内有效)
# 奖励徽章数据源:地图战斗资源 身份解析只读回调(已开格身份 icon——挖掘期 Active 相位内有效)
board_view.set_identity_resolver(_identity_lookup)
logger = PlaytestLogger.new()
@@ -184,7 +179,6 @@ func _start_full_level() -> void:
runner = LevelRunner.new(db, get_node("/root/RngService"))
runner.set_hero_override(tune_panel.atk_pct(), tune_panel.hp_pct())
runner.step_row.connect(_on_step_row)
runner.window_opened.connect(_on_window_opened)
runner.round_started.connect(_on_round_started)
runner.board_changed.connect(_on_board_changed)
runner.node_entered.connect(_on_runner_node)
@@ -207,7 +201,6 @@ func _reset_run_scope() -> void:
_hints_shown = {}
_refresh_used_round = 0
_boss_active = false
inventory_panel.close_window()
func _on_runner_node(node: Dictionary, info: Dictionary) -> void:
@@ -219,10 +212,9 @@ func _on_runner_node(node: Dictionary, info: Dictionary) -> void:
_show_board_fx_for_node(t)
log_view.append_bb(Narrator.node_banner(runner, node, t))
elif t == "reward":
var snap: Dictionary = info.get("snapshot", {})
_open_modal(node_overlay)
node_overlay.show_reward(snap)
log_view.append_bb(Narrator.reward_line(snap))
node_overlay.show_reward()
log_view.append_bb(Narrator.reward_line())
elif t == "rest":
var healed: int = int(info.get("healed", 0))
_open_modal(node_overlay)
@@ -241,9 +233,7 @@ func _on_level_finished(payload: Dictionary) -> void:
info_panel.set_status_cleared_feedback() # 状态槽全清可见反馈(增益状态×4④)
stage.gray_all_enemies() # 死亡兜底:卡体灰化残位(禁瞬消语义的战斗收尾面)
if result == "victory":
# 关卡结算:库存清空可见反馈(奖励道具经济 D1×⑤)+ 胜利终局 overlay(金蜡封落于棋盘中央)
var remaining: int = int(payload.get("remaining_items", 0))
inventory_panel.play_cleared_feedback(remaining)
# 胜利终局 overlay(金蜡封落于棋盘中央)
_end_overlay.open_victory(Narrator.level_end_text("victory", payload, runner.effective_max_hp()),
"返回冒险", board_view.get_global_rect())
else:
@@ -319,7 +309,7 @@ func _scaled(base: int, pct: int) -> int:
return maxi(1, int(round(float(base) * (100.0 + float(pct)) / 100.0)))
# 奖励徽章身份解析(组合根守卫:仅挖掘期 Active 相位内查询——奖励道具经济 Ready 域外零读、零诊断噪音)
# 奖励徽章身份解析(组合根守卫:仅挖掘期 Active 相位内查询——地图战斗资源 Ready 域外零读、零诊断噪音)
func _identity_lookup(c: Vector2i):
if settlement == null or settlement.minesweeper.current_board() == null or settlement.action_economy.phase != settlement.action_economy.PHASE_ACTIVE:
return null
@@ -342,21 +332,20 @@ func _on_battle_finished(payload: Dictionary) -> void:
var decision: Dictionary = _debug.on_battle_finished(payload)
match String(decision.outcome):
"next_layer":
_inter_label.text = "第 %d 层通过!\n库存道具 ×%d · HP %d/%d 带入下一层" % [
int(decision.layer), int(decision.items_left), int(decision.carry_hp), int(db.hero.max_hp)]
_inter_label.text = "第 %d 层通过!\nHP %d/%d 带入下一层" % [
int(decision.layer), int(decision.carry_hp), int(db.hero.max_hp)]
_inter_overlay.visible = true
_refresh_presentations()
return
"level_end":
inventory_panel.play_cleared_feedback(int(decision.remaining))
_end_overlay.open_victory(Narrator.battle_end_text("victory", true, int(decision.remaining)),
_end_overlay.open_victory(Narrator.battle_end_text("victory", true),
"返回", board_view.get_global_rect())
"standalone_end":
if result == "victory":
_end_overlay.open_victory(Narrator.battle_end_text("victory", false, 0),
_end_overlay.open_victory(Narrator.battle_end_text("victory", false),
"回到遭遇选择", board_view.get_global_rect())
else:
_end_overlay.open_defeat(Narrator.battle_end_text("defeat", false, 0),
_end_overlay.open_defeat(Narrator.battle_end_text("defeat", false),
"返回", board_view.get_global_rect())
_open_modal(_end_overlay)
_refresh_presentations()
@@ -366,7 +355,6 @@ func _on_battle_finished(payload: Dictionary) -> void:
func _wire_battle(settlement_ref) -> void:
settlement_ref.step_row.connect(_on_step_row)
settlement_ref.window_opened.connect(_on_window_opened)
settlement_ref.battle_finished.connect(_on_battle_finished)
settlement_ref.round_started.connect(_on_round_started)
settlement_ref.board_changed.connect(_on_board_changed)
@@ -431,40 +419,7 @@ func _show_hint(text: String, hint_id: String) -> void:
board_zone.toast.show_toast(text, "info")
# ---- 道具窗口(E 区原位激活——布局决策 D2 / pattern 2) ----
func _on_window_opened(items: Array) -> void:
_window_payload = items
var hero: Dictionary = settlement.presentation_snapshot().hero # E1 收口:经呈现快照面读 HP
var hp: int = int(hero.get("hp", 0)) if not hero.is_empty() else 0
var max_hp: int = int(hero.get("max_hp", 0)) if not hero.is_empty() else 1
inventory_panel.open_window(HudPresenter.window_rows(items, hp, max_hp))
_sync_settlement_visuals() # 阶段指示 → 道具窗口
func _on_window_item_selected(pos: int) -> void:
# 选择语义 = 窗口条目位置(缺陷④契约——非库存句柄)
if pos >= 0 and pos < _window_payload.size():
var it: Dictionary = _window_payload[pos]
logger.log_ux("ux_window_choice", {"selected_item_id": String(it.get("item_id", "")),
"usable_now": true, "skip": false})
inventory_panel.close_window()
if settlement != null:
settlement.submit_window_selection([pos])
_refresh_presentations()
func _on_window_skipped() -> void:
logger.log_ux("ux_window_choice", {"selected_item_id": "", "usable_now": false, "skip": true})
inventory_panel.close_window()
if settlement != null:
settlement.submit_window_selection([])
_refresh_presentations()
func _on_inventory_reject(text: String) -> void:
board_zone.toast.show_toast(text, "warn")
# ---- 节点覆盖层"继续"(奖励/恢复点确认) ----
func _on_node_continue() -> void:
_close_modal(node_overlay)
@@ -475,8 +430,8 @@ func _on_node_continue() -> void:
# ==================== 玩家意图 → 核心写调用(唯一出口)====================
func _on_board_action(cell: Vector2i, kind: String) -> void:
# 玩家动作 → 核心写调用的唯一通道(模态/窗口期/无战斗拒绝在此收口)
if _input_locked() or inventory_panel.is_window_open():
# 玩家动作 → 核心写调用的唯一通道(模态/无战斗拒绝在此收口)
if _input_locked():
return
var outcomes: Array = settlement.minesweeper.settle_batch([{"action": kind, "cell": cell}])
var accepted: bool = not outcomes.is_empty() and bool(outcomes[0].accepted)
@@ -491,7 +446,7 @@ func _on_board_action(cell: Vector2i, kind: String) -> void:
func _on_stop_pressed() -> void:
if _input_locked() or inventory_panel.is_window_open():
if _input_locked():
return
# stop 经批次提交(排雷玩法 D1×6①——不直调 行动经济)
var outcomes: Array = settlement.minesweeper.settle_batch([{"action": "stop"}])
@@ -506,7 +461,7 @@ func _on_stop_pressed() -> void:
func _on_refresh_confirmed() -> void:
# 弃图刷新(挖掘期 Active 当场换图继续挖——D4;RAP−8 · 旧板旗作废,每轮上限 2 次)
if _input_locked() or inventory_panel.is_window_open():
if _input_locked():
return
var ok: bool = false
if _level_active and runner != null:
@@ -549,7 +504,7 @@ func _handle_outcome_feedback(outcome: Dictionary) -> void:
func _refresh_presentations() -> void:
# 每个核心信号/写调用返回后的全量重查装配(更新触发注记——无推送信号,命令式重查)
var refresh_remaining: int = maxi(0, int(db.action_economy.refresh_max_per_round) - _refresh_used_round)
HudPresenter.refresh(stage, info_panel, ap_bar, inventory_panel, settlement, runner, _level_active, db, refresh_remaining)
HudPresenter.refresh(stage, info_panel, ap_bar, settlement, runner, _level_active, db, refresh_remaining)
_sync_settlement_visuals()
@@ -574,7 +529,7 @@ func _phase_text() -> String:
if settlement == null:
return "第 — 轮 · —"
return Narrator.phase_text(int(settlement.round_index), String(settlement.battle_result),
inventory_panel.is_window_open(), settlement.action_economy.phase == settlement.action_economy.PHASE_WAITING,
settlement.action_economy.phase == settlement.action_economy.PHASE_WAITING,
_boss_active, Narrator.boss_name(runner, db))
@@ -7,7 +7,7 @@
# 文件 = user://logs/playtest_<seed>_<时间戳>.jsonl(每行一次结算/终局/节点的全量状态 JSON)。
# ux 行族(呈现层观测,不改 core):ux_round_input_ratio(原始输入动作数∶AP 消耗)、
# ux_round_exit{path_tag}(快照 round_facts.path——呈现层只订 战斗结算 的 ADR 纪律下唯一合法来源)、
# ux_refresh_attempt / ux_window_choice / ux_reject / ux_hint_shown(组合根显式上报)。
# ux_refresh_attempt / ux_reject / ux_hint_shown(组合根显式上报;ux_window_choice 随道具窗口删除下线 2026-09-15)。
# 诊断转储 = 全量核心状态 + 最近结算/操作环形缓冲 → user://logs/diag_<时间戳>.json(卡死时点按钮)。
class_name PlaytestLogger
extends RefCounted
@@ -55,7 +55,7 @@ func attach_battle(settlement) -> void:
settlement.settlement_snapshot.connect(_on_settlement_snapshot)
settlement.battle_finished.connect(func(p): _append({
"kind": "battle_end", "result": String(p.get("result", "")),
"hero_hp": int(p.get("hero_hp", 0)), "remaining_items": int(p.get("remaining_items", 0))}))
"hero_hp": int(p.get("hero_hp", 0))}))
# ---- ux 遥测(battle.md §Events Fired——呈现层观测行族,不改 core) ----
@@ -96,12 +96,9 @@ func log_node(node: Dictionary, info: Dictionary, node_index: int) -> void:
var line := {"kind": "node", "type": t, "index": node_index}
if t == "battle" or t == "boss":
line["depth"] = int(node.get("node_depth", 0))
elif t == "reward":
var snap: Dictionary = info.get("snapshot", {})
line["reward_id"] = String(snap.get("reward_id", ""))
line["power"] = int(snap.get("power", 0))
elif t == "rest":
line["healed"] = int(info.get("healed", 0))
# reward 节点:内容待拍板(格面资源批 2026-09-15 道具删除)——零载荷
_append(line)
@@ -115,13 +112,12 @@ func log_run(meta: Dictionary) -> void:
func log_level_end(payload: Dictionary) -> void:
_append({"kind": "level_end", "result": String(payload.get("result", "")),
"node_index": int(payload.get("node_index", -1)), "hp": int(payload.get("hp", 0)),
"remaining_items": int(payload.get("remaining_items", -1))})
"node_index": int(payload.get("node_index", -1)), "hp": int(payload.get("hp", 0))})
func _on_step_row(row: Dictionary) -> void:
_ring(_recent_steps, {"t": row.type, "src": row.source_class, "v": int(row.value), "r": int(row.round_index)})
# 结算明细行逐行落盘(2026-09-12 用户要求——七段结算完整回放数据面)
# 结算明细行逐行落盘(2026-09-12 用户要求——五段结算完整回放数据面)
_append({"kind": "step", "type": row.type, "source": row.source_class,
"cell": row.origin_cell, "round": int(row.round_index),
"text": String(row.display_name), "value": int(row.value),
@@ -7,8 +7,8 @@
extends RefCounted
# 战斗屏五区(A 进度条 / B 战斗画面 / C·D·E 三列)。返回句柄字典:
# {strip_view, stage, log_view, info_panel, board_zone, board_view, ap_bar, inventory_panel}
# 战斗屏四区(A 进度条 / B 战斗画面 / C·D 两列——E 区道具面板已随可选类删除下线,2026-09-15 格面资源批)。
# 返回句柄字典:{strip_view, stage, log_view, info_panel, board_zone, board_view, ap_bar}
static func build_five_zone(root: Control, db) -> Dictionary:
# set_anchors_and_offsets_preset(非 set_anchors_preset):后者会把控件重置回最小内容尺寸
# (实测 927×613——五区缩顶、棋盘 45px 格 ×10 行超出 D 区 333px 高被裁,2026-09-13 用户报告回归)
@@ -30,7 +30,7 @@ static func build_five_zone(root: Control, db) -> Dictionary:
var stage := BattleStageView.new(db)
root.add_child(stage)
# C/D/E 三列(520px)
# C/D 两列(520px)——E 区库存面板删除后 D 区横向加宽
var bottom := HBoxContainer.new()
bottom.size_flags_vertical = Control.SIZE_EXPAND_FILL
bottom.add_theme_constant_override("separation", 4)
@@ -42,9 +42,6 @@ static func build_five_zone(root: Control, db) -> Dictionary:
var board_zone := BoardZoneView.new()
bottom.add_child(board_zone)
var inventory_panel := InventoryPanelView.new()
bottom.add_child(inventory_panel)
return {
"strip_view": strip_view,
"stage": stage,
@@ -53,7 +50,6 @@ static func build_five_zone(root: Control, db) -> Dictionary:
"board_zone": board_zone,
"board_view": board_zone.board_view,
"ap_bar": board_zone.ap_bar,
"inventory_panel": inventory_panel,
}
@@ -106,14 +102,14 @@ static func build_start_overlay(host: Control) -> Dictionary:
seed_edit.custom_minimum_size = Vector2(120, 0)
seed_row.add_child(seed_edit)
var lvbtn := UiKit.button("▶ 第一关 · 苔藓回廊(战×2 → 道具 → 战×2 → 恢复 → Boss)")
var lvbtn := UiKit.button("▶ 第一关 · 苔藓回廊(战×2 → 奖励点 → 战×2 → 恢复 → Boss)")
box.add_child(lvbtn)
var tunebtn := UiKit.button("测试用 · 角色数值调整板(模拟天赋树属性提升)")
box.add_child(tunebtn)
var dbgbtn := UiKit.button("调试 · 连续关卡两层(库存与 HP 关内延续)")
var dbgbtn := UiKit.button("调试 · 连续关卡两层(HP 关内延续)")
box.add_child(dbgbtn)
var help := UiKit.label("玩法:每轮 8 AP——挖开(1AP)/插旗(1AP)/移动(免费)/连锁(免费)。\n安全格=拾取战利品(轮末结算生效)· 雷=敌方获得伏击 · 标中旗=削弱敌人。\n停手:剩余 AP 转存 RAP;弃图刷新:RAP−8 当场换图继续挖(每轮剩 2 次)。\n敌人全灭胜、主角倒下负。悬停可看三态光标与可挖域微光。", 13, false)
var help := UiKit.label("玩法:每轮 8 AP——挖开(1AP)/插旗(1AP)/移动(免费)/连锁(免费)。\n每个非封锁格都藏一种格面资源(增伤/减伤/回血/爆裂/削弱)。开安全格、标中雷 = 正向兑付;\n开雷格、标错安全格 = 负向兑付——全部在轮末结算逐行兑现。\n停手:剩余 AP 转存 RAP;弃图刷新:RAP−8 当场换图继续挖(每轮剩 2 次)。\n敌人全灭胜、主角倒下负。悬停可看三态光标与可挖域微光。", 13, false)
help.add_theme_color_override("font_color", Color8(178, 166, 146))
box.add_child(help)
@@ -3,8 +3,9 @@
# 实时流 / 点击展开回看 overlay〔pattern 6 模态纪律〕;行模板「{行为} {值} → {目标}(来源)」、
# 聚合计数行、零值行可见、效果行 value≠pool_value 禁相加、未生效四类行 icon/划线形状冗余、
# highlight 金框〔flag_hit/clear_reward_h2/perfect_broken〕、击杀标记)。
# Date: 2026-09-12(基础 = 2026-09-11 13 类行协议格式化版;2026-09-15 窗口锚行批 13→15 类:
# 新增 window_open / window_skip 两类锚行格式化——战斗结算 行协议清单同步)
# Date: 2026-09-12(基础 = 2026-09-11 13 类行协议格式化版;2026-09-15 格面资源批 15→10 类:
# 删 item / consumed_ineffective / use_rejected / window_open / window_skip 五类——可选类道具与窗口下线,
# 战斗结算 行协议清单同步)
# 高内聚边界:战斗结算 步骤流 → 玩家文案的全部格式化规则内聚于此;来源文案映射来自 SettlementTable。
class_name BattleLogView
extends PanelContainer
@@ -13,7 +14,7 @@ signal review_requested
const BAND_H_DIG := 24
const BAND_H_SETTLE := 84
const AGG_TYPES := ["reward_ineffective", "consumed_ineffective", "use_rejected", "overflow"]
const AGG_TYPES := ["reward_ineffective", "overflow"]
const COL_GOLD := "#f0a828"
const COL_INK := "#33261b"
@@ -181,10 +182,8 @@ func _format_row(row: Dictionary, agg_count := -1) -> String:
return prefix + "[color=%s]%s[/color]" % [body_col, body]
"heal":
return prefix + "[color=#7cc47c]%s +%d%s%s[/color]" % [name_, v, tail, suffix]
"item":
return prefix + "[color=#d0c090]%s(+%d)%s[/color]" % [name_, v, suffix]
"reward_ineffective", "consumed_ineffective", "use_rejected", "overflow":
# 未生效四类行:⚠ icon + 划线形状冗余(非色相独载)+ 聚合计数
"reward_ineffective", "overflow":
# 未生效两类行:⚠ icon + 划线形状冗余(非色相独载)+ 聚合计数
var n := (" ×%d" % agg_count) if agg_count > 1 else ""
return prefix + "[s][color=#b08060]⚠ %s%s[/color][/s]%s" % [name_, n, suffix]
"flag_miss":
@@ -200,10 +199,6 @@ func _format_row(row: Dictionary, agg_count := -1) -> String:
return "[color=#e0b860][b]◆ %s[/b][/color]" % name_
"highlight":
return prefix + _gold_frame("★ %s" % name_)
"window_open":
return prefix + "[color=#c8b070]▤ %s[/color]" % name_
"window_skip":
return prefix + "[color=#9a8a72]▤ %s[/color]" % name_
return prefix + name_ + suffix
@@ -4,7 +4,7 @@
# Date: 2026-09-12(battle.md 五区改造批;基础 = 2026-09-11 HUD框架-lite 版)
# 棋盘视图:格子渲染(art-bible 明亮风、数字高对比、
# 绘制优先级 数字>小人>旗>奖励徽章>底色)+ 玩家输入意图翻译 + 悬停预承诺三态。
# 高内聚边界:只读 排雷玩法 查询面(+奖励道具经济 身份解析回调)做渲染与意图识别,动作经信号外发——不调用核心写接口
# 高内聚边界:只读 排雷玩法 查询面(+地图战斗资源 身份解析回调)做渲染与意图识别,动作经信号外发——不调用核心写接口
# (settle_batch 归组合根编排),不持久持有战斗态(minesweeper 引用随 refresh 更新,仅作只读查询)。
# 缺陷①回归约束:格子控件常驻、逐格重绘在 refresh 内完成,无布局回流(格控件/子标记定长,不增删)。
# ADR-0003 形态约束:GridContainer + 逐格常驻控件(探针耦合 _cells/click_cell——禁盲目迁移 _draw)。
@@ -24,8 +24,8 @@ const COL_GOLD := Color8(240, 168, 40) # 奖励金 #F0A828
const COL_PATH := Color8(46, 110, 142) # 路径预览点迹——小人蓝细线
const COL_DIG_EDGE := Color8(120, 190, 120) # 可挖域呼吸微光基色(绿相,明度贴格底)
const COL_HL_TARGET := Color8(240, 168, 40) # 悬停目标格边框(金)
const REWARD_BADGE := { # 已开格奖励身份 icon(tier 档位另经字号/形状区分)
"instant_aoe": "爆", "instant_heal": "急", "damage_item": "刃", "heal_pack": "医", "buff": "纹",
const REWARD_BADGE := { # 已开格格面资源身份 icon(cell_type 五型键;极性视觉归批 2)
"attack": "攻", "defense": "守", "heal": "医", "blast": "爆", "weaken": "弱",
}
const BADGE_PLACEHOLDER := "?" # 未知图标键占位纹样(HUD框架:禁静默空图标)
const CURSOR_DIG := Input.CURSOR_CROSS # 挖——镐形自绘纹理载体
@@ -39,13 +39,13 @@ var _cell_sb := {} # Vector2i → StyleBoxFlat(呼吸微光就地改
var _marker := {} # Vector2i → {"dot": ColorRect, "badge": Label, "hl": Panel}
var _board: BoardData = null
var _minesweeper = null # 排雷玩法 只读查询引用(refresh 更新;null = 无战斗)
var _identity_cb := Callable() # 奖励道具经济 身份解析回调(组合根注入只读面)——Vector2i → Dictionary/null
var _identity_cb := Callable() # 地图战斗资源 身份解析回调(组合根注入只读面)——Vector2i → Dictionary/null
var _diggable := {} # 可挖域(8 向相邻未开格)——呼吸微光作用集
var _breath_t := 0.0
var _dig_glow := false # 挖掘期才允许唯一在动元素(art-bible §2.1)
var _hover := Vector2i(-9, -9)
var _last_click := {"cell": Vector2i(-9, -9), "ms": -100000}
var _r0 := {} # 出生展开区 R₀(奖励道具经济 mapping 资格域 = S∖R₀——徽章查询跳过,防 E19 诊断噪音)
var _r0 := {} # 出生展开区 R₀(地图战斗资源 mapping 资格域 = S∖R₀——徽章查询跳过,防 E19 诊断噪音)
var _desat_mat: ShaderMaterial
var _desat_tween: Tween
var _cell := 46.0 # 当前格边长(set_cell_area 按容器尺寸重算——battle.md 格尺寸公式)
@@ -87,7 +87,7 @@ func set_desaturated(on: bool) -> void:
(_cells[c] as Button).material = _desat_mat if on else null
# 组合根注入 奖励道具经济 身份解析只读回调(query_identity)——奖励徽章数据源
# 组合根注入 地图战斗资源 身份解析只读回调(query_identity)——奖励徽章数据源
func set_identity_resolver(cb: Callable) -> void:
_identity_cb = cb
@@ -266,19 +266,19 @@ func _update_badge(c: Vector2i, btn: Button) -> void:
if _minesweeper == null or not _minesweeper.is_revealed(c) or not _identity_cb.is_valid():
return
if _r0.has(c):
return # R₀ 初始展开格无奖励身份(奖励道具经济 资格域 = S∖R₀)
return # R₀ 初始展开格无奖励身份(地图战斗资源 资格域 = S∖R₀)
var rec: Dictionary = _minesweeper.reveal_record(c) # E2 收口:已开记录经查询面(禁直读 排雷玩法 状态字典)
if rec.get("kind", "") != "safe":
return
var ident = _identity_cb.call(c)
if not (ident is Dictionary):
return
badge.text = _badge_glyph(String(ident.get("category", "")))
badge.text = _badge_glyph(String(ident.get("cell_type", "")))
badge.add_theme_font_size_override("font_size", maxi(10, int(_cell * 0.28)))
badge.visible = true
# 奖励图标键解析(HUD框架:查不到的键立即断言 + ? 占位纹样 + 诊断;键集 = 奖励道具经济 category 五值枚举)
# 奖励图标键解析(HUD框架:查不到的键立即断言 + ? 占位纹样 + 诊断;键集 = 格面资源 cell_type 五值枚举)
func _badge_glyph(category: String) -> String:
if REWARD_BADGE.has(category):
return REWARD_BADGE[category]
@@ -1,226 +0,0 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 实现 GDD:design/ux/battle.md(E 区道具 280×520——库存常读列表〔逐件 icon+名称+属性简述,
# 枚举序 = stock 受理序、固定槽高 + clip_contents(缺陷①教训)〕;窗口期原位激活为道具使用窗口
# 〔布局决策 D2 / pattern 2 原位窗口激活〕:描边金光点亮、标题切换+时机指示、usable_now 禁选态
# +原因〔pattern 8 禁用态即理由〕、跳过按钮、政策注记常驻)。
# Date: 2026-09-12(改造自 item_window_overlay.gd——中央模态形态废弃,原位激活替代)
# 契约红线:选择语义 = 窗口条目位置(战斗结算 义务④"选定序"),库存句柄不出本类
# (缺陷④契约约束的呈现侧收口点)。来源 {cell, round} 不呈现于列表与 tooltip(2026-09-12 拍板)。
class_name InventoryPanelView
extends PanelContainer
signal item_selected(pos: int)
signal skipped
signal reject_reason(text: String) # 禁选件点击 → pattern 8 原因 toast(组合根接 toast 槽)
const COL_GOLD := Color8(240, 168, 40)
const ROW_H := 44
var _title: Label
var _timing: Label
var _list: VBoxContainer
var _skip_btn: Button
var _policy: Label
var _normal_title := "道包 · 关卡域"
var _window_title := "⏳ 轮末道具窗口 · 至多选 1 件"
var _panel_sb: StyleBoxFlat
var _window_mode := false
var _cleared_hold_until := 0 # 库存清空反馈持有期(ms 时间戳——期间常读刷新不覆盖提示)
func _init() -> void:
custom_minimum_size = Vector2(280, 0)
clip_contents = true
_panel_sb = StyleBoxFlat.new()
_panel_sb.bg_color = Color8(232, 220, 196)
_panel_sb.set_corner_radius_all(0)
_panel_sb.set_border_width_all(1)
_panel_sb.border_color = Color8(120, 106, 88)
_panel_sb.content_margin_left = 8.0
_panel_sb.content_margin_right = 8.0
_panel_sb.content_margin_top = 6.0
_panel_sb.content_margin_bottom = 6.0
add_theme_stylebox_override("panel", _panel_sb)
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 4)
add_child(col)
_title = UiKit.label(_normal_title, 16, true)
col.add_child(_title)
_timing = UiKit.label("", 12, false)
_timing.add_theme_color_override("font_color", Color8(140, 124, 100))
col.add_child(_timing)
_list = VBoxContainer.new()
_list.add_theme_constant_override("separation", 2)
_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
col.add_child(_list)
_skip_btn = UiKit.button("跳过")
_skip_btn.visible = false
_skip_btn.pressed.connect(func(): skipped.emit())
col.add_child(_skip_btn)
_policy = UiKit.label("注:未选用件随关卡结束清空", 12, false)
_policy.add_theme_color_override("font_color", Color8(140, 124, 100))
_policy.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
col.add_child(_policy)
# ---- 常读态(挖掘期规划面——奖励道具经济 D1 义务①) ----
func show_inventory(rows: Array) -> void:
if Time.get_ticks_msec() < _cleared_hold_until:
return # 清空反馈展示中(「剩余道具 ×N 随关卡结束清空」)
_window_mode = false
_set_window_visual(false)
_title.text = _normal_title
_timing.text = ""
_skip_btn.visible = false
_rebuild_rows(rows, false)
# 空态(指引性,非报错——奖励道具经济 EC 空态)
func show_empty() -> void:
if Time.get_ticks_msec() < _cleared_hold_until:
return
show_inventory([])
_add_empty_hint()
# ---- 窗口态(pattern 2 原位激活:面板描边金光点亮、标题切换 + 时机指示常驻) ----
# items = 战斗结算 组装窗口投影(usable_now 由 战斗结算 组装——呈现层只消费不推断)
func open_window(items: Array) -> void:
_window_mode = true
_set_window_visual(true)
_title.text = _window_title
_timing.text = "时机:授予段后 · 增益/减益段前"
_skip_btn.visible = true
_rebuild_rows(items, true)
func close_window() -> void:
_window_mode = false
_set_window_visual(false)
func is_window_open() -> bool:
return _window_mode
# 探针兼容面:窗口悬置判定
func is_open() -> bool:
return _window_mode
# 库存清空反馈(奖励道具经济 D1×⑤):逐件化光点飞出 + 「剩余道具 ×N 随关卡结束清空」占位
func play_cleared_feedback(remaining: int) -> void:
_cleared_hold_until = Time.get_ticks_msec() + 3000
var rows := _list.get_children()
for r in rows:
var tw := (r as Control).create_tween()
tw.tween_property(r, "modulate:a", 0.0, 0.5)
tw.tween_property(r, "position:y", (r as Control).position.y - 40.0, 0.5)
_tw_after_clear(remaining)
func _tw_after_clear(remaining: int) -> void:
var tw := create_tween()
tw.tween_interval(0.6)
tw.tween_callback(func():
_title.text = _normal_title
_cleared_hold_until = 0
_rebuild_rows([], false)
_add_empty_hint("剩余道具 ×%d 随关卡结束清空" % remaining))
# ---- 行构建(固定槽高 + clip——缺陷①回归约束) ----
func _rebuild_rows(rows: Array, window: bool) -> void:
for child in _list.get_children():
child.queue_free()
for r in rows:
_list.add_child(_make_row(r, window))
if rows.is_empty() and not window:
_add_empty_hint()
func _add_empty_hint(text := "道包空空——安全格里藏着补给") -> void:
var lab := UiKit.label(text, 13, false)
lab.add_theme_color_override("font_color", Color8(140, 124, 100))
lab.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
lab.custom_minimum_size = Vector2(0, ROW_H)
lab.clip_contents = true
_list.add_child(lab)
func _make_row(r: Dictionary, window: bool) -> PanelContainer:
var row := PanelContainer.new()
row.custom_minimum_size = Vector2(0, ROW_H)
row.clip_contents = true
var sb := StyleBoxFlat.new()
sb.bg_color = Color8(224, 208, 180)
sb.set_corner_radius_all(0)
sb.set_border_width_all(1)
sb.border_color = Color8(150, 134, 112)
sb.content_margin_left = 6.0
sb.content_margin_right = 6.0
sb.content_margin_top = 3.0
sb.content_margin_bottom = 3.0
row.add_theme_stylebox_override("panel", sb)
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 6)
row.add_child(h)
var icon := UiKit.label(_icon_glyph(String(r.get("category", ""))), 16, true)
icon.custom_minimum_size = Vector2(20, 0)
h.add_child(icon)
var mid := VBoxContainer.new()
mid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
mid.alignment = BoxContainer.ALIGNMENT_CENTER
h.add_child(mid)
var name_lab := UiKit.label(String(r.get("display_name", "")), 14, true)
mid.add_child(name_lab)
var desc := UiKit.label(String(r.get("desc", "")), 11, false)
desc.add_theme_color_override("font_color", Color8(120, 106, 88))
desc.clip_text = true
desc.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
mid.add_child(desc)
row.tooltip_text = String(r.get("tooltip", "")) # 完整描述 + 触发注记(不含来源——拍板)
if not window:
var st := UiKit.label(String(r.get("status", "")), 12, false)
st.size_flags_vertical = Control.SIZE_SHRINK_CENTER
h.add_child(st)
return row
# 窗口态:usable_now 禁选 + 原因(pattern 8——灰置 + tooltip「HP 已满 · 禁选」+ 点击原因 toast)
var usable: bool = bool(r.get("usable_now", false))
var use_btn := UiKit.button("选用")
use_btn.custom_minimum_size = Vector2(56, 0)
use_btn.size_flags_vertical = Control.SIZE_SHRINK_CENTER
use_btn.disabled = not usable
use_btn.pressed.connect(func(): item_selected.emit(int(r.get("pos", -1))))
h.add_child(use_btn)
if not usable:
name_lab.add_theme_color_override("font_color", Color8(150, 140, 130))
row.tooltip_text = String(r.get("reason", "暂不可用")) + "\n" + row.tooltip_text
row.gui_input.connect(func(ev: InputEvent):
if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT:
reject_reason.emit(String(r.get("reason", "暂不可用"))))
return row
func _icon_glyph(category: String) -> String:
match category:
"damage_item":
return "刃"
"heal_pack":
return "医"
"buff":
return "纹"
return "◆"
func _set_window_visual(on: bool) -> void:
# 原位激活视觉(pattern 2):描边金光点亮(占位级——静态金描边)
if on:
_panel_sb.set_border_width_all(3)
_panel_sb.border_color = COL_GOLD
else:
_panel_sb.set_border_width_all(1)
_panel_sb.border_color = Color8(120, 106, 88)
@@ -1 +0,0 @@
uid://cnd5vc3sonulf
@@ -32,10 +32,10 @@ func _init() -> void:
visible = false
func show_reward(snap: Dictionary) -> void:
_label.text = "关卡奖励点\n获得「%s」(威力 %d)\n已收入库存——跨战斗延续 · 关卡末清空" % [
String(snap.get("display_name", "")), int(snap.get("power", 0))]
_btn.text = "收下,继续"
func show_reward() -> void:
# 格面资源批(2026-09-15):奖励点内容随可选类道具删除悬置(功能道具渠道 P1 待拍板)——直通节点确认
_label.text = "关卡奖励点\n(节点内容待定——冒险结构/关卡节点地图 定稿批裁决)"
_btn.text = "继续"
visible = true
@@ -10,9 +10,9 @@ extends HBoxContainer
const MAX_SLOTS := 4
# 极性/语义映射(数据源 = SettlementTable.effect_pool_map 效果语义 + StatusEffectTable display_name):
# 益 = ▲/◆/▼(金/蓝族);害 = ⚡/⚔/✕(旗朱族)——形状+色相双通道,文字名常驻第三通道。
# 状态表六行全映射(attack_up/defense_up/weaken/ambush/enrage/off_guard)——HUD框架 图标键契约
# "一键一枚专属纹样,查不到的键立即断言 + ? 占位 + 诊断"(2026-09-15 补暴走/失衡两键)。
# 益 = ▲/◆/▼(金/蓝族);害 = ⚡/⚔/✕/☾(旗朱族)——形状+色相双通道,文字名常驻第三通道。
# 状态表七行全映射(attack_up/defense_up/weaken/ambush/enrage/off_guard/sapped)——HUD框架 图标键契约
# "一键一枚专属纹样,查不到的键立即断言 + ? 占位 + 诊断"(2026-09-15 格面资源批补 sapped 键)。
const EFFECT_ICON := {
"attack_up": ["▲", Color8(176, 122, 20), "益"],
"defense_up": ["◆", Color8(46, 110, 142), "益"],
@@ -20,14 +20,16 @@ const EFFECT_ICON := {
"ambush": ["⚡", Color8(198, 61, 34), "害"],
"enrage": ["⚔", Color8(198, 61, 34), "害"],
"off_guard": ["✕", Color8(198, 61, 34), "害"],
"sapped": ["☾", Color8(198, 61, 34), "害"],
}
const EFFECT_SEMANTIC := {
"attack_up": "主角攻击加成(hero_offense 池加算)",
"defense_up": "受到伤害减免(hero_defense 池加算)",
"weaken": "敌方输出削弱(enemy_output 池乘算)",
"ambush": "敌方全体攻击附加(flat_attach——踩雷转化,战斗段共享不消耗)",
"enrage": "敌方输出加成(enemy_output 池加算——标错反噬)",
"off_guard": "主角承伤加深(hero_defense 池加算——标错反噬)",
"ambush": "敌方全体攻击附加(flat_attach——blast 型负向,战斗段共享不消耗)",
"enrage": "敌方输出加成(enemy_output 池加算——attack 型负向)",
"off_guard": "主角承伤加深(hero_defense 池加算——defense 型负向)",
"sapped": "主角输出降低(hero_offense 池加算——weaken 型负向)",
}
const SOURCE_NAMES := {"safe_cell": "安全格", "flag_hit": "标中旗", "mine_hit": "踩雷", "clear_reward": "清图奖励"}
# 未知图标键的占位纹样(HUD框架:占位也要可见,禁静默空图标)
@@ -1,8 +1,8 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-13(O4/W-3 收口——自动玩家策略四份复制单一实现)
# 自动玩家策略集(headless 冒烟/扫描/转储共用的理性玩家近似):
# choose_dig = 挖最近可挖格(先移到邻接已开格);window_selection = 道具窗口确定性选择
# (治疗包优先于刀;半血阈值/轮次门/刀力口径经参数表达——四调用点原语义逐一保留)。
# choose_dig = 挖最近可挖格(先移到邻接已开格)。
# 2026-09-15 格面资源批:window_selection(道具窗口确定性选择)随道具窗口删除下线。
# 读取面:战斗结算.presentation_snapshot() / 排雷玩法 查询面(E1/E2 收口后的成文 API——禁直读核心状态)。
# 等价性证明:smoke_all 同种子回放断言(步骤流逐行相同)在重构后保持全绿。
extends RefCounted
@@ -28,47 +28,6 @@ static func choose_dig(minesweeper) -> Dictionary:
return {}
# 道具窗口确定性选择(s = settlement.presentation_snapshot();半血以下治疗包优先,否则刀):
# opts.heal_hp_div = 治疗包启用分母(hero_hp × div ≤ max_hp——原四实现 2/3 两档)
# opts.round_gate = 刀启用轮次门(round ≥ gate 才用刀;0 = 不设门——monte_carlo 口径)
# opts.knife_compare = "best"(最强单刀 ≥ 敌余 HP)| "sum"(刀力合计——dump/monte 口径)
static func window_selection(s: Dictionary, items: Array, opts: Dictionary = {}) -> Array:
var heal_div: int = int(opts.get("heal_hp_div", 3))
var round_gate: int = int(opts.get("round_gate", 3))
var compare: String = String(opts.get("knife_compare", "best"))
var enemy_hp := 0
for u in s.enemies:
if bool(u.alive):
enemy_hp += int(u.hp)
var best_knife := -1
var best_power := 0
var sum_power := 0
var best_heal := -1
var best_heal_power := 0
for i in items.size():
var it: Dictionary = items[i]
if not bool(it.usable_now):
continue
if it.category == "damage_item":
sum_power += int(it.power)
if int(it.power) > best_power:
best_power = int(it.power)
best_knife = i
elif it.category == "heal_pack":
best_heal = i
best_heal_power = int(it.power)
var knife_power: int = best_power if compare == "best" else sum_power
var selection: Array = []
# heal_no_waste(完美档):缺口 < 治疗量时不用——满血贴药 = 白费一次性资源(严格劣策略)
var heal_missing_ok: bool = not bool(opts.get("heal_no_waste", false)) \
or int(s.hero.max_hp) - int(s.hero.hp) >= best_heal_power
if best_heal >= 0 and int(s.hero.hp) * heal_div <= int(s.hero.max_hp) and heal_missing_ok:
selection.append(best_heal)
elif best_knife >= 0 and knife_power >= enemy_hp and (round_gate <= 0 or int(s.round) >= round_gate):
selection.append(best_knife)
return selection
# ==================== 完美策略档(2026-09-14 用户拍板:OQ7 技能轴上界测量仪——只作测量,非玩法数值) ====================
# 策略语义:零盲挖(只挖已证安全格 ⇒ 构造保证零踩雷)· 已证雷格全标旗(旗先于挖——旗 = 纯收益
# 〔轮末 weaken + 魔法 首次充能〕且雷上旗不阻连锁)· 卡死且刷新资源可用 → 轮内刷新
@@ -77,7 +36,6 @@ static func window_selection(s: Dictionary, items: Array, opts: Dictionary = {})
# 口径差异 = unknown 集不含 棋盘生成 生成期 safe_set oracle(真实玩家约束的未知邻格含雷格)——比生成期闸
# 保守,卡死率略高属口径诚实。输入只经 排雷玩法 查询面(reveal_record/is_revealed/has_flag/current_board
# 的邻居与可达面),禁读棋盘雷区真值(测推理上界,非全知作弊)。
# 道具窗口:轮次门解除(round_gate=0)+ 治疗无浪费(heal_no_waste)。
# 待拍板(未实现):非卡死主动刷新、囤刀倾泻 vs 即用即耗、完美板目标加权。
# 单步决策入口(每动作一查——挖掘揭示新数字后重推理):move/dig/flag/refresh/stop 五值。
@@ -88,9 +88,6 @@ func _run_boss(db, seed_val: int, dig: bool) -> Dictionary:
settlement.step_row.connect(func(row): cap["rows"].append(row))
settlement.settlement_snapshot.connect(func(s): cap["snaps"].append(s))
settlement.battle_finished.connect(func(p): cap["result"] = String(p.get("result", "")))
settlement.window_opened.connect(func(items):
var s: Dictionary = settlement.presentation_snapshot()
settlement.submit_window_selection(AutoPlayer.window_selection(s, items, {"heal_hp_div": 2, "round_gate": 3, "knife_compare": "best"})))
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(db.hero.max_hp)}):
cap["result"] = "begin_failed"
return cap
@@ -301,7 +298,6 @@ func _run_level(db, seed_val: int) -> Dictionary:
var runner = load("res://core/level_runner.gd").new(db, rng)
var cap := {"battles": [], "rows": [], "result": "", "end_node": -1}
runner.step_row.connect(func(row): cap["rows"].append(row))
runner.window_opened.connect(_on_level_window.bind(runner))
runner.node_entered.connect(_on_level_node_entered.bind(runner, cap))
runner.round_started.connect(_on_level_round_started.bind(runner, cap))
runner.level_finished.connect(_on_level_finished.bind(cap))
@@ -331,15 +327,6 @@ func _battle_nodes_upto(db, level_index: int, node_idx: int) -> int:
return n
func _on_level_window(items: Array, runner) -> void:
var settlement = runner.current_battle()
if settlement == null:
runner.submit_window_selection([])
return
var s: Dictionary = settlement.presentation_snapshot()
runner.submit_window_selection(AutoPlayer.window_selection(s, items, {"heal_hp_div": 2, "round_gate": 3, "knife_compare": "best"}))
func _on_level_node_entered(node: Dictionary, _info: Dictionary, runner, cap: Dictionary) -> void:
var t := String(node.get("type", ""))
if t == "battle" or t == "boss":
@@ -15,11 +15,6 @@ func _initialize() -> void:
var settlement = load("res://core/battle_orchestrator.gd").new(db, rng)
var stream: Array = []
settlement.step_row.connect(func(row): stream.append(row))
settlement.window_opened.connect(func(items):
var s: Dictionary = settlement.presentation_snapshot() # E1 收口:经呈现快照面读英雄/敌况
var selection: Array = AutoPlayer.window_selection(s, items,
{"heal_hp_div": 3, "round_gate": 3, "knife_compare": "sum"}) # O4:原 dump_battle 口径
settlement.submit_window_selection(selection))
var level := 2
if OS.get_cmdline_user_args().size() > 0:
level = int(OS.get_cmdline_user_args()[0])
@@ -4,7 +4,8 @@
# 批量调用方传共享 logger(log_run 局次标记切分)。
# 关卡自动通关策略(理性玩家近似)——呈现层数值调整板的批量模拟与 headless 期望扫描(level_monte_carlo)
# 共用同一实现,保证"面板模拟读数"与"扫描表读数"同口径。策略与 smoke_all 战斗自动玩家同族:
# 挖最近可挖格 / 无可挖停手 / 半血以下用治疗包 / 击杀有把握且第 3 轮起用最强刀 / 奖励恢复点立即确认。
# 挖最近可挖格 / 无可挖停手 / 奖励恢复点立即确认。
# 2026-09-15 格面资源批:道具窗口选择与可选类获取计数随可选类道具删除下线。
# 2026-09-14 增完美策略档(policy="perfect",默认 "normal" 零行为变更——既有调用方签名兼容):
# 零盲挖/全标旗/卡死刷新——上界测量仪,求解器与动作语义见 auto_player.gd 完美策略档节。
# 运行前提:db 已 load_tables;每个 run_level 全新 RngService(同种子 ⇒ 全程逐步可回放)。
@@ -26,13 +27,12 @@ static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct:
var runner = load("res://core/level_runner.gd").new(db, rng)
var st := {
"result": "", "hp_left": 0, "battles": 0, "rounds": 0, "hero_dmg": 0,
"reward_snapshot": {}, "rest_healed": 0, "hp_before_rest": -1,
"defeat_node_index": -1, "defeat_node_type": "", "remaining_items": -1,
"rest_healed": 0, "hp_before_rest": -1,
"defeat_node_index": -1, "defeat_node_type": "",
"boss_depth": -1, "hp_at_boss": -1, "rows": [], "timeout": false,
"actives_total": 0, "log_path": "", "policy": policy, "refreshes": 0, "mine_hits": 0,
"log_path": "", "policy": policy, "refreshes": 0, "mine_hits": 0,
}
runner.step_row.connect(_on_step_row.bind(runner, st, collect_rows))
runner.window_opened.connect(_on_window_opened.bind(runner, st, logger))
runner.node_entered.connect(_on_node_entered.bind(runner, st, logger))
runner.level_finished.connect(_on_level_finished.bind(st, logger))
runner.set_hero_override(atk_pct, hp_pct)
@@ -63,29 +63,11 @@ static func _on_step_row(row: Dictionary, _runner, st: Dictionary, collect_rows:
if row.type == "damage" and row.source_class == "enemy_action":
st.hero_dmg += int(row.value)
if row.source_class == "mine_hit":
st.mine_hits += 1 # 踩雷证据行(effect/overflow——完美档断言恒 0;盲挖惩罚链埋点)
if row.type == "item":
st.actives_total += 1 # 可选类入库行 = 获取计数(供给控制器批度量口径)
st.mine_hits += 1 # 踩雷证据行(负向兑付——完美档断言恒 0;盲挖惩罚链埋点)
if collect_rows:
st.rows.append(row)
static func _on_window_opened(items: Array, runner, st: Dictionary, logger) -> void:
var settlement = runner.current_battle()
var s: Dictionary = settlement.presentation_snapshot() # E1 收口:经呈现快照面读英雄/敌况
var opts := {"heal_hp_div": 2, "round_gate": 3, "knife_compare": "best"} # O4:原 autoplay 口径
if String(st.get("policy", "normal")) == "perfect":
opts = {"heal_hp_div": 2, "round_gate": 0, "knife_compare": "best", "heal_no_waste": true}
var sel: Array = AutoPlayer.window_selection(s, items, opts)
if logger != null:
var chosen: Array = []
for i in sel:
chosen.append(String(items[i].get("item_id", "")))
logger.log_ux("ux_window_choice", {"selected_item_id": ",".join(chosen),
"usable_now": not sel.is_empty(), "skip": sel.is_empty()})
runner.submit_window_selection(sel)
static func _on_node_entered(node: Dictionary, info: Dictionary, runner, st: Dictionary, logger) -> void:
var t := String(node.get("type", ""))
if t == "battle" or t == "boss":
@@ -96,8 +78,7 @@ static func _on_node_entered(node: Dictionary, info: Dictionary, runner, st: Dic
st.boss_depth = int(node.get("node_depth", -1))
st.hp_at_boss = runner.hero_hp
elif t == "reward":
st.reward_snapshot = info.get("snapshot", {})
runner.continue_after_node()
runner.continue_after_node() # 奖励点 = 内容待拍板直通节点(格面资源批 2026-09-15)
elif t == "rest":
st.rest_healed = int(info.get("healed", 0))
st.hp_before_rest = int(info.get("hp", 0)) - st.rest_healed
@@ -111,7 +92,6 @@ static func _on_level_finished(p: Dictionary, st: Dictionary, logger) -> void:
st.hp_left = int(p.get("hp", 0))
st.defeat_node_index = int(p.get("node_index", -1))
st.defeat_node_type = String(p.get("node_type", ""))
st.remaining_items = int(p.get("remaining_items", -1))
if logger != null:
logger.log_level_end(p)
@@ -29,6 +29,6 @@ func _initialize() -> void:
print("开始:seed=%d atk+%d%% hp+%d%%" % [seed_val, atk_pct, hp_pct])
var st = autoplay.run_level(db, seed_val, 1, atk_pct, hp_pct)
print("结果: %s · 用时 %.2f 秒" % [st.result, float(Time.get_ticks_msec() - t0) / 1000.0])
print(" 战斗节点=%d 轮数=%d 整关承伤=%d 剩余HP=%d 进BossHP=%d 可选类获取=%d" % [st.battles, st.rounds, st.hero_dmg, st.hp_left, st.hp_at_boss, st.actives_total])
print(" 败北节点=%d(%s) 恢复点回复=%d(前HP %d) 奖励=%s" % [st.defeat_node_index, st.defeat_node_type, st.rest_healed, st.hp_before_rest, str(st.reward_snapshot)])
print(" 战斗节点=%d 轮数=%d 整关承伤=%d 剩余HP=%d 进BossHP=%d 踩雷=%d" % [st.battles, st.rounds, st.hero_dmg, st.hp_left, st.hp_at_boss, st.mine_hits])
print(" 败北节点=%d(%s) 恢复点回复=%d(前HP %d)" % [st.defeat_node_index, st.defeat_node_type, st.rest_healed, st.hp_before_rest])
quit(0)
@@ -41,7 +41,7 @@ func _initialize() -> void:
var atk_axis: Array = [0, 50, 100, 150, 200]
var hp_axis: Array = [0, 100]
print("=== 关卡期望扫描:第一关(战×2→道具→战×2→恢复→Boss)· N=%d/格 · 种子 %d+%d · 策略=%s ===" % [n, seed0, n, policy_arg])
print("%-6s %-10s %-8s %-14s %-14s %-14s %-10s %-12s %-12s %-10s %-10s %-8s %-8s" % ["策略", "atk%/hp%", "通关率", "整关承伤均/p90", "通关剩余HP均(胜)", "进BossHP均", "平均轮数", "可选类/战", "普通承伤/场%", "跳战率", "Boss承伤%", "踩雷/局", "刷新/局"])
print("%-6s %-10s %-8s %-14s %-14s %-14s %-10s %-12s %-12s %-10s %-10s %-8s %-8s" % ["策略", "atk%/hp%", "通关率", "整关承伤均/p90", "通关剩余HP均(胜)", "进BossHP均", "平均轮数", "踩雷/战", "普通承伤/场%", "跳战率", "Boss承伤%", "踩雷/局", "刷新/局"])
var perfect_mine_total := 0 # 完美档构造保证 = 零踩雷(只挖已证安全格)——扫描末断言
for policy in policies:
var tag := "基线" if policy == "normal" else "完美"
@@ -52,7 +52,7 @@ func _initialize() -> void:
var hp_left_win: Array = []
var hp_boss: Array = []
var rounds_all: Array = []
var actives_per_battle: Array = []
var mines_per_battle: Array = []
var normal_dmg_pct: Array = [] # 每普通战斗承伤占生效 max_hp 比(难度曲线与Boss F4 口径——≤20% 带)
var boss_dmg_pct: Array = [] # Boss 战承伤占生效 max_hp 比(难度曲线与Boss F4 口径——≤50% 带)
var wipes := 0 # 普通战斗 round-1 即胜数(首轮全灭跳战——拍板④观测面)
@@ -66,7 +66,7 @@ func _initialize() -> void:
rounds_all.append(st.rounds)
mine_per_run.append(int(st.mine_hits))
refresh_per_run.append(int(st.refreshes))
actives_per_battle.append(float(st.actives_total) / float(maxi(1, st.battles)))
mines_per_battle.append(float(st.mine_hits) / float(maxi(1, st.battles)))
var eff_max := maxi(1, int(round(float(db.hero.max_hp) * (100.0 + float(hp_pct)) / 100.0)))
# 逐战斗承伤切分(round_index 回卷 = 新战斗;末段 = Boss 战——当且仅当本局抵达 Boss 节点)
var segs := _damage_segments(st.rows)
@@ -97,9 +97,9 @@ func _initialize() -> void:
perfect_mine_total += int(round(mine_mean * float(n)))
print("%-6s atk%+3d%% hp%+3d%% %5.0f%% %6.1f / %-6.1f %6.1f %6.1f %6.1f %5.2f %6.1f %5.0f%% %6.1f %6.2f %6.2f %s" % [
tag, atk_pct, hp_pct, 100.0 * float(wins) / float(n),
_mean(dmg_all), _p90(dmg_all), _mean(hp_left_win), _mean(hp_boss), _mean(rounds_all), _mean(actives_per_battle),
_mean(dmg_all), _p90(dmg_all), _mean(hp_left_win), _mean(hp_boss), _mean(rounds_all), _mean(mines_per_battle),
_mean(normal_dmg_pct), 100.0 * float(wipes) / float(maxi(1, normal_battles)), _mean(boss_dmg_pct), mine_mean, _mean(refresh_per_run), death_txt])
print("(F4 带:承伤 ≤20%%/普通场 · ≤50%%/Boss 场,占生效 max_hp · F5 带:L1 基线通关率 [30,50]%%——2026-09-14 带锚定稿 · 跳战率 = 普通战斗 round-1 即胜(首轮全灭)占比 · 承伤含败者至死 · 败北节点 0-3=战斗节点序 4=Boss · 可选类/战=每战斗实际获取件数期望 · 完美档 = 技能轴上界测量(零盲挖/全标旗/卡死刷新——口径见 auto_player.gd)· 耗时 %.1f 秒)" % [float(Time.get_ticks_msec() - t0) / 1000.0])
print("(F4 带:承伤 ≤20%%/普通场 · ≤50%%/Boss 场,占生效 max_hp · F5 带:L1 基线通关率 [30,50]%%——2026-09-14 带锚定稿 · 跳战率 = 普通战斗 round-1 即胜(首轮全灭)占比 · 承伤含败者至死 · 败北节点 0-3=战斗节点序 4=Boss · 踩雷/战=每战斗负向兑付触发期望(格面资源批口径) · 完美档 = 技能轴上界测量(零盲挖/全标旗/卡死刷新——口径见 auto_player.gd)· 耗时 %.1f 秒)" % [float(Time.get_ticks_msec() - t0) / 1000.0])
if "perfect" in policies and perfect_mine_total > 0:
push_error("[level_monte_carlo] 完美策略档出现踩雷 %d 次——构造保证被破坏(只挖已证安全格),求解器/驱动有缺陷" % perfect_mine_total)
quit(1)
@@ -1,10 +1,12 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-11
# 战斗期望蒙特卡洛:当前参数下"胜轮数 / 每轮承伤 / 清图次数 / 开格数"的分布实测。
# 政策 = 理性玩家近似:击杀有把握即用刀、半血以下用库存治疗包、挖最近可挖格。
# 政策 = 理性玩家近似:挖最近可挖格(AutoPlayer 单一实现——O4 收口;道具有效期政策随可选类删除退役 2026-09-15)。
# 运行:Godot_console.exe --headless --path . --script res://smoke/monte_carlo.gd
extends SceneTree
const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口(批注 2026-09-15:原文件缺失 preload——HEAD 起即解析失败,本批顺手修复)
const N := 30
const SEED0 := 42
@@ -64,11 +66,6 @@ func _run_battle(db, seed_val: int, L: int) -> Dictionary:
settlement.minesweeper.cell_revealed.connect(func(evt):
if evt.kind == "safe" and evt.channel != "initial":
out.safe_opened += 1)
settlement.window_opened.connect(func(items):
var s: Dictionary = settlement.presentation_snapshot() # E1 收口:经呈现快照面读英雄/敌况
var selection: Array = AutoPlayer.window_selection(s, items,
{"heal_hp_div": 2, "round_gate": 0, "knife_compare": "sum"}) # O4:原 monte_carlo 口径
settlement.submit_window_selection(selection))
if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": int(db.hero.max_hp)}):
return out
var guard_rounds := 40
@@ -1,7 +1,8 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-11
# 奖励期望测量(供给控制器批口径):逐深度实测 |资格格|(safe ∖ R0)分布、每板注入件数(j=0 → 恒 1)、
# 注入种类分布(60/30/10 拍板值)、格子池零可选类验证——走真实 奖励道具经济.roll_board 管线(含注入判定取号序)。
# Date: 2026-09-11(2026-09-15 格面资源批重写:类型频次测量——供给控制器批口径随可选类删除退役)
# 奖励期望测量:逐深度实测 |非封锁格|(safe ∪ mines ∖ R₀)分布、格面资源类型频次
# (应涌现自正向行权重 attack 20 / defense 16 / blast 16 / heal 15 / weaken 12,合计 79)、
# 封锁格零泄漏(R₀ 格不在摇号清单)——走真实 地图战斗资源.roll_board 管线。
# 运行:Godot_console.exe --headless --path . --script res://smoke/reward_expectation.gd
extends SceneTree
@@ -17,14 +18,13 @@ func _initialize() -> void:
var board_generator = load("res://core/board_generator.gd").new()
var reward_economy_factory = load("res://core/reward_economy.gd")
var rng_factory = load("res://core/rng_service.gd")
var active_ids := {"knife_t1": true, "knife_adv": true, "heal_pack": true}
print("=== 奖励期望测量(供给控制器批 · N=%d 板/深度 · 每板全新战斗态 j=0)===" % N)
print("depth | 资格格均/min/max | 注入件数/板 均 | 小刀%% | 精工%% | 治疗包%% | 格池可选类泄漏")
var types := ["attack", "defense", "heal", "blast", "weaken"]
print("=== 奖励期望测量(格面资源批 · N=%d 板/深度)===" % N)
print("depth | 非封锁格均/min/max | attack% | defense% | heal% | blast% | weaken% | 封锁格泄漏板")
for depth in range(5):
var elig: Array = []
var act: Array = []
var type_counts := {"attack": 0.0, "defense": 0.0, "heal": 0.0, "blast": 0.0, "weaken": 0.0}
var leak := 0
var species := {"knife_t1": 0.0, "knife_adv": 0.0, "heal_pack": 0.0}
for i in range(N):
var rng = rng_factory.new()
rng.set_master_seed(90000 + depth * 1000 + i)
@@ -38,6 +38,9 @@ func _initialize() -> void:
for c in board.safe_cells():
if not r0.has(c):
eligible.append(c)
for c in board.mines.keys():
if not r0.has(c):
eligible.append(c)
elig.append(eligible.size())
var reward_economy = reward_economy_factory.new(db, rng)
var list: Array = []
@@ -45,24 +48,28 @@ func _initialize() -> void:
list.append({"cell": c})
if not reward_economy.roll_board(list):
continue
var n_active := 0
for c in eligible:
var snap = reward_economy.query_identity(c)
if snap != null and active_ids.has(String(snap.reward_id)):
n_active += 1
species[String(snap.reward_id)] += 1.0
act.append(n_active)
if n_active > 1:
# 封锁格零泄漏:R₀ 格必不在映射(is_mapped 静默查询——零诊断噪音)
var r0_listed := false
for c in r0.keys():
if reward_economy.is_mapped(c):
r0_listed = true
if r0_listed:
leak += 1
var total: float = species.knife_t1 + species.knife_adv + species.heal_pack
var kt: float = 100.0 * float(species.knife_t1) / maxf(1.0, total)
var ka: float = 100.0 * float(species.knife_adv) / maxf(1.0, total)
var hp: float = 100.0 * float(species.heal_pack) / maxf(1.0, total)
print("D%d %dx%d | %6.1f / %3d / %3d | %5.2f | %4.0f%% | %4.0f%% | %4.0f%% | 多件板 %d" % [
for c in eligible:
var ident = reward_economy.query_identity(c)
if ident != null:
type_counts[String(ident.cell_type)] += 1.0
var total := 0.0
for t in types:
total += type_counts[t]
print("D%d %dx%d | %6.1f / %3d / %3d | %5.1f%% | %6.1f%% | %4.1f%% | %5.1f%% | %6.1f%% | %d" % [
depth, 10 if depth <= 1 else 12, 10 if depth <= 1 else 12,
_mean(elig), _min(elig), _max(elig), _mean(act), kt, ka, hp, leak])
print("(口径:每板全新战斗态 j=0 → 首件保证恒注入 1 件;战斗内后续板的 0.78/板 期望 = 1 ÷ 每战板数折算,")
print(" 实际获取期望由 level_monte_carlo 的\"可选类/战\"列度量——获取以实际挖到入库为准,残余格未挖不计数)")
_mean(elig), _min(elig), _max(elig),
100.0 * type_counts.attack / total, 100.0 * type_counts.defense / total,
100.0 * type_counts.heal / total, 100.0 * type_counts.blast / total,
100.0 * type_counts.weaken / total, leak])
print("(口径:类型频次 = 正向行权重涌现——attack 25.3%% / defense 20.3%% / blast 20.3%% / heal 19.0%% / weaken 15.2%%;")
print(" 非封锁格 = safe ∪ mines ∖ R₀,雷安同池同分布;封锁格泄漏 = R₀ 格 query_identity 非 null 的板数,恒应为 0)")
quit(0)
@@ -55,7 +55,6 @@ func _initialize() -> void:
# ---- Core 集成冒烟(表加载成功才执行)----
var _db
var _rng
var _last_settlement = null
func _run_core_smoke(db, rng) -> void:
@@ -68,7 +67,7 @@ func _run_core_smoke(db, rng) -> void:
_smoke_full_battle(2, 0, "L2 · ambusher")
_smoke_level()
_smoke_flag_ap_exhausted()
_smoke_supply()
_smoke_reward_identity_unit()
_smoke_power_interval()
_smoke_active_refresh()
_smoke_difficulty_table()
@@ -78,86 +77,55 @@ func _run_core_smoke(db, rng) -> void:
_smoke_attack_chain_fresh_pool()
_smoke_clear_reward_chain()
_smoke_refresh_provenance_guard()
_smoke_window_anchor_rows()
_smoke_polarity_routing()
_smoke_negative_dictionary()
_smoke_resource_surface()
_smoke_settlement_segments()
# ---- 可选类供给控制器冒烟(2026-09-11 供给控制器批:首件保证/指数递减/清图新语义/注入占比)----
# ---- 地图战斗资源·身份面单元冒烟(格面资源批:roll 恰一次 / 雷安同池 / 幂等 / 清图批尾)----
func _smoke_supply() -> void:
print("--- 奖励道具经济·可选类供给控制器 ---")
func _smoke_reward_identity_unit() -> void:
print("--- 地图战斗资源·格面资源身份面 ---")
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260911)
var reward_economy_factory = load("res://core/reward_economy.gd")
# 合成资格清单:6 格
var reward_economy = reward_economy_factory.new(_db, rng)
var eligible: Array = []
for i in range(6):
eligible.append({"cell": Vector2i(i, 0)})
# ① 首件保证 + 每板恰 1 注入 + 注入占比(j=0 恒注入;种子固定 → 分布确定,宽容差断言)
var reward_economy = reward_economy_factory.new(_db, rng)
var ok_roll: bool = reward_economy.roll_board(eligible)
check(ok_roll, "roll_board 受理(供给控制器批)")
var species := {"knife_t1": 0, "knife_adv": 0, "heal_pack": 0}
var multi := 0
for trial in range(400):
var reward_economy_trial = reward_economy_factory.new(_db, rng)
if not reward_economy_trial.roll_board(eligible):
continue
var actives := 0
for e in eligible:
var snap = reward_economy_trial.query_identity(e.cell)
if snap != null and (snap.category == "damage_item" or snap.category == "heal_pack"):
actives += 1
species[snap.reward_id] = float(species[snap.reward_id]) + 1.0
if actives != 1:
multi += 1
check(multi == 0, "j=0 时每板恰注入 1 件可选类(400 板,多件板=%d)" % multi)
var total: float = species.knife_t1 + species.knife_adv + species.heal_pack
check(total == 400.0, "注入总量 = 板数(%d)" % int(total))
check(species.knife_t1 / total > 0.52 and species.knife_t1 / total < 0.68, "小刀占比 ≈60%%(实测 %.0f%%)" % [100.0 * species.knife_t1 / total])
check(species.knife_adv / total > 0.23 and species.knife_adv / total < 0.37, "精工短刀占比 ≈30%%(实测 %.0f%%)" % [100.0 * species.knife_adv / total])
check(species.heal_pack / total > 0.05 and species.heal_pack / total < 0.15, "治疗包占比 ≈10%%(实测 %.0f%%——稀缺)" % [100.0 * species.heal_pack / total])
# ② 格子池零可选类:身份批不含任何可选类身份(j=0 板上唯一可选类 = 注入件——已由①覆盖计数 =1)
# ③ 清图槽新语义:一次性高额伤害(instant_aoe,当场兑付类)
check(reward_economy.grant_clear().category == "instant_aoe", "清图奖励 = 一次性伤害(instant_aoe,非入库道具)")
check(int(reward_economy.grant_clear().power) >= 1, "清图奖励正当量(power=%d)" % int(reward_economy.grant_clear().power))
# ④ 递减判定:j=1 → ≈p0;j=2 → ≈p0×decay(固定种子统计,宽容差)
var ctrl = load("res://core/reward_supply_controller.gd").new(_db.reward_config)
var raw := RandomNumberGenerator.new()
raw.seed = 777
ctrl.notify_granted()
var hits1 := 0
for i in range(2000):
if ctrl.should_inject(raw):
hits1 += 1
check(absf(float(hits1) / 2000.0 - float(_db.reward_config.active_p0)) < 0.03,
"j=1 注入率 ≈ p0=%.2f(实测 %.3f)" % [float(_db.reward_config.active_p0), float(hits1) / 2000.0])
ctrl.notify_granted()
var hits2 := 0
for i in range(2000):
if ctrl.should_inject(raw):
hits2 += 1
var expect2: float = float(_db.reward_config.active_p0) * float(_db.reward_config.active_decay)
check(absf(float(hits2) / 2000.0 - expect2) < 0.03,
"j=2 注入率 ≈ p0×decay=%.2f(实测 %.3f——指数递减)" % [expect2, float(hits2) / 2000.0])
# ⑤ 首件保证恒真:j=0 时 2000 次判定全注入
ctrl.notify_battle_start()
var all_true := true
for i in range(2000):
if not ctrl.should_inject(raw):
all_true = false
check(all_true, "j=0 首件保证恒注入(2000 次判定)")
# ⑥ 发放计数 + 战斗重置
ctrl.notify_granted()
ctrl.notify_granted()
check(ctrl.granted_count() == 2, "发放计数(%d)" % ctrl.granted_count())
ctrl.notify_battle_start()
check(ctrl.granted_count() == 0, "战斗开始计数清零(每战重新保底)")
# ⑦ 节点奖励入账回归(playtest-2026-09-12 报告 Bug#3):grant_node_active 此前直调 stock 绕过
# _supply.notify_granted——发放即入账与格子授予(grant)同一口径,绕过通道已封
var reward_economy_node = reward_economy_factory.new(_db, rng)
var node_inst = reward_economy_node.grant_node_active(Vector2i(-1, -1), 0)
check(node_inst is Dictionary, "节点奖励入库受理(%s)" % str(node_inst.get("item_id", "无")))
check(reward_economy_node.supply_granted_count() == 1, "节点奖励计入供给控制器(granted=%d——Bug#3 回归:发放即入账)" % reward_economy_node.supply_granted_count())
check(reward_economy.roll_board(eligible), "roll_board 受理")
check(not reward_economy.roll_board(eligible), "重复 roll_board 被拒(恰一次——double-roll 守卫)")
# 身份查询投影 = {reward_id, cell_type, tier};五型封闭
var types_ok := true
var types_seen := {}
for e in eligible:
var ident: Dictionary = reward_economy.query_identity(e.cell)
if not (ident.has("reward_id") and ident.has("cell_type") and ident.has("tier")):
types_ok = false
if not RewardTable.CELL_TYPES.has(String(ident.cell_type)):
types_ok = false
types_seen[String(ident.cell_type)] = true
check(types_ok, "query_identity 投影 = {reward_id, cell_type, tier} 且五型封闭(样本 %s)" % str(types_seen.keys()))
# 清单外坐标 = null(E19——封锁格不入清单的同款出口)
check(reward_economy.query_identity(Vector2i(99, 99)) == null, "清单外坐标查询 = null(封锁格无身份同口径)")
# 同格重复受理幂等:同一快照逐字段相同
var snap1: Dictionary = reward_economy.grant(eligible[0].cell)
var snap2: Dictionary = reward_economy.grant(eligible[0].cell)
check(JSON.stringify(snap1) == JSON.stringify(snap2), "同格重复 grant 幂等(同一快照——受理恰一次)")
check(reward_economy.grant(Vector2i(99, 99)) == null, "清单外 grant 拒绝")
# 清图批尾身份:clear_default 唯一活行 clear_blast(blast 型正向);重调幂等
var cs1: Dictionary = reward_economy.grant_clear()
var cs2: Dictionary = reward_economy.grant_clear()
check(String(cs1.cell_type) == "blast" and String(cs1.reward_id) == "clear_blast", "清图批尾身份 = clear_blast(clear_default 唯一活行)")
check(int(cs1.power) >= 25 and int(cs1.power) <= 55, "清图奖励 power ∈ [25,55](区间行 roll 值 %d)" % int(cs1.power))
check(JSON.stringify(cs1) == JSON.stringify(cs2), "grant_clear 同批次重调幂等")
# 退役后受理拒绝
reward_economy.retire_mapping()
check(reward_economy.grant(eligible[0].cell) == null, "映射退役后 grant 拒绝(生命周期闭合)")
# ---- 地图战斗资源·伤害量值区间批冒烟(2026-09-14:身份批 roll 域内 + 中点锚 + 变异实际流动 + 定值行不受扰)----
# ---- 排雷玩法 旗动作耗尽末点 AP 回归(playtest 缺陷②——v1 冒烟盲区:自动玩家不用旗)----
@@ -169,7 +137,6 @@ func _smoke_flag_ap_exhausted() -> void:
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rounds: Array = []
settlement.round_started.connect(func(ri): rounds.append(ri))
settlement.window_opened.connect(func(items): settlement.submit_window_selection([])) # 结算窗口立即应答
check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "回归战斗启动(Boss 行——D0 20HP 级会被盲挖粉尘秒杀,无法承载旗回归场景)")
var minesweeper = settlement.minesweeper
# 恰花 7 点 AP 挖掘,留末点给旗
@@ -206,11 +173,6 @@ func _smoke_level() -> void:
check(win.result == "victory", "关卡·满配(atk+200/hp+100)通关(实测:%s @节点 %d)" % [win.result, win.defeat_node_index])
check(win.battles == 5, "战斗节点数 = 5(战×4 + Boss)")
check(win.boss_depth == 4, "Boss 节点以 Dmax=4 解析(棋盘生成 第三轴登记约定)")
check(win.reward_snapshot.has("reward_id") and (win.reward_snapshot.category == "damage_item" or win.reward_snapshot.category == "heal_pack"),
"奖励点 rolled 可选类主动道具(%s)" % str(win.reward_snapshot.get("reward_id", "无")))
check(win.remaining_items >= 0, "关卡结束 level_clear 快照计数返回(清库前 %d 件随关卡结束清空)" % win.remaining_items)
check(win.actives_total >= 1 and win.actives_total <= win.battles + 1,
"可选类获取 ∈ [1, 战斗数+奖励点](实测 %d 件/%d 战——供给控制器批口径)" % [win.actives_total, win.battles])
check(win.hp_at_boss >= 1 and win.hp_at_boss <= int(_db.hero.max_hp) * 3, "进 Boss HP 携带在域内(%d)" % win.hp_at_boss)
# ② 恢复点:回复 = min(floor(生效max_hp×pct/100), 缺口)——hp+100% 档生效 max = 基线×2
var rest = autoplay.run_level(_db, 4242, 1, 100, 100)
@@ -338,43 +300,22 @@ func _smoke_full_battle(L: int, D: int, label: String) -> void:
check(has_verdict, "%s 步骤流含判定行" % label)
check(hero_attacks >= 1, "%s 主角先手攻击行存在(2026-09-11 拍板①)×%d" % [label, hero_attacks])
check(hero_breakdown_ok, "%s 主角攻击行携带三分量 normal/pierce/magic(克制批)" % label)
if run1.terminated and run1.result == "victory":
var lc = _last_settlement.level_clear() # 拍板③:关卡结束清库路径(战斗域不再清)
check(lc.has("remaining"), "%s level_clear 返回快照计数(剩余 %d)" % [label, int(lc.get("remaining", -1))])
func _run_battle(L: int, D: int) -> Dictionary:
# 每遍全新 RngService(同 master 种子)——回放断言的隔离前提(奖励道具经济 AC"两套全新 RngService+奖励道具经济"同款)
# 每遍全新 RngService(同 master 种子)——回放断言的隔离前提(地图战斗资源 AC"两套全新 RngService+地图战斗资源"同款)
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(42)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var out := {"stream": [], "events": [], "rounds": 0, "result": "", "terminated": false}
var waiting_probe := {"tested": false, "rejected": false} # 等待期刷新反向断言触达标记(通道 2026-09-15 整体移除)
settlement.step_row.connect(func(row): out.stream.append(row))
settlement.battle_finished.connect(func(payload): out.result = payload.get("result", ""))
settlement.window_opened.connect(func(items):
# 窗口数据契约断言:UI 读取的全部字段必须齐备(防呈现层中途崩溃把游戏锁死)
for it in items:
for key in ["index", "item_id", "display_name", "category", "power", "source_cell", "round_index", "usable_now"]:
if not it.has(key):
print(" [FAIL] 窗口条目缺字段 %s" % key)
_failures += 1
# 等待期刷新通道已于 2026-09-15 移除:结算窗口期(Waiting)发起刷新必须被拒且状态不变
if settlement.round_index == 2:
waiting_probe.tested = true
waiting_probe.rejected = not settlement.player_request_refresh()
# 确定性策略(窗口至多一件——2026-09-11 用户拍板):
# 半血以下优先用治疗包;否则第 3 轮起且最强单刀 ≥ 敌剩余总 HP(击杀有把握)时用该刀;否则跳过
var s: Dictionary = settlement.presentation_snapshot() # E1 收口:经呈现快照面读英雄/敌况(禁直读 enemy_system)
var selection: Array = AutoPlayer.window_selection(s, items) # O4:策略单一实现(div=3/gate=3/best 默认口径)
settlement.submit_window_selection(selection))
settlement.minesweeper.cell_revealed.connect(func(evt): out.events.append(evt))
settlement.minesweeper.flag_judged.connect(func(evt): out.events.append(evt))
settlement.minesweeper.round_end_board.connect(func(p): out.events.append({"ev": "round_end_board", "path_tag": p.path_tag}))
settlement.action_economy.round_end_stopped.connect(func(p): out.events.append({"ev": "round_end_stopped", "path_tag": p.path_tag}))
var minesweeper = settlement.minesweeper
var action_economy = settlement.action_economy
_last_settlement = settlement
if not settlement.begin_battle({"level_index": L, "node_depth": D, "hero_hp": _db.hero.max_hp}):
out.stream.append({"type": "verdict", "value": "begin_failed"})
return out
@@ -385,11 +326,6 @@ func _run_battle(L: int, D: int) -> Dictionary:
if out.result == "":
out.result = "timeout"
out.terminated = out.result == "victory" or out.result == "defeat"
# 等待期刷新反向断言(等待期换图通道 2026-09-15 整体移除):Waiting 相位请求必须被拒
if waiting_probe.tested:
check(waiting_probe.rejected, "L%d·D%d 结算等待期刷新被拒(等待期换图通道已移除)" % [L, D])
else:
print(" (跳过)L%d·D%d 等待期刷新反向断言未触达(该场战斗无第 2 轮结算窗口)" % [L, D])
return out
@@ -414,12 +350,12 @@ func _auto_play_round(settlement, out: Dictionary) -> void:
# ---- 奖励道具经济·伤害量值区间批冒烟(2026-09-14:身份批 roll 域内 + 中点锚 + 变异实际流动 + 定值行不受扰)----
# ---- 地图战斗资源·伤害量值区间批冒烟(2026-09-14:身份批 roll 域内 + 中点锚 + 变异实际流动 + 定值行不受扰)----
func _smoke_power_interval() -> void:
print("--- 奖励道具经济·伤害量值区间(上阶/下阶随机)---")
# ① 表侧静态锚:五行配对齐全、power = 中点(行动经济 条款①期望当量锚)
var interval_rows := {"heal_minor": [2, 6], "knife_t1": [5, 11], "heal_pack": [6, 18], "knife_adv": [10, 22], "clear_blast": [25, 55]}
print("--- 地图战斗资源·伤害量值区间(上阶/下阶随机)---")
# ① 表侧静态锚:区间行配对齐全、power = 中点(行动经济 条款①期望当量锚)——现行区间行 = heal_minor/clear_blast 两行
var interval_rows := {"heal_minor": [2, 6], "clear_blast": [25, 55]}
var anchors_ok := true
for row in _db.reward.rows:
var rid := String(row.reward_id)
@@ -429,16 +365,16 @@ func _smoke_power_interval() -> void:
if lo != interval_rows[rid][0] or hi != interval_rows[rid][1] or int(row.power) != (lo + hi) / 2:
anchors_ok = false
elif row.has("power_min") or row.has("power_max"):
anchors_ok = false # 区间配对只许五行(buff/spark_aoe 定值)
check(anchors_ok, "五行区间域与中点锚在位(其余行零区间字段)")
# ② 运行侧:24 板 roll——区间行快照 power 全域内 + 至少上下阶都出现过 + 定值行不受扰
anchors_ok = false # 区间配对只许两行(其余全定值行)
check(anchors_ok, "区间行域与中点锚在位(heal_minor/clear_blast——其余行零区间字段)")
# ② 运行侧:24 板 roll——区间行快照 power 全域内 + 上下阶实际流动 + 定值行不受扰
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260914)
var reward_economy_factory = load("res://core/reward_economy.gd")
var eligible: Array = []
for i in range(6):
eligible.append({"cell": Vector2i(i, 0)})
var seen := {"heal_minor": {}, "knife_t1": {}, "heal_pack": {}, "knife_adv": {}, "clear_blast": {}} # rid → {roll 值集}
var seen := {"heal_minor": {}, "clear_blast": {}} # rid → {roll 值集}
var in_domain := true
var fixed_ok := true
for trial in range(24):
@@ -448,8 +384,6 @@ func _smoke_power_interval() -> void:
for e in eligible:
var snap: Dictionary = reward_economy.query_full_snapshot(e.cell)
var rid := String(snap.reward_id)
if not seen.has(rid):
seen[rid] = {}
if interval_rows.has(rid):
var v := int(snap.power)
seen[rid][v] = true
@@ -463,22 +397,10 @@ func _smoke_power_interval() -> void:
if cv < 25 or cv > 55:
in_domain = false
check(in_domain, "24 板全区间行快照 power ∈ [power_min, power_max](含清图槽)")
check(fixed_ok, "定值行(buff/spark_aoe)快照 power 恒等表值——区间批零外溢")
var variance_ok: bool = seen.has("knife_t1") and int(seen["knife_t1"].size()) >= 2 \
and seen.has("clear_blast") and int(seen["clear_blast"].size()) >= 2 \
and seen.has("heal_minor") and int(seen["heal_minor"].size()) >= 2
check(variance_ok, "上阶/下阶实际流动(knife_t1/clear_blast/heal_minor 各 ≥2 个不同 roll 值)")
# ③ 节点奖励路径:grant_node_active 同样 roll(域内)
var reward_economy_node = reward_economy_factory.new(_db, rng)
var node_ok := true
for trial in range(8):
var inst = reward_economy_node.grant_node_active(Vector2i(-1, -1), 0)
var rid := String(inst.item_id)
if interval_rows.has(rid):
var v := int(inst.snapshot.power)
if v < interval_rows[rid][0] or v > interval_rows[rid][1]:
node_ok = false
check(node_ok, "grant_node_active 区间行 roll 域内(节点奖励路径)")
check(fixed_ok, "定值行快照 power 恒等表值——区间批零外溢")
var variance_ok: bool = int(seen["clear_blast"].size()) >= 2 \
and int(seen["heal_minor"].size()) >= 2
check(variance_ok, "上阶/下阶实际流动(clear_blast/heal_minor 各 ≥2 个不同 roll 值)")
func _row_power(rid: String) -> int:
@@ -498,15 +420,19 @@ func _smoke_active_refresh() -> void:
rng.set_master_seed(20260912)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
# 计数器用容器(GDScript lambda 对局部 int 按值捕获——int 计数在 lambda 内加无效)
var ct := {"board_swaps": 0, "safe_events": 0, "safe_rows": 0}
var ct := {"board_swaps": 0, "safe_events": 0, "safe_rows": 0, "waiting_refresh_rejected": true, "waiting_probe_hit": false}
settlement.board_changed.connect(func(): ct["board_swaps"] += 1)
settlement.minesweeper.cell_revealed.connect(func(evt):
if evt.kind == "safe" and evt.channel != "initial":
ct["safe_events"] += 1)
settlement.window_opened.connect(func(items): settlement.submit_window_selection([]))
settlement.step_row.connect(func(row):
if row.source_class == "safe_cell":
ct["safe_rows"] += 1) # 结算流中 safe_cell 来源行(含兑付/未生效——每格必有行)
ct["safe_rows"] += 1 # 结算流中 safe_cell 来源行(含兑付/未生效——每格必有行)
# 结算中(Waiting 相位——段内)发起刷新必须被拒(等待期换图通道 2026-09-15 整体移除的回归探针:
# 衰减行恒在每结算出现,借其在段内同步探测一次)
if String(row.get("type", "")) == "decay" and not bool(ct["waiting_probe_hit"]):
ct["waiting_probe_hit"] = true
ct["waiting_refresh_rejected"] = not settlement.player_request_refresh())
check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "轮内刷新战斗启动(Boss 行 150HP——承载多轮样本)")
var minesweeper = settlement.minesweeper
# 轮 1–3:零挖掘停手攒 RAP 24(每轮 8 AP 全转存)
@@ -553,6 +479,7 @@ func _smoke_active_refresh() -> void:
check(settlement.round_index == 5 and settlement.battle_result == "", "轮 4 挖至 AP 耗尽自动结算(round=%d result='%s')" % [settlement.round_index, settlement.battle_result])
check(int(ct["safe_rows"]) == int(ct["safe_events"]), "跨图 facts 完整兑现(safe_cell 结算行 %d == safe 事件 %d——快照批次隔离)" % [int(ct["safe_rows"]), int(ct["safe_events"])])
check(int(ct["safe_events"]) >= 3, "跨图挖掘 safe 事件 ≥3(%d——断言非空转)" % int(ct["safe_events"]))
check(bool(ct["waiting_refresh_rejected"]) and bool(ct["waiting_probe_hit"]), "结算段内(Waiting 相位)刷新被拒(等待期换图通道已移除)")
# 回放确定性:同种子两遍步骤流逐行相同(含两次轮内刷新取号序)
var rep1 = _active_refresh_replay()
var rep2 = _active_refresh_replay()
@@ -566,7 +493,6 @@ func _active_refresh_replay() -> Array:
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
settlement.window_opened.connect(func(items): settlement.submit_window_selection([]))
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}):
return rows
var minesweeper = settlement.minesweeper
@@ -649,42 +575,45 @@ func _smoke_damage_types() -> void:
check(base_magic >= 1, "充能落桶基线:标中旗奖励进魔法桶(魔法分量合计 %d)" % base_magic)
check(swap_magic == 0, "改投穿刺桶后:主角攻击行魔法分量归零(×%d——改奖励即改道,攻击类型定义未动)" % swap_magic)
check(JSON.stringify(run_base.rows) == JSON.stringify(_run_flag_backlash_round(20260912).rows), "还原奖励定义后步骤流逐行相同(改道只经奖励定义,矩阵零改动)")
# ③ 标错惩罚 + 标中充能(奖励定义现投魔法桶;Boss 行 150HP 多轮样本;板真相直读保证 hit/miss 确定性)
# ③ 标错(类型化负向兑付)+ 标中充能(奖励定义现投魔法桶;Boss 行 150HP 多轮样本;板真相直读保证 hit/miss 确定性)
var run1 = _run_flag_backlash_round(20260912)
var miss_rows := 0
var backlash_rows := 0
var negative_rows := 0
var magic_round := -1
var magic_seen := 0
for row in run1.rows:
if row.get("type", "") == "flag_miss":
miss_rows += 1
if row.get("type", "") == "effect" and String(row.get("source_class", "")) == "flag_miss":
backlash_rows += 1 # 反噬效果行(暴走/伏击/失衡三选一)
if String(row.get("source_class", "")) == "flag_miss" and String(row.get("type", "")) in ["effect", "damage"]:
negative_rows += 1 # 标错的负向兑付行(该安全格资源的负向字典行——effect/直伤)
if row.get("source_class", "") == "hero_attack" and int(row.get("magic", 0)) >= 1:
magic_round = int(row.get("round_index", -1))
magic_seen = int(row.get("magic", 0))
check(miss_rows >= 1, "标错旗产生 flag_miss 行(×%d)" % miss_rows)
check(backlash_rows >= 1, "标错旗触发随机反噬效果行(×%d——敌增伤/敌buff/我方debuff 三选一)" % backlash_rows)
check(miss_rows >= 1, "标错旗产生 flag_miss 归因行(×%d)" % miss_rows)
check(negative_rows >= 1, "标错旗兑付该安全格资源的负向字典行(×%d——attack/defense/weaken 效果行或 heal 直伤行)" % negative_rows)
check(magic_round >= 1, "标中雷充能进主角攻击(magic=%d @R%d——每雷实例一次)" % [magic_seen, magic_round])
# ④ 同种子回放(确定性派生反噬——零新流同种子逐行相同)
# ④ 同种子回放(极性路由零随机——同种子逐行相同)
var run2 = _run_flag_backlash_round(20260912)
check(JSON.stringify(run1.rows) == JSON.stringify(run2.rows), "标错惩罚轮回放断言:同种子步骤流逐行相同(反噬派生确定性)")
check(JSON.stringify(run1.rows) == JSON.stringify(run2.rows), "标错惩罚轮回放断言:同种子步骤流逐行相同(类型化负向兑付确定性)")
# 构造轮:标 1 面"安全格旗"(必 miss → 反噬)+ 标 1 面"雷旗"(必 hit → 充能)后停手收轮
# 构造轮:标 1 面"安全格旗"(必 miss → 该格资源负向兑付)+ 标 1 面"雷旗"(必 hit → 正向兑付 + 充能)后停手收轮。
# 返回 flagged safe 格与其 cell_type(Ready 域内查询——类型化断言的数据面)。
func _run_flag_backlash_round(seed_val: int) -> Dictionary:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
settlement.window_opened.connect(func(items): settlement.submit_window_selection([]))
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}):
return {"rows": rows}
return {"rows": rows, "safe_cell": Vector2i(-1, -1), "safe_type": ""}
var minesweeper = settlement.minesweeper
var safe_t := _find_flag_target(minesweeper, false)
var mine_t := _find_flag_target(minesweeper, true)
var safe_type := ""
if not safe_t.is_empty():
var ident = settlement.query_reward_identity(safe_t.flag_cell)
safe_type = String(ident.get("cell_type", "")) if ident != null else ""
if safe_t.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": safe_t.hero_cell}])
minesweeper.settle_batch([{"action": "flag_place", "cell": safe_t.flag_cell}])
@@ -693,7 +622,7 @@ func _run_flag_backlash_round(seed_val: int) -> Dictionary:
minesweeper.settle_batch([{"action": "move", "cell": mine_t.hero_cell}])
minesweeper.settle_batch([{"action": "flag_place", "cell": mine_t.flag_cell}])
minesweeper.settle_batch([{"action": "stop"}])
return {"rows": rows}
return {"rows": rows, "safe_cell": safe_t.get("flag_cell", Vector2i(-1, -1)), "safe_type": safe_type}
# 板真相直读找旗目标:未开 + 指定雷/安全属性 + 存在可达已开邻格(测试允许读板真相——排雷玩法 冒烟同口径)
@@ -733,7 +662,7 @@ func _smoke_playtest_logger() -> void:
minesweeper.settle_batch([{"action": "stop"}])
check(snaps.size() == 2, "每结算一快照(%d 次结算 → %d 快照)" % [2, snaps.size()])
var s1: Dictionary = snaps[0]
var required := ["kind", "level", "depth", "round", "verdict", "hero", "economy", "board", "enemies", "buffs", "ambush_layers", "inventory_in_stock", "round_facts", "spawn_count"]
var required := ["kind", "level", "depth", "round", "verdict", "hero", "economy", "board", "enemies", "buffs", "ambush_layers", "round_facts", "spawn_count"]
var missing := []
for k in required:
if not s1.has(k):
@@ -848,7 +777,6 @@ func _smoke_attack_chain_fresh_pool() -> void:
settlement.step_row.connect(func(row):
if row.type == "damage" and row.source_class == "enemy_action":
ticks.append({"raw": int(row.baseline) + int(row.attach), "tick": int(row.value)}))
settlement.window_opened.connect(func(items): settlement.submit_window_selection([]))
check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "fresh 池回归战斗启动(Boss 行)")
# 停手轮:零挖掘零旗 → 零授予 → 池恒空;Boss pattern 前 4 拍 = attack/attack/charge/heavy
for i in range(4):
@@ -883,7 +811,7 @@ var _regular_clear_reward_rows := 0 # 常规(非刷新图)清图的 clear_
func _smoke_clear_reward_chain() -> void:
print("--- 清图奖励·双份兑付链(完美排雷 / 零兑付 / 第二份被拒 / 高光记实)---")
# ① 真实流完美排雷:标中一面真雷旗 + 零误标 + 清全图 → grant_clear 身份(instant_aoe)双份真实兑付
# ① 真实流完美排雷:标中一面真雷旗 + 零误标 + 清全图 → grant_clear 身份(clear_blast)双份真实兑付
var rows: Array = []
var run := _run_full_clear(20260915, false, rows)
check(bool(run.cleared), "① 完美排雷清图达成(%d 轮)" % int(run.rounds))
@@ -893,7 +821,7 @@ func _smoke_clear_reward_chain() -> void:
dmg.append(r)
var hl := _rows_of_type(rows, "highlight")
_regular_clear_reward_rows = dmg.size()
check(dmg.size() == 2, "① 清图奖励双份真实兑付(clear_reward 伤害行 ×%d——instant_aoe 身份两份各打一发)" % dmg.size())
check(dmg.size() == 2, "① 清图奖励双份真实兑付(clear_reward 伤害行 ×%d——clear_blast 身份两份各打一发)" % dmg.size())
if dmg.size() == 2:
check(String(dmg[0].display_name).contains("清图奖励") and String(dmg[1].display_name).contains("完美第二份"),
"① 第二份按身份类型走真实兑付(首份『清图奖励』/ 第二份『完美第二份』)")
@@ -913,26 +841,24 @@ func _smoke_clear_reward_zero_paths() -> void:
var s = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
s.step_row.connect(func(row): rows.append(row))
s.window_opened.connect(func(items): s.submit_window_selection([]))
check(s.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80}),
"零兑付构造战斗启动")
for u in s._enemy_system.all_enemies():
s._enemy_system.apply_damage(u, int(u.hp)) # 测试通道:构造"无存活敌"(弱形式——全灭而非 despawn)
var snap = s._reward_economy.grant_clear()
check(snap != null and String(snap.category) == "instant_aoe",
"清图槽身份 = instant_aoe(clear_default 唯一活行——第二份同身份兑付)")
check(snap != null and String(snap.cell_type) == "blast",
"清图槽身份 = blast 型(clear_default 唯一活行 clear_blast——第二份同身份兑付)")
rows.clear()
var first_ok: bool = s._grant_clear_identity(snap, 1)
var first_ok: bool = s._grant_clear_identity(snap, 1, "(清图奖励)")
check(not first_ok, "② 即时类首份零兑付(无存活敌)→ first_ok=false")
check(_has_source_row(rows, "reward_ineffective", "clear_reward", "instant_aoe"),
"② 零兑付产出 reward_ineffective 行(batch_key=instant_aoe)")
check(_has_source_row(rows, "reward_ineffective", "clear_reward", "clear_blast"),
"② 零兑付产出 reward_ineffective 行(batch_key=clear_blast)")
# ②-b 高光守卫:perfect 谓词成立但首份零兑付 → 高光行不发(不记名义值)
var rng_b = load("res://core/rng_service.gd").new()
rng_b.set_master_seed(20260915)
var s_b = load("res://core/battle_orchestrator.gd").new(_db, rng_b)
var rows_b: Array = []
s_b.step_row.connect(func(row): rows_b.append(row))
s_b.window_opened.connect(func(items): s_b.submit_window_selection([]))
check(s_b.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80}),
"高光守卫构造战斗启动")
for u in s_b._enemy_system.all_enemies():
@@ -941,7 +867,7 @@ func _smoke_clear_reward_zero_paths() -> void:
s_b._perfect_hit_false = 0
rows_b.clear()
s_b._grant_clear_reward(1)
check(_has_source_row(rows_b, "reward_ineffective", "clear_reward", "instant_aoe"),
check(_has_source_row(rows_b, "reward_ineffective", "clear_reward", "clear_blast"),
"② _grant_clear_reward 守卫路径重走首份 → 未生效行仍在案")
check(_rows_of_type(rows_b, "highlight").is_empty(), "② 首份零兑付 → 高光行不发(不记名义值 2)")
# ③ 第二份被拒:唯一存活敌 HP ≤ 首份 aoe 量 → 首份击杀 → 第二份无存活敌(zero + 未生效行)
@@ -950,7 +876,6 @@ func _smoke_clear_reward_zero_paths() -> void:
var s2 = load("res://core/battle_orchestrator.gd").new(_db, rng2)
var rows2: Array = []
s2.step_row.connect(func(row): rows2.append(row))
s2.window_opened.connect(func(items): s2.submit_window_selection([]))
check(s2.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80}),
"第二份被拒构造战斗启动")
var boss = s2._enemy_system.all_enemies()[0]
@@ -960,7 +885,7 @@ func _smoke_clear_reward_zero_paths() -> void:
rows2.clear()
s2._grant_clear_reward(1)
check(_has_source_row(rows2, "damage", "clear_reward"), "③ 首份 aoe 真实兑付(击杀唯一存活敌)")
check(_has_source_row(rows2, "reward_ineffective", "clear_reward", "instant_aoe"),
check(_has_source_row(rows2, "reward_ineffective", "clear_reward", "clear_blast"),
"③ 第二份被拒产出 reward_ineffective 行(零兑付)")
var hl2 := _rows_of_type(rows2, "highlight")
check(hl2.size() == 1 and int(hl2[0].value) == 1, "④ 高光行 value = 实际有效份数 1(不记名义值 2)")
@@ -985,44 +910,306 @@ func _smoke_refresh_provenance_guard() -> void:
check(_regular_clear_reward_rows > 0, "对照基线:常规换图清图有 clear_reward 行(×%d——守卫非全局静默)" % _regular_clear_reward_rows)
# 窗口段锚行(战斗结算 验收:"窗口段恒在案,零选择记跳过""空库存记跳过")
func _smoke_window_anchor_rows() -> void:
print("--- 道具窗口段锚行(打开 / 零选择跳过 / 空库存跳过)---")
# ==================== 格面资源批新增覆盖(极性路由 / 负向字典 / 资源面 / 五段全序) ====================
# 极性路由断言(战斗结算 验收):挖安全格/标中旗只出正向、挖雷格/标错旗只出负向、兑付行指回来源格。
# 正向字典效果集 = {attack_up, defense_up, weaken}(percent)+ spark_aoe/heal_minor(flat);
# 负向字典效果集 = {enrage, off_guard, sapped}(percent)+ 直伤(heal flat)/ ambush(blast flat)。
const POSITIVE_EFFECTS := ["attack_up", "defense_up", "weaken"]
const NEGATIVE_EFFECTS := ["enrage", "off_guard", "sapped", "ambush"]
func _smoke_polarity_routing() -> void:
print("--- 极性路由(挖安全格正向 / 挖雷格负向 / 标中正向 / 标空负向)---")
var probe := _run_polarity_probe(20260918)
check(bool(probe["flag_hit_done"]) and bool(probe["flag_miss_done"]), "构造触达:标中真雷 + 标空安全格均入队")
check(int(probe["mine_events"]) >= 1, "构造触达:挖到雷格 ≥1(%d)" % int(probe["mine_events"]))
check(int(probe["safe_events"]) >= 1, "构造触达:挖到安全格 ≥1(%d)" % int(probe["safe_events"]))
# 挖到的安全格:只出正向——其全部来源行必存在且 effect 行 ∈ 正向集
var safe_all_pos := true
for e in probe["safe_cells"].keys():
var cell_arr: Array = [e.x, e.y]
var rows := _rows_at(probe["rows"], "safe_cell", cell_arr)
if rows.is_empty():
safe_all_pos = false # 每个资源格必有 ≥1 兑付行(每格必有货 → 每格必有行)
for r in rows:
var eff := String(r.get("effect_id", ""))
if eff != "" and not POSITIVE_EFFECTS.has(eff):
safe_all_pos = false
check(safe_all_pos, "挖安全格 → 只出正向兑付(效果行 ∈ 正向字典 + 每格 ≥1 行——%d 格)" % probe["safe_cells"].size())
# 挖到的雷格:只出负向——来源行必存在且 effect 行 ∈ 负向集(ambush = blast 负向;heal 负向 = damage 行)
var mine_all_neg := true
for e in probe["mine_cells"].keys():
var cell_arr: Array = [e.x, e.y]
var rows := _rows_at(probe["rows"], "mine_hit", cell_arr)
if rows.is_empty():
mine_all_neg = false
for r in rows:
var eff := String(r.get("effect_id", ""))
if eff != "" and not NEGATIVE_EFFECTS.has(eff):
mine_all_neg = false
check(mine_all_neg, "挖雷格 → 只出负向兑付(效果行 ∈ 负向字典 + 每格 ≥1 行——%d 格)" % probe["mine_cells"].size())
# 标中旗:该雷格正向兑付行(flag_hit 来源)+ 效果 ∈ 正向字典
var hit_ok := false
for r in probe["rows"]:
if String(r.get("source_class", "")) == "flag_hit" and r.get("origin_cell", [0, 0]) == probe["hit_cell"]:
hit_ok = true
var eff := String(r.get("effect_id", ""))
if eff != "" and not POSITIVE_EFFECTS.has(eff):
hit_ok = false
check(hit_ok, "标中旗 → 该雷格资源的正向兑付行(origin 指回雷格,效果 ∈ 正向字典)")
# 标空旗:归因行 + 该安全格负向兑付行(flag_miss 来源)
var miss_rows := _rows_at(probe["rows"], "flag_miss", probe["miss_cell"])
var miss_neg_ok := false
for r in miss_rows:
if String(r.get("type", "")) == "flag_miss":
miss_neg_ok = true
else:
var eff := String(r.get("effect_id", ""))
if eff != "" and not NEGATIVE_EFFECTS.has(eff):
miss_neg_ok = false
check(not miss_rows.is_empty() and miss_neg_ok, "标空旗 → 标旗未中归因行 + 该安全格资源负向兑付(同格同批)")
# 抵消过滤既有语义:标旗后被撤旗不判(发射面基线过滤)——由排雷玩法 冒烟与旗判定基线覆盖,此处核兑付行零泄漏
var flag_srcs := {}
for r in probe["rows"]:
if String(r.get("source_class", "")) in ["flag_hit", "flag_miss"]:
flag_srcs[r.get("origin_cell")] = true
check(int(flag_srcs.size()) <= 2, "旗判定兑付仅旗判定批两旗在案(撤旗抵消过滤零泄漏——%d 格)" % flag_srcs.size())
# 极性路由探针驱动:标 1 面真雷旗(标中)+ 1 面安全格旗(标空)→ AutoPlayer 挖到战斗终局(多轮样本)。
# 收集全部 cell_revealed / flag_judged 事件与结算行;返回格子集合与旗格坐标。
func _run_polarity_probe(seed_val: int) -> Dictionary:
_db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 10.0, "atk_mult": 0.5}) # 抬敌血压低敌攻——多轮挖掘样本不被终局截断
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260917)
var s = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
var probe := {"opens": 0, "open_ok": 0, "skips": 0, "empty_skips": 0}
s.step_row.connect(func(row):
rows.append(row)
if String(row.get("type", "")) == "window_skip":
probe["skips"] = int(probe["skips"]) + 1
if int(row.value) == 0 and String(row.display_name).contains("空库存"):
probe["empty_skips"] = int(probe["empty_skips"]) + 1)
s.window_opened.connect(func(items):
probe["opens"] = int(probe["opens"]) + 1
if not rows.is_empty():
var last: Dictionary = rows[rows.size() - 1]
if String(last.get("type", "")) == "window_open" and int(last.value) == items.size():
probe["open_ok"] = int(probe["open_ok"]) + 1
s.submit_window_selection([]))
check(s.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": CLEAR_SMOKE_HERO_HP, "hero_max_hp": CLEAR_SMOKE_HERO_HP}),
"窗口锚行战斗启动")
# 轮 1 零挖掘停手 → 库存空 → 记跳过锚行(空库存不弹窗)
s.minesweeper.settle_batch([{"action": "stop"}])
check(int(probe["opens"]) == 0 and int(probe["empty_skips"]) == 1,
"空库存记跳过(window_skip ×%d,窗口未打开 ×%d)" % [int(probe["empty_skips"]), int(probe["opens"])])
# 后续轮:挖到首件可选类入库 → 窗口打开锚行 + 零选择跳过锚行
var guard := 10
rng.set_master_seed(seed_val)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var probe := {"rows": [], "safe_cells": {}, "mine_cells": {}, "hit_cell": [], "miss_cell": [],
"mine_events": 0, "safe_events": 0, "flag_hit_done": false, "flag_miss_done": false}
settlement.step_row.connect(func(row): probe["rows"].append(row))
settlement.minesweeper.cell_revealed.connect(func(evt):
if String(evt.get("channel", "")) == "initial":
return
if evt.kind == "mine":
probe["mine_cells"][evt.cell] = true
probe["mine_events"] = int(probe["mine_events"]) + 1
elif evt.kind == "safe":
probe["safe_cells"][evt.cell] = true
probe["safe_events"] = int(probe["safe_events"]) + 1)
settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 300, "hero_max_hp": 300})
var ms = settlement.minesweeper
# 旗 1:真雷(标中 → 正向);旗 2:安全格(标空 → 负向)
var mine_t := _find_flag_target(ms, true)
var safe_t := _find_flag_target(ms, false)
if not mine_t.is_empty():
if mine_t.hero_cell != ms.hero_pos():
ms.settle_batch([{"action": "move", "cell": mine_t.hero_cell}])
ms.settle_batch([{"action": "flag_place", "cell": mine_t.flag_cell}])
probe["hit_cell"] = [mine_t.flag_cell.x, mine_t.flag_cell.y]
probe["flag_hit_done"] = true
if not safe_t.is_empty():
if safe_t.hero_cell != ms.hero_pos():
ms.settle_batch([{"action": "move", "cell": safe_t.hero_cell}])
ms.settle_batch([{"action": "flag_place", "cell": safe_t.flag_cell}])
probe["miss_cell"] = [safe_t.flag_cell.x, safe_t.flag_cell.y]
probe["flag_miss_done"] = true
# 挖到终局(旗挡链不影响 AutoPlayer——choose_dig 跳过旗格)
var out := {"result": "", "rounds": 0, "terminated": false, "stream": []}
while int(s.presentation_snapshot().in_stock) == 0 and guard > 0 and s.battle_result == "":
var guard := 40
while settlement.battle_result == "" and guard > 0:
guard -= 1
_auto_play_round(s, out)
check(int(probe["opens"]) >= 1, "窗口打开锚行触达(window_opened ×%d)" % int(probe["opens"]))
check(int(probe["open_ok"]) == int(probe["opens"]),
"每个 window_opened 前恰有 window_open 锚行且值 = 可选件数(%d/%d)" % [int(probe["open_ok"]), int(probe["opens"])])
check(int(probe["skips"]) >= int(probe["opens"]),
"零选择记跳过锚行(window_skip %d ≥ window_open %d)" % [int(probe["skips"]), int(probe["opens"])])
_auto_play_round(settlement, out)
_db.difficulty.rows.clear() # 测试台账还原
return probe
func _rows_at(rows: Array, source_class: String, cell) -> Array:
var out: Array = []
for r in rows:
if String(r.get("source_class", "")) == source_class and r.get("origin_cell", [9, 9]) == cell:
out.append(r)
return out
# 负向字典逐型直验(类型化兑付行协议):找真实板上各型非封锁格,直调 _grant_routed 负向路由,
# 断言每型的兑付形态(enrage/off_guard/sapped 效果行、heal 直伤行、blast ambush+cap 溢出协议)。
func _smoke_negative_dictionary() -> void:
print("--- 负向字典逐型直验(sapped 在位 / heal 直伤 / blast 伏击+2 与 cap 溢出)---")
# 数据面:sapped 行在位(状态表七行 + effect_pool_map 收编)
var sapped: Dictionary = _db.status_effect_row("sapped")
check(not sapped.is_empty() and String(sapped.get("target_side", "")) == "hero" \
and int(sapped.get("magnitude", 0)) == -25 and int(sapped.get("duration_settlements", 0)) == 1 \
and int(sapped.get("max_stack", 0)) == 16 and String(sapped.get("stack_policy", "")) == "stack",
"sapped 行在位(hero · −25 percent · dur 1 · max 16 · stack——weaken 型负向)")
check(String(_db.settlement.effect_pool_map.get("sapped", "")) == "hero_offense",
"effect_pool_map 收编 sapped → hero_offense(attack_up 镜像池)")
# 行为面:逐型负向兑付形态(真实板 + 真实路由路径)
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260919)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
check(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 50, "hero_max_hp": 50}),
"负向字典战斗启动")
var board = settlement.minesweeper.current_board()
var r0 := {}
for c in board.compute_r0():
r0[c] = true
var by_type := {} # cell_type → 首个非封锁格(query_identity 雷安同池——雷格可查)
for y in board.height:
for x in board.width:
var c := Vector2i(x, y)
if r0.has(c) or by_type.size() == 5:
continue
var ident = settlement.query_reward_identity(c)
if ident != null and not by_type.has(String(ident.cell_type)):
by_type[String(ident.cell_type)] = c
check(by_type.size() == 5, "板上五型齐备(%s)" % str(by_type.keys()))
# attack/defense/weaken:percent 负向行走 apply_effect
var percent_map := {"attack": "enrage", "defense": "off_guard", "weaken": "sapped"}
for ctype in percent_map.keys():
if not by_type.has(ctype):
continue
rows.clear()
var snap: Dictionary = settlement._reward_economy.query_full_snapshot(by_type[ctype])
settlement._grant_routed(snap, "negative", "mine_hit", by_type[ctype], 1)
var eff: String = percent_map[ctype]
check(_has_source_row(rows, "effect", "mine_hit") and _has_effect_id(rows, eff),
"%s 型负向 → %s 效果行(mine_hit 来源指回该格)" % [ctype, eff])
# heal:负向 = 主角直伤 4(不经增益状态)
if by_type.has("heal"):
rows.clear()
var hp_before: int = int(settlement.presentation_snapshot().hero.hp)
var snap_h: Dictionary = settlement._reward_economy.query_full_snapshot(by_type["heal"])
settlement._grant_routed(snap_h, "negative", "mine_hit", by_type["heal"], 1)
var hp_after: int = int(settlement.presentation_snapshot().hero.hp)
check(hp_after == hp_before - 4, "heal 型负向 → 主角直伤 4(%d → %d,不经增益状态)" % [hp_before, hp_after])
# blast:负向 = 伏击 +2 层;cap 内生效、超 cap 逐层溢出(cap 读敌行值——零字面量)
if by_type.has("blast"):
var cap: int = int(_db.resolve_enemy_rowset(1, 0).get("advantage_per_round_cap", 0))
rows.clear()
var snap_b: Dictionary = settlement._reward_economy.query_full_snapshot(by_type["blast"])
settlement._grant_routed(snap_b, "negative", "mine_hit", by_type["blast"], 1)
var layers_after_first: int = settlement._status_effects.pool_layers("ambush")
check(layers_after_first == mini(2, cap), "blast 型负向 → 伏击 +%d 层(cap=%d)" % [layers_after_first, cap])
while settlement._status_effects.pool_layers("ambush") < cap:
settlement._grant_routed(snap_b, "negative", "mine_hit", by_type["blast"], 1)
rows.clear()
settlement._grant_routed(snap_b, "negative", "mine_hit", by_type["blast"], 1)
check(_rows_of_type(rows, "overflow").size() >= 1 and settlement._status_effects.pool_layers("ambush") == cap,
"blast 型负向超 cap → 溢出未生效行(伏击池守恒在 cap=%d)" % cap)
# 资源面断言:chain/dig 事件恒携带 resource=true(雷格同权);initial 恒 false(封锁格);
# 封锁格查询 = null、雷格未开恒可查。
func _smoke_resource_surface() -> void:
print("--- 格面资源资格面(雷格携带 / 封锁格无资源)---")
var run1 = _run_battle(1, 0)
var run2 = _run_battle(2, 0)
var all_events: Array = run1.events + run2.events
var digchain_bad := 0
var initial_bad := 0
var mine_with_resource := 0
var initial_count := 0
for evt in all_events:
if not evt.has("resource"):
continue # 非揭示事件(round_end 标记等)
if String(evt.get("channel", "")) == "initial":
initial_count += 1
if bool(evt.resource):
initial_bad += 1
else:
if not bool(evt.resource):
digchain_bad += 1
if evt.kind == "mine":
mine_with_resource += 1
check(digchain_bad == 0, "chain/dig 揭示事件 resource 恒 true(雷安同池——违例 %d)" % digchain_bad)
check(initial_bad == 0 and initial_count >= 1, "initial 揭示事件(封锁格)resource 恒 false(×%d 事件)" % initial_count)
check(mine_with_resource >= 1, "雷格揭示携带资源(mine ×resource=true %d——雷格不再恒无)" % mine_with_resource)
# 封锁格无身份 / 雷格未开恒可查(未开格恒可查——魔法道具 桥接结构前提)
var rng3 = load("res://core/rng_service.gd").new()
rng3.set_master_seed(20260920)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng3)
settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 50})
var board = settlement.minesweeper.current_board()
var r0_first: Variant = null
for c in board.compute_r0():
r0_first = c
break
var mine_unrevealed = null
for c in board.mines.keys():
if not settlement.minesweeper.is_revealed(c):
mine_unrevealed = c
break
check(settlement.query_reward_identity(r0_first) == null, "封锁格(R₀)身份查询 = null(不入摇号清单)")
check(mine_unrevealed != null and settlement.query_reward_identity(mine_unrevealed) != null,
"未开雷格身份恒可查({reward_id, cell_type, tier}——魔法道具 桥接前提)")
# 五段全序断言(战斗结算 验收"五段全序"+ 行型封闭 10 类):逐轮行相位序 = 授予 → 增益/减益 → 战斗 → 衰减 → 判定。
func _smoke_settlement_segments() -> void:
print("--- 五段全序 + 行型封闭 ---")
check(Array(_db.settlement.segment_order) == SettlementTable.FROZEN_SEGMENT_ORDER
and SettlementTable.FROZEN_SEGMENT_ORDER == ["grant", "buff_debuff", "combat", "decay", "verdict"],
"segment_order 五段冻结在位(表值 = 拍板序恒等)")
var run = _run_battle(1, 0)
var legacy := {"item": 0, "consumed_ineffective": 0, "use_rejected": 0, "window_open": 0, "window_skip": 0}
for row in run.stream:
if legacy.has(String(row.get("type", ""))):
legacy[String(row.get("type", ""))] += 1
var legacy_total := 0
for k in legacy:
legacy_total += int(legacy[k])
check(legacy_total == 0, "行型封闭 10 类(删五类零残留:item/consumed_ineffective/use_rejected/window_open/window_skip)")
var order_ok := true
var decay_verdict_ok := true
var by_round := {}
for row in run.stream:
var ri: int = int(row.get("round_index", 0))
if not by_round.has(ri):
by_round[ri] = []
by_round[ri].append(row)
for ri in by_round.keys():
var phases: Array = []
for row in by_round[ri]:
phases.append(_row_phase(row, ri))
var max_phase := 0
for p in phases:
if int(p) < max_phase:
order_ok = false
max_phase = maxi(max_phase, int(p))
var decays := 0
var verdicts := 0
for row in by_round[ri]:
if String(row.get("type", "")) == "decay":
decays += 1
if String(row.get("type", "")) == "verdict":
verdicts += 1
if decays != 1 or verdicts != 1:
decay_verdict_ok = false # 衰减/判定每结算恰一次
check(order_ok, "每轮行相位单调(授予→增益/减益→战斗→衰减→判定)")
check(decay_verdict_ok, "衰减/判定段每结算恰一段(先兑付后衰减——含末轮)")
# 行相位归类(五段序断言用):0=授予(含高光/归因)1=增益减益(续存组合行)2=战斗 3=衰减 4=判定
func _row_phase(row: Dictionary, ri: int) -> int:
match String(row.get("type", "")):
"decay":
return 3
"verdict":
return 4
"charge":
return 2
"damage":
if String(row.get("source_class", "")) in ["hero_attack", "enemy_action"]:
return 2
return 0 # 授予段 flat 兑付(爆裂/直伤)
match String(row.get("source_class", "")):
"safe_cell", "mine_hit", "flag_hit", "flag_miss", "clear_reward":
if int(row.get("round_index", ri)) < ri:
return 1 # 前轮授予实例的续存组合行(增益/减益段)
return 0
_:
return 0
# 清图驱动(真实流):标一面真雷旗(完美前置)→ 逐轮 AutoPlayer 挖到轮末;board_seq 递增 = 清图换图。
@@ -1033,7 +1220,6 @@ func _run_full_clear(seed_val: int, from_refresh: bool, rows: Array) -> Dictiona
rng.set_master_seed(seed_val)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
settlement.step_row.connect(func(row): rows.append(row))
settlement.window_opened.connect(func(items): settlement.submit_window_selection([]))
var res := {"cleared": false, "refreshed": false, "rounds": 0}
if not settlement.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH,
"hero_hp": CLEAR_SMOKE_HERO_HP, "hero_max_hp": CLEAR_SMOKE_HERO_HP}):
@@ -1089,6 +1275,13 @@ func _has_source_row(rows: Array, t: String, source: String, batch_key: String =
return true
return false
func _has_effect_id(rows: Array, effect_id: String) -> bool:
for r in rows:
if String(r.get("effect_id", "")) == effect_id:
return true
return false
func check_silent(cond: bool) -> void:
_checks += 1
if not cond:
@@ -1,80 +0,0 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-13(playtest-2026-09-12 Top1 复核——"seed 42 每局 ≥3 治疗包"是固定种子重放还是系统性偏置?)
# 跨种子可选类供给扫描:每局统计治疗包/小刀/精工短刀的实际获取数(step_row item 行 + 关卡奖励点补计),
# 输出构成占比(对 9:1 设计配比)与"≥3 治疗包"局数分布——单种子坏抽与系统性偏置由此分割。
# 运行:Godot_console.exe --headless --path . --script res://smoke/supply_scan.gd -- [N] [seed0]
extends SceneTree
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var n := 16
if args.size() >= 1 and args[0].is_valid_int():
n = maxi(4, int(args[0]))
var seed0 := 100
if args.size() >= 2 and args[1].is_valid_int():
seed0 = int(args[1])
var db = load("res://core/config_db.gd").new()
if not db.load_tables("res://data/tables", false):
print("表加载失败")
quit(1)
return
var autoplay = load("res://smoke/level_autoplay.gd")
print("=== 可选类供给跨种子扫描:第一关 · N=%d · 种子 %d+%d(基线数值)===" % [n, seed0, n])
print("%-8s %-8s %-6s %-8s %-16s %-10s" % ["seed", "结果", "轮数", "承伤", "治疗包/小刀/短刀", "进BossHP"])
var heals: Array = []
var total_h := 0
var total_k := 0
var total_a := 0
for i in range(n):
var seed_val: int = seed0 + i
var st = autoplay.run_level(db, seed_val, 1, 0, 0, true)
var h := 0
var k := 0
var a := 0
for row in st.rows:
if String(row.get("type", "")) != "item":
continue
var txt := String(row.get("display_name", ""))
if txt.begins_with("治疗包"):
h += 1
elif txt.begins_with("小刀"):
k += 1
elif txt.begins_with("精工短刀"):
a += 1
# 关卡奖励点(grant_node_active)不发 step_row——经 node 快照补计
var nid := String(st.reward_snapshot.get("reward_id", ""))
if nid == "heal_pack":
h += 1
elif nid == "knife_t1":
k += 1
elif nid == "knife_adv":
a += 1
heals.append(h)
total_h += h
total_k += k
total_a += a
print("%-8d %-8s %-6d %-8d %-16s %-10d" % [seed_val, st.result, st.rounds, st.hero_dmg,
"%d/%d/%d" % [h, k, a], st.hp_at_boss])
var t := total_h + total_k + total_a
print("---- 合计 %d 件:治疗包 %d(%.0f%%)· 小刀 %d(%.0f%%)· 精工短刀 %d(%.0f%%)—— 伤害:治疗 = %.1f:1(设计 9:1)" % [
t, total_h, 100.0 * total_h / maxi(1, t), total_k, 100.0 * total_k / maxi(1, t),
total_a, 100.0 * total_a / maxi(1, t), float(total_k + total_a) / maxi(1, total_h)])
var ge3 := 0
var zero := 0
for c in heals:
if c >= 3:
ge3 += 1
elif c == 0:
zero += 1
print("每关治疗包:均值 %.2f · ≥3 包 %d/%d 局 · 0 包 %d/%d 局" % [_mean(heals), ge3, n, zero, n])
quit(0)
func _mean(a: Array) -> float:
if a.is_empty():
return 0.0
var t := 0.0
for v in a:
t += float(v)
return t / float(a.size())
@@ -3,7 +3,7 @@
# Date: 2026-09-12(五区改造批扩展;基础 = 2026-09-11 问题3 复现器 v2——既有断言全保留)
# 合成鼠标事件走真实呈现层路径(BoardView.click_cell / 按钮信号 → 组合根动作通道),
# 随机混合玩家行为——插旗/拔旗、提前停手、弃图刷新确认链、图例模态、明细回看模态、
# tooltip 悬停、窗口选用/跳过、无效点击——扫描结算停滞与选用失效(缺陷②④回归载体)
# tooltip 悬停、无效点击——扫描结算停滞(缺陷②回归载体;缺陷④窗口契约随道具窗口删除退役 2026-09-15)
# + 模态遮挡断言(缺陷⑤回归:图例/回看/终局 overlay 打开时上方遮挡层隐藏、输入锁定)
# + 性能 AC:场景实例化到首格可点 ≤500ms 探针计时。
# 行为序列由 PROBE_SEED 决定(shell 传参);程序玩法数值不受影响(随机只用于探针策略)。
@@ -19,14 +19,11 @@ var _done := false
var _rand := RandomNumberGenerator.new()
var _flags_placed: Array = []
var _ap_spent_this_round := 0
var _used_ok := 0
var _used_bad := 0
var _problems: Array = [] # 新增覆盖的问题收集(退出码合并判定)
var _tested_legend := false
var _tested_review := false
var _tested_refresh_chain := false
var _tested_tooltip := false
var _tested_window_assert := false
func _ready() -> void:
@@ -61,14 +58,6 @@ func _ready() -> void:
main.node_overlay.press_continue()
_flags_placed.clear()
continue
if main.inventory_panel.is_window_open():
if not _tested_window_assert:
# 去饱和渐变(0.15s Tween)完成后断言——窗口悬置期无自动关闭,等待安全
for _wait in 20:
await get_tree().process_frame
_assert_window_state_once()
_answer_window()
continue
if main._end_overlay.is_open():
# 终局 overlay 模态断言(pattern 6:打开时输入锁定)
if not main._input_locked():
@@ -82,8 +71,7 @@ func _ready() -> void:
var st := {
"node": main.runner.node_index, "round": main.settlement.round_index,
"phase": main.settlement.action_economy.phase, "ap": main.settlement.action_economy.ap,
"win": main.inventory_panel.is_window_open(), "settling": diag.settling,
"await": diag.awaiting_window,
"settling": diag.settling,
}
if JSON.stringify(st) == JSON.stringify(_last_state):
_stall_frames += 1
@@ -97,14 +85,14 @@ func _ready() -> void:
if main.settlement.battle_result == "" and main.settlement.action_economy.phase == "Active":
_run_modal_probes_once()
_play_one_action()
print("[click_probe] 循环退出(done=%s budget=%s)选用生效=%s 选用失效=%s 问题=%d" % [
str(_done), str(action_budget), str(_used_ok), str(_used_bad), _problems.size()])
print("[click_probe] 循环退出(done=%s budget=%s)问题=%d" % [
str(_done), str(action_budget), _problems.size()])
for p in _problems:
push_error("[click_probe] FAIL — " + p)
var exit_code := 0
if not _done:
exit_code = 1
if _used_bad != 0 or not _problems.is_empty():
if not _problems.is_empty():
exit_code = 1
get_tree().quit(exit_code)
@@ -176,20 +164,6 @@ func _run_modal_probes_once() -> void:
_problems.append("刷新轮内计数错误(%d)" % main._refresh_used_round)
func _assert_window_state_once() -> void:
if _tested_window_assert:
return
_tested_window_assert = true
# 原位窗口态(pattern 2):E 区描边金光点亮 + 标题切换 + D 区去饱和锁输入(明度不变)
if not main.inventory_panel._title.text.contains("轮末道具窗口"):
_problems.append("窗口态标题未切换(%s)" % main.inventory_panel._title.text)
if not main.inventory_panel._skip_btn.visible:
_problems.append("窗口态缺跳过按钮")
var strength: float = float(main.board_view._desat_mat.get_shader_parameter("strength"))
if strength < 0.9:
_problems.append("窗口期棋盘未去饱和(strength=%.2f)" % strength)
func _play_one_action() -> void:
var settlement = main.settlement
if settlement == null:
@@ -228,45 +202,6 @@ func _play_one_action() -> void:
_click_stop()
func _answer_window() -> void:
# 行为⑥:选用首件可用道具 / 跳过(选择语义 = 窗口条目位置——与 UI 契约一致,缺陷④回归载体)
if _rand.randf() < 0.5 and main.settlement != null:
var settlement = main.settlement
var s: Dictionary = settlement.presentation_snapshot() # E1 收口:经呈现快照面读库存/英雄
var pos := -1
for inst in s.inventory:
if inst.status != "InStock":
continue
pos += 1
var usable: bool = int(s.hero.hp) < int(s.hero.max_hp) or inst.category == "damage_item"
if usable:
var inv_index: int = int(inst.index)
settlement.submit_window_selection([pos])
main.inventory_panel.close_window()
# 缺陷④回归断言:被选件必须真实消耗(Used),不得误耗邻件/留存
var after: Array = settlement.presentation_snapshot().inventory
if not settlement.is_battle_live():
# 终局结算窗口:选用后战斗立即结束、level_clear 合法清库——Used 断言不适用
_used_ok += 1
elif inv_index < after.size() and String(after[inv_index].status) == "Used":
_used_ok += 1
else:
_used_bad += 1
print("[dbg-use] FAIL pos=%d inv_index=%d cat=%s statuses=%s" % [
pos, inv_index, inst.category,
str(after.map(func(x): return [int(x.index), String(x.status)]))])
return
_skip_window()
return
_skip_window()
func _skip_window() -> void:
if main.settlement != null:
main.settlement.submit_window_selection([])
main.inventory_panel.close_window()
func _random_unknown_adjacent(minesweeper) -> Vector2i:
var hero: Vector2i = minesweeper.hero_pos()
var cands: Array = []
@@ -289,10 +224,8 @@ func _click_stop() -> void:
func _report_stall(st: Dictionary) -> void:
var why := ""
if st.phase == "Waiting" and not st.win and not st.await:
why = "结算停滞:Waiting 相位无窗口悬置"
elif st.phase == "Waiting" and st.await:
why = "窗口悬置未被消费(面板窗口态=%s)" % str(main.inventory_panel.is_window_open())
if st.phase == "Waiting":
why = "结算停滞:Waiting 相位无推进(结算应同步走完——窗口段已删除)"
elif st.phase == "Active" and st.ap == 0:
why = "Active 相位 AP=0(应在归零瞬间翻 Waiting)"
elif st.phase == "Idle" and main.settlement.battle_result == "":
@@ -309,8 +242,7 @@ func _dump_state() -> void:
print(" node_index=%s round=%s phase=%s ap=%s rap=%s" % [
str(main.runner.node_index), str(main.settlement.round_index),
str(main.settlement.action_economy.phase), str(main.settlement.action_economy.ap), str(main.settlement.action_economy.rap)])
print(" battle_result='%s' settling=%s awaiting_window=%s window_visible=%s window_open=%s" % [
str(diag.battle_result), str(diag.settling), str(diag.awaiting_window),
str(main.inventory_panel._window_mode), str(main.inventory_panel.is_window_open())])
print(" battle_result='%s' settling=%s" % [
str(diag.battle_result), str(diag.settling)])
print(" board_installed=%s facts_path='%s' refresh_used=%s flags=%s" % [
str(diag.minesweeper_board_installed), str(diag.facts_path), str(diag.refresh_used_round), str(_flags_placed)])
@@ -22,7 +22,7 @@ func _ready() -> void:
_dump_ctrl(c, 0)
var zones := {
"board_zone": main.board_zone, "board_view": main.board_view,
"info_panel": main.info_panel, "inventory_panel": main.inventory_panel,
"info_panel": main.info_panel,
"stage": main.stage,
}
for k in zones:
@@ -2,7 +2,7 @@
# 实现 GDD:design/ux/battle.md(场景级 headless 探针——五区布局实例化断言;entry 17②③)。
# Date: 2026-09-12(五区改造批扩展;基础 = 2026-09-11 首战棋盘探针——既有断言全保留)
# 项目运行模式(autoload 在位)实例化 main.tscn,直接驱动 _start_full_level(),
# 断言五区真实构建(棋盘/进度条/英雄卡/敌人卡/AP 点阵/明细带/数据框/库存面板)。
# 断言四区真实构建(棋盘/进度条/英雄卡/敌人卡/AP 点阵/明细带/数据框;E 区道具面板已删除)。
# 运行:Godot_console.exe --headless --path . res://smoke/ui_probe.tscn
extends Node
@@ -44,8 +44,7 @@ func _ready() -> void:
problems.append("C 区状态效果槽缺失")
if main.log_view.mode() != "dig" or main.log_view.custom_minimum_size.y != 24.0:
problems.append("B 区明细带挖掘期应收起 24px(mode=%s h=%s)" % [main.log_view.mode(), str(main.log_view.custom_minimum_size.y)])
if main.inventory_panel._title.text != "道包 · 关卡域":
problems.append("E 区库存常读标题错误(%s)" % main.inventory_panel._title.text)
# E 区道具面板已随可选类删除下线(格面资源批 2026-09-15)——四区构建由其余断言覆盖
# ---- 敌人卡投影(顺位/意图徽标常驻——敌人系统×4)----
var card = main.stage.enemy_box.get_child(0)
if card != null and card._name_label.text == "":
@@ -75,11 +74,12 @@ func _ready() -> void:
slots_row.set_effects([])
if not slots_row._slots.is_empty():
problems.append("BUG-0001 回归:空态未清槽(%d)" % slots_row._slots.size())
# ---- 图标键回归(HUD框架 图标键契约:状态表六行全映射、奖励 category 五值全映射;
# ---- 图标键回归(HUD框架 图标键契约:状态表七行全映射、格面资源 cell_type 五值全映射;
# 查不到的键 fail-fast + ? 占位——此处钉死映射不残缺,未知键断言由 _icon_cfg/_badge_glyph 承担)----
if not StatusSlotRow.EFFECT_ICON.has("enrage") or not StatusSlotRow.EFFECT_ICON.has("off_guard"):
problems.append("状态图标表缺暴走/失衡两键(states 表六行须全映射)")
for cat in ["instant_aoe", "instant_heal", "damage_item", "heal_pack", "buff"]:
if not StatusSlotRow.EFFECT_ICON.has("enrage") or not StatusSlotRow.EFFECT_ICON.has("off_guard") \
or not StatusSlotRow.EFFECT_ICON.has("sapped"):
problems.append("状态图标表缺暴走/失衡/乏力键(states 表七行须全映射)")
for cat in ["attack", "defense", "heal", "blast", "weaken"]:
if not BoardView.REWARD_BADGE.has(cat):
problems.append("奖励图标表缺键(%s)" % cat)
var probe_kb: Array = [