iter10 T3-T9: 奖励重构实装——adventure表扩列(回血20/池六种/权重/上限/同调位过渡键)+boss_keystone重锚3-2+rng扩签reward_pick+E-S加权洗牌+三选一停点(零自动推进/可中断)+加算区四轴AP(BattleOrchestrator排队兑现)+钥石逐杀掉落(收束无二次派生)+同调位约束(停点替换两路/战斗沉没/节点拒绝)+BonusPickOverlay+Narrator文案族+save v3迁移+冒烟619/0(+23断言)+断点P5往返单测+run_all 15套全绿

This commit is contained in:
DKChan committed 2026-10-01 04:30:13 +08:00
1 parent 49b263fbc8
commit 71502ceecd
18 files changed
+992 -169

No files matched your search

+29 -9
View File
@@ -15,9 +15,9 @@
# 驱动循环按 route 策略显式 pick_next_node;route = "min"/"max"(候选节点编号最小/最大——
# 确定性路径轴,零随机流自开;默认 "min" 既有调用方签名兼容)。
# 2026-09-21 冒险结构实现批:run_adventure 三关连探驱动(AdventureRunner——主种子恰一次、
# 大奖励立即确认 continue_to_next_level、adventure_finished 置 result/summary;外层每关重取
# runner 实例经 _drive_level 复用单关驱动)。run_level 驱动段抽成 _drive_level——签名与
# 返回结构零变化,行为逐位不变。
# 通关兑付停点确认、adventure_finished 置 result/summary;外层每关重取 runner 实例经 _drive_level
# 复用单关驱动)。run_level 驱动段抽成 _drive_level——签名与返回结构零变化,行为逐位不变。
# 2026-10-01 迭代 10 奖励重构批:大奖励立即确认改三选一停点驱动(候选取首件/替换放弃新件)。
# 运行前提:db 已 load_tables;run_level/run_adventure 均须全新 RngService(同种子 ⇒ 全程逐步可回放)。
# 实现注:信号处理体全部为静态方法 + bind 传状态——GDScript 多行 lambda 内嵌 match/控制流的解析不可靠。
extends RefCounted
@@ -124,7 +124,11 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_
adr.step_row.connect(_on_step_row.bind(adr, st, collect_rows))
adr.node_entered.connect(_on_adventure_node_entered.bind(adr, st, logger))
adr.wallet_changed.connect(_on_adventure_wallet.bind(st, logger))
adr.big_reward_paid.connect(_on_adventure_reward.bind(st, adr))
# 三选一停点驱动(迭代 10 奖励重构批——big_reward_paid 退役:确定性策略候选取首件、
# 道具件满位替换放弃新件、兑付后推进下一关——零随机流自开,同种子逐位可复放)
adr.bonus_choice_presented.connect(_on_adventure_bonus.bind(st, adr))
adr.bonus_resolved.connect(_on_adventure_bonus_resolved.bind(st, adr))
adr.aux_replace_presented.connect(_on_adventure_aux_replace.bind(st, adr))
adr.adventure_finished.connect(_on_adventure_finished.bind(st, logger))
adr.set_hero_override(atk_pct, hp_pct)
if not adr.start_adventure(seed_val, {"hero_override": hero_override, "first_script": false}): # 测量夹具恒抽签(v0.4——首次脚本分支不进任何读数)
@@ -132,7 +136,7 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_
if own_session:
logger.close_session()
return st
var guard_drives := 10 # 每次外层迭代 = 驱动一关(关卡更替由 big_reward 确认与 runner 重取承载)
var guard_drives := 10 # 每次外层迭代 = 驱动一关(关卡更替由三选一兑付确认与 runner 重取承载)
while st.result == "" and guard_drives > 0:
guard_drives -= 1
_drive_level(adr.runner(), st, logger, policy, route)
@@ -183,18 +187,34 @@ static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr,
# 冒险钱包事件采集(wallet 行族——GP 跨关延续与冒险收束 clear 的验收取数面;同日志合规转发)
static func _on_adventure_wallet(event: String, gp_val: int, extra: Dictionary, st: Dictionary, logger) -> void:
st.wallet_events.append({"event": String(event), "gp": int(gp_val),
"amount": int(extra.get("amount", 0)), "product": String(extra.get("product", ""))})
"amount": int(extra.get("amount", 0)), "product": String(extra.get("product", "")),
"source": String(extra.get("source", ""))})
if logger != null:
logger.log_wallet(event, gp_val, extra)
# 大奖励立即确认(非末关通关 → continue_to_next_level 进下一关——自动路径无大奖励屏停点)
static func _on_adventure_reward(reward: Dictionary, st: Dictionary, adr) -> void:
st.reward_events.append(reward.duplicate(true))
# 三选一停点驱动(迭代 10——确定性策略:候选取首件;奖励事件随 reward_events 留档)
static func _on_adventure_bonus(payload: Dictionary, st: Dictionary, adr) -> void:
st.reward_events.append({"bonus_pick": payload.duplicate(true)})
var candidates: Array = payload.get("candidates", []) as Array
if candidates.is_empty() or not adr.pick_bonus(String(candidates[0])):
st.result = "advance_failed"
# 三选一兑付完成 → 推进下一关(自动路径无停点等待——零自动推进契约由显式确认满足)
static func _on_adventure_bonus_resolved(result: Dictionary, st: Dictionary, adr) -> void:
st.reward_events.append({"bonus_resolved": result.duplicate(true)})
if not adr.continue_to_next_level():
st.result = "advance_failed"
# 同调位满位替换子交互(确定性策略:放弃新件不获取——-1)
static func _on_adventure_aux_replace(items: Array, incoming: Dictionary, st: Dictionary, adr) -> void:
st.reward_events.append({"aux_replace": incoming.duplicate(true)})
if not adr.resolve_aux_replace(-1):
st.result = "advance_failed"
# 冒险终局(completed/failed/abandoned——result 载 summary.result,外层驱动循环随之收敛)
static func _on_adventure_finished(summary: Dictionary, st: Dictionary, logger) -> void:
st.summary = summary.duplicate(true)
+1
View File
@@ -0,0 +1 @@
uid://dv81k73ruw7xe
+181 -67
View File
@@ -189,17 +189,27 @@ func _smoke_rest_stub(pct: int, hp: int, max_hp: int) -> int:
# ---- 冒险结构实现批冒烟(2026-09-21——AdventureTable 建表 + AdventureRunner + 冒险验收断言)----
# ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸)
# ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸;迭代 10 扩列域检)
func _smoke_adventure_table() -> void:
print("--- 冒险表(AdventureTable 建表——大奖励/钥石行值单点)---")
print("--- 冒险表(AdventureTable——三层奖励框架行值单点:回血/件值/权重/上限/同调位)---")
check(_db.adventure != null, "db.adventure 已加载(adventure.tres 入 manifest)")
if _db.adventure == null:
return
check(_db.adventure.validate().is_empty(), "AdventureTable.validate() 零违例(两字段域检在案)")
var hp_bonus: int = int(_db.adventure.level_clear_hp_bonus)
var ks: int = int(_db.adventure.keystone_per_level)
check(hp_bonus >= 0 and hp_bonus <= 999, "level_clear_hp_bonus ∈ [0,999](%d)" % hp_bonus)
check(ks >= 0 and ks <= 999, "keystone_per_level ∈ [0,999](%d)" % ks)
check(_db.adventure.validate().is_empty(), "AdventureTable.validate() 零违例(全键域检在案)")
check(int(_db.adventure.clear_heal_pct) >= 0 and int(_db.adventure.clear_heal_pct) <= 100,
"clear_heal_pct ∈ [0,100](%d——通关部分回血比例)" % int(_db.adventure.clear_heal_pct))
for id in _db.adventure.BONUS_IDS:
var v := int(_db.adventure.bonus_value(id))
var w := int(_db.adventure.bonus_weight(id))
check(v >= 0 and v <= 999 and w >= 1 and w <= 99, "三选一件 %s:值 ∈[0,999](%d)· 权重 ∈[1,99](%d)" % [id, v, w])
check(int(_db.adventure.bonus_cap_ap) == 2 and int(_db.adventure.bonus_cap_attack) == int(_db.adventure.bonus_cap_max_hp)
and int(_db.adventure.bonus_cap_max_hp) == int(_db.adventure.bonus_cap_armor),
"次数上限口径:AP 件=%d(全冒险最紧)· 属性件=%d(三件同)" % [int(_db.adventure.bonus_cap_ap), int(_db.adventure.bonus_cap_attack)])
check(int(_db.adventure.bonus_cap("gp")) == _db.adventure.BONUS_CAP_UNLIMITED
and int(_db.adventure.bonus_cap("aux_item")) == _db.adventure.BONUS_CAP_UNLIMITED,
"GP/道具件无次数上限(牌库恒非空的结构保证)")
check(int(_db.adventure.transitional_sync_slots) >= 0 and int(_db.adventure.transitional_sync_slots) <= 9,
"transitional_sync_slots ∈ [0,9](%d——同调位过渡读数源)" % int(_db.adventure.transitional_sync_slots))
# ② 冒险结构 API 闸(状态机受理面——重复开始拒绝 / 入口停点 / 放弃两态)
@@ -279,43 +289,86 @@ func _smoke_adventure() -> void:
if not bg_keys.has(key) or not en_keys.has(key):
miss += 1
check(miss == 0, "行集内每个战斗/Boss 节点在 board_gen + enemy 有精确行(缺失 %d)" % miss)
# ② 满配通关 + ④ 大奖励兑付(恰 2 次 · 读数=表值 · 回满=新上限 · 覆盖值通道传播 L2/L3 首战上限)
# ② 满配通关 + ④ 三层奖励兑付(三选一恰 2 次 · 回血封顶 · 钥石逐杀 · 候选零重复)
var st: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
var sum: Dictionary = st["summary"]
check(String(st["result"]) == "completed" and bool(sum.get("cleared", false)),
"满配通关(42 / atk+900 / hp+400——cleared=true;v2 怪阶梯后旧 200/100 档不再满配)")
check(int(sum.get("levels_cleared", -1)) == 3, "通关几关 = 3(levels_cleared)")
check(int(sum.get("keystone_total", -1)) == 3 * int(_db.adventure.keystone_per_level),
"钥石合计 = 3 × keystone_per_level(%d)" % int(sum.get("keystone_total", -1)))
var ks_exp := 0
for li in range(1, int(_db.level.rows.size()) + 1):
ks_exp += int(_db.meta_boss_keystone_row(li).get("first_kill", 0))
check(int(sum.get("keystone_total", -1)) == ks_exp,
"钥石合计 = ΣBoss 首杀行值(%d——逐杀掉落新口径;断点未注入恒首杀)" % int(sum.get("keystone_total", -1)))
var drops: Array = sum.get("keystone_drops", []) as Array
check(drops.size() == 3, "逐杀掉落明细恰 3 笔(L1/L2/L3——additive 摘要键 keystone_drops)")
check(int(sum.get("master_seed", -1)) == 42, "摘要携带主种子(42)")
var rewards: Array = st["reward_events"]
check(rewards.size() == 2, "大奖励恰兑付 2 次(L1/L2 后——末关不发上限提升)")
var hp_bonus: int = int(_db.adventure.level_clear_hp_bonus)
var reward_ok: bool = rewards.size() == 2
if reward_ok:
for i in rewards.size():
var r: Dictionary = rewards[i]
if int(r.get("hp_bonus", -1)) != hp_bonus or int(r.get("from_level", -1)) != i + 1 \
or int(r.get("new_max_hp", -1)) != int(_db.hero.max_hp) + hp_bonus * (i + 1) \
or int(r.get("keystone_gained", -1)) != int(_db.adventure.keystone_per_level):
reward_ok = false
check(reward_ok, "大奖励读数:hp_bonus=表值 · 新上限累加 · 回满=新上限 · 钥石逐关注账")
var picks: Array = []
var resolves: Array = []
for ev in (st["reward_events"] as Array):
if (ev as Dictionary).has("bonus_pick"):
picks.append((ev as Dictionary)["bonus_pick"])
elif (ev as Dictionary).has("bonus_resolved"):
resolves.append((ev as Dictionary)["bonus_resolved"])
check(picks.size() == 2 and resolves.size() == 2,
"三选一恰呈现 2 次、恰兑付 2 次(L1/L2 后——末关不发,奖励只朝下一战生效)")
var heal_ok := picks.size() == 2
var cand_ok := picks.size() == 2
var ks_gain_ok := picks.size() == 2
var resolve_ok := resolves.size() == 2
var max_flat := int(_db.adventure.bonus_max_hp_flat)
var heal_pct := int(_db.adventure.clear_heal_pct)
var hp_acc := 0 # max_hp 件累加(回血封顶断言的生效上限推导)
var ks_prefix := 0
for i in picks.size():
var p: Dictionary = picks[i]
var cands: Array = p.get("candidates", []) as Array
if cands.size() < 2 or cands.size() > 3:
cand_ok = false
var seen := {}
for c in cands:
if seen.has(String(c)):
cand_ok = false
seen[String(c)] = true
var heal: int = int(p.get("heal_gained", -1))
var hp_before: int = int(p.get("hp_before", -1))
var hp_after: int = int(p.get("hp_after", -1))
var max_eff := int(_db.hero.max_hp) + hp_acc # 回血口径 = 表值+加算区(无 TunePanel 百分比)
if heal != mini(int(floor(float(max_eff) * float(heal_pct) / 100.0)), max_eff - hp_before) \
or hp_after != hp_before + heal or hp_after > max_eff:
heal_ok = false
ks_prefix += int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0))
if int(p.get("keystone_gained", -1)) != int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) \
or int(p.get("keystone_total", -1)) != ks_prefix:
ks_gain_ok = false
if resolve_ok:
var r: Dictionary = resolves[i]
if not (p.get("candidates", []) as Array).has(String(r.get("picked", ""))) \
or int(r.get("value", -1)) != int(_db.adventure.bonus_value(String(r.get("picked", "")))):
resolve_ok = false
if String(r.get("picked", "")) == "max_hp":
hp_acc += max_flat
check(cand_ok, "三选一候选 2–3 件且零重复(加权洗牌不放回)")
check(heal_ok, "通关部分回血封顶(floor(上限×比例) 封顶缺口——血回满退役)")
check(ks_gain_ok, "钥石逐杀入账(首杀档位 + 累计逐位)")
check(resolve_ok, "点选恰兑付选中一件(值=表值;未选项零生效)")
var seq: Array = st["encounter_max_hp_seq"]
var k := 5.0 # hp+400 ⇒ TunePanel 百分比后乘 ×5(加算先加、百分比后乘——生效次序口径)
var exp_base := maxi(1, int(round(float(int(_db.hero.max_hp)) * k)))
var exp_l2 := maxi(1, int(round(float(int(_db.hero.max_hp) + hp_bonus) * k)))
var exp_l3 := maxi(1, int(round(float(int(_db.hero.max_hp) + 2 * hp_bonus) * k)))
var idx_l2 := seq.find(exp_l2)
var idx_l3 := seq.find(exp_l3)
check(seq.size() > 0 and int(seq[0]) == exp_base,
"L1 首战上限入参 = 基础表值(%d——加算区冒险开始全 0)" % exp_base)
check(idx_l2 > 0 and idx_l3 > idx_l2,
"L2/L3 首战上限入参 = 基础+加算(%d→%d——大奖励经覆盖值通道传播)" % [exp_l2, exp_l3])
# ③ 种子域可复现(同种子两局:summary + 上限序列 + 行进序列逐位一致)
var valid_hp := {}
for acc in 3:
valid_hp[maxi(1, int(round(float(int(_db.hero.max_hp) + acc * max_flat) * k)))] = true
var seq_ok := seq.size() > 0 and int(seq[0]) == exp_base
for v in (seq as Array):
if not valid_hp.has(int(v)):
seq_ok = false
check(seq_ok,
"各战上限入参 ∈ {基础, 基础+件值, 基础+2×件值}(覆盖值通道传播——首战=基础表值 %d;AP 件不加上限)" % exp_base)
# ③ 种子域可复现(同种子两局:summary + 上限序列 + 行进序列 + 三选一停点载荷逐位一致)
var st2: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
check(st2["summary"] == sum and st2["encounter_max_hp_seq"] == seq
and st2["visited_ids"] == st["visited_ids"],
"同种子两局逐位可复现(summary/上限序列/行进序列——主种子单点派生)")
and st2["visited_ids"] == st["visited_ids"] and st2["reward_events"] == st["reward_events"],
"同种子两局逐位可复现(summary/上限序列/行进序列/三选一载荷——reward_pick 封闭流)")
# ⑤ 败北线(零配 L1 败北 + 半配 L2 败北——种子扫描,仿商店房 range(42,92) 先例)
var lose := {}
for s in range(42, 92):
@@ -343,9 +396,9 @@ func _smoke_adventure() -> void:
if not half.is_empty():
var sh: Dictionary = half["st"]["summary"]
check(not bool(sh["cleared"]) and int(sh["levels_cleared"]) == 1 \
and int(sh["keystone_total"]) == int(_db.adventure.keystone_per_level) \
and int(sh["keystone_total"]) == int(_db.meta_boss_keystone_row(1).get("first_kill", 0)) \
and int(sh["stopped_at"]) == 2,
"L2 败北摘要:L1 已过(钥石 1×表值)· 止步第 2 层")
"L2 败北摘要:L1 已过(钥石=L1 首杀掉落)· 止步第 2 层")
var dh: Dictionary = sh.get("defeat", {})
check(int(dh.get("level_index", -1)) == 2 and int(dh.get("node_id", -1)) >= 0 \
and String(dh.get("node_type", "")) in ["battle", "boss"],
@@ -372,15 +425,15 @@ func _smoke_adventure() -> void:
for ev in wallet:
if String(ev["event"]) == "clear":
clears += 1
elif String(ev["event"]) == "gain":
gains += 1
check(gains == int(st["battles"]), "每战完成入账恰一次(gain %d = battles %d)" % [gains, int(st["battles"])])
elif String(ev["event"]) == "gain" and String(ev.get("source", "")) != "bonus_pick":
gains += 1 # 战斗完成入账计数(三选一 GP 袋件入账另有 source 标记——不计战斗口径)
check(gains == int(st["battles"]), "每战完成入账恰一次(gain %d = battles %d——GP 袋件入账单列)" % [gains, int(st["battles"])])
check(clears == 1 and wallet.size() > 0 and String(wallet[wallet.size() - 1]["event"]) == "clear",
"钱包 clear 恰一次且为末事件(关卡收束不清——GP 冒险收束清零归冒险结构)")
var shop_seq: Array = st["shop_gp_seq"]
check(shop_seq.size() == 2 and int(shop_seq[0]) > 0,
"L2 商店房到达余额 > 0(%d——L1 积累跨关延续;两关商店房必经各 1)" % (shop_seq[0] if shop_seq.size() > 0 else -1))
# ⑧ 加算区同账(手工场景:L2 商店购买 → 大奖励直调——同一本账累加 + 回满新上限)
# ⑧ 加算区同账(手工场景:L2 商店购买 → 三选一直调——同一本账累加 + 部分回血封顶)
var same := {}
for s in range(42, 92):
var a8 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(s))
@@ -406,14 +459,22 @@ func _smoke_adventure() -> void:
check(gp0 >= hp_price + l2_price_bonus,
"进店余额 ≥ hp_up L2 首价(%d = 基价 %d + 关卡加成 %d——配置推导)" % [hp_price + l2_price_bonus, hp_price, l2_price_bonus])
check(r8.buy_product("hp_up"), "购买 hp_up 受理(出账 + 加算区)")
check(int(a8.bonus.get("max_hp", -1)) == int(_db.adventure.level_clear_hp_bonus) + 3,
"统一冒险加算区含商店值(max_hp = %d[L1 大奖励] + 3[商店]——同一本账前半)" % int(_db.adventure.level_clear_hp_bonus))
var acc_shop := int(a8.bonus.get("max_hp", -1)) # L1 三选一后 + 商店 3(首件随种子)
check(acc_shop >= 3, "统一冒险加算区含商店值(max_hp=%d ≥ 商店 3——L1 三选一件值另计)" % acc_shop)
a8._on_level_finished({"result": "victory", "hp": int(r8.hero_hp), "node_index": r8.node_index,
"node_id": r8.current_node_id(), "node_type": "shop"}) # 模拟大奖励(victory 载荷直调——L2 非末关)
check(int(a8.bonus.get("max_hp", -1)) == 2 * int(_db.adventure.level_clear_hp_bonus) + 3,
"大奖励与商店购买同账累加(max_hp = %d + 3 + %d)" % [int(_db.adventure.level_clear_hp_bonus), int(_db.adventure.level_clear_hp_bonus)])
check(int(a8.ledger["hero_hp"]) == int(_db.hero.max_hp) + int(a8.bonus["max_hp"]),
"血回满 = 新上限(%d/%d)" % [int(a8.ledger["hero_hp"]), int(_db.hero.max_hp) + int(a8.bonus["max_hp"])])
"node_id": r8.current_node_id(), "node_type": "shop"}) # 模拟 L2 通关(victory 载荷直调——非末关)
check(not a8._bonus_pending.is_empty(), "L2 通关三选一停点在案(直调兑付前——零自动推进)")
check(not a8.continue_to_next_level(), "未兑付推进拒绝(选择停点零自动推进契约)")
var l2_cands: Array = a8._bonus_pending["candidates"]
check(a8.pick_bonus(String(l2_cands[0])), "三选一点选兑付受理(候选取首件)")
var picked2: String = String(a8._bonus_result.get("picked", ""))
var expect_acc: int = acc_shop + (int(_db.adventure.bonus_max_hp_flat) if picked2 == "max_hp" else 0)
check(int(a8.bonus.get("max_hp", -1)) == expect_acc,
"三选一属性件与商店购买同账累加(max_hp=%d = %d + 三选一%s)" % [int(a8.bonus.get("max_hp", -1)), acc_shop, picked2])
var max_eff8 := int(_db.hero.max_hp) + int(a8.bonus["max_hp"])
check(int(a8.ledger["hero_hp"]) <= max_eff8,
"通关部分回血封顶(hp %d ≤ 生效上限 %d——血回满语义退役)" % [int(a8.ledger["hero_hp"]), max_eff8])
check(a8.continue_to_next_level(), "兑付后推进受理(下一关开始)")
# ⑨ 恢复点数学(接口③封顶公式三读数 + 同式两落点一致)
var a9 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(99))
check(int(a9.apply_rest(30, 70, 100)) == 30 and int(a9.apply_rest(30, 95, 100)) == 5 \
@@ -436,6 +497,42 @@ func _smoke_adventure() -> void:
g10 -= 1
autoplay._autoplay_battle(a10.runner(), b10, st10, null)
check(a10.runner().current_battle() == null and a10.abandon(), "非战斗期(选路态)abandon 受理")
# ⑪ 上限剔牌库 + 同调位替换两路 + 满血回 0(迭代 10——white-box:计数/携带面直设)
var a11 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(11))
check(a11.start_adventure(11, {"first_script": true, "aux_unlocked": {"blast_kit": true}}),
"剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 blast_kit)")
a11._bonus_taken["attack"] = int(_db.adventure.bonus_cap_attack) # 直设计数(white-box——计数面本身即断点承载)
a11._bonus_taken["ap"] = int(_db.adventure.bonus_cap_ap)
var draw: Array = a11._draw_bonus_candidates()
check(not (draw as Array).has("attack") and not (draw as Array).has("ap"),
"达上限件剔牌库(attack 计满 %d / ap 计满 %d 不出现在候选——%s)" % [int(_db.adventure.bonus_cap_attack), int(_db.adventure.bonus_cap_ap), str(draw)])
check((draw as Array).size() >= 2, "剔牌库后候选恒 ≥2(GP/道具件无次数上限——防「三选项全不可选」死角)")
# 同调位替换·丢旧收新路(slots 过渡键=1,carry 满 1 件再取道具件 → 满位转替换)
var slots := int(_db.adventure.transitional_sync_slots)
a11._bonus_pending = {"candidates": ["aux_item", "gp", "attack"], "payload": {}}
a11._bonus_aux_carry.assign(["probe_dust"])
check(a11.pick_bonus("aux_item") and a11._bonus_pending.has("incoming"),
"满位道具件转替换子交互(持有 %d/%d)" % [a11._bonus_aux_carry.size(), slots])
check(a11.resolve_aux_replace(0) and (a11._bonus_aux_carry as Array).has("blast_kit") \
and (a11._bonus_aux_carry as Array).size() <= slots,
"替换受理·丢旧收新(终态 ≤ 同调位 %d——%s)" % [slots, str(a11._bonus_aux_carry)])
# 同调位替换·放弃新件路(原持有集不动、新件不获取)
a11._bonus_pending = {"candidates": ["aux_item"], "payload": {}}
a11._bonus_result = {}
a11._bonus_aux_carry.assign(["probe_dust"])
check(a11.pick_bonus("aux_item"), "替换场景二:满位再转替换子交互")
check(a11.resolve_aux_replace(-1) and a11._bonus_aux_carry.size() == 1 and String(a11._bonus_aux_carry[0]) == "probe_dust",
"替换受理·放弃新件(原持有集不动、新件不获取——两路终态恒 ≤ 同调位)")
# 满血回 0 合法(通关回血封顶口径)
var a12 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(12))
a12.start_adventure(12, {"first_script": true})
var cap := {"payload": {}}
a12.bonus_choice_presented.connect(func(p): cap["payload"] = p)
var max_full := int(_db.hero.max_hp) + int(a12._base_override.get("max_hp", 0))
a12.ledger["hero_hp"] = max_full
a12._on_level_finished({"result": "victory", "hp": max_full, "node_index": 1, "node_id": 1, "node_type": "shop"})
check(int(cap["payload"].get("heal_gained", -1)) == 0,
"满血通关回血 0 合法(封顶口径——heal_gained=0 非负溢出)")
var s10: Dictionary = a10.summary
check(String(s10.get("result", "")) == "abandoned" and not bool(s10.get("cleared", true)),
"放弃摘要:abandoned · 未打穿(已获大奖励不追回——同失败型结算)")
@@ -450,6 +547,10 @@ func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool:
if String(st["result"]) != "":
return false
if a._pending_next_level:
# 三选一停点确认(迭代 10——候选取首件后推进,与 run_adventure 自动策略同口径)
if not a._bonus_pending.is_empty():
var bcands: Array = a._bonus_pending["candidates"]
a.pick_bonus(String(bcands[0]))
a.continue_to_next_level()
continue
var r = a.runner()
@@ -3397,27 +3498,37 @@ func _meta_settle_and_workshop() -> void:
var mp = load("res://core/meta_progression.gd").new(_db, sv)
var row1: Dictionary = _db.meta_boss_keystone_row(1)
var row2: Dictionary = _db.meta_boss_keystone_row(2)
# ⑥ 新档首通 L1:first = true、金额 = first_kill;total = big_reward + Σ drops;余额 = 累计入账
# ⑥ 逐杀掉落收束入账(迭代 10——掉落已在冒险内逐杀入账〔summary.keystone_total 含全部〕,
# keystone_drops 明细随摘要归档;首杀标记仍由 apply 逐通关关次置位)
var d1: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1,
"stopped_at": 2, "keystone_total": 3, "master_seed": 11})
"stopped_at": 2, "keystone_total": int(row1.get("first_kill", 0)),
"keystone_drops": [{"level": 1, "first": true, "amount": int(row1.get("first_kill", 0))}],
"master_seed": 11})
var drop1: Dictionary = d1["boss_drops"][0]
check(bool(drop1.get("first", false)) and int(drop1.get("amount", 0)) == int(row1.get("first_kill", -1))
and int(drop1.get("level", 0)) == 1, "新档首通 L1:boss_drops[0] {first:true, amount=first_kill}(验收5)")
check(int(d1.get("total", 0)) == 3 + int(row1.get("first_kill", 0)), "明细 total = big_reward + Σ drops")
check(sv.keystone_balance() == 3 + int(row1.get("first_kill", 0)), "余额 = 累计入账(大奖励 + Boss 两档)")
# ⑥ 同档再通 → 重复杀档且恒定不递减(第三轮同额)
and int(drop1.get("level", 0)) == 1, "新档首通 L1:boss_drops[0] {first:true, amount=first_kill}(验收5——明细随摘要归档)")
check(int(d1.get("total", 0)) == int(row1.get("first_kill", 0)), "明细 total = keystone_total(逐杀掉落全源——无二次派生加账)")
check(sv.keystone_balance() == int(row1.get("first_kill", 0)), "余额 = 累计入账(逐杀掉落合计——无重复加账)")
# ⑥ 再通转复杀档(首杀标记置位后小额恒定不递减——标记面在 apply,金额面在逐杀裁决)
var d2: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1,
"stopped_at": 2, "keystone_total": 3, "master_seed": 12})
"stopped_at": 2, "keystone_total": int(row1.get("repeat_kill", 0)),
"keystone_drops": [{"level": 1, "first": false, "amount": int(row1.get("repeat_kill", 0))}],
"master_seed": 12})
var drop2: Dictionary = d2["boss_drops"][0]
check(not bool(drop2.get("first", false)) and int(drop2.get("amount", 0)) == int(row1.get("repeat_kill", -1)),
check(not bool(drop2.get("first", true)) and int(drop2.get("amount", 0)) == int(row1.get("repeat_kill", -1)),
"再通 L1:{first:false, amount=repeat_kill}(首杀标记置位后转小额)")
var d3: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1,
"stopped_at": 2, "keystone_total": 3, "master_seed": 13})
"stopped_at": 2, "keystone_total": int(row1.get("repeat_kill", 0)),
"keystone_drops": [{"level": 1, "first": false, "amount": int(row1.get("repeat_kill", 0))}],
"master_seed": 13})
check(not bool(d3["boss_drops"][0].get("first", true)) and int(d3["boss_drops"][0].get("amount", 0)) == int(row1.get("repeat_kill", -1)),
"重复杀第三轮恒定不递减(验收5——无衰减曲线)")
# 混合归因:通关两关 → L1 重复 + L2 首杀
# 混合归因:通关两关 → L1 重复 + L2 首杀(明细随摘要逐笔归档)
var d4: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 2,
"stopped_at": 0, "keystone_total": 4, "master_seed": 14})
"stopped_at": 0, "keystone_total": int(row1.get("repeat_kill", 0)) + int(row2.get("first_kill", 0)),
"keystone_drops": [{"level": 1, "first": false, "amount": int(row1.get("repeat_kill", 0))},
{"level": 2, "first": true, "amount": int(row2.get("first_kill", 0))}],
"master_seed": 14})
check(not bool(d4["boss_drops"][0].get("first", true)) and bool(d4["boss_drops"][1].get("first", false))
and int(d4["boss_drops"][1].get("amount", 0)) == int(row2.get("first_kill", -1)),
"两关通关逐关归因:L1 重复档 + L2 首杀档(first_kill 金额)")
@@ -3457,7 +3568,8 @@ func _meta_settle_and_workshop() -> void:
# ⑩ recent_summaries:元素含 boss_drops 副本、最新在前、cap 10
var rs: Array = sv.recent_summaries()
check(rs.size() >= 5 and String(rs[0].get("result", "")) != "" and rs[0].has("boss_drops")
and int(rs[0].get("master_seed", -1)) == 15, "摘要最新在前 + 元素含 boss_drops 副本(复盘可归因——验收5)")
and (rs[1] as Dictionary).has("keystone_drops")
and int(rs[0].get("master_seed", -1)) == 15, "摘要最新在前 + 元素含掉落明细副本(复盘可归因——验收5)")
var sv_c = _meta_new_save(_META_SMOKE_PATHS[4])
var mp_c = load("res://core/meta_progression.gd").new(_db, sv_c)
for i in 12:
@@ -3603,8 +3715,8 @@ func _smoke_save() -> void:
fm.store_string(JSON.stringify({"version": 0, "keystone_old": 12}))
fm.close()
check(f7.load_from_disk() and f7.keystone_balance() == 12, "v0 旧档经迁移链入账(keystone_old 12 → 余额 12)")
check(f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 2,
"迁移链 0→1(测试钩)→2(生产) 写盘 version == 2(v2=断点域出生,迭代 8 卡①)")
check(f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 3,
"迁移链 0→1(测试钩)→3(生产) 写盘 version == 3(v3=断点附随件扩列,迭代 10 奖励重构)")
# ⑧ 域界干净:meta 永久进度域不含局内资源键(行动点/RAP/GP/道具库存/生命账本不入档——契约 2;
# 迭代 8 卡①修订:扫描收窄到 meta 域——断点域=冒险进行中瞬态快照,生命/GP 按拍板面入域豁免)
@@ -3632,9 +3744,11 @@ func _smoke_save() -> void:
var st9: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
var f9 = sm_script.new(paths[8])
f9.apply_adventure_summary(st9.get("summary", {}))
var kpl := int(_db.adventure.keystone_per_level)
var ks_run := 0
for li in range(1, int(_db.level.rows.size()) + 1):
ks_run += int(_db.meta_boss_keystone_row(li).get("first_kill", 0))
check(String(st9.get("result", "")) == "completed", "摘要集成:冒险驱动满配通关(seed 42)")
check(f9.keystone_balance() == 3 * kpl, "集成钥石余额 = 3 × keystone_per_level(%d)" % f9.keystone_balance())
check(f9.keystone_balance() == ks_run, "集成钥石余额 = ΣBoss 首杀行值(%d——逐杀掉落全源)" % f9.keystone_balance())
check(f9.clear_count(1) == 1 and f9.clear_count(2) == 1 and f9.clear_count(3) == 1, "集成通关次数表全 1")
check(f9.boss_first_kill_marked(1) and f9.boss_first_kill_marked(2) and f9.boss_first_kill_marked(3),
"集成三关首杀标记齐")
@@ -3643,11 +3757,11 @@ func _smoke_save() -> void:
var st9b: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
f9.apply_adventure_summary(st9b.get("summary", {}))
check(f9.clear_count(1) == 2 and f9.clear_count(2) == 2 and f9.clear_count(3) == 2, "第二局后通关次数全 2")
check(f9.keystone_balance() == 6 * kpl, "第二局后余额翻倍(6 × keystone_per_level)")
check(f9.keystone_balance() == 2 * ks_run, "第二局后余额翻倍(2 × Σ首杀——模拟驱动恒首杀口径)")
check(f9.recent_summaries().size() == 2, "集成 recent 长度 2")
check(f9.write_to_disk(), "集成写盘成功")
var r9 = sm_script.new(paths[8])
check(r9.load_from_disk() and r9.keystone_balance() == 6 * kpl and r9.clear_count(3) == 2
check(r9.load_from_disk() and r9.keystone_balance() == 2 * ks_run and r9.clear_count(3) == 2
and r9.recent_summaries().size() == 2 and r9.boss_first_kill_marked(3),
"集成写盘读回全量保持")
@@ -3698,9 +3812,9 @@ func _smoke_save() -> void:
fold.store_string("{\"version\": 1, \"meta\": {\"keystone_balance\": 4}}")
fold.close()
check(fo.load_from_disk() and fo.keystone_balance() == 4 and not fo.has_breakpoint(),
"v1 旧档迁移 v2:断点域补空(合法空态,无断点域不炸)")
check(fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 2,
"v1 旧档迁移后写盘 version == 2")
"v1 旧档迁移 v3:断点域补空(合法空态,无断点域不炸)")
check(fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 3,
"v1 旧档迁移后写盘 version == 3")
# 段尾清理(测试文件绝不残留)
for p in stale: