feat(content): GP+商店房实施批落地——九型格面+随机掉落+Boss前商店房+统一加算区(台账 116)

This commit is contained in:
DKChan committed 2026-09-21 02:57:08 +08:00
1 parent ba6b5e6046
commit bb34335a1e
40 files changed
+1251 -126

No files matched your search

@@ -8,6 +8,9 @@
# behavior_roll〔回合内、开轮时点、步骤流之外——敌人技能池批 2026-09-16〕+ 护甲等级掷点〔回合内、
# 步骤流之内〕;同种子同输入逐位复现)· 高光双落(完美排雷 ×2 / 标中旗 highlight)·
# Waiting 相位结算触发(不依赖事件到达序)。
# GP+商店房实施批(2026-09-21):授予段极性路由增 GP 型兑付(effect_id gp_gain/gp_loss → 战斗内缓冲
# escrow 记账,不入钱包不入增益容器);战斗结束分支掷 gp_drop 封闭流(每战斗末恰一 scope)出
# gp_dropped = 掷定 + level_gp_bonus + escrow 随 battle_finished 上抛——战斗域零 GP 状态持久。
# 切片登记:① 事件总线载体 = 直连信号(实现期 ADR 项,provisional)② Waiting 相位刷新通道 2026-09-15 用户拍板整体
# 拆除(仅 Active 挖掘期受理)③ 效果行协议 = 授予段逐 grant 行 + 增益/减益段续存实例组合行。
# 实现 GDD:design/gdd/systems/战斗循环/战斗结算.md(v0.7 格面资源批——registry 追溯锚)
@@ -15,7 +18,7 @@ class_name BattleOrchestrator
extends RefCounted
signal step_row(row: Dictionary)
signal battle_finished(payload: Dictionary) # {result, hero_hp}——hero 在 despawn 前捕获(呈现层销毁后读状态的时序防护)
signal battle_finished(payload: Dictionary) # {result, hero_hp, gp_dropped}——hero 在 despawn 前捕获(呈现层销毁后读状态的时序防护);gp_dropped = 战斗掉落合计(GP+商店房实施批 2026-09-21)
signal round_started(round_index: int)
signal board_changed()
signal settlement_snapshot(snap: Dictionary) # 每结算末尾的全量状态快照(playtest_logger 消费——JSONL 数值分析)
@@ -46,6 +49,7 @@ var _reward_economy: RewardEconomy
var encounter: Dictionary = {}
var round_index := 0
var battle_result := "" # "" / "victory" / "defeat"
var escrow := 0 # 战斗内 GP 缓冲(战斗域中间态——begin_battle 清零;gp_gain/gp_loss 兑付累记;随 gp_dropped 上抛即弃,不入钱包)
var _facts_reveals: Array = []
var _facts_flags: Array = []
@@ -83,6 +87,7 @@ func begin_battle(enc: Dictionary) -> bool:
push_warning("[战斗结算] begin_battle 重复受理")
return false
encounter = enc
escrow = 0 # 战斗域零 GP 持久(GP+商店房实施批——每场战斗缓冲从零起算)
db.set_reload_gate(true)
if not _status_effects.notify_battle_start():
_fatal("增益状态 开域被拒(编排违例)")
@@ -352,9 +357,12 @@ func _dispatch_row(row: Dictionary, source_class: String, cell: Vector2i, ri: in
return false
# percent 行兑付:apply_effect 入容器;满层拒收 = "奖励未生效"行(零静默)
# percent 行兑付:GP 型行走战斗内缓冲记账(GP+商店房实施批);其余 apply_effect 入容器;
# 满层拒收 = "奖励未生效"行(零静默)
func _apply_percent_row(row: Dictionary, source_class: String, cell: Vector2i, ri: int, highlight: String = "", label_suffix: String = "") -> bool:
var eff := String(row.effect_id)
if eff == "gp_gain" or eff == "gp_loss":
return _apply_gp_row(row, source_class, cell, ri, highlight, label_suffix)
var 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)
@@ -364,6 +372,17 @@ func _apply_percent_row(row: Dictionary, source_class: String, cell: Vector2i, r
return false
# GP 型行兑付(GP+商店房实施批 2026-09-21):战斗内缓冲 escrow 记账——不入钱包不入增益容器。
# 正向(gp_gain)= 挖开金币安全格/标中旗;负向(gp_loss)= 挖雷格/标错旗。兑付行经既有 effect 行
# 通道外发(escrow 随行——归因可读),战斗结束随 gp_dropped 一次上抛后即弃。
func _apply_gp_row(row: Dictionary, source_class: String, cell: Vector2i, ri: int, highlight: String = "", label_suffix: String = "") -> bool:
var eff := String(row.effect_id)
var delta: int = int(row.power) if eff == "gp_gain" else -int(row.power)
escrow += delta
_emit_row("effect", source_class, cell, ri, "%s %+d%s" % [String(row.display_name), delta, label_suffix], delta, {"effect_id": eff, "escrow": escrow}, highlight)
return true
# flat 行兑付(负向恒即时,无库存语义):
# blast 正向 = 全体存活敌各 power 点(无敌 → 奖励未生效行);blast 负向 = 警觉 percent 行(effect_id=alert,走 _apply_percent_row 不入本函数);
# heal 正向 = 主角回复 clamp(满血 → 奖励未生效行);heal 负向 = 主角直伤 power 点(不经增益状态;归零 → 战斗段短路/判定段判负)。
@@ -770,13 +789,29 @@ func _battle_over(result: String) -> void:
battle_result = result
# 映射随战斗结束退役(地图战斗资源 义务④——下一场战斗 = 新板新 scope 批,roll_board 相位守卫的前提)
var hero_hp_left := int(_enemy_system.hero_unit().get("hp", 0)) # despawn 前捕获(信号载荷供呈现层"HP 带入下一层")
var gp_dropped := _roll_gp_drop() # 战斗掉落(GP+商店房实施批——escrow 随此值上抛即弃)
_reward_economy.retire_mapping()
_enemy_system.despawn_all()
_status_effects.notify_battle_end()
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})
battle_finished.emit({"result": result, "hero_hp": hero_hp_left, "gp_dropped": gp_dropped})
# 战斗掉落掷定(GP+商店房实施批 2026-09-21):tier = Boss 节点 ? "boss" : "normal"(elite 留分支位);
# gp_dropped = gp_drop 封闭流掷定(tier 行的 roll_min..roll_max 均匀整数——每战斗末恰一 scope)
# + level_gp_bonus(level_index) + escrow。掷定行缺位 = 数据面缺行(fail 报错按 0 兑付——不哑掉)。
func _roll_gp_drop() -> int:
var tier := "boss" if String(encounter.get("node_type", "")) == "boss" else "normal"
var drops: Dictionary = db.shop_gp_drop_row(tier)
var roll := 0
if drops.is_empty():
push_error("[战斗结算] gp_drops 缺行 tier=%s——掉落掷定按 0 兑付" % tier)
else:
var stream: RandomNumberGenerator = rng.open_stream("gp_drop")
roll = stream.randi_range(int(drops.get("roll_min", 0)), int(drops.get("roll_max", 0)))
return roll + db.shop_level_gp_bonus(int(encounter.get("level_index", 0))) + escrow
# ---- 呈现状态面(E1 封装收口 2026-09-13):呈现层 / headless 自动玩家的唯一读取出口——
@@ -20,6 +20,7 @@ const TABLE_MANIFEST := {
"settlement": "settlement.tres",
"level": "level.tres",
"difficulty": "difficulty.tres",
"shop": "shop.tres", # GP+商店房实施批 2026-09-21(表与 schema 落籍归并行数据面——本批只接线加载与消费)
}
var action_economy: ActionEconomyTable
@@ -33,6 +34,9 @@ var reward: RewardTable
var settlement: SettlementTable
var level: LevelTable
var difficulty: DifficultyTable
# GP+商店房实施批(2026-09-21):ShopTable 暂持无类型注记——schema class_name 落籍归并行数据面,
# 落籍后可收紧为 `var shop: ShopTable`(消费面一律走下方 shop_* 访问器,禁直读行集)
var shop = null
var is_ready := false
var _diagnostics: Array = [] # {severity, category, message}
@@ -114,6 +118,7 @@ func load_tables(root: String, collect_only: bool) -> bool:
settlement = loaded.get("settlement")
level = loaded.get("level")
difficulty = loaded.get("difficulty")
shop = loaded.get("shop")
is_ready = not has_errors
return not has_errors
@@ -127,10 +132,12 @@ func _cross_validate(loaded: Dictionary) -> void:
var effect_ids := {}
for row in st.rows:
effect_ids[row.get("effect_id", "")] = true
# ① RewardTable effect_id 悬空(percent 行引用 StatusEffectTable——悬空加载致命)
# ① RewardTable effect_id 悬空(percent 行引用 StatusEffectTable——悬空加载致命)。
# GP+商店房实施批(2026-09-21)例外:gp_gain/gp_loss = GP 战斗内缓冲(escrow)通道的路由键
# (战斗结算 _apply_gp_row 兑付——不入增益状态容器),词表封闭按字面豁免悬空引用校验。
for row in rt.rows:
var eff: String = row.get("effect_id", "")
if eff != "" and not effect_ids.has(eff):
if eff != "" and eff != "gp_gain" and eff != "gp_loss" and not effect_ids.has(eff):
push_diag("error", "dangling_effect", "RewardTable %s 引用不存在的 effect_id: %s" % [row.get("reward_id"), eff])
# ② SettlementTable.effect_pool_map 悬空
for eff in st_tab.effect_pool_map.keys():
@@ -322,6 +329,52 @@ func status_effect_row(effect_id: String) -> Dictionary:
return status_effects.row_by_id(effect_id)
# ---- GP+商店房实施批(2026-09-21):ShopTable 消费面(行集解析单点——runner/战斗编排禁自扫表)----
# 行族四轴(数据契约,行集归 shop.tres):gp_drops {tier, mean, roll_min, roll_max} ·
# level_gp_bonus {level_index, bonus} · products {product_id, display_name, attr, flat_value,
# base_price, price_increment} · price_level_bonus {level_index, bonus}。
# 行族经 ShopTable 同名数组属性读取(.get 容缺——表落籍前查询恒空,掉落按 0 兑付的兜底在调用方)。
func shop_gp_drop_row(tier: String) -> Dictionary:
for row in _shop_rows("gp_drops"):
if String(row.get("tier", "")) == tier:
return row
return {}
func shop_level_gp_bonus(level_index: int) -> int:
for row in _shop_rows("level_gp_bonus"):
if int(row.get("level_index", -1)) == level_index:
return int(row.get("bonus", 0))
return 0
func shop_price_level_bonus(level_index: int) -> int:
for row in _shop_rows("price_level_bonus"):
if int(row.get("level_index", -1)) == level_index:
return int(row.get("bonus", 0))
return 0
func shop_products() -> Array:
return _shop_rows("products")
func shop_product_row(product_id: String) -> Dictionary:
for row in _shop_rows("products"):
if String(row.get("product_id", "")) == product_id:
return row
return {}
# 行族数组读取(ShopTable 属性名 = 行族名;表未落籍/属性缺失 = 空集非错误态)
func _shop_rows(family: String) -> Array:
if shop == null:
return []
var arr = shop.get(family)
return arr if arr is Array else []
# 技能行单点读取(敌人技能池批——单位容器执行上报 / 战斗结算兑现的消费面)
func skill_row(skill_id: String) -> Dictionary:
return skill.row_by_id(skill_id)
@@ -13,21 +13,29 @@
# E4 收口(2026-09-13):覆盖值经 encounter 显式传入战斗(不再写 db.hero 表——表值运行期不可变,
# 双基线同步/双写叠加 6→18→54 整类隐患随表写一并移除)。
# 对应 GDD:关卡节点地图 v1.0(图版结构——本文件为 P1 批实现位;命名规范见 CLAUDE.md)
# GP+商店房实施批(2026-09-21):GP 冒险钱包(战斗完成入账 clamp ≥0 · 冒险收束清零)+ 统一冒险
# 加算区 bonus{atk,max_hp,armor_class}(商店购买与关卡大奖励共用账本——战斗开始参数 = 基础表值 +
# 加算区,TunePanel 百分比在加算之后乘)+ 商店房节点兑付(开覆盖层/购买出账/离开回选路确认——
# 复用 continue_after_node 兑付确认纪律,零自动推进)。
class_name LevelRunner
extends RefCounted
signal node_entered(node: Dictionary, info: Dictionary) # info 按 type:battle/boss={} · reward={}(内容待拍板) · rest={healed,hp,max_hp}
signal node_entered(node: Dictionary, info: Dictionary) # info 按 type:battle/boss={} · reward={}(内容待拍板) · rest={healed,hp,max_hp} · shop={gp}
signal choices_presented(candidates: Array) # 选路口(兑付确认后——候选节点引用数组,零自动推进)
signal step_row(row: Dictionary) # 转发当前 战斗结算 结算行
signal round_started(round_index: int)
signal board_changed()
signal level_finished(payload: Dictionary) # {result, hp, node_index, node_id, node_type}
signal wallet_changed(event: String, gp: int, extra: Dictionary) # GP 钱包事件投影:gain{amount}/buy{product,price}/clear——试玩日志与商店覆盖层刷新源(GP+商店房实施批)
var db
var rng
var level_row: Dictionary = {}
var node_index := -1
var hero_hp := 0
var gp := 0 # GP 冒险钱包(局内货币——冒险开始 = 0、收束清零;不入存档不入摘要)
var bonus := {"atk": 0, "max_hp": 0, "armor_class": 0} # 统一冒险加算区(冒险开始全 0)——商店购买与关卡大奖励 hp 上限提升写同一本账(大奖励在切片序列未生效,契约位:大奖励 hp 上限提升写本加算区,随正式序列批接线)
var _shop_purchased := {} # product_id → 已购档数(现价重算输入;冒险开始清零)
# 协作对象私有(E1/E3 封装收口 2026-09-13):settlement 读取经 current_battle() 成文出口
var _settlement = null # 当前战斗编排器(关卡结束置 null)
@@ -53,12 +61,21 @@ func set_hero_override(atk_pct: int, hp_pct: int) -> void:
hero_hp_pct = hp_pct
# 叠加次序(GP+商店房实施批):基础表值 + 加算区(商店/大奖励 flat 先加)→ TunePanel 百分比后乘
# (调整板 = 测试工具按现状保持——百分比恒乘在加算生效值上)
func effective_atk() -> int:
return maxi(1, int(round(float(db.hero.atk) * (100.0 + float(hero_atk_pct)) / 100.0)))
var base: int = int(db.hero.atk) + int(bonus.get("atk", 0))
return maxi(1, int(round(float(base) * (100.0 + float(hero_atk_pct)) / 100.0)))
func effective_max_hp() -> int:
return maxi(1, int(round(float(db.hero.max_hp) * (100.0 + float(hero_hp_pct)) / 100.0)))
var base: int = int(db.hero.max_hp) + int(bonus.get("max_hp", 0))
return maxi(1, int(round(float(base) * (100.0 + float(hero_hp_pct)) / 100.0)))
# 护甲等级生效值(加算区 armor_class 轴——无 TunePanel 百分比通道,纯 flat)
func effective_ac() -> int:
return maxi(0, int(db.hero.armor_class) + int(bonus.get("armor_class", 0)))
# 覆盖复位(关卡结束归零——表值不可变,仅清百分比;调用方兼容保留)
@@ -81,6 +98,9 @@ func start_level(level_index: int, seed_val: int) -> bool:
_candidates = []
visited = []
_graph_mode = false
gp = 0 # 冒险钱包清零开局(GP+商店房实施批——局内货币生命周期起点)
bonus = {"atk": 0, "max_hp": 0, "armor_class": 0} # 加算区冒险开始全 0
_shop_purchased = {} # 已购档数清零(跨冒险不延续——递增定价按冒险内累计)
for node in nodes():
if node.has("next"):
_graph_mode = true
@@ -214,6 +234,11 @@ func _enter_node(node: Dictionary) -> bool:
var healed := _apply_rest()
node_entered.emit(node, {"healed": healed, "hp": hero_hp, "max_hp": effective_max_hp()})
return true
"shop":
# 商店房兑付(GP+商店房实施批 2026-09-21):到达即开店(覆盖层归呈现层),购买出账/
# 离开归玩家意图——零自动推进;离开确认经 continue_after_node 回选路口
node_entered.emit(node, {"gp": gp})
return true
push_error("[关卡节点地图] 未知节点类型: %s" % str(node))
return false
@@ -230,7 +255,8 @@ func _start_battle(node: Dictionary) -> bool:
# board_changed(首板安装)并重建棋盘——后发会导致首战棋盘不渲染(2026-09-11 playtest 缺陷①)
node_entered.emit(node, {})
if not _settlement.begin_battle({"level_index": int(level_row.level_index), "node_depth": int(node.get("node_depth", 0)),
"hero_hp": hero_hp, "hero_atk": effective_atk(), "hero_max_hp": effective_max_hp()}):
"hero_hp": hero_hp, "hero_atk": effective_atk(), "hero_max_hp": effective_max_hp(),
"hero_armor_class": effective_ac(), "node_type": String(node.get("type", ""))}):
push_error("[关卡节点地图] 战斗启动失败(depth %s)" % str(node.get("node_depth", -1)))
return false
return true
@@ -253,10 +279,12 @@ func _on_battle_finished(payload: Dictionary) -> void:
var result: String = String(payload.get("result", ""))
if result == "victory":
hero_hp = maxi(1, int(payload.get("hero_hp", 0))) # despawn 前捕获值(载荷自包含)
_gain_wallet(int(payload.get("gp_dropped", 0))) # 战斗完成入账(clamp ≥0——破财可把 escrow 打负,钱包入账时钳制)
if String(node.get("type", "")) == "boss" or result == "defeat":
# Boss 胜利 = 关卡通关;任意战斗败北 = 关卡失败(Roguelike——冒险即终,重开归 冒险结构/元进度)
level_result = "victory" if result == "victory" else "defeat"
var hp_left := hero_hp if result == "victory" else 0
_close_wallet() # 冒险收束(通关/倒下)→ GP 清零(局内货币生命周期终点)
_running = false
_settlement = null
level_finished.emit({"result": level_result, "hp": hp_left, "node_index": node_index,
@@ -310,15 +338,80 @@ func current_battle():
# ---- 玩家输入转发(呈现层只与 runner 对话)----
# 奖励/恢复点兑付确认(呈现层在覆盖层上点击"继续"后调用;自动模拟在 node_entered 回调内立即调用)
# 奖励/恢复/商店房兑付确认(呈现层在覆盖层上点击"继续/离开"后调用;自动模拟在 node_entered 回调内立即调用)
# 泛化语义(接口漂移清偿):确认 ≠ 推进——确认后进选路口,下一跳归 pick_next_node
func continue_after_node() -> bool:
var t := String(current_node().get("type", ""))
if t != "reward" and t != "rest":
if t != "reward" and t != "rest" and t != "shop":
return false
return _present_choices()
# ---- GP 冒险钱包(GP+商店房实施批 2026-09-21:入账/出账/收束清零三收口)----
# 战斗完成入账(gp_dropped 载荷——clamp ≥0;破财可把战斗域 escrow 打负,钱包不透支)
func _gain_wallet(amount: int) -> void:
gp = maxi(0, gp + amount)
wallet_changed.emit("gain", gp, {"amount": amount})
# 冒险收束清零(通关/倒下——局内货币生命周期终点,不入存档不入摘要)
func _close_wallet() -> void:
gp = 0
wallet_changed.emit("clear", 0, {})
# ---- 商店房兑付(GP+商店房实施批:呈现层只读投影 + 购买出账)----
# 商店覆盖层数据源(只读投影——现价/已购档数在此现算,呈现层禁复算定价公式)
func shop_products() -> Array:
var out: Array = []
for prow in db.shop_products():
out.append({
"product_id": String(prow.get("product_id", "")),
"display_name": String(prow.get("display_name", "")),
"attr": String(prow.get("attr", "")),
"flat_value": int(prow.get("flat_value", 0)),
"price": current_price(prow),
"purchased": purchased_tier(String(prow.get("product_id", ""))),
})
return out
# 现价 = base_price + price_level_bonus(level_index) + price_increment × 该商品已购档数
func current_price(prow: Dictionary) -> int:
return int(prow.get("base_price", 0)) + db.shop_price_level_bonus(int(level_row.level_index)) \
+ int(prow.get("price_increment", 0)) * purchased_tier(String(prow.get("product_id", "")))
func purchased_tier(product_id: String) -> int:
return int(_shop_purchased.get(product_id, 0))
# 购买(未知商品/属性键出域/余额不足拒绝——返回 false 零副作用):
# 出账 → 加算区 += flat_value → 购血同步当前生命(封顶新上限)→ 已购档数 +1
func buy_product(product_id: String) -> bool:
var prow: Dictionary = db.shop_product_row(product_id)
if prow.is_empty():
push_error("[关卡节点地图] 商店房未知商品: %s" % product_id)
return false
var attr := String(prow.get("attr", ""))
if not bonus.has(attr):
push_error("[关卡节点地图] 商品属性键出域: %s(attr=%s)" % [product_id, attr])
return false
var price := current_price(prow)
if gp < price:
return false # 余额不足(覆盖层按钮禁用为常态防线——此处兜底拒绝)
gp -= price
var flat := int(prow.get("flat_value", 0))
bonus[attr] = int(bonus[attr]) + flat
if attr == "max_hp":
hero_hp = mini(hero_hp + flat, effective_max_hp()) # 购血:上限与当前生命同步 +Δ(封顶新上限)
_shop_purchased[product_id] = purchased_tier(product_id) + 1
wallet_changed.emit("buy", gp, {"product": product_id, "price": price})
return true
func player_request_refresh() -> bool:
if _settlement == null:
return false
@@ -7,7 +7,7 @@
# 取号语义:全新实例首个 open_stream = scope 0(地图战斗资源 预推进术语的登记口径)。
extends Node
const STREAM_NAMES := ["board_gen", "reward_roll", "encounter", "armor_roll", "behavior_roll", "variant_place"] # 2026-09-12 增 encounter(遭遇随机化批);2026-09-16 增 armor_roll(护甲等级掷点批)+ behavior_roll(敌人技能池批——开轮摇号,每敌派生独立子流,registry rng_service 条目同步);2026-09-19 增 variant_place(地形变异首批类型定稿批——变异布置流,一板恰一 scope)
const STREAM_NAMES := ["board_gen", "reward_roll", "encounter", "armor_roll", "behavior_roll", "variant_place", "gp_drop"] # 2026-09-12 增 encounter(遭遇随机化批);2026-09-16 增 armor_roll(护甲等级掷点批)+ behavior_roll(敌人技能池批——开轮摇号,每敌派生独立子流,registry rng_service 条目同步);2026-09-19 增 variant_place(地形变异首批类型定稿批——变异布置流,一板恰一 scope);2026-09-21 增 gp_drop(GP+商店房实施批——战斗掉落掷定流,每战斗末恰一 scope,registry rng_service 条目随批同步)
# 分段表达式书写(数据配置 R3 实现级规约——禁 ≥2^63 完整字面量)
const GOLDEN_RATIO := (0x9E3779B9 << 32) | 0x7F4A7C15 # 0x9E3779B97F4A7C15
@@ -21,6 +21,7 @@ const SALT_BEHAVIOR_ROLL := (0x85EBCA77 << 32) | 0x58C24E15 # 0x85EBCA7758C24E
const MIX_MUL_A := (0xBF58476D << 32) | 0x1CE4E5B9 # 0xBF58476D1CE4E5B9
const MIX_MUL_B := (0x94D049BB << 32) | 0x133111EB # 0x94D049BB133111EB
const SALT_VARIANT_PLACE := (0x9AE16A3F << 32) | 0x2FAD6C39 # 0x9AE16A3F2FAD6C39(变异布置流——互异奇常数 ≠ 乘数;地形变异首批类型定稿批 2026-09-20,黄金向量按 R3 公式登记)
const SALT_GP_DROP := (0xD6E8FEB8 << 32) | 0x665FD7F1 # 0xD6E8FEB8665FD7F1(战斗掉落流——互异奇常数 ≠ 乘数;GP+商店房实施批 2026-09-21,黄金向量按 R3 公式登记归冒烟/registry 同批)
# behavior_roll 派生向量(registry 同批登记):子流 scope = spawn_index × STRIDE + 单敌摇号序(0 起)。
# 每敌消费自己的 scope 段 → 单敌出招轨迹可独立复放、敌间互不穿插(proposal 约束二)。
const BEHAVIOR_SUBSTREAM_STRIDE := 1000000
@@ -32,7 +33,7 @@ const INT64_MIN := -9223372036854775808
var _master_seed: int = -1 # -1 = 未播种(取数即致命,EC7)
var master_seed: int:
get: return _master_seed
var _counters := {"board_gen": 0, "reward_roll": 0, "encounter": 0, "armor_roll": 0, "behavior_roll": 0, "variant_place": 0}
var _counters := {"board_gen": 0, "reward_roll": 0, "encounter": 0, "armor_roll": 0, "behavior_roll": 0, "variant_place": 0, "gp_drop": 0}
func _salt_for(stream: String) -> int:
@@ -49,6 +50,8 @@ func _salt_for(stream: String) -> int:
return SALT_BEHAVIOR_ROLL
"variant_place":
return SALT_VARIANT_PLACE
"gp_drop":
return SALT_GP_DROP
push_error("[RngService] 未知名流: %s" % stream)
assert(false, "未知名流(流集合封闭,EC12)")
return 0
@@ -82,11 +85,12 @@ func set_master_seed(seed_val: int) -> void:
if seed_val < 0:
seed_val = -seed_val
push_warning("[RngService] master_seed 负输入 abs 归一化: %d" % seed_val)
assert(_counters["board_gen"] == 0 and _counters["reward_roll"] == 0 and _counters["encounter"] == 0 and _counters["armor_roll"] == 0 and _counters["behavior_roll"] == 0 and _counters["variant_place"] == 0, "冒险中途重播种会击穿同冒险无碰撞前提(仅许全部流计数器全零时调用)")
assert(_counters["board_gen"] == 0 and _counters["reward_roll"] == 0 and _counters["encounter"] == 0 and _counters["armor_roll"] == 0 and _counters["behavior_roll"] == 0 and _counters["variant_place"] == 0 and _counters["gp_drop"] == 0, "冒险中途重播种会击穿同冒险无碰撞前提(仅许全部流计数器全零时调用)")
_master_seed = seed_val
_counters["board_gen"] = 0
_counters["reward_roll"] = 0
_counters["variant_place"] = 0
_counters["gp_drop"] = 0
# 切片登记:冒险开始重播种入口(正式侧调用权归 冒险结构;切片主菜单 = 冒险起点)
@@ -97,6 +101,7 @@ func new_adventure(seed_val: int) -> void:
_counters["armor_roll"] = 0
_counters["behavior_roll"] = 0
_counters["variant_place"] = 0
_counters["gp_drop"] = 0
set_master_seed(seed_val)
@@ -2,7 +2,9 @@
# Validation Question: 线性第一关(战×2→道具→战×2→恢复→Boss)能否承载"数值提升→通关期望改善"的元进度验证
# Date: 2026-09-11(2026-09-17 分支节点图批:nodes 升级图结构——单链=出边恰一的退化形态)
# 数据配置 LevelTable schema(关卡节点地图 v1.0 承载面)。
# 节点类型 closed enum:battle(战斗,须带 node_depth)/ reward(直通占位点)/ rest(血量恢复点)/ boss(关底 Boss,须带 node_depth)。
# 节点类型 closed enum:battle(战斗,须带 node_depth)/ reward(直通占位点)/ rest(血量恢复点)/ boss(关底 Boss,须带 node_depth)
# / shop(商店房——GP+商店房实施批 2026-09-21 五类落地:同 reward/rest 无战斗行、无 node_depth 要求,
# 图形态仍须编号化;零新增图合法性检查,必经位由冒烟断言承载非加载校验)。
# 图形态(行集内任一节点带 "next" 出边表即整行按图校验):节点编号 = node_depth(关卡内唯一定位地址,
# 全节点须带,reward/rest 亦然);next = [出边节点编号...](连线只朝前进方向:目标编号严格大于自身)。
# 单链形态(零 "next" 键——第一关现状零迁移):隐式出边 = 数组顺序下一项,节点编号 = 数组下标
@@ -15,7 +17,7 @@ extends Resource
@export var rows: Array = [] # Array[Dictionary]
const NODE_TYPES := ["battle", "reward", "rest", "boss"]
const NODE_TYPES := ["battle", "reward", "rest", "boss", "shop"] # 商店房批 2026-09-21 四→五类(关卡节点地图 v1.1)
func validate() -> Array:
@@ -3,17 +3,19 @@
# Date: 2026-09-10(2026-09-15 格面资源批结构性重写:category/use_timing/库存/供给轴下线,五型 × 极性字典取代)
# 数据配置 RewardTable schema(地图战斗资源 回填值)。
# 行 = Dictionary,键 reward_id(Rule 4 豁免:定义表);跨字段校验族 + 池护栏。
# 八型封闭枚举(attack/defense/heal/blast/weaken/pierce/magic/action)× 极性(positive/negative):
# 九型封闭枚举(attack/defense/heal/blast/weaken/pierce/magic/action/gp)× 极性(positive/negative):
# 正向行 = 摇号池(权重 = 类型频次);负向行 = 纯兑付字典(不消费权重)。
# 九型批(2026-09-20 词表 v1.1 注记——用户拍板 GP 进第九型):gp 型正负两行入池,
# effect_id gp_gain/gp_loss = 战斗内缓冲通道(战斗结算 escrow 记账,不入增益容器)。
class_name RewardTable
extends Resource
@export var rows: Array = [] # Array[Dictionary]
const CELL_TYPES := ["attack", "defense", "heal", "blast", "weaken", "pierce", "magic", "action"] # 八型封闭(格面资源八型批 2026-09-20 七→八)——清单外加载拒绝
const CELL_TYPES := ["attack", "defense", "heal", "blast", "weaken", "pierce", "magic", "action", "gp"] # 九型封闭(格面资源九型批 2026-09-20 八→九,词表 v1.1)——清单外加载拒绝
const POLARITIES := ["positive", "negative"]
const POOL_TAGS := ["cell", "clear_default"] # cell = 格面资源单一池;clear_<biome> 命名空间预留拒绝
const CELL_TYPE_KEYS := ["attack", "defense", "heal", "blast", "weaken", "pierce", "magic", "action"] # 字典完整性校验轴(类型 × 极性 = 十六格)
const CELL_TYPE_KEYS := ["attack", "defense", "heal", "blast", "weaken", "pierce", "magic", "action", "gp"] # 字典完整性校验轴(类型 × 极性 = 十八格)
func validate() -> Array:
@@ -93,7 +95,7 @@ func validate() -> Array:
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]})
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)——摇号池空"})
@@ -0,0 +1,90 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否在 Boss 前商店房用战斗掉落 GP 购买三属性锻造档,兑现「数值提升→后续战斗改善」的局内成长闭环(P1)
# Date: 2026-09-21(GP+商店房实施批——2026-09-20 用户拍板,设计权威 design/research/proposal-gp-shop-room-2026-09-20.md 裁决记录)
# 数据配置 ShopTable schema(GP 冒险钱包 × 商店房 行族四轴)。
# 行族四轴(数据契约对齐 ConfigDB.shop_* 访问器——消费面一律走访问器,禁直读行集):
# gp_drops = 战斗掉落掷定行(键 tier:normal/elite/boss 封闭词表;掷定 = roll_min..roll_max 均匀整数,
# mean = 期望锚——roll_min ≤ mean ≤ roll_max,行动经济 条款①期望当量锚同款纪律);elite 为预留行
#(当前无消费方,随精英节点内容批启用)。level_gp_bonus = 关级掉落加值行(level_index 连续从 1 递增)。
# products = 商品行(键 product_id;三件全陈列无刷新无摇号——2026-09-20 拍板);
# 现价公式 = base_price + price_level_bonus(关卡) + price_increment × 该商品已购档数(已购档数在 冒险结构)。
# price_level_bonus = 关级定价加值行(level_index 连续从 1 递增)。
# 行值全部 provisional 归 难度曲线与Boss(用户拍板骨架先行、数值整体后置)。
class_name ShopTable
extends Resource
@export var gp_drops: Array = [] # Array[Dictionary] 键 tier
@export var level_gp_bonus: Array = [] # Array[Dictionary] 键 level_index
@export var products: Array = [] # Array[Dictionary] 键 product_id
@export var price_level_bonus: Array = [] # Array[Dictionary] 键 level_index
const GP_TIERS := ["normal", "elite", "boss"]
const PRODUCT_ATTRS := ["atk", "max_hp", "armor_class"]
func validate() -> Array:
var errs: Array = []
# 行族空护栏(四轴皆为 ConfigDB 消费面——缺族即致命)
for family in ["gp_drops", "level_gp_bonus", "products", "price_level_bonus"]:
if get(family).is_empty():
errs.append({"category": "empty_table", "message": "ShopTable 行族为空: %s" % family})
# gp_drops:tier 词表封闭 + 掷定域 + 均值锚 + tier 唯一
var seen_tier := {}
for row in gp_drops:
var tier: String = row.get("tier", "")
if not GP_TIERS.has(tier):
errs.append({"category": "enum", "message": "gp_drops tier 出域 {normal, elite, boss}: %s" % tier})
if seen_tier.has(tier):
errs.append({"category": "dup_key", "message": "gp_drops tier 重复: %s" % tier})
seen_tier[tier] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "gp_drops %s display_name 空" % tier})
var rmin: int = int(row.get("roll_min", -1))
var mean: int = int(row.get("mean", -1))
var rmax: int = int(row.get("roll_max", -1))
if rmin < 0 or mean < 0 or rmax < 0 or rmax > 999:
errs.append({"category": "domain", "message": "gp_drops %s 掷定域越域 [0,999]: %d..%d..%d" % [tier, rmin, mean, rmax]})
if rmin > mean or mean > rmax:
errs.append({"category": "cross_field", "message": "gp_drops %s 违反 roll_min(%d) ≤ mean(%d) ≤ roll_max(%d)——均值恒等期望锚" % [tier, rmin, mean, rmax]})
# level_gp_bonus / price_level_bonus:level_index 连续从 1 递增 + 加值域
for family in ["level_gp_bonus", "price_level_bonus"]:
var arr: Array = get(family)
var seen := {}
for row in arr:
var li: int = int(row.get("level_index", 0))
if seen.has(li):
errs.append({"category": "dup_key", "message": "%s level_index 重复: %d" % [family, li]})
seen[li] = true
var bonus: int = int(row.get("bonus", -1))
if bonus < 0 or bonus > 999:
errs.append({"category": "domain", "message": "%s level %d bonus 越域 [0,999]: %d" % [family, li, bonus]})
for i in arr.size():
var li: int = int(arr[i].get("level_index", 0))
if li != i + 1:
errs.append({"category": "structural", "message": "%s level_index 不连续(须从 1 连续递增): 位 %d = %d" % [family, i + 1, li]})
break
# products:product_id 唯一 + attr 词表 + 价格域 [1,999]
var seen_pid := {}
for row in products:
var pid: String = row.get("product_id", "")
if pid == "":
errs.append({"category": "domain", "message": "products product_id 空"})
continue
if seen_pid.has(pid):
errs.append({"category": "dup_key", "message": "products product_id 重复: %s" % pid})
seen_pid[pid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % pid})
var attr: String = row.get("attr", "")
if not PRODUCT_ATTRS.has(attr):
errs.append({"category": "enum", "message": "%s attr 出域 {atk, max_hp, armor_class}: %s" % [pid, attr]})
var flat: int = int(row.get("flat_value", 0))
if flat < 1 or flat > 999:
errs.append({"category": "domain", "message": "%s flat_value 越域 [1,999]: %d" % [pid, flat]})
var base: int = int(row.get("base_price", 0))
if base < 1 or base > 999:
errs.append({"category": "domain", "message": "%s base_price 越域 [1,999]: %d" % [pid, base]})
var inc: int = int(row.get("price_increment", -1))
if inc < 0 or inc > 999:
errs.append({"category": "domain", "message": "%s price_increment 越域 [0,999]: %d" % [pid, inc]})
return errs
@@ -127,7 +127,7 @@ rows = [{
"board_type": "underdark"
}, {
"level_index": 2,
"node_depth": 8,
"node_depth": 9,
"board_width": 12,
"board_height": 12,
"mine_density": 0.25,
@@ -204,7 +204,7 @@ rows = [{
"board_type": "deep_underdark"
}, {
"level_index": 3,
"node_depth": 8,
"node_depth": 9,
"board_width": 14,
"board_height": 14,
"mine_density": 0.25,
@@ -236,3 +236,10 @@ rows = [{
; min_safe_count 按既有 ~0.42 格占比外推(13×13 → 70 / 14×14 → 82)。
; 降档登记:Boss max_gen_attempts 批内拟值 36 越 schema 域 [1,32](config_fatal 修行值纪律——不许放宽 schema),
; 按域上限取 32(批内降档梯子「Boss 回 13×13 时 attempts 32」同款值)。行值全 provisional 归 难度曲线与Boss。
;
; 商店房批连带(2026-09-21——level.tres 商店房批 Boss 重编号 8→9):新增 (2,9)/(3,9) 精确行
; (跨表校验强制——ConfigDB ④ level_node_dangling),行值照抄原 Boss (L,8) 行;
; 商店房 (L,8) 不开战斗、无板行(跨表校验只查 battle/boss 节点)。行值 provisional 归 难度曲线与Boss。
; 陈旧行清理(GP+商店房实施批收尾 2026-09-21):并行表代理曾把 Boss 板行照抄到 (2,8)/(3,8)——节点 8 现为
; 商店房、无板行需求,原 (2,8)/(3,8) 行成陈旧冗余已删(删前核 (·,9) 行在位且内容与被删行逐字段一致);
; 本行集迁移 = Boss 重编号 8→9 的商店房批连带。
@@ -248,7 +248,7 @@ rows = [{
"armor_class": 0
}, {
"level_index": 2,
"node_depth": 8,
"node_depth": 9,
"enemy_id": "drider",
"display_name": "蛛化卓尔精灵",
"unit_count": 1,
@@ -408,7 +408,7 @@ rows = [{
"armor_class": 0
}, {
"level_index": 3,
"node_depth": 8,
"node_depth": 9,
"enemy_id": "duergar_king",
"display_name": "灰矮人石君王",
"unit_count": 1,
@@ -507,3 +507,10 @@ rows = [{
; (provisional 锚,域 [0.5,1.1]);Boss 专属池独占技 earth_pulse(第二妨害词汇 sapped 首发);
; armor_class 恒 0(正式投值仍归「敌方护甲等级正式行值」在案条目全表同批)。行值全 provisional
; 归 难度曲线与Boss。
;
; 商店房批连带(2026-09-21——level.tres 商店房批 Boss 重编号 8→9):新增 (2,9)/(3,9) 精确行
; (跨表校验强制——ConfigDB ④ level_node_dangling),行值照抄原 Boss (L,8) 行(drider / duergar_king);
; 商店房 (L,8) 不开战斗、无行集。行值 provisional 归 难度曲线与Boss。
; 陈旧行清理(GP+商店房实施批收尾 2026-09-21):并行表代理曾把 Boss 行照抄到 (2,8)/(3,8)——节点 8 现为
; 商店房、无战斗行需求,原 (2,8)/(3,8) 行成陈旧冗余已删(删前核 (·,9) 行在位且内容与被删行逐字段一致);
; 本行集迁移 = Boss 重编号 8→9 的商店房批连带。
@@ -66,6 +66,10 @@ rows = [{
"next": [8]
}, {
"node_depth": 8,
"type": "shop",
"next": [9]
}, {
"node_depth": 9,
"type": "boss"
}]
}, {
@@ -106,6 +110,10 @@ rows = [{
"next": [8]
}, {
"node_depth": 8,
"type": "shop",
"next": [9]
}, {
"node_depth": 9,
"type": "boss"
}]
}]
@@ -140,3 +148,11 @@ rows = [{
; L3 行集复用 L2 九节点图模板(6 战 + Boss + 奖励 + 恢复,真实岔路 4 处);灰矮人矿城主题
; (DND 幽暗地域最深层方向)。仅行集落地,正式三关序列推进仍登记 Alpha 验收条件
; (测试直入口 ≠ 序列生效)。行值全 provisional 归 难度曲线与Boss。
;
; 商店房批(2026-09-20 用户拍板——GP+商店房实施批,关卡节点地图 v1.1 四类→五类):编排规则锚 =
; 每关恰 1 商店房、Boss 前一位必经位(参考哈迪斯商店房位置);L2/L3 行集 rewiring:原 Boss 行 (8, boss)
; 改为 (8, shop, next [9])、新增 (9, boss)——图六检全绿(Boss 恒最大编号 9、无出边恒终点;节点 6/7 出边
; [8] 字面不变、指向现为商店房,全路径穿商店房 8);L1 行集零改动(教学期最小修订面,L1 掉落攒进 L2 首店)。
; 必经位(全路径穿 8)由冒烟断言承载、非加载校验——level_table.gd 零新增图合法性检查。
; 连带:board_gen/enemy 补 (2,9)/(3,9) 精确行(跨表校验 ④ 强制,行值照抄原 Boss (L,8) 行)。
; 行值全 provisional 归 难度曲线与Boss。
@@ -180,7 +180,27 @@ rows = [{
"weight": 0,
"pool_tag": "cell",
"tier": 1
}]
}, {
"reward_id": "gp_coin",
"cell_type": "gp",
"polarity": "positive",
"effect_id": "gp_gain",
"display_name": "金币袋",
"power": 3,
"weight": 2,
"pool_tag": "cell",
"tier": 1
}, {
"reward_id": "gp_lose",
"cell_type": "gp",
"polarity": "negative",
"effect_id": "gp_loss",
"display_name": "破财",
"power": 2,
"weight": 0,
"pool_tag": "cell",
"tier": 1
}]
; 格面资源批(2026-09-15 用户拍板——registry reward_table 条目为现行 schema 事实源):
; 十行格面池(五型 × 正负各一行)+ 清图槽一行。安全格与雷格同池同分布(雷安同池),
@@ -215,3 +235,9 @@ rows = [{
; ② 权重再平衡(保 spark_aoe 16 不动、总 67 不变):attack_up 20→14 · ac_up 16→11 · heal_minor 3→2 · weaken 12→8;
; 新行各 8。等比基准 = 其余四行 51→35(×0.686),整取后 14/11/2/8,微差登记已知偏差。
; 类型频次合计仍 67:attack 14 / defense 11 / blast 16 / heal 2 / weaken 8 / pierce 8 / magic 8。
;
; 九型批(2026-09-20 用户拍板——GP 进第九型,获取来源=战斗结束掉落+雷图低概率惊喜获取〔量与战斗掉落相当〕;
; 金币袋=挖开安全格入战斗内缓冲、战斗完成统一入账;破财=雷格摇到 gp 面的字典负向行〔weight 0 不入池,
; 挖雷格兑付=缓冲 -2 钱包侧 clamp ≥0〕;正向权重合计 74→76);行值 provisional 归 难度曲线与Boss。
; gp 两行 tier=1(provisional 占位);effect_id gp_gain/gp_loss = 战斗内缓冲(escrow)通道路由键
;(战斗结算 _apply_percent_row 分流,不入增益容器——ConfigDB ① status_effects 悬空豁免归 core 面)。
@@ -0,0 +1,75 @@
[gd_resource type="Resource" script_class="ShopTable" load_steps=2 format=3]
[ext_resource type="Script" path="res://data/schemas/shop_table.gd" id="1"]
[resource]
script = ExtResource("1")
gp_drops = [{
"tier": "normal",
"display_name": "普通战斗",
"mean": 3,
"roll_min": 2,
"roll_max": 4
}, {
"tier": "elite",
"display_name": "精英战斗",
"mean": 5,
"roll_min": 4,
"roll_max": 6
}, {
"tier": "boss",
"display_name": "Boss 战斗",
"mean": 8,
"roll_min": 6,
"roll_max": 10
}]
level_gp_bonus = [{
"level_index": 1,
"bonus": 0
}, {
"level_index": 2,
"bonus": 1
}, {
"level_index": 3,
"bonus": 2
}]
products = [{
"product_id": "atk_up",
"display_name": "攻击锻升",
"attr": "atk",
"flat_value": 1,
"base_price": 4,
"price_increment": 2
}, {
"product_id": "hp_up",
"display_name": "生命锻升",
"attr": "max_hp",
"flat_value": 3,
"base_price": 2,
"price_increment": 2
}, {
"product_id": "armor_up",
"display_name": "护甲锻升",
"attr": "armor_class",
"flat_value": 1,
"base_price": 4,
"price_increment": 2
}]
price_level_bonus = [{
"level_index": 1,
"bonus": 0
}, {
"level_index": 2,
"bonus": 2
}, {
"level_index": 3,
"bonus": 4
}]
; GP+商店房实施批(2026-09-20 用户拍板,设计权威 design/research/proposal-gp-shop-room-2026-09-20.md 裁决记录):
; 掉落=均值±方差掷定(gp_drop 封闭流)+关级加值;精英行为预留行(当前无消费方,随精英节点内容批启用);
; 价格公式 = base_price + price_level_bonus(关卡) + price_increment × 该商品已购档数(已购档数在 冒险结构——
; 冒险内累计、跨冒险清零);商品三件全陈列无刷新无摇号(2026-09-20 拍板,提案 §2.4 的三选一+刷新方案退役)。
; 期望账:L2 商店前可得 GP 期望 ≈ 35–40(L1 全程 22 ± 惊喜 + L2 商店前五战 21 ± ),三件首购总价 L2 =
; (4+2)+(2+2)+(4+2)=16、全购欲望 ≈ 26–34——取舍空间成立;量级与方差全部 provisional-pending-曲线调参批
;(用户拍板骨架先行、数值整体后置,行值归 难度曲线与Boss)。
@@ -21,6 +21,11 @@ static func rest_line(healed: int, hp: int, max_hp: int) -> String:
return "[color=#7cc47c]恢复点:回复 %d HP(→ %d/%d)[/color]" % [healed, hp, max_hp]
# 商店房行(GP+商店房实施批——到达即开店;购买/离开归玩家意图)
static func shop_line(gp: int) -> String:
return "[color=#e0b860]商店房:金币 %d——购买即时生效,离开后选下一跳[/color]" % gp
# 选路口行(零自动推进停点——战斗胜利/兑付确认后出现)
static func route_line(candidates: Array, runner) -> String:
var names: Array = []
@@ -16,7 +16,10 @@ extends RefCounted
# 每次结算行/轮次/节点变化/动作返回后调用(组合根全量重查装配)
static func refresh(stage: BattleStageView, info: HeroInfoPanelView, ap_bar: ApBarView,
settlement, runner, level_active: bool, db, refresh_remaining: int) -> void:
settlement, runner, level_active: bool, db, refresh_remaining: int, gp_label: Label = null) -> void:
# GP 余额常驻(GP+商店房实施批 2026-09-21——A 区顶栏邻接位;runner 缺位 = 非冒险态按 0 读数)
if gp_label != null:
gp_label.text = "金币 %d" % (int(runner.gp) if runner != null else 0)
if settlement == null:
_refresh_out_of_battle(stage, info, runner, level_active, db)
return
@@ -13,6 +13,8 @@
# 以 encounter 参数入战斗,db.hero 运行期只读)
# ④ 全屏 States 状态机(挖掘/结算/终局)与一次性提示去重。
# 2026-09-15 格面资源批:道具窗口与库存面板随可选类道具删除下线——E 区移除,窗口接线/选择序列删除。
# GP+商店房实施批(2026-09-21):商店覆盖层挂接与信号接线(装配器零信号连接纪律——句柄返回、
# 本类连接,仿 lv2btn 模式)+ runner.wallet_changed 转发试玩日志(wallet 行族)+ A 区顶栏金币余额。
# 渲染与文案归 views/,核心状态→文案装配归 hud_presenter.gd 与 battle_narrator.gd,节点构建归
# ui_assembler.gd,模态层级归 modal_stack.gd,批量模拟编排归 tune_sim_controller.gd,调试模式编排归
# debug_session.gd——本类不拼呈现字符串。
@@ -49,10 +51,12 @@ var board_zone: BoardZoneView # D 区(含 ap_bar/board_view/toast/
var board_view: BoardView # 探针兼容别名(= board_zone.board_view)
var ap_bar: ApBarView # 探针兼容别名(= board_zone.ap_bar)
var settlement_fx: SettlementFxView # 结算演出覆盖层(跨区弹道/光带——A–D 之上、模态之下)
var gp_label: Label # A 区顶栏 GP 余额常驻位(GP+商店房实施批——文案装配归 HudPresenter)
# 模态 overlay(z 序 = 登记序;pattern 6 层级纪律)
var legend_overlay: LegendOverlay
var review_overlay: SettlementReviewOverlay
var node_overlay: NodeOverlay
var shop_overlay: ShopOverlay # 商店覆盖层(GP+商店房实施批——商店房节点兑付呈现)
var tune_panel: TunePanel
var _modals = null # 模态层级状态机(modal_stack.gd——缺陷⑤纪律载体)
@@ -85,6 +89,7 @@ func _build_ui() -> void:
add_child(root)
var battle_ui: Dictionary = UiAssembler.build_battle_ui(root, db)
strip_view = battle_ui.strip_view
gp_label = battle_ui.gp_label
stage = battle_ui.stage
log_view = battle_ui.log_view
info_panel = battle_ui.info_panel
@@ -108,28 +113,31 @@ func _build_ui() -> void:
logger = PlaytestLogger.new()
logger.ap_per_round = int(db.action_economy.ap_per_round)
# 模态覆盖层(登记序 = 树序 = z 遮挡序:图例 < 明细回看 < 节点 < 选路 < 终局 < 调整板 < 开始)
# 模态覆盖层(登记序 = 树序 = z 遮挡序:图例 < 明细回看 < 节点 < 选路 < 终局 < 商店 < 调整板 < 开始)
var overlays: Dictionary = UiAssembler.build_overlays(self, db, int(db.hero.atk), int(db.hero.max_hp))
legend_overlay = overlays.legend_overlay
review_overlay = overlays.review_overlay
node_overlay = overlays.node_overlay
_end_overlay = overlays.end_overlay
shop_overlay = overlays.shop_overlay
tune_panel = overlays.tune_panel
route_overlay = RouteOverlay.new()
add_child(route_overlay)
# 树序 = 模态登记序 = z 遮挡序(缺陷⑤纪律)——选路层须夹在节点层与终局层之间
# 树序 = 模态登记序 = z 遮挡序(缺陷⑤纪律)——选路层须夹在节点层与商店层之间
move_child(route_overlay, node_overlay.get_index() + 1)
legend_overlay.closed.connect(_on_modal_closed.bind(legend_overlay))
review_overlay.closed.connect(_on_modal_closed.bind(review_overlay))
node_overlay.continue_pressed.connect(_on_node_continue)
route_overlay.route_chosen.connect(_on_route_chosen)
shop_overlay.buy_requested.connect(_on_shop_buy)
shop_overlay.leave_pressed.connect(_on_shop_leave)
_end_overlay.review_pressed.connect(_on_end_review)
_end_overlay.continue_pressed.connect(_on_battle_end_continue)
tune_panel.closed.connect(_close_tune_panel)
tune_panel.apply_requested.connect(_on_tune_apply)
tune_panel.simulate_requested.connect(_on_tune_simulate)
_modals = ModalStack.new()
for m: Control in [legend_overlay, review_overlay, node_overlay, route_overlay, _end_overlay, tune_panel]:
for m: Control in [legend_overlay, review_overlay, node_overlay, route_overlay, _end_overlay, shop_overlay, tune_panel]:
_register_modal(m)
_tune_sim = TuneSimController.new(db, tune_panel, int(db.hero.max_hp))
@@ -184,8 +192,8 @@ func _on_modal_closed(m: Control) -> void:
func _input_locked() -> bool:
# 模态/非战斗期棋盘输入锁(各 overlay 自身可见即锁定)
return legend_overlay.is_open() or review_overlay.is_open() or _end_overlay.is_open() \
or node_overlay.visible or route_overlay.visible or tune_panel.visible or _start_overlay.visible \
or _inter_overlay.visible or settlement == null
or node_overlay.visible or route_overlay.visible or shop_overlay.visible or tune_panel.visible \
or _start_overlay.visible or _inter_overlay.visible or settlement == null
# ==================== 完整关卡模式(关卡节点地图 图版推进器)====================
@@ -201,6 +209,7 @@ func _start_full_level(level_index: int = 1) -> void:
runner.node_entered.connect(_on_runner_node)
runner.choices_presented.connect(_on_choices_presented)
runner.level_finished.connect(_on_level_finished)
runner.wallet_changed.connect(_on_wallet_changed)
_level_active = true
_reset_run_scope()
log_view.clear()
@@ -238,6 +247,12 @@ func _on_runner_node(node: Dictionary, info: Dictionary) -> void:
_open_modal(node_overlay)
node_overlay.show_rest(healed, int(info.get("hp", 0)), int(info.get("max_hp", 0)))
log_view.append_bb(Narrator.rest_line(healed, int(info.get("hp", 0)), int(info.get("max_hp", 0))))
elif t == "shop":
# 商店房兑付(GP+商店房实施批):到达即开店——商品快照/余额经 runner 只读投影喂入;
# 购买/离开归玩家意图(buy_requested/leave_pressed 接线),零自动推进
_open_modal(shop_overlay)
shop_overlay.open_shop(runner.shop_products(), int(runner.gp))
log_view.append_bb(Narrator.shop_line(int(runner.gp)))
logger.log_node(node, info, runner.node_index)
strip_view.refresh(runner)
_refresh_presentations()
@@ -499,6 +514,35 @@ func _on_route_chosen(node_id: int) -> void:
push_error("[组合根] 选路被拒(编号 %d 不在候选)" % node_id)
# ---- 商店覆盖层与 GP 钱包(GP+商店房实施批——写调用唯一归组合根)----
func _on_shop_buy(product_id: String) -> void:
if runner == null:
return
if not runner.buy_product(product_id):
# 余额不足(按钮禁用为常态防线——兜底拒绝可见,pattern 1)
logger.log_ux("ux_reject", {"source": "wallet", "sub_reason": "insufficient_gp"})
board_zone.toast.show_toast("金币不足——买不起这件商品", "warn")
return
shop_overlay.refresh_state(runner.shop_products(), int(runner.gp))
_refresh_presentations()
func _on_shop_leave() -> void:
_close_modal(shop_overlay)
if runner != null:
# 复用兑付确认纪律(接口漂移清偿):离开 ≠ 推进——确认后经 choices_presented 弹出选路口
runner.continue_after_node()
func _on_wallet_changed(event: String, gp: int, extra: Dictionary) -> void:
# wallet 行族转发(到账/购买/清零——试玩日志验收取数面)+ 常驻余额与在开商店覆盖层重刷
logger.log_wallet(event, gp, extra)
if shop_overlay.visible and runner != null:
shop_overlay.refresh_state(runner.shop_products(), int(runner.gp))
_refresh_presentations()
# ==================== 玩家意图 → 核心写调用(唯一出口)====================
func _on_board_action(cell: Vector2i, kind: String) -> void:
@@ -576,7 +620,7 @@ func _handle_outcome_feedback(outcome: Dictionary) -> void:
func _refresh_presentations() -> void:
# 每个核心信号/写调用返回后的全量重查装配(更新触发注记——无推送信号,命令式重查)
var refresh_remaining: int = maxi(0, int(db.action_economy.refresh_cap_per_round) - _refresh_used_round)
HudPresenter.refresh(stage, info_panel, ap_bar, settlement, runner, _level_active, db, refresh_remaining)
HudPresenter.refresh(stage, info_panel, ap_bar, settlement, runner, _level_active, db, refresh_remaining, gp_label)
_sync_settlement_visuals()
@@ -55,7 +55,17 @@ 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))}))
"hero_hp": int(p.get("hero_hp", 0)),
"gp_dropped": int(p.get("gp_dropped", 0))})) # 战斗掉落列(GP+商店房实施批 2026-09-21——验收取数面)
# ---- GP 钱包行(GP+商店房实施批——到账/购买/清零事件;runner wallet_changed 投影经组合根转发)----
func log_wallet(event: String, gp: int, extra: Dictionary = {}) -> void:
var line := {"kind": "wallet", "event": event, "gp": gp}
for k in extra.keys():
line[k] = extra[k]
_append(line)
# ---- ux 遥测(battle.md §Events Fired——呈现层观测行族,不改 core) ----
@@ -98,6 +108,8 @@ func log_node(node: Dictionary, info: Dictionary, node_index: int) -> void:
line["depth"] = int(node.get("node_depth", 0))
elif t == "rest":
line["healed"] = int(info.get("healed", 0))
elif t == "shop":
line["gp"] = int(info.get("gp", 0)) # 进店时点余额(GP+商店房实施批)
# reward 节点:内容待拍板(格面资源批 2026-09-15 道具删除)——零载荷
_append(line)
@@ -9,21 +9,28 @@ extends RefCounted
# 战斗屏四区(A 进度条 / B 战斗画面 / C·D 两列——E 区道具面板已随可选类删除下线,2026-09-15 格面资源批)
# + 结算演出覆盖层(A–D 之上、模态之下——跨区弹道/光带载体,结算演出链批 2026-09-20)。
# 返回句柄字典:{strip_view, stage, log_view, info_panel, board_zone, board_view, ap_bar, settlement_fx}
# 返回句柄字典:{strip_view, gp_label, stage, log_view, info_panel, board_zone, board_view, ap_bar, settlement_fx}
static func build_battle_ui(root: Control, db) -> Dictionary:
# set_anchors_and_offsets_preset(非 set_anchors_preset):后者会把控件重置回最小内容尺寸
# (实测 927×613——缩顶、棋盘 45px 格 ×10 行超出 D 区 333px 高被裁,2026-09-13 用户报告回归)
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_theme_constant_override("separation", 0)
# A 区:关卡进度条(全宽 × 28px 顶栏)
# A 区:关卡进度条(全宽 × 28px 顶栏)+ GP 余额常驻位(GP+商店房实施批 2026-09-21——
# 顶栏邻接位,显示名「金币」;文案装配归 HudPresenter,此处只建节点)
var strip_panel := PanelContainer.new()
strip_panel.custom_minimum_size = Vector2(0, 28)
var sb := UiKit.panel_flat(UiKit.BG_ROOT, UiKit.BG_ROOT, 0, 0)
strip_panel.add_theme_stylebox_override("panel", sb)
root.add_child(strip_panel)
var strip_row := HBoxContainer.new()
strip_panel.add_child(strip_row)
var strip_view := LevelStripView.new()
strip_panel.add_child(strip_view)
strip_view.size_flags_horizontal = Control.SIZE_EXPAND_FILL
strip_row.add_child(strip_view)
var gp_label := UiKit.label("金币 0", 15, true)
gp_label.add_theme_color_override("font_color", UiKit.GOLD_DEEP)
strip_row.add_child(gp_label)
# B 区:战斗画面(全宽 × 252px——常驻双舞台,布局决策 D1)
var stage := BattleStageView.new(db)
@@ -48,6 +55,7 @@ static func build_battle_ui(root: Control, db) -> Dictionary:
return {
"strip_view": strip_view,
"gp_label": gp_label,
"stage": stage,
"log_view": stage.log_view,
"info_panel": info_panel,
@@ -58,8 +66,8 @@ static func build_battle_ui(root: Control, db) -> Dictionary:
}
# 模态覆盖层组(z 序 = 登记序:图例 < 明细回看 < 节点 < 终局 < 调整板)。
# 返回 {legend_overlay, review_overlay, node_overlay, end_overlay, tune_panel};调用方按本序 addChild。
# 模态覆盖层组(z 序 = 登记序:图例 < 明细回看 < 节点 < 终局 < 商店 < 调整板)。
# 返回 {legend_overlay, review_overlay, node_overlay, end_overlay, shop_overlay, tune_panel};调用方按本序 addChild。
static func build_overlays(host: Control, db, base_atk: int, base_hp: int) -> Dictionary:
var legend_overlay := LegendOverlay.new()
host.add_child(legend_overlay)
@@ -73,6 +81,10 @@ static func build_overlays(host: Control, db, base_atk: int, base_hp: int) -> Di
var end_overlay := BattleEndOverlay.new()
host.add_child(end_overlay)
# 商店覆盖层(GP+商店房实施批 2026-09-21——商店房节点兑付呈现;按钮接线归组合根 lv2btn 模式)
var shop_overlay := ShopOverlay.new()
host.add_child(shop_overlay)
var tune_panel := TunePanel.new(db, base_atk, base_hp)
tune_panel.visible = false # 显式初始化——原版依赖"被不透明开始层遮蔽"的树序巧合(缺陷⑤家族);模态锁输入以 visible 为准
host.add_child(tune_panel)
@@ -82,6 +94,7 @@ static func build_overlays(host: Control, db, base_atk: int, base_hp: int) -> Di
"review_overlay": review_overlay,
"node_overlay": node_overlay,
"end_overlay": end_overlay,
"shop_overlay": shop_overlay,
"tune_panel": tune_panel,
}
@@ -168,6 +168,12 @@ func _format_row(row: Dictionary, agg_count := -1) -> String:
var suffix := "(%s)" % src if src != "" else ""
match String(row.get("type", "")):
"effect":
# GP 型效果行(GP+商店房实施批 2026-09-21):「金币袋 +3」兑付反馈——value = 本行 ±power,
# escrow = 战斗内缓冲现值(入钱包在战斗末一次上抛——呈现语义与增益池分账)
if String(row.get("effect_id", "")) in ["gp_gain", "gp_loss"]:
var gp_col := "#e0b860" if v > 0 else "#c47c7c"
var gp_body := "%s %+d(战斗暂存 %+d)%s" % [name_, v, int(row.get("escrow", 0)), suffix]
return prefix + (_gold_frame(gp_body) if hl != "" else "[color=%s]%s[/color]" % [gp_col, gp_body])
# 效果行 value = 单次贡献值,pool_value = 池合成值——禁相加呈现(战斗结算 呈现细则)
var pool: int = int(row.get("pool_value", 0))
var col := "#7cc47c" if v > 0 else "#c47c7c"
@@ -24,9 +24,10 @@ const COL_GOLD := UiKit.GOLD # 奖励金 #F0A828
const COL_PATH := UiKit.HERO_BLUE # 路径预览点迹——小人蓝细线
const COL_DIG_EDGE := UiKit.DIG_EDGE # 可挖域呼吸微光基色(绿相,明度贴格底)
const COL_HL_TARGET := UiKit.GOLD # 悬停目标格边框(金)
const REWARD_BADGE := { # 已开格格面资源身份 icon(cell_type 八型键;极性视觉归批 2)
const REWARD_BADGE := { # 已开格格面资源身份 icon(cell_type 九型键;极性视觉归批 2)
"attack": "攻", "defense": "守", "heal": "医", "blast": "爆", "weaken": "弱", "pierce": "穿", "magic": "魔", # 穿/魔 = 伤害道具批 2026-09-18
"action": "行", # 行 = 行动点型(八型批 2026-09-20——ap_up/ap_drain 两行;徽章键漏网补齐,ui_click_probe 91 断言修复)
"gp": "金", # 金 = GP 型(GP+商店房实施批 2026-09-21——gp_gain/gp_loss 两行;金色系 ~40° 相位,禁入带紫赤 300–345° 外,取色 UiKit.GOLD 徽标色单点)
}
const BADGE_PLACEHOLDER := "?" # 未知图标键占位纹样(HUD框架:禁静默空图标)
const CURSOR_DIG := Input.CURSOR_CROSS # 挖——镐形自绘纹理载体
@@ -290,7 +291,7 @@ func _update_badge(c: Vector2i, btn: Button) -> void:
badge.visible = true
# 奖励图标键解析(HUD框架:查不到的键立即断言 + ? 占位纹样 + 诊断;键集 = 格面资源 cell_type 七值枚举——伤害道具批 2026-09-18 五→七)
# 奖励图标键解析(HUD框架:查不到的键立即断言 + ? 占位纹样 + 诊断;键集 = 格面资源 cell_type 九值枚举——GP+商店房实施批 2026-09-21 八→九)
func _badge_glyph(category: String) -> String:
if REWARD_BADGE.has(category):
return REWARD_BADGE[category]
@@ -12,7 +12,7 @@ const COL_CURRENT := UiKit.GOLD # 当前——奖励金高亮
const COL_UPCOMING := UiKit.DARK_UPCOMING
const COL_CANDIDATE := UiKit.GOLD_LIT # 候选下一跳——亮金边框
const COL_BOSS_BORDER := UiKit.FLAG_RED # Boss 边框——旗朱
const NODE_LABELS := {"battle": "战", "reward": "赏", "rest": "疗", "boss": "王"}
const NODE_LABELS := {"battle": "战", "reward": "赏", "rest": "疗", "boss": "王", "shop": "商"} # 商 = GP+商店房实施批 2026-09-21
func _init() -> void:
@@ -16,7 +16,7 @@ const COL_ROOM_FUTURE := UiKit.DARK_UPCOMING # 未达——暗
const COL_PASSAGE := UiKit.STONE # 通道——石灰
const COL_PASSAGE_LIT := UiKit.GOLD_LIT # 候选通道——亮金
const COL_BOSS_BORDER := UiKit.FLAG_RED # Boss 边框——旗朱
const NODE_LABELS := {"battle": "战", "reward": "赏", "rest": "疗", "boss": "王"}
const NODE_LABELS := {"battle": "战", "reward": "赏", "rest": "疗", "boss": "王", "shop": "商"} # 商 = GP+商店房实施批 2026-09-21
var _runner = null
var _btn_box: VBoxContainer
@@ -87,6 +87,7 @@ func _type_name(t: String) -> String:
"reward": return "奖励点"
"rest": return "恢复点"
"boss": return "Boss 房"
"shop": return "商店房"
return t
@@ -0,0 +1,102 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-21(GP+商店房实施批——商店房覆盖层呈现)
# 商店覆盖层(模态):标题「商店」/ 金币余额 / 三商品卡全陈列(显示名·档位文案·现价·已购档数)/
# 购买按钮(余额不足禁用置灰)/ 离开按钮。模态挂接方式仿既有覆盖层(route_overlay 现成参照——
# dim + CenterContainer + box,组合根 _register_modal 收口层级)。
# 高内聚边界:纯视图 setter 面——商品快照与余额经组合根喂入(runner.shop_products() 只读投影,
# 现价/已购档数由 runner 现算),购买/离开意图经信号外发——零核心写调用、零 runner 持有。
class_name ShopOverlay
extends Control
signal buy_requested(product_id: String)
signal leave_pressed
const ATTR_LABELS := {"atk": "攻击", "max_hp": "生命上限", "armor_class": "护甲"} # 档位文案轴(数据契约 attr 词表)
var _gp_label: Label
var _cards_box: VBoxContainer
var _card_rows: Array = [] # [{product_id, price, bought, btn}]——refresh_state 就地重刷(零重建回流)
func _init() -> void:
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.6)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.add_child(center)
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 12)
center.add_child(box)
var title := UiKit.label("商店", 26, true)
title.add_theme_color_override("font_color", UiKit.WARM)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(title)
_gp_label = UiKit.label("金币 0", 18, true)
_gp_label.add_theme_color_override("font_color", UiKit.GOLD)
_gp_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_gp_label)
_cards_box = VBoxContainer.new()
_cards_box.add_theme_constant_override("separation", 8)
box.add_child(_cards_box)
var leave_btn := UiKit.button("离开商店")
leave_btn.pressed.connect(func(): leave_pressed.emit())
box.add_child(leave_btn)
visible = false
# 打开商店(products = runner.shop_products() 投影——[{product_id, display_name, attr, flat_value, price, purchased}])
func open_shop(products: Array, gp: int) -> void:
for child in _cards_box.get_children():
child.queue_free()
_card_rows.clear()
for p in products:
_card_rows.append(_build_card(p))
refresh_state(products, gp)
visible = true
# 状态重刷(购买/钱包变化后调用——就地改文案与按钮禁用态,零重建)
func refresh_state(products: Array, gp: int) -> void:
_gp_label.text = "金币 %d" % gp
var by_id := {}
for p in products:
by_id[String(p.product_id)] = p
for card in _card_rows:
var p: Dictionary = by_id.get(String(card.product_id), {})
if p.is_empty():
continue
card.price.text = "现价 %d 金币" % int(p.price)
card.bought.text = "已购 %d 档" % int(p.purchased)
card.btn.disabled = gp < int(p.price) # 余额不足禁用置灰
# 商品卡(显示名 + 档位文案 | 现价 | 已购档数 | 购买按钮)
func _build_card(p: Dictionary) -> Dictionary:
var card := PanelContainer.new()
card.add_theme_stylebox_override("panel", UiKit.panel_flat(UiKit.BG_OVERLAY, UiKit.GOLD_DIM, 4, 1, Vector4(12, 6, 12, 6)))
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 14)
card.add_child(row)
var name_l := UiKit.label(_card_title(p), 15, true)
name_l.custom_minimum_size = Vector2(190, 0)
row.add_child(name_l)
var price_l := UiKit.label("", 14, false)
price_l.add_theme_color_override("font_color", UiKit.WARM_SOFT)
price_l.custom_minimum_size = Vector2(110, 0)
row.add_child(price_l)
var bought_l := UiKit.label("", 14, false)
bought_l.add_theme_color_override("font_color", UiKit.WARM_FAINT)
row.add_child(bought_l)
var btn := UiKit.button("购买")
btn.pressed.connect(func(): buy_requested.emit(String(p.product_id)))
row.add_child(btn)
_cards_box.add_child(card)
return {"product_id": String(p.product_id), "price": price_l, "bought": bought_l, "btn": btn}
# 卡题 = 显示名 + 档位文案(attr →「攻击 +1」形)
func _card_title(p: Dictionary) -> String:
var attr := String(p.get("attr", ""))
return "%s(%s %+d)" % [String(p.get("display_name", "")), String(ATTR_LABELS.get(attr, attr)), int(p.get("flat_value", 0))]
@@ -0,0 +1 @@
uid://bv0bs2ase1m5s
@@ -106,6 +106,8 @@ static func _on_node_entered(node: Dictionary, info: Dictionary, runner, st: Dic
st.rest_healed = int(info.get("healed", 0))
st.hp_before_rest = int(info.get("hp", 0)) - st.rest_healed
runner.continue_after_node()
elif t == "shop":
runner.continue_after_node() # 商店房零购离开(GP+商店房批——自动路径直通 Boss;购买行为归冒烟商店专项断言/真人试玩)
if logger != null:
logger.log_node(node, info, runner.node_index)
@@ -93,6 +93,11 @@ func _run_core_smoke(db, rng) -> void:
_smoke_settlement_segments()
_smoke_skill_pool()
_smoke_level_graph()
_smoke_gp_wallet()
_smoke_gp_escrow()
_smoke_shop_room()
_smoke_shop_graph()
_smoke_nine_types_gp_stream()
# ---- 战斗屏棋盘布局·格边长公式回归(2026-09-19 真人试玩 bug⑥:L2 12×12 板下缘被裁约 1/6 格高——
@@ -1400,10 +1405,11 @@ func _smoke_refresh_provenance_guard() -> void:
# ==================== 格面资源批新增覆盖(极性路由 / 负向字典 / 资源面 / 五段全序) ====================
# 极性路由断言(战斗结算 验收):挖安全格/标中旗只出正向、挖雷格/标错旗只出负向、兑付行指回来源格。
# 正向字典效果集 = {attack_up, ac_up, weaken}(ac_up = 2026-09-16 护甲等级掷点批换装,原 defense_up)
# + spark_aoe/heal_minor(flat);
# 负向字典效果集 = {enrage, off_guard, sapped}(percent)+ 直伤(heal flat)/ alert 警觉(blast percent——2026-09-20 换装)。
const POSITIVE_EFFECTS := ["attack_up", "ac_up", "weaken"]
const NEGATIVE_EFFECTS := ["enrage", "off_guard", "sapped", "alert"]
# + spark_aoe/heal_minor(flat)+ gp_gain(九型批 2026-09-20——金币袋,escrow 缓冲路由键非增益容器行);
# 负向字典效果集 = {enrage, off_guard, sapped}(percent)+ 直伤(heal flat)/ alert 警觉(blast percent——2026-09-20 换装)
# + gp_loss(九型批——破财,escrow 缓冲路由键)。
const POSITIVE_EFFECTS := ["attack_up", "ac_up", "weaken", "gp_gain"]
const NEGATIVE_EFFECTS := ["enrage", "off_guard", "sapped", "alert", "gp_loss"]
func _smoke_polarity_routing() -> void:
@@ -1413,6 +1419,7 @@ func _smoke_polarity_routing() -> void:
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 行 ∈ 正向集
# (action 型排队行 effect_id=ap_delta 正负两极共用路由键——按行值符号辨极性:正向格恒 +power)
var safe_all_pos := true
for e in probe["safe_cells"].keys():
var cell_arr: Array = [e.x, e.y]
@@ -1421,10 +1428,15 @@ func _smoke_polarity_routing() -> void:
safe_all_pos = false # 每个资源格必有 ≥1 兑付行(每格必有货 → 每格必有行)
for r in rows:
var eff := String(r.get("effect_id", ""))
if eff == "ap_delta":
if int(r.get("value", 0)) <= 0:
safe_all_pos = false
continue
if eff != "" and not POSITIVE_EFFECTS.has(eff):
safe_all_pos = false
check(safe_all_pos, "挖安全格 → 只出正向兑付(效果行 ∈ 正向字典 + 每格 ≥1 行——%d 格)" % probe["safe_cells"].size())
# 挖到的雷格:只出负向——来源行必存在且 effect 行 ∈ 负向集(alert = blast 负向;heal 负向 = damage 行)
# 挖到的雷格:只出负向——来源行必存在且 effect 行 ∈ 负向集(alert = blast 负向;heal 负向 = damage 行;
# action 型排队行同按行值符号辨极性:雷格恒 −power)
var mine_all_neg := true
for e in probe["mine_cells"].keys():
var cell_arr: Array = [e.x, e.y]
@@ -1433,6 +1445,10 @@ func _smoke_polarity_routing() -> void:
mine_all_neg = false
for r in rows:
var eff := String(r.get("effect_id", ""))
if eff == "ap_delta":
if int(r.get("value", 0)) >= 0:
mine_all_neg = false
continue
if eff != "" and not NEGATIVE_EFFECTS.has(eff):
mine_all_neg = false
check(mine_all_neg, "挖雷格 → 只出负向兑付(效果行 ∈ 负向字典 + 每格 ≥1 行——%d 格)" % probe["mine_cells"].size())
@@ -1860,14 +1876,14 @@ func _smoke_skill_pool_data() -> void:
"2_5": {"attack": 5, "lunge": 2, "heavy": 2},
"2_6": {"attack": 6, "bite": 2, "charge": 1},
"2_7": {"attack": 6, "bite": 2, "charge": 1},
"2_8": {"attack": 6, "heavy": 2, "venom_burst": 2, "web_snare": 2},
"2_9": {"attack": 6, "heavy": 2, "venom_burst": 2, "web_snare": 2},
"3_0": {"attack": 6, "charge": 2, "heavy": 2},
"3_1": {"attack": 6, "bite": 2, "charge": 1},
"3_4": {"attack": 6, "bite": 2, "charge": 1},
"3_5": {"attack": 5, "lunge": 2, "heavy": 2},
"3_6": {"attack": 6, "bite": 2, "charge": 1},
"3_7": {"attack": 6, "charge": 2, "heavy": 2},
"3_8": {"attack": 6, "heavy": 2, "earth_pulse": 2},
"3_9": {"attack": 6, "heavy": 2, "earth_pulse": 2},
}
var pools_ok := true
for row in _db.enemy.rows:
@@ -1880,7 +1896,7 @@ func _smoke_skill_pool_data() -> void:
got[String(entry.skill_id)] = int(entry.weight)
if got != expect_pools[rk]:
pools_ok = false
check(pools_ok, "十九行池模板 = 建议池行值(L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 兄弟精 6/1/2 · 蜘蛛 6/2/1 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2;L3 灰矮人 6/2/2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional)")
check(pools_ok, "十九行池模板 = 建议池行值(L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 兄弟精 6/1/2 · 蜘蛛 6/2/1 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔(2,9)——商店房批 Boss 重编号〕;L3 灰矮人 6/2/2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2 · 石君王 6/2/2〔earth_pulse·(3,9)〕——全部 provisional)")
var basic_count_ok := true
for row in _db.enemy.rows:
var n := 0
@@ -2149,8 +2165,9 @@ func _has_err_category(errs: Array, category: String) -> bool:
# ⑦ 新效应词汇批实装探针(2026-09-19——妨害技蛛网束缚 + 毒爆必中附加分量,实装面 = L2 Boss 卓尔):
# 停手轮驱动 (2,8)——零挖掘零旗 → 零池,伤害行可逐拍精确复算;种子 hunt 至少各命一粒毒爆伤害行
# 停手轮驱动 (2,9)——零挖掘零旗 → 零池,伤害行可逐拍精确复算;种子 hunt 至少各命一粒毒爆伤害行
# 与蛛网束缚施放行;毒分量随开战掷定的攻击基线缩放(atk 区间 2–4 → rider 1–3)。
# (2026-09-21 商店房批连带:Boss 重编号 8→9——(2,8) 现为商店房无战斗行,驱动点随行迁移。)
func _smoke_debuff_rider() -> void:
var venom_seed := -1
var venom_check_ok := true
@@ -2167,7 +2184,7 @@ func _smoke_debuff_rider() -> void:
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rg)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 2, "node_depth": 8, "hero_hp": int(_db.hero.max_hp)}):
if not settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp)}):
continue
var boss_atk: int = int(settlement._enemy_system.all_enemies()[0].atk) # 开战掷定攻击基线(毒分量随其缩放)
for i in range(6):
@@ -2228,15 +2245,15 @@ func _smoke_level_graph() -> void:
for row in _db.level.rows:
if int(row.level_index) == 2:
l2 = row
check(not l2.is_empty() and l2.nodes.size() == 9, "L2 节点数 = 9(入口 1 + 中间三层 2/3/2 + Boss)")
var tcount := {"battle": 0, "reward": 0, "rest": 0, "boss": 0}
check(not l2.is_empty() and l2.nodes.size() == 10, "L2 节点数 = 10(入口 1 + 中间三层 2/3/2 + 商店房 + Boss——商店房批 Boss 重编号 8→9)")
var tcount := {"battle": 0, "reward": 0, "rest": 0, "boss": 0, "shop": 0}
var branch := 0
for node in l2.nodes:
tcount[String(node.get("type", ""))] = int(tcount[String(node.get("type", ""))]) + 1
if node.get("next", []).size() >= 2:
branch += 1
check(int(tcount.battle) == 6 and int(tcount.boss) == 1, "L2 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)")
check(int(tcount.reward) == 1 and int(tcount.rest) == 1, "L2 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)")
check(int(tcount.reward) == 1 and int(tcount.rest) == 1 and int(tcount.shop) == 1, "L2 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)")
check(branch >= 2, "L2 真实岔路 = %d 处(≥2)" % branch)
# ③ 图校验正反断言(违规行集定位到类别——加载期致命的同款算法)
var lt = load("res://data/schemas/level_table.gd").new()
@@ -2275,12 +2292,12 @@ func _smoke_level_graph() -> void:
# ④ L2 选路推进:min / max 双路径全通(确定性路径轴——零随机流自开)
var win_min = autoplay.run_level(_db, 42, 2, 200, 100)
check(win_min.result == "victory", "L2·满配 min 路径通关(实测:%s)" % win_min.result)
check(win_min.boss_depth == 8, "L2 Boss 以编号 8 解析(终点编号最大)")
check(win_min.visited_ids == [0, 1, 3, 6, 8], "min 路径 = 0→1→3(恢复)→6→8(实测 %s)" % str(win_min.visited_ids))
check(win_min.boss_depth == 9, "L2 Boss 以编号 9 解析(终点编号最大——商店房批重编号 8→9)")
check(win_min.visited_ids == [0, 1, 3, 6, 8, 9], "min 路径 = 0→1→3(恢复)→6→8(商店零购离开)→9(实测 %s)" % str(win_min.visited_ids))
check(win_min.choices >= 3, "选路口发生 %d 次(每节点兑付后——零自动推进)" % win_min.choices)
var win_max = autoplay.run_level(_db, 42, 2, 200, 100, false, null, "normal", "max")
check(win_max.result == "victory" and win_max.visited_ids == [0, 2, 5, 7, 8],
"L2·满配 max 路径(经奖励点 2)通关 = 0→2→5→7→8(实测 %s %s)" % [win_max.result, str(win_max.visited_ids)])
check(win_max.result == "victory" and win_max.visited_ids == [0, 2, 5, 7, 8, 9],
"L2·满配 max 路径(经奖励点 2)通关 = 0→2→5→7→8(商店零购离开)→9(实测 %s %s)" % [win_max.result, str(win_max.visited_ids)])
# ⑤ 零自动推进状态机(手动驱动——兑付确认 / 选路拒绝语义逐拍断言)
var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42))
runner.set_hero_override(200, 100)
@@ -2343,24 +2360,24 @@ func _smoke_level_graph() -> void:
check(cross_ok, "跨表行完整:L2 全部战斗/Boss 节点在 board_gen + enemy 精确行(7 节点)")
var l2_spider: Dictionary = _db.enemy.resolve_rowset(2, 4)
check(String(l2_spider.get("enemy_id", "")) == "giant_spider", "巨蜘蛛首遇行集 (2,4) = giant_spider(固定 2 只——首遇教学位不加骰)")
var l2_boss: Dictionary = _db.enemy.resolve_rowset(2, 8)
var l2_boss: Dictionary = _db.enemy.resolve_rowset(2, 9)
check(String(l2_boss.get("enemy_id", "")) == "drider" and String(l2_boss.get("armor_type", "")) == "ward",
"蛛化卓尔精灵 Boss 行集 (2,8) = drider(魔法甲——护甲类型重配批 2026-09-18:关卡 2 唯一非轻甲)")
"蛛化卓尔精灵 Boss 行集 (2,9) = drider(魔法甲——护甲类型重配批 2026-09-18:关卡 2 唯一非轻甲;商店房批重编号 (2,8)→(2,9))")
# ---- 第三关内容批(2026-09-20)L3 灰矮人矿城断言段 ----
# ⑧ L3 行集结构(复用 L2 九节点图模板——6 战 + Boss + 奖励 + 恢复,真实岔路 4 处)
var l3 := {}
for row in _db.level.rows:
if int(row.level_index) == 3:
l3 = row
check(not l3.is_empty() and l3.nodes.size() == 9, "L3 节点数 = 9(入口 1 + 中间三层 2/3/2 + Boss——L2 同模板)")
var tcount3 := {"battle": 0, "reward": 0, "rest": 0, "boss": 0}
check(not l3.is_empty() and l3.nodes.size() == 10, "L3 节点数 = 10(入口 1 + 中间三层 2/3/2 + 商店房 + Boss——L2 同模板 + 商店房批)")
var tcount3 := {"battle": 0, "reward": 0, "rest": 0, "boss": 0, "shop": 0}
var branch3 := 0
for node in l3.nodes:
tcount3[String(node.get("type", ""))] = int(tcount3[String(node.get("type", ""))]) + 1
if node.get("next", []).size() >= 2:
branch3 += 1
check(int(tcount3.battle) == 6 and int(tcount3.boss) == 1, "L3 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)")
check(int(tcount3.reward) == 1 and int(tcount3.rest) == 1, "L3 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)")
check(int(tcount3.reward) == 1 and int(tcount3.rest) == 1 and int(tcount3.shop) == 1, "L3 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)")
check(branch3 == 4, "L3 真实岔路 = %d 处(0→[1,2] · 1→[3,4] · 2→[4,5] · 4→[6,7])" % branch3)
# ⑨ 跨表行完整:L3 每战斗/Boss 节点 (3, 编号) 在 board_gen 与 enemy 精确行(⑦ 的 bg_keys/en_keys 复用)
var cross_ok3 := true
@@ -2376,9 +2393,9 @@ func _smoke_level_graph() -> void:
var l3_lead: Dictionary = _db.enemy.resolve_rowset(3, 0)
check(String(l3_lead.get("enemy_id", "")) == "duergar" and String(l3_lead.get("armor_type", "")) == "heavy",
"灰矮人首遇行集 (3,0) = duergar(重甲首次登台——固定 1 只,首遇教学位不加骰)")
var l3_king: Dictionary = _db.enemy.resolve_rowset(3, 8)
var l3_king: Dictionary = _db.enemy.resolve_rowset(3, 9)
check(String(l3_king.get("enemy_id", "")) == "duergar_king" and String(l3_king.get("armor_type", "")) == "heavy",
"灰矮人石君王 Boss 行集 (3,8) = duergar_king(重甲收束「轻甲→魔法甲→重甲」教学梯度)")
"灰矮人石君王 Boss 行集 (3,9) = duergar_king(重甲收束「轻甲→魔法甲→重甲」教学梯度;商店房批重编号 (3,8)→(3,9))")
# ⑪ L3 成长代理档首遇绿档:模拟 GP 商店成长后的入关参数(atk_pct=200 / hp_pct=100)· 完美档多种子首遇战稳过
# (零加成基线红档属预期——断言口径 = 成长代理档,非基线;跑法同 ⑥)
var green3_ok := true
@@ -2412,6 +2429,397 @@ func _fresh_rng(seed_val: int):
return rng
# ==================== GP+商店房实施批冒烟(2026-09-21——接 L3 断言段后) ====================
# ① GP 到账(战斗完成入账恰一次 + Boss 关级加值)② escrow 隔离(格面兑付入缓冲/破财不透支)
# ③ 商店三收口(购买出账/加算区/余额不足拒/离开/收束清零)④ 图不变式(商店必经位/跨表行集迁移)
# ⑤ 九型与 gp_drop 流(奖励字典 18 格 + 黄金向量)。
# ---- ① GP 冒险钱包·到账 ----
func _smoke_gp_wallet() -> void:
print("--- GP 冒险钱包·到账(战斗完成入账恰一次 + Boss 加值)---")
var autoplay = load("res://smoke/level_autoplay.gd")
# ① L1 首战(普通战档——掷定 [2,4] + 关级加值 0):runner 驱动,收集 battle_finished 与钱包事件
var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42))
runner.set_hero_override(200, 100)
var fin := {}
var wallet_events: Array = []
runner.wallet_changed.connect(func(ev, gp_val, extra): wallet_events.append({"event": String(ev), "gp": int(gp_val), "amount": int(extra.get("amount", 0))}))
check(runner.start_level(1, 42), "GP 到账:L1 启动(普通战档)")
var battle = runner.current_battle()
var rows: Array = []
battle.step_row.connect(func(row): rows.append(row))
battle.battle_finished.connect(func(p): fin["p"] = p)
var st := {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"}
var guard := 30
while runner.current_battle() == battle and not fin.has("p") and guard > 0:
guard -= 1
autoplay._autoplay_battle(runner, battle, st, null)
check(fin.has("p") and String(fin["p"].get("result", "")) == "victory", "L1 首战胜利(battle_finished gp_dropped 载荷在案)")
var escrow_sum := _escrow_sum_of(rows)
var gp_dropped: int = int(fin["p"].get("gp_dropped", -999))
check(gp_dropped - escrow_sum >= 2 and gp_dropped - escrow_sum <= 4,
"L1 普通战 gp_dropped − escrow ∈ [2+0, 4+0](掷定+关级加值 %d,escrow %+d)" % [gp_dropped - escrow_sum, escrow_sum])
var gains: Array = []
for ev in wallet_events:
if ev["event"] == "gain":
gains.append(ev)
check(gains.size() == 1, "钱包恰入账一次(gain 事件 ×%d)" % gains.size())
if gains.size() == 1:
check(int(gains[0]["amount"]) == gp_dropped, "入账金额 = gp_dropped 载荷(%d)" % gp_dropped)
check(runner.gp == maxi(0, gp_dropped) and runner.gp == int(gains[0]["gp"]),
"钱包读数 = 入账后投影(gp=%d,破财负值场景经 clamp ≥0)" % runner.gp)
# ② L2 Boss 战(节点 9——商店房批重编号):掷定 [6,10] + 关级加值 1
var boss = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(42))
var rows_b: Array = []
var fin_b := {}
boss.step_row.connect(func(row): rows_b.append(row))
boss.battle_finished.connect(func(p): fin_b["p"] = p)
check(boss.begin_battle({"level_index": 2, "node_depth": 9, "node_type": "boss", "hero_hp": int(_db.hero.max_hp)}),
"L2 Boss 战启动((2,9)——Boss 档掷定 [6,10] + 关级加值 1)")
var guard_b := 60
while boss.battle_result == "" and guard_b > 0:
guard_b -= 1
boss.minesweeper.settle_batch([{"action": "stop"}])
check(fin_b.has("p"), "L2 Boss 战终局(%s——gp_dropped 于战斗末掷定上抛)" % String(boss.battle_result))
var escrow_b := _escrow_sum_of(rows_b)
var gp_b: int = int(fin_b.get("p", {}).get("gp_dropped", -999)) if fin_b.has("p") else -999
check(gp_b - escrow_b >= 7 and gp_b - escrow_b <= 11,
"L2 Boss 战 gp_dropped − escrow ∈ [6+1, 10+1](掷定+关级加值 %d,escrow %+d)" % [gp_b - escrow_b, escrow_b])
# 步骤流中 GP 型兑付行(effect_id gp_gain/gp_loss)的 value 代数和 = escrow 会计算(归因行随行携带)
func _escrow_sum_of(rows: Array) -> int:
var sum := 0
for row in rows:
if String(row.get("effect_id", "")) == "gp_gain" or String(row.get("effect_id", "")) == "gp_loss":
sum += int(row.get("value", 0))
return sum
# ---- ② escrow 隔离(金币安全格兑付 / 破财直验 / 钱包 clamp)----
func _smoke_gp_escrow() -> void:
print("--- GP escrow 隔离(金币安全格兑付入缓冲 · 破财不透支)---")
# ① 种子扫描:找金币袋(gp 型安全格)可挖掘局——挖开兑付 +3 入 escrow,战斗末随 gp_dropped 一次上抛
var scan := {}
for s in range(1, 121):
var rng = _fresh_rng(s)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
var fin := {}
settlement.battle_finished.connect(func(p): fin["p"] = p)
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}):
continue
var target := _find_gp_dig(settlement.minesweeper, settlement)
if target.is_empty():
continue
scan = {"seed": s, "settlement": settlement, "target": target, "rows": rows, "fin": fin}
break
check(not scan.is_empty(), "金币袋(gp 型安全格)可挖掘局种子可达(seed=%s)" % str(scan.get("seed", -1)))
if not scan.is_empty():
var settlement2 = scan["settlement"]
var ms = settlement2.minesweeper
var t: Dictionary = scan["target"]
var dig_arr: Array = [t["dig_cell"].x, t["dig_cell"].y]
if t["hero_cell"] != ms.hero_pos():
ms.settle_batch([{"action": "move", "cell": t["hero_cell"]}])
ms.settle_batch([{"action": "dig", "cell": t["dig_cell"]}])
var guard_stop := 12
while settlement2.battle_result == "" and guard_stop > 0:
guard_stop -= 1
ms.settle_batch([{"action": "stop"}]) # 收轮结算:授予段兑付 +3 → 战斗段先手 999 清场 → 胜利上抛
check(settlement2.battle_result == "victory", "金币局战斗终局(先手 999 收束)")
var gain_ok := false
for row in scan["rows"]:
if String(row.get("effect_id", "")) == "gp_gain" and row.get("origin_cell", []) == dig_arr and int(row.get("value", 0)) == 3:
gain_ok = true
check(gain_ok, "金币袋兑付行在案(金币袋 +3 → 战斗内缓冲,origin 指回来源格)")
check(int(settlement2.escrow) >= 3, "escrow 累计缓冲 ≥3(实测 %d——战斗域中间态,不入钱包不入增益容器)" % int(settlement2.escrow))
var gp_d: int = int(scan["fin"].get("p", {}).get("gp_dropped", -999))
check(gp_d - int(settlement2.escrow) >= 2 and gp_d - int(settlement2.escrow) <= 4,
"格面兑付计入 gp_dropped(%d − escrow %d ∈ [2,4]——战斗末一次到账,随上抛即弃)" % [gp_d, int(settlement2.escrow)])
# ② 破财(gp_loss)直验:兑付入口直调(weight 0 不入池——字典负向行单测通道,负向字典冒烟前例)
var rng_l = _fresh_rng(20260921)
var settle_l = load("res://core/battle_orchestrator.gd").new(_db, rng_l)
var fin_l := {}
settle_l.battle_finished.connect(func(p): fin_l["p"] = p)
check(settle_l.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}),
"破财直验战斗启动")
for i in range(3):
settle_l._grant_routed({"reward_id": "gp_lose", "cell_type": "gp", "polarity": "negative", "display_name": "破财", "power": 2, "effect_id": "gp_loss", "stacks": 1},
"negative", "mine_hit", Vector2i(0, 0), 1)
check(int(settle_l.escrow) == -6, "破财 ×3 → escrow −6(负向缓冲记账,零钱包触达)")
settle_l.minesweeper.settle_batch([{"action": "stop"}])
var gp_l: int = int(fin_l.get("p", {}).get("gp_dropped", 999))
check(gp_l + 6 >= 2 and gp_l + 6 <= 4, "破财压制 gp_dropped 负值上抛(%d + 6 ∈ [2,4]——掷定不受破财污染)" % gp_l)
# ③ 钱包不透支(clamp ≥0):runner 驱动 L1 首战 + 战斗中注入破财 → gp_dropped 负值 → 钱包钳制归 0
var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(4242))
runner.set_hero_override(49850, 0) # atk 2 → 999(快速终局;hp 基线)
var ev_box := {"gains": [], "min_gp": 999999}
runner.wallet_changed.connect(func(ev, gp_val, extra):
ev_box["gains"].append({"event": String(ev), "gp": int(gp_val), "amount": int(extra.get("amount", 0))})
ev_box["min_gp"] = mini(ev_box["min_gp"], int(gp_val)))
check(runner.start_level(1, 4242), "clamp 场景:L1 启动(冒险钱包开局 0)")
var b3 = runner.current_battle()
var fin3 := {}
b3.battle_finished.connect(func(p): fin3["p"] = p)
for i in range(3):
b3._grant_routed({"reward_id": "gp_lose", "cell_type": "gp", "polarity": "negative", "display_name": "破财", "power": 2, "effect_id": "gp_loss", "stacks": 1},
"negative", "mine_hit", Vector2i(0, 0), 1)
var guard3 := 12
while runner.current_battle() == b3 and not fin3.has("p") and guard3 > 0:
guard3 -= 1
b3.minesweeper.settle_batch([{"action": "stop"}])
var gp3: int = int(fin3.get("p", {}).get("gp_dropped", 999))
check(gp3 < 0, "破财压制入账载荷为负(gp_dropped=%d)" % gp3)
var gain3: Array = []
for ev in ev_box["gains"]:
if ev["event"] == "gain":
gain3.append(ev)
check(gain3.size() == 1 and int(gain3[0]["amount"]) == gp3 and int(gain3[0]["gp"]) == 0,
"破财入账恰一次且投影读数 0(amount=%d,clamp 后 gp=0)" % gp3)
check(runner.gp == 0 and int(ev_box["min_gp"]) >= 0, "钱包全程不透支(gp=%d,事件投影最小读数 %d ≥ 0)" % [runner.gp, int(ev_box["min_gp"])])
# 板真相直读找金币袋挖掘目标:未开安全格 + gp 型资源身份 + 存在可达已开邻格(_find_flag_target 同款口径)
func _find_gp_dig(minesweeper, settlement) -> Dictionary:
var board = minesweeper.current_board()
var revealed := {}
for c in minesweeper.revealed_cells():
revealed[c] = true
for y in board.height:
for x in board.width:
var c := Vector2i(x, y)
if revealed.has(c) or board.is_mine(c):
continue
var ident = settlement.query_reward_identity(c)
if ident == null or String(ident.get("cell_type", "")) != "gp":
continue
for n in board.neighbors8(c):
if revealed.has(n) and (minesweeper.compute_move_path(n).size() > 0 or n == minesweeper.hero_pos()):
return {"dig_cell": c, "hero_cell": n}
return {}
# ---- ③ 商店房三收口 ----
func _smoke_shop_room() -> void:
print("--- 商店房三收口(现价/加算区/余额不足拒/离开回选路/收束清零)---")
var autoplay = load("res://smoke/level_autoplay.gd")
# 行进到商店房(min 路径:0→1→3(恢复)→6→8)——种子扫描保证进店余额 ≥ 三件首购总价 16(6+4+6)
var shop_run := {}
for s in range(42, 92):
var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(s))
runner.set_hero_override(400, 200) # 行进段快速通关档(购买断言与百分比轴正交——加算区单独断言)
runner.start_level(2, s)
var st := {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"}
var guard := 40
while String(runner.level_result) == "" and int(runner.current_node_id()) != 8 and guard > 0:
guard -= 1
if runner.awaiting_choice():
var ids: Array = runner.next_candidate_ids()
ids.sort()
runner.pick_next_node(int(ids[0]))
continue
var t := String(runner.current_node().get("type", ""))
if t == "reward" or t == "rest":
runner.continue_after_node()
continue
if t == "shop":
break
var b = runner.current_battle()
if b == null:
break
var fin := {}
b.battle_finished.connect(func(p): fin["p"] = p)
var g2 := 80
while runner.current_battle() == b and not fin.has("p") and g2 > 0:
g2 -= 1
autoplay._autoplay_battle(runner, b, st, null)
if String(runner.level_result) == "" and int(runner.current_node_id()) == 8 and runner.gp >= 16:
shop_run = {"seed": s, "runner": runner}
break
check(not shop_run.is_empty(), "商店房到达种子可达(seed=%s——进店 GP ≥ 16)" % str(shop_run.get("seed", -1)))
if shop_run.is_empty():
return
var runner = shop_run["runner"]
check(String(runner.current_node().get("type", "")) == "shop" and int(runner.current_node_id()) == 8,
"L2 行进到商店房(节点 8,next=[9]——Boss 前必经位)")
check(not runner.awaiting_choice() and runner.next_candidate_ids().is_empty(),
"到店开店态(覆盖层数据源就位——离开前不出选路口,零自动推进)")
# 现价 = 基价 + price_level_bonus(L2)=2 + 步长 × 已购档数
var prods: Array = runner.shop_products()
check(prods.size() == 3, "三商品全陈列(atk_up/hp_up/armor_up——无刷新无摇号)")
var by_id := _shop_products_by_id(prods)
check(int(by_id["atk_up"]["price"]) == 6 and int(by_id["hp_up"]["price"]) == 4 and int(by_id["armor_up"]["price"]) == 6,
"L2 首购现价 = 基价 + 关级加值 2(atk_up 6 / hp_up 4 / armor_up 6)")
# 购买 atk_up:钱包 −6 · 加算区 atk +1 · 二购现价 8
var gp0: int = runner.gp
check(runner.buy_product("atk_up"), "购买 atk_up 受理(现价 6)")
check(runner.gp == gp0 - 6, "钱包扣现价 6(%d → %d)" % [gp0, runner.gp])
check(int(runner.bonus.get("atk", -1)) == 1 and int(runner.bonus.get("max_hp", -1)) == 0 and int(runner.bonus.get("armor_class", -1)) == 0,
"统一冒险加算区 atk +1(bonus={atk,max_hp,armor_class} 账本)")
by_id = _shop_products_by_id(runner.shop_products())
check(int(by_id["atk_up"]["price"]) == 8 and int(by_id["atk_up"]["purchased"]) == 1,
"atk_up 二购现价 = 4+2+2=8(步长 2 × 已购 1 档,现价现算)")
# 行进段百分比归零(购买/生效断言按加算区裸值口径——TunePanel 百分比后乘;hp 快照钳到新口径上限防 spawn 越界,测试通道)
runner.set_hero_override(0, 0)
runner.hero_hp = mini(runner.hero_hp, runner.effective_max_hp())
# hp_up:上限与当前生命同步 +3(封顶新上限)
var hp_before: int = runner.hero_hp
var mhp_before: int = runner.effective_max_hp()
check(runner.buy_product("hp_up"), "购买 hp_up 受理(现价 4)")
check(runner.gp == gp0 - 6 - 4, "钱包扣 4(%d)" % runner.gp)
check(runner.effective_max_hp() == mhp_before + 3, "生命上限 +3(%d → %d——加算区 max_hp 轴)" % [mhp_before, runner.effective_max_hp()])
check(runner.hero_hp == mini(hp_before + 3, runner.effective_max_hp()), "当前生命同步 +3 封顶新上限(%d → %d)" % [hp_before, runner.hero_hp])
# armor_up:AC 10+1
check(runner.buy_product("armor_up"), "购买 armor_up 受理(现价 6)")
check(runner.effective_ac() == int(_db.hero.armor_class) + 1, "AC = 10+1(armor_class 加算区轴,无百分比通道)")
# 余额不足购买被拒(零副作用):hp_up 连购至不可负担后兜底拒绝
var guard_drain := 20
while guard_drain > 0:
guard_drain -= 1
var hp_price: int = int(_shop_products_by_id(runner.shop_products())["hp_up"]["price"])
if runner.gp < hp_price:
break
if not runner.buy_product("hp_up"):
break
var gp_dry: int = runner.gp
var tier_dry: int = runner.purchased_tier("hp_up")
var hp_dry: int = int(_shop_products_by_id(runner.shop_products())["hp_up"]["price"])
check(not runner.buy_product("hp_up") and runner.gp == gp_dry and runner.purchased_tier("hp_up") == tier_dry,
"余额不足购买被拒(gp=%d < 现价 %d——零扣费零档位变动)" % [gp_dry, hp_dry])
# 离开 → 回选路口(候选 [9])
check(runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [9],
"离开商店 → 回选路口(候选 [9]——零自动推进收口)")
# 下一战参数 = 基础表值 + 加算区(百分比已归零——加算先乘后乘序的前半在案)
check(runner.effective_atk() == int(_db.hero.atk) + 1, "主角攻击生效值 = 2+1(基础表值 + 加算区)")
check(runner.pick_next_node(9) and runner.current_battle() != null, "选 Boss(节点 9)开战")
var snap: Dictionary = runner.current_battle().presentation_snapshot()
check(int(snap["hero"].get("atk", -1)) == int(_db.hero.atk) + 1 and int(snap["hero"].get("max_hp", -1)) == int(_db.hero.max_hp) + 3,
"战斗开始参数 = 基础表值 + 加算区(atk %d = 2+1 / max_hp %d = 17+3)" % [int(snap["hero"].get("atk", -1)), int(snap["hero"].get("max_hp", -1))])
# 冒险收束(Boss 胜或败北——基线裸装口径任一结局)→ 钱包清零
var fin_end := {}
runner.level_finished.connect(func(p): fin_end["p"] = p)
var g3 := 60
while not fin_end.has("p") and g3 > 0:
g3 -= 1
var b_end = runner.current_battle()
if b_end == null:
break
autoplay._autoplay_battle(runner, b_end, {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"}, null)
check(fin_end.has("p") and String(fin_end["p"].get("result", "")) in ["victory", "defeat"],
"冒险收束(Boss 战终局:%s)" % String(fin_end.get("p", {}).get("result", "")))
check(runner.gp == 0, "冒险收束钱包清零(局内货币生命周期终点——不入存档不入摘要)")
func _shop_products_by_id(prods: Array) -> Dictionary:
var out := {}
for p in prods:
out[String(p["product_id"])] = p
return out
# ---- ④ 商店房图不变式(恰 1 商店 · 必经位 · 跨表行集迁移)----
func _smoke_shop_graph() -> void:
print("--- 商店房图不变式(恰 1 商店房 · 全路径必经 · 跨表行集迁移)---")
for L in [2, 3]:
var row := {}
for r in _db.level.rows:
if int(r.level_index) == L:
row = r
var shops: Array = []
var boss_id := -1
var max_id := -1
for n in row.nodes:
var t := String(n.get("type", ""))
if t == "shop":
shops.append(int(n.get("node_depth", -1)))
if t == "boss":
boss_id = int(n.get("node_depth", -1))
max_id = maxi(max_id, int(n.get("node_depth", -1)))
check(shops.size() == 1 and int(shops[0]) == 8, "L%d 商店房恰 1 且 node_depth=8(next=[9])" % L)
check(boss_id == 9 and boss_id == max_id, "L%d Boss = 9 = 全图最大编号(商店房批重编号 8→9)" % L)
var paths: Array = _all_entry_boss_paths(row.nodes)
var via_shop := true
for p in paths:
if not p.has(8):
via_shop = false
check(paths.size() >= 2 and via_shop, "L%d 全部 %d 条入口→Boss 路径穿商店房 8(必经位——DFS 枚举)" % [L, paths.size()])
# 跨表校验:(·,9) Boss 行精确存在 · (·,8) 无战斗行残留(陈旧行清理确认)
var bg_keys := {}
for row in _db.board_gen.rows:
bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
var en_keys := {}
for row in _db.enemy.rows:
en_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
for L in [2, 3]:
check(bg_keys.has("%d_9" % L) and en_keys.has("%d_9" % L), "(%d,9) Boss 行在 board_gen + enemy 精确存在" % L)
check(not bg_keys.has("%d_8" % L) and not en_keys.has("%d_8" % L), "(%d,8) 商店房无板/行集残留(陈旧行已清理)" % L)
check(bg_keys.has("1_4") and en_keys.has("1_4") and not bg_keys.has("1_8") and not en_keys.has("1_8"),
"L1 行集零改动(单链 Boss (1,4)——教学期最小修订面)")
# 入口→Boss 全路径枚举(DFS 迭代栈——图合法性校验保证单入口无环,路径有限)
func _all_entry_boss_paths(nodes: Array) -> Array:
var by_id := {}
var inbound := {}
for n in nodes:
by_id[int(n.get("node_depth", -1))] = n
for n in nodes:
for tid in n.get("next", []):
inbound[int(tid)] = true
var entry := -1
for n in nodes:
if not inbound.has(int(n.get("node_depth", -1))):
entry = int(n.get("node_depth", -1))
var paths: Array = []
var stack: Array = [[entry]]
while not stack.is_empty():
var path: Array = stack.pop_back()
var cur: Dictionary = by_id[path[path.size() - 1]]
var outs: Array = cur.get("next", [])
if outs.is_empty():
paths.append(path)
continue
for t in outs:
var np := path.duplicate()
np.append(int(t))
stack.append(np)
return paths
# ---- ⑤ 格面资源九型 + gp_drop 流(黄金向量)----
func _smoke_nine_types_gp_stream() -> void:
print("--- 格面资源九型 + gp_drop 流(奖励字典 18 格 + 黄金向量)---")
check(RewardTable.CELL_TYPES.size() == 9 and RewardTable.CELL_TYPES.has("gp"),
"格面资源九型封闭(第九型 gp——九型批 2026-09-20,词表 v1.1)")
var cell_rows := 0
var pos_weight := 0
var gp_pos := -1
var gp_neg := -1
for row in _db.reward.rows:
if String(row.get("pool_tag", "")) == "cell":
cell_rows += 1
if String(row.get("polarity", "")) == "positive":
pos_weight += int(row.get("weight", 0))
if String(row.get("reward_id", "")) == "gp_coin":
gp_pos = int(row.weight)
if String(row.get("reward_id", "")) == "gp_lose":
gp_neg = int(row.weight)
check(cell_rows == 18, "奖励字典 18 格(九型 × 正负——清图槽 clear_default 独立批尾不计)")
check(pos_weight == 76, "正向权重合计 76(gp 正向 weight 2 入池后 74→76——九型批)")
check(gp_pos == 2 and gp_neg == 0, "gp 型两行(金币袋 gp_gain 正向 weight 2 / 破财 gp_loss 负向 weight 0 不入池)")
var rng = _fresh_rng(42)
check(rng.derive_at("gp_drop", 0) == 6226476397888000011, "R3 向量 gp_drop m=42 scope=0(GP+商店房实施批——registry salt_gp_drop=0xD6E8FEB8665FD7F1 权威值)")
check(rng.derive_at("gp_drop", 1) == 5031558177015478025, "R3 向量 gp_drop m=42 scope=1(每战斗末恰一 scope 的派生复放锚)")
check(rng.counter("gp_drop") == 0, "derive_at 纯函数不推计数器(gp_drop 流计数器恒 0)")
# ---- 战斗日志视图·行格式化呈现层同步闸(2026-09-20 真人试玩卡断 bug 回归:八型批伏击退役拆除
# 伤害行 attach 字段后本视图拆行分支漏网未同步——敌方首笔攻击行带 baseline 无 attach,
# _format_row Invalid access 使结算段中途夭折、界面卡死。行协议变更必须同步呈现层〔战斗结算 §行协议〕,
@@ -66,6 +66,11 @@ func _ready() -> void:
_route_clicks += 1
_flags_placed.clear()
continue
if main.shop_overlay.visible:
# 商店房覆盖层(GP+商店房批——L2 商店为 Boss 前必经位):零购离开(真实 leave 信号路径)放行选路
main.shop_overlay.leave_pressed.emit()
_flags_placed.clear()
continue
if main._end_overlay.is_open():
# 终局 overlay 模态断言(pattern 6:打开时输入锁定)
if not main._input_locked():
@@ -85,12 +85,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框架 图标键契约:状态表七行全映射、格面资源 cell_type 五值全映射;
# ---- 图标键回归(HUD框架 图标键契约:状态表七行全映射、格面资源 cell_type 九型全映射〔gp 键 = 九型批 2026-09-20〕;
# 查不到的键 fail-fast + ? 占位——此处钉死映射不残缺,未知键断言由 _icon_cfg/_badge_glyph 承担)----
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"]:
for cat in ["attack", "defense", "heal", "blast", "weaken", "pierce", "magic", "action", "gp"]:
if not BoardView.REWARD_BADGE.has(cat):
problems.append("奖励图标表缺键(%s)" % cat)
var probe_kb: Array = [