4586 lines
278 KiB
GDScript
4586 lines
278 KiB
GDScript
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
|
||
# Date: 2026-09-10
|
||
# headless 冒烟入口(承数据配置冒烟框架:extends SceneTree + autoload 显式实例化)。
|
||
# 运行:Godot_console.exe --headless --path <proj> --script res://smoke/smoke_all.gd
|
||
extends SceneTree
|
||
|
||
const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口:自动玩家策略单一实现
|
||
const ConfigDBScript := preload("res://core/config_db.gd") # T6 收口:表宿主 preload 常量(原 _initialize 运行期 load)
|
||
|
||
var _failures := 0
|
||
var _checks := 0
|
||
|
||
|
||
func check(cond: bool, label: String, quiet: bool = false) -> void:
|
||
_checks += 1
|
||
if cond:
|
||
if not quiet:
|
||
print(" [PASS] " + label)
|
||
elif quiet:
|
||
_failures += 1
|
||
print(" [FAIL] (silent check)") # 原 check_silent 输出逐位保持
|
||
else:
|
||
_failures += 1
|
||
print(" [FAIL] " + label)
|
||
|
||
|
||
func _initialize() -> void:
|
||
print("=== Dungeon Sweeper 垂直切片冒烟 ===")
|
||
var db = ConfigDBScript.new()
|
||
var load_ok: bool = db.load_tables("res://data/tables", true)
|
||
check(load_ok, "全部表加载 + 校验通过(含跨表护栏)")
|
||
if not load_ok:
|
||
for d in db.diagnostics():
|
||
if d.severity == "error":
|
||
print(" error: [%s] %s" % [d.category, d.message])
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(42)
|
||
# 统一流黄金向量(数据配置 AC-3——ADR-0007 裁丙 2026-09-27 单盐重登记:单盐沿用并流前 board_gen 盐,
|
||
# scope 0-3 与旧 R3 board_gen 向量逐位连续;Python 参考实现逐位交叉验证一致;并流前分盐向量随批作废)
|
||
check(rng.derive_at(0) == 8672514112661456564, "统一流向量 m=42 scope=0(=旧 board_gen scope0 连续性锚)")
|
||
check(rng.derive_at(1) == 8834291164406037215, "统一流向量 m=42 scope=1")
|
||
check(rng.derive_at(2) == 7714373262647006282, "统一流向量 m=42 scope=2")
|
||
check(rng.derive_at(3) == 1280102845971471347, "统一流向量 m=42 scope=3")
|
||
check(rng.derive_at(4) == 82585501446482399, "统一流向量 m=42 scope=4")
|
||
check(rng.derive_at(5) == 8078924421474470922, "统一流向量 m=42 scope=5")
|
||
check(rng.derive_at(6) == 6638052740430622424, "统一流向量 m=42 scope=6")
|
||
check(rng.derive_at(7) == 8264196488417721294, "统一流向量 m=42 scope=7")
|
||
check(rng.derive_at(0) == rng.derive_at(0), "derive_at 纯函数恒等")
|
||
# scope 取号语义:首个 open_stream = 全局 scope 0;计数器单调;用途标签不参与派生(只进事件日志)
|
||
var s0 = rng.open_stream("board_gen")
|
||
var s1 = rng.open_stream("encounter")
|
||
check(rng.counter() == 2, "open_stream 全局计数器单调 +1 ×2")
|
||
check(s0.seed == rng.derive_at(0) and s1.seed == rng.derive_at(1), "首个取号 = scope 0(顺序确定)")
|
||
rng.new_adventure(4242)
|
||
var s2 = rng.open_stream("gp_drop")
|
||
check(rng.counter() == 1 and s2.seed == rng.derive_at(0), "new_adventure 重置全局计数器")
|
||
check(FileAccess.file_exists("user://logs/rng_log_4242.jsonl"), "事件日志会话落盘(ADR-0007 丙案)")
|
||
|
||
if load_ok:
|
||
_run_core_smoke(db, rng)
|
||
|
||
print("=== 冒烟完成:%d 项检查,%d 项失败 ===" % [_checks, _failures])
|
||
quit(1 if _failures > 0 else 0)
|
||
|
||
|
||
# ---- Core 集成冒烟(表加载成功才执行)----
|
||
var _db
|
||
var _rng
|
||
|
||
|
||
func _run_core_smoke(db, rng) -> void:
|
||
_db = db
|
||
_rng = rng
|
||
_smoke_ac_swap_guard()
|
||
_smoke_armor_roll()
|
||
_smoke_pool_wiring()
|
||
_smoke_board_generator()
|
||
_smoke_layout_cell_formula()
|
||
_smoke_variation_framework()
|
||
_smoke_variant_placement(1)
|
||
_smoke_variant_placement(2)
|
||
_smoke_global_variant()
|
||
_smoke_full_battle(1, 0, "L1 · 地精")
|
||
_smoke_full_battle(2, 0, "L2 · 熊地精")
|
||
_smoke_level()
|
||
_smoke_flag_ap_exhausted()
|
||
_smoke_reward_identity_unit()
|
||
_smoke_power_interval()
|
||
_smoke_active_refresh()
|
||
_smoke_difficulty_table()
|
||
_smoke_damage_types()
|
||
_smoke_playtest_logger()
|
||
_smoke_battle_log_view()
|
||
_smoke_encounter_roll()
|
||
_smoke_attack_chain_fresh_pool()
|
||
_smoke_clear_reward_chain()
|
||
_smoke_refresh_provenance_guard()
|
||
_smoke_polarity_routing()
|
||
_smoke_flag_irreversible_quadrants()
|
||
_smoke_negative_dictionary()
|
||
_smoke_resource_surface()
|
||
_smoke_settlement_segments()
|
||
_smoke_skill_pool()
|
||
_smoke_level_graph()
|
||
_smoke_gp_wallet()
|
||
_smoke_gp_escrow()
|
||
_smoke_shop_room()
|
||
_smoke_shop_graph()
|
||
_smoke_shop_pool()
|
||
_smoke_nine_types_gp_stream()
|
||
_smoke_adventure_table()
|
||
_smoke_adventure_api()
|
||
_smoke_adventure()
|
||
_smoke_relics()
|
||
_smoke_save()
|
||
_smoke_meta()
|
||
_smoke_tree()
|
||
_smoke_node_map_templates()
|
||
|
||
|
||
# ---- 关卡节点图生成·模板池冒烟(M-3 迭代 5 T5——v0.4 §P0.6 断言面)----
|
||
func _smoke_node_map_templates() -> void:
|
||
# ① 扩签:node_map 入用途标签封闭词表(派生零改动——标签只进事件日志)
|
||
var svc = load("res://core/rng_service.gd").new()
|
||
check((svc.PURPOSE_TAGS as Array).has("node_map"), "node_map 标签入 rng_service 封闭词表(扩签通道=改代码+冒烟守卫)")
|
||
# ② 池参数:三关各 5 模板、模板 0 存在(回滚档成员)
|
||
for li in range(1, 4):
|
||
var row := {}
|
||
for r in _db.level.rows:
|
||
if int(r.level_index) == li:
|
||
row = r
|
||
var tpls: Array = row.get("templates", [])
|
||
check(tpls.size() == 5, "L%d 模板池 = 5 张(拍板点④——首版池规模)" % li)
|
||
check(not tpls.is_empty() and String(tpls[0].get("display_name", "")).ends_with("(现行)"),
|
||
"L%d 模板 0 = 现行固定图(回滚档成员)" % li)
|
||
# ③ 强制取 0 = 模板 0 节点表逐位相等 + 零 scope 消费
|
||
var rng0 = load("res://core/rng_service.gd").new()
|
||
rng0.new_adventure(910001)
|
||
var runner0 = load("res://core/level_runner.gd").new(_db, rng0)
|
||
var c_before: int = rng0.counter()
|
||
check(runner0.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, {},
|
||
Callable(self, "_smoke_rest_stub"), {}, true, 0), "强制 template_pick=0 起关受理")
|
||
check(rng0.counter() == c_before, "强制取模板零 RNG 消耗(零抽签——首次脚本语义)")
|
||
var tpl0: Dictionary = {}
|
||
for r in _db.level.rows:
|
||
if int(r.level_index) == 2:
|
||
tpl0 = r.get("templates", [])[0]
|
||
check(runner0.level_row.get("nodes", []) == tpl0.get("nodes", []),
|
||
"强制 0 的生效节点表 = 模板 0 逐位相等(回滚档成立)")
|
||
# ④ 抽签确定性:同种子两实例同模板(同种子同操作序列 → 逐位同图)
|
||
var names := []
|
||
for i in range(2):
|
||
var rg = load("res://core/rng_service.gd").new()
|
||
rg.new_adventure(910002)
|
||
var rn = load("res://core/level_runner.gd").new(_db, rg)
|
||
rn.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, {},
|
||
Callable(self, "_smoke_rest_stub"), {}, true, -1)
|
||
names.append(rn.active_template_name)
|
||
check(names[0] == names[1] and String(names[0]) != "",
|
||
"抽签确定性:同种子逐位同图(实测 %s)" % String(names[0]))
|
||
# ⑤ N=1 池与现行固定图等价:单模板行过图校验(抽签取模 1 恒 0 = 回滚档)
|
||
var lt = load("res://data/schemas/level_table.gd").new()
|
||
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30,
|
||
"templates": [tpl0]}])
|
||
check(lt.validate().is_empty(), "N=1 池表通过图校验(回滚档形态合法)")
|
||
# ⑥ 首次脚本分支:first_script=true 全关模板 0;false = 抽签(scope 恰一差异)
|
||
var rngF = load("res://core/rng_service.gd").new()
|
||
rngF.new_adventure(910003)
|
||
var adrF = load("res://core/adventure_runner.gd").new(_db, rngF)
|
||
adrF.start_adventure(910003, {"first_script": true})
|
||
check(String(adrF.runner().active_template_name).ends_with("(现行)"),
|
||
"首次脚本模式:L1 取模板 0(全关零抽签——存档布尔位 false 语义)")
|
||
var cF: int = rngF.counter()
|
||
var rngT = load("res://core/rng_service.gd").new()
|
||
rngT.new_adventure(910003)
|
||
var adrT = load("res://core/adventure_runner.gd").new(_db, rngT)
|
||
adrT.start_adventure(910003, {"first_script": false})
|
||
check(rngT.counter() == cF + 1, "抽签模式恰多消费 1 scope(node_map 逐关恰一——L1 起关抽签)")
|
||
check(String(adrT.runner().active_template_name) != "",
|
||
"抽签模式 active_template_name 有值(MC 分层出数取数面在位)")
|
||
# ⑦ 收束置位:新档 false → settle 置 true 恰一次(幂等)
|
||
DirAccess.remove_absolute("user://smoke_node_map_save.json") # 测试隔离——上轮 settle 写盘的残留档先删
|
||
var sv = load("res://core/save_manager.gd").new("user://smoke_node_map_save.json")
|
||
sv.load_from_disk()
|
||
check(sv.is_first_adventure_done() == false, "新档首次冒险布尔位 = false(默认域)")
|
||
var mp = load("res://core/meta_progression.gd").new(_db, sv)
|
||
mp.settle_adventure({"levels_cleared": 1, "keystone_total": 10, "cleared": false, "result": "failed"})
|
||
check(sv.is_first_adventure_done() == true, "冒险收束点置位(三结局同写——写入只在收束挂点)")
|
||
mp.settle_adventure({"levels_cleared": 0, "keystone_total": 0, "cleared": false, "result": "failed"})
|
||
check(sv.is_first_adventure_done() == true, "置位幂等(重复收束不翻转)")
|
||
|
||
|
||
# 冒烟恢复执行体(LevelRunner 注入用——与 AdventureRunner.apply_rest 同式占位)
|
||
func _smoke_rest_stub(pct: int, hp: int, max_hp: int) -> int:
|
||
return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp)
|
||
|
||
|
||
# ---- 冒险结构实现批冒烟(2026-09-21——AdventureTable 建表 + AdventureRunner + 冒险验收断言)----
|
||
|
||
# 令牌候选匹配(TP0——kind+tier 复合身份;冒烟断言面共用)
|
||
func _smoke_find_token(candidates: Array, kind: String, tier: String) -> Dictionary:
|
||
for t in candidates:
|
||
var tok: Dictionary = t as Dictionary
|
||
if String(tok.get("kind", "")) == kind and String(tok.get("tier", "")) == tier:
|
||
return tok
|
||
return {}
|
||
|
||
|
||
# ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸;TP0 令牌池换代:配额结构/件值对/池总量域检)
|
||
func _smoke_adventure_table() -> void:
|
||
print("--- 冒险表(AdventureTable——令牌池行值单点:回血/配额/件值对/同调位)---")
|
||
check(_db.adventure != null, "db.adventure 已加载(adventure.tres 入 manifest)")
|
||
if _db.adventure == null:
|
||
return
|
||
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 tier in _db.adventure.TIERS:
|
||
check(int(_db.adventure.tier_total(tier)) == int(_db.adventure.TIER_QUOTAS[tier]),
|
||
"档位配额结构锚(v2.2 追认 b):%s = %d 张" % [tier, int(_db.adventure.TIER_QUOTAS[tier])])
|
||
check(int(_db.adventure.deck_total()) == 98, "令牌池总量 = 98 张(普通 70/稀有 25/罕见 3——罕见档随道具件池种退役收窄)")
|
||
check(int(_db.adventure.curio_value("gp", "common")) == 20 and int(_db.adventure.curio_value("gp", "rare")) == 50
|
||
and int(_db.adventure.curio_value("attack", "common")) == 1 and int(_db.adventure.curio_value("attack", "rare")) == 2
|
||
and int(_db.adventure.curio_value("max_hp", "common")) == 5 and int(_db.adventure.curio_value("max_hp", "rare")) == 10
|
||
and int(_db.adventure.curio_value("armor", "common")) == 1 and int(_db.adventure.curio_value("armor", "rare")) == 2,
|
||
"数值对(v2.2):小GP 20/大GP 50 · 小增伤 +1/大 +2 · 小生命 +5/大 +10 · 小护甲 +1/大 +2")
|
||
check(int(_db.adventure.curio_value("heal", "rare")) == 6 and int(_db.adventure.curio_value("ap", "rarest")) == 1,
|
||
"恢复件 +6 当即回血(新入池种类,rare 档)· AP +1(rarest 档)")
|
||
check(_db.attunement_growth != null and int(_db.attunement_slot_cap()) == 3
|
||
and Attunement.slots(_db, null) == 0,
|
||
"同调位读数源=attunement_growth 谓词表(slot_cap 3;未注入档求值 0——transitional_sync_slots 过渡键已退役,迭代 11 第①批 T7)")
|
||
|
||
|
||
# ② 冒险结构 API 闸(状态机受理面——重复开始拒绝 / 入口停点 / 放弃两态)
|
||
func _smoke_adventure_api() -> void:
|
||
print("--- 冒险结构 API 闸(重复开始拒绝 · 入口停点 · 放弃两态受理)---")
|
||
var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(42))
|
||
adr.set_hero_override(900, 400) # 首战速胜档(放弃的非战斗受理要走到选路态)
|
||
# 停点前移批 2026-09-23:入口停点观测(信号序 + 入口候选快照——容器写回规避 lambda 按值捕获)
|
||
var sig_seq: Array = []
|
||
var obs := {"entry_cands": []}
|
||
adr.level_started.connect(_api_mark_started.bind(sig_seq))
|
||
adr.choices_presented.connect(_api_mark_choices.bind(sig_seq, obs))
|
||
adr.node_entered.connect(_api_mark_node.bind(sig_seq))
|
||
check(adr.start_adventure(42, {"first_script": true}), "冒险启动受理(主种子注入恰一次——state=running;first_script=固定模板 0——精确路径断言需要现行图,零抽签)")
|
||
check(not adr.start_adventure(43), "进行中重复 start_adventure 拒绝(重开须待结算完毕)")
|
||
check(sig_seq.size() == 2 and String(sig_seq[0]) == "level_started" and String(sig_seq[1]) == "choices_presented",
|
||
"停点前移次序契约:level_started 先于入口候选呈现(反序选路口会被呈现层 runner 空防御丢弃)")
|
||
check(adr.runner().awaiting_choice() and (obs["entry_cands"] as Array).size() == 1 \
|
||
and adr.runner().current_battle() == null,
|
||
"入口停点:选路态 + 入口单候选 + 首战未开(呈现零 RNG 消耗——pick 前零战斗域)")
|
||
check(adr.runner().pick_next_node(int(adr.runner().next_candidate_ids()[0])),
|
||
"pick 入口候选受理(同一 _enter_node——与旧自动进入逐位等价)")
|
||
check(sig_seq.size() == 3 and String(sig_seq[2]) == "node_entered" and adr.runner().current_battle() != null,
|
||
"pick 后 node_entered 到达 + 首战在飞(接口①开始关卡——停点闭合直达战斗节点)")
|
||
check(not adr.abandon(), "战斗中 abandon 拒绝(战斗域单事务在飞)")
|
||
# 驱动首战到胜利 → 选路态(非战斗期)→ abandon 受理
|
||
var autoplay = load("res://smoke/level_autoplay.gd")
|
||
var battle = adr.runner().current_battle()
|
||
var st := _battle_st("normal")
|
||
var guard := 30
|
||
while adr.runner().current_battle() == battle and guard > 0:
|
||
guard -= 1
|
||
autoplay._autoplay_battle(adr.runner(), battle, st, null)
|
||
check(adr.runner().current_battle() == null and adr.runner().awaiting_choice(),
|
||
"首战胜后选路态(非战斗期——current_battle 置空)")
|
||
check(adr.abandon(), "非战斗期 abandon 受理(选路态——同失败型结算)")
|
||
var sum: Dictionary = adr.summary
|
||
check(String(sum.get("result", "")) == "abandoned" and not bool(sum.get("cleared", true)),
|
||
"放弃结算:result=abandoned · cleared=false(已获面不追回——钥石累计 %d)" % int(sum.get("keystone_total", -1)))
|
||
check(not adr.abandon(), "结算后重复 abandon 拒绝(结算受理只算一次——state=idle)")
|
||
|
||
|
||
# 停点前移批 2026-09-23 观测桩(信号序采集——bind 容器写回,签名按各信号实参定制)
|
||
func _api_mark_started(_li: int, _dn: String, seq: Array) -> void:
|
||
seq.append("level_started")
|
||
|
||
|
||
func _api_mark_choices(cands: Array, seq: Array, obs: Dictionary) -> void:
|
||
seq.append("choices_presented")
|
||
obs["entry_cands"] = cands.duplicate()
|
||
|
||
|
||
func _api_mark_node(_node: Dictionary, _info: Dictionary, seq: Array) -> void:
|
||
seq.append("node_entered")
|
||
|
||
|
||
# ③ 冒险验收断言(映射 冒险结构.md 验收标准——表覆盖/满配通关/种子复现/大奖励/败北线/
|
||
# 重复拒绝/GP 跨关延续/加算区同账/恢复数学/放弃)
|
||
func _smoke_adventure() -> void:
|
||
print("--- 冒险结构验收(三关序列 · 大奖励 · 摘要五件事 · 账本封闭)---")
|
||
var autoplay = load("res://smoke/level_autoplay.gd")
|
||
# ① 表与覆盖义务(序列关数 = 行集关数;行集内每个战斗/Boss 节点跨表精确行——覆盖义务断言)
|
||
check(int(_db.level.rows.size()) == 3, "序列关数 = 行集关数(三关正式序列生效)")
|
||
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
|
||
var miss := 0
|
||
for row in _db.level.rows:
|
||
for n in _level_active_nodes(int(row.level_index)):
|
||
var nt := String(n.get("type", ""))
|
||
if nt != "battle" and nt != "boss":
|
||
continue
|
||
var key := "%d_%d" % [int(row.level_index), int(n.get("node_depth", -1))]
|
||
if not bg_keys.has(key) or not en_keys.has(key):
|
||
miss += 1
|
||
check(miss == 0, "行集内每个战斗/Boss 节点在 board_gen + enemy 有精确行(缺失 %d)" % miss)
|
||
# ② 满配通关 + ④ 三层奖励兑付(三选一恰 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)")
|
||
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 picks: Array = []
|
||
var resolves: Array = []
|
||
var node_picks := 0
|
||
for ev in (st["reward_events"] as Array):
|
||
if (ev as Dictionary).has("curio_pick"):
|
||
picks.append((ev as Dictionary)["curio_pick"])
|
||
elif (ev as Dictionary).has("node_curio_pick"):
|
||
node_picks += 1 # 奖励节点三选一(追加批——每关奖励节点各 1 次)
|
||
elif (ev as Dictionary).has("curio_resolved"):
|
||
resolves.append((ev as Dictionary)["curio_resolved"])
|
||
check(picks.size() == 2 and resolves.size() == 2 + node_picks,
|
||
"通关三选一恰呈现 2 次、恰兑付 2 次(L1/L2 后——末关不发;resolves 含节点回执另计)")
|
||
check(node_picks >= 1, "奖励节点三选一 ≥1 次(L1 链必经 1 个;L2/L3 图可选路经若干——实际经停 %d)" % node_picks)
|
||
var node_resolves := 0
|
||
for r0 in resolves:
|
||
if String((r0 as Dictionary).get("context", "")) == "reward_node":
|
||
node_resolves += 1
|
||
check(node_resolves == node_picks, "奖励节点兑付回执与经停逐笔相等(context=reward_node——同账计数 %d/%d)" % [node_resolves, node_picks])
|
||
var level_resolves: Array = []
|
||
for r0 in resolves:
|
||
if String((r0 as Dictionary).get("context", "")) != "reward_node":
|
||
level_resolves.append(r0)
|
||
var heal_ok := picks.size() == 2
|
||
var cand_ok := picks.size() == 2
|
||
var ks_gain_ok := picks.size() == 2
|
||
var resolve_ok := level_resolves.size() == 2
|
||
var max_flat := int(_db.adventure.curio_value_max_hp_common)
|
||
var heal_pct := int(_db.adventure.clear_heal_pct)
|
||
var hp_acc := 0 # max_hp 件累加(回血封顶断言的生效上限推导——TP0 起按兑付令牌实值,大小档分别计)
|
||
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:
|
||
# 身份键 = kind|tier|relic_id(迭代14① T4 断言基线复核——TP0 v2.2 relic 按 relic_id 分列
|
||
# 不参与同 kind 去重〔_token_identity relic: 前缀〕,同 tier 三枚不同流派件=三个不同选项合法态)
|
||
var ckey := "%s|%s|%s" % [String((c as Dictionary).get("kind", "")), String((c as Dictionary).get("tier", "")), String((c as Dictionary).get("relic_id", ""))]
|
||
if seen.has(ckey):
|
||
cand_ok = false
|
||
seen[ckey] = 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))
|
||
# 模拟档 v3.4 商店贪心购血合法抬升(L2/L3 店在 Boss 前)——回血口径窗口 = 加算区件 + 购血余量 0..15
|
||
# T17 账本口径修复随批:置满/回血封顶改生效上限口径(flat 后乘调试浮窗 pct)——窗口同步 ×5(本段 pct=400)
|
||
var heal_ok_i := false
|
||
for extra in 46:
|
||
var m := int(round(float(int(_db.hero.max_hp) + hp_acc + extra) * 5.0))
|
||
if heal == mini(int(floor(float(m) * float(heal_pct) / 100.0)), m - hp_before):
|
||
heal_ok_i = true
|
||
break
|
||
if not heal_ok_i or hp_after != hp_before + heal or hp_after > int(round(float(int(_db.hero.max_hp) + hp_acc + 45) * 5.0)):
|
||
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 = level_resolves[i]
|
||
var rk := String(r.get("picked", ""))
|
||
var rt := String(r.get("tier", ""))
|
||
if _smoke_find_token(p.get("candidates", []) as Array, rk, rt).is_empty() or int(r.get("value", -1)) != int(_db.adventure.curio_value(rk, rt)):
|
||
resolve_ok = false
|
||
if rk == "max_hp":
|
||
hp_acc += int(r.get("value", max_flat))
|
||
check(cand_ok, "三选一候选 2–3 枚且零重复(令牌洗牌不放回——kind+tier 复合身份)")
|
||
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 ⇒ 调试浮窗百分比后乘 ×5(加算先加、百分比后乘——生效次序口径)
|
||
var exp_base := maxi(1, int(round(float(int(_db.hero.max_hp)) * k)))
|
||
# 模拟档 v3.4 商店贪心购血(数值修复批)合法抬升——上限窗口 = 基础 + 加算区 3 件 + 购血余量;
|
||
# TP0 数值对后 max_hp 件有大生命 +10(rare 档)——上界按大件最坏构成 3×10+购血 15=+45(TP0 放宽)
|
||
var hp_ceiling := maxi(1, int(round(float(int(_db.hero.max_hp) + 3 * int(_db.adventure.curio_value("max_hp", "rare")) + 15) * k)))
|
||
var seq_ok := seq.size() > 0 and int(seq[0]) == exp_base
|
||
for v in (seq as Array):
|
||
if int(v) < exp_base or int(v) > hp_ceiling:
|
||
seq_ok = false
|
||
check(seq_ok,
|
||
"各战上限入参 ∈ [基础, 基础+3×件值+购血余量](覆盖值通道——双渠道共享上限 3;模拟档贪心购血合法叠加〔数值修复批 v3.4〕;首战=基础 %d)" % 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"] and st2["reward_events"] == st["reward_events"],
|
||
"同种子两局逐位可复现(summary/上限序列/行进序列/三选一载荷——reward_pick 封闭流)")
|
||
# ⑤ 败北线(零配 L1 败北 + 半配 L2 败北——种子扫描,仿商店房 range(42,92) 先例)
|
||
var lose := {}
|
||
for s in range(42, 92):
|
||
var stl: Dictionary = autoplay.run_adventure(_db, s, 0, 0)
|
||
if String(stl["result"]) == "failed" and int(stl["summary"].get("stopped_at", -1)) == 1:
|
||
lose = {"s": s, "st": stl}
|
||
break
|
||
check(not lose.is_empty(), "零配 L1 败北种子可达(seed=%s)" % str(lose.get("s", -1)))
|
||
if not lose.is_empty():
|
||
var sl: Dictionary = lose["st"]["summary"]
|
||
check(not bool(sl["cleared"]) and int(sl["levels_cleared"]) == 0 \
|
||
and int(sl["keystone_total"]) == 0 and int(sl["stopped_at"]) == 1,
|
||
"L1 败北摘要:未打穿 · 通关 0 关 · 钥石 0 · 止步第 1 层")
|
||
var dl: Dictionary = sl.get("defeat", {})
|
||
check(int(dl.get("level_index", -1)) == 1 and int(dl.get("node_id", -1)) >= 0 \
|
||
and String(dl.get("node_type", "")) in ["battle", "boss"],
|
||
"L1 败北败退明细:层号/节点编号/节点类型在案(数值验证取数面 2026-09-22)")
|
||
var half := {}
|
||
for s in range(42, 142):
|
||
var sth: Dictionary = autoplay.run_adventure(_db, s, 50, 0)
|
||
if String(sth["result"]) == "failed" and int(sth["summary"].get("stopped_at", -1)) == 2:
|
||
half = {"s": s, "st": sth}
|
||
break
|
||
check(not half.is_empty(), "半配(atk+50)L2 败北种子可达(seed=%s)" % str(half.get("s", -1)))
|
||
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.meta_boss_keystone_row(1).get("first_kill", 0)) \
|
||
and int(sh["stopped_at"]) == 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"],
|
||
"L2 败北败退明细:层号=2 · 节点在案(数值验证取数面 2026-09-22)")
|
||
# ⑥ 重复宣布拒绝(settle 后再调 _on_level_finished——摘要零变化)
|
||
var adr6 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(7))
|
||
adr6.set_hero_override(900, 400)
|
||
check(adr6.start_adventure(7, {"first_script": true}), "重复拒绝场景:冒险启动(seed 7——固定模板 0 同上)")
|
||
adr6.runner().pick_next_node(int(adr6.runner().next_candidate_ids()[0])) # 停点前移批 2026-09-23:入口停点 pick 进首战
|
||
var b6 = adr6.runner().current_battle()
|
||
var st6 := _adv_st()
|
||
var g6 := 30
|
||
while adr6.runner().current_battle() == b6 and g6 > 0:
|
||
g6 -= 1
|
||
autoplay._autoplay_battle(adr6.runner(), b6, st6, null)
|
||
check(adr6.abandon(), "重复拒绝场景就位:选路态放弃 → 已结算")
|
||
var sum6_before: Dictionary = adr6.summary.duplicate(true)
|
||
adr6._on_level_finished({"result": "victory", "hp": 10, "node_index": 1, "node_id": 1, "node_type": "battle"})
|
||
check(adr6.summary == sum6_before, "重复关卡结束宣布拒绝(账本与摘要零变化——契约 7)")
|
||
# ⑦ GP 跨关延续(L1 收束无 clear · 每战入账恰一次 · L2 商店房余额 > 0 · 冒险收束 clear 恰一次为末事件)
|
||
var wallet: Array = st["wallet_events"]
|
||
var clears := 0
|
||
var gains := 0
|
||
for ev in wallet:
|
||
if String(ev["event"]) == "clear":
|
||
clears += 1
|
||
elif String(ev["event"]) == "gain" and String(ev.get("source", "")) != "curio_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 商店购买 → 三选一直调——同一本账累加 + 部分回血封顶)
|
||
var same := {}
|
||
for s in range(42, 92):
|
||
var a8 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(s))
|
||
a8.set_hero_override(900, 400)
|
||
if not a8.start_adventure(s, {"first_script": true}): # 固定模板 0——种子扫描语义按现行图
|
||
continue
|
||
if _adv_drive_to_shop(a8, _adv_st(), autoplay):
|
||
same = {"a": a8, "s": s}
|
||
break
|
||
check(not same.is_empty(), "同账场景种子可达(seed=%s——L2 商店房就位)" % str(same.get("s", -1)))
|
||
if not same.is_empty():
|
||
var a8 = same["a"]
|
||
var r8 = a8.runner()
|
||
var gp0: int = int(r8.gp)
|
||
var hp_price := -1
|
||
for p in _db.shop.products:
|
||
if String(p.get("product_id", "")) == "hp_up":
|
||
hp_price = int(p.get("base_price", -1))
|
||
var l2_price_bonus := 0
|
||
for pb in _db.shop.price_level_bonus:
|
||
if int(pb.get("level_index", -1)) == 2:
|
||
l2_price_bonus = int(pb.get("bonus", 0))
|
||
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 受理(出账 + 加算区)")
|
||
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"}) # 模拟 L2 通关(victory 载荷直调——非末关)
|
||
check(not a8._curio_pending.is_empty(), "L2 通关三选一停点在案(直调兑付前——零自动推进)")
|
||
check(not a8.continue_to_next_level(), "未兑付推进拒绝(选择停点零自动推进契约)")
|
||
var l2_cands: Array = a8._curio_pending["candidates"]
|
||
var l2_pick: Dictionary = l2_cands[0]
|
||
check(a8.pick_curio(String(l2_pick.get("kind", "")), String(l2_pick.get("tier", ""))), "三选一点选兑付受理(候选取首件)")
|
||
var picked2: String = String(a8._curio_result.get("picked", ""))
|
||
var expect_acc: int = acc_shop + (int(a8._curio_result.get("value", 0)) 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 = a8.effective_max_hp() # T17 口径修复随批:账本生效上限含调试浮窗 pct(a8 已 set 400)
|
||
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 \
|
||
and int(a9.apply_rest(30, 100, 100)) == 0,
|
||
"恢复点数学:floor(100×30%)=30 · 封顶缺口 5 · 满血回 0 合法")
|
||
var lr9 = load("res://core/level_runner.gd").new(_db, _fresh_rng(99))
|
||
check(int(lr9.rest_heal_amount(30, 70, 100)) == 30 and int(lr9.rest_heal_amount(30, 95, 100)) == 5,
|
||
"同一公式两个落点逐位一致(LevelRunner.rest_heal_amount 内置默认)")
|
||
# ⑩ 放弃(战斗中拒绝 · 非战斗期受理同失败型结算)
|
||
var a10 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(15))
|
||
a10.set_hero_override(900, 400)
|
||
check(a10.start_adventure(15, {"first_script": true}), "放弃场景:冒险启动(seed 15——固定模板 0 同上)")
|
||
check(a10.runner().pick_next_node(int(a10.runner().next_candidate_ids()[0])),
|
||
"放弃场景:入口停点 pick 进首战(停点前移批 2026-09-23 驱动口径)")
|
||
check(not a10.abandon(), "战斗中 abandon 拒绝(战斗域单事务在飞)")
|
||
var b10 = a10.runner().current_battle()
|
||
var st10 := _adv_st()
|
||
var g10 := 30
|
||
while a10.runner().current_battle() == b10 and g10 > 0:
|
||
g10 -= 1
|
||
autoplay._autoplay_battle(a10.runner(), b10, st10, null)
|
||
check(a10.runner().current_battle() == null and a10.abandon(), "非战斗期(选路态)abandon 受理")
|
||
# ⑪ 池耗即枯竭 + 池收缩 + 满血回 0(TP0——white-box:耗尽 attack/ap 令牌后候选零出现;
|
||
# 种类次数上限退役,「达上限剔除」语义由牌库耗尽承载)
|
||
var a11 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(11))
|
||
check(a11.start_adventure(11, {"first_script": true, "treasure_unlocked": {"blast_kit": true}}),
|
||
"剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 blast_kit)")
|
||
for di in range(a11._curio_deck.size() - 1, -1, -1):
|
||
var dk := String((a11._curio_deck[di] as Dictionary).get("kind", ""))
|
||
if dk == "attack" or dk == "ap":
|
||
a11._curio_deck.remove_at(di)
|
||
var draw: Array = a11._draw_curio_candidates()
|
||
var draw_kinds := {}
|
||
for t in draw:
|
||
draw_kinds[String((t as Dictionary).get("kind", ""))] = true
|
||
check(not draw_kinds.has("attack") and not draw_kinds.has("ap"),
|
||
"池耗即枯竭(attack/ap 令牌耗尽不出候选——%s)" % str(draw))
|
||
check((draw as Array).size() >= 2, "池耗后候选恒 ≥2(稀有/罕见档余量——防「三选项全不可选」死角)")
|
||
# 池收缩(迭代 11 同调批 T7——aux 卡退役出三选一牌库:重复抽样零 aux 候选;同调位替换两路
|
||
# white-box 测试随停点渠道退役拆除——机械惰性承载,零投放零行为)
|
||
var treasure_seen := false
|
||
for i in 40:
|
||
for t in a11._draw_curio_candidates():
|
||
if String((t as Dictionary).get("kind", "")) == "treasure_item":
|
||
treasure_seen = true
|
||
break
|
||
check(not treasure_seen, "池收缩——40 次重抽零 aux 候选(停点渠道 aux 卡退役,迭代 11 第①批 T7)")
|
||
# 满血回 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.curio_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 非负溢出)")
|
||
# ⑬ 奖励节点共享池(迭代 10 追加批——同库同池同摘牌;TP0:attack 令牌耗尽后节点域同样零出现)
|
||
var a13 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(13))
|
||
check(a13.start_adventure(13, {"first_script": true, "treasure_unlocked": {"blast_kit": true}}),
|
||
"节点共享池场景:冒险启动(seed 13)")
|
||
for di13 in range(a13._curio_deck.size() - 1, -1, -1):
|
||
if String((a13._curio_deck[di13] as Dictionary).get("kind", "")) == "attack":
|
||
a13._curio_deck.remove_at(di13)
|
||
var ndraw: Dictionary = a13.node_curio_candidates()
|
||
var ncands13: Array = ndraw.get("candidates", []) as Array
|
||
var has_attack13 := false
|
||
for t in ncands13:
|
||
if String((t as Dictionary).get("kind", "")) == "attack":
|
||
has_attack13 = true
|
||
break
|
||
check(not has_attack13,
|
||
"奖励节点共享池:令牌耗尽同样不出候选(同库同池——%s)" % str(ncands13))
|
||
a13.present_node_curio(ncands13, ndraw.get("aux_detail", []) as Array)
|
||
a13.present_node_curio([{"kind": "gp", "tier": "common"}])
|
||
var gp13 := int(a13.ledger.get("gp", 0))
|
||
check(a13.apply_node_curio("gp", "common") and int(a13.ledger.get("gp", 0)) == gp13 + int(_db.adventure.curio_value("gp", "common")),
|
||
"奖励节点 GP 件入账(钱包「奖励入账」收口——+%d)" % int(_db.adventure.curio_value("gp", "common")))
|
||
check(a13._node_curio_pending.is_empty(), "节点停点态兑付后清零(恰一次语义同构)")
|
||
var s10: Dictionary = a10.summary
|
||
check(String(s10.get("result", "")) == "abandoned" and not bool(s10.get("cleared", true)),
|
||
"放弃摘要:abandoned · 未打穿(已获大奖励不追回——同失败型结算)")
|
||
|
||
|
||
# ---- 双层 BD 规则改写型特性基建冒烟(迭代 12 T1——relic_table/档位激活引擎/规则改写型特性带池接线)----
|
||
|
||
func _smoke_relics() -> void:
|
||
print("--- 规则改写型特性基建(relic_table · 档位激活引擎 · 规则改写型特性带接线)---")
|
||
# ① 现行表牌库构成:七流派 27 件全量入池——98 数值令牌+27 relic 令牌(批次一铺量完成)
|
||
check(_db.relics != null and (_db.relics.rows as Array).size() == 27, "relic_table 已加载(七流派 27 件全量——批次一 T2-T6 铺量完成)")
|
||
var a_r0 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(71))
|
||
check(a_r0.start_adventure(71, {"first_script": true}), "规则改写型特性带场景:冒险启动(seed 71)")
|
||
var relic_tokens := 0
|
||
var rarest_tokens := 0
|
||
for t in a_r0._curio_deck:
|
||
if String((t as Dictionary).get("kind", "")) == "relic":
|
||
relic_tokens += 1
|
||
if String((t as Dictionary).get("tier", "")) == "rarest":
|
||
rarest_tokens += 1
|
||
check(relic_tokens == 27 and rarest_tokens == 6 and a_r0._curio_deck.size() == 125,
|
||
"规则改写型特性带入池:27 件(rare 档 21 + rarest 锚点 6)——牌库 98+27=125")
|
||
check(_db.relics.validate().is_empty(), "relic_table.validate 零违例(有行流派扫雷端铁律——magic first_dig_spell 在案)")
|
||
# ② 档位激活引擎(纯函数直测——构造表 4 件 magic + 1 件 flag + 1 件 rarest 锚点)
|
||
var rt := RelicTable.new()
|
||
rt.rows = [
|
||
{"relic_id": "m1", "display_name": "魔一", "rarity": "common", "archetype": "magic", "hook": "hero_attack_rider", "effects": {"value": 1}},
|
||
{"relic_id": "m2", "display_name": "魔二", "rarity": "common", "archetype": "magic", "hook": "first_dig_spell", "effects": {"value": 1}},
|
||
{"relic_id": "m3", "display_name": "魔三", "rarity": "rare", "archetype": "magic", "hook": "clamp_modifier", "effects": {"value": 1}},
|
||
{"relic_id": "m4", "display_name": "魔四", "rarity": "rare", "archetype": "magic", "hook": "hero_attack_rider", "effects": {"tier_value": 2}},
|
||
{"relic_id": "f1", "display_name": "旗一", "rarity": "common", "archetype": "flag", "hook": "flag_resource", "effects": {"value": 1}},
|
||
]
|
||
check(rt.validate().is_empty(), "构造 relic 表 validate 零违例(magic 4 件含 first_dig_spell 扫雷端——红线①铁律过)")
|
||
var tiers := RelicsEngine.tiers_for(["m1", "m2", "m3", "m4", "f1"], rt)
|
||
check(int(tiers.get("magic", -1)) == 2 and int(tiers.get("flag", -1)) == 0,
|
||
"档位激活:magic 4 件→档二 · flag 1 件→散件档 0(集 2 档一/4 档二——以撒套装/小骨铭文先例)")
|
||
check(RelicsEngine.is_formed(tiers, "magic") and not RelicsEngine.is_formed(tiers, "flag"),
|
||
"成型判读:magic 成型(tier≥1)· flag 未成型(散件单体保底——档位只质变不启动)")
|
||
var t2 := RelicsEngine.tiers_for(["m1", "m2"], rt)
|
||
check(int(t2.get("magic", -1)) == 1, "档位激活:2 件→档一(同集合同档位确定性——MC 逐位可复放承载)")
|
||
var hooks := RelicsEngine.hooks_for(["m1", "f1"], rt)
|
||
check(hooks.size() == 2 and String(hooks[0].get("hook", "")) == "hero_attack_rider",
|
||
"挂点投影:表序稳定(同集合→同投影)")
|
||
# ③ 规则改写型特性带接线(临时表注入——relic 令牌入牌库/兑付入持有集/踢池/断点往返;测试尾还原现行表)
|
||
var live_rows: RelicTable = _db.relics
|
||
_db.relics = rt
|
||
var a_r = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(72))
|
||
check(a_r.start_adventure(72, {"first_script": true}), "规则改写型特性带场景:冒险启动(seed 72——注入 5 行临时表)")
|
||
var deck_relic := 0
|
||
for t in a_r._curio_deck:
|
||
if String((t as Dictionary).get("kind", "")) == "relic":
|
||
deck_relic += 1
|
||
check(deck_relic == 5 and a_r._curio_deck.size() == 103,
|
||
"规则改写型特性带入池:5 行 relic 每件 1 张(common/rare→稀有档 rarest→罕见档)——牌库 98+5=103")
|
||
var relic_seen: Dictionary = {}
|
||
for i in 60:
|
||
for t in a_r._draw_curio_candidates():
|
||
if String((t as Dictionary).get("kind", "")) == "relic":
|
||
relic_seen[String(t.get("relic_id", ""))] = true
|
||
check(relic_seen.size() >= 2, "抽签出 relic 候选(60 次重抽 ≥2 种不同 relic——身份键 relic_id 同档可多件)")
|
||
a_r._curio_pending = {"candidates": [{"kind": "relic", "tier": "rare", "relic_id": "m3"}, {"kind": "gp", "tier": "common"}], "payload": {}}
|
||
var deck_m3 := 0
|
||
for t in a_r._curio_deck:
|
||
if String((t as Dictionary).get("relic_id", "")) == "m3":
|
||
deck_m3 += 1
|
||
check(a_r.pick_curio("relic", "rare", "m3") and (a_r.relics_held() as Array).has("m3") and a_r.pool_stock("relic") == deck_relic - 1,
|
||
"relic 兑付:入持有集 + 牌库摘牌(选中踢池——池库存 %d→%d)" % [deck_relic, deck_relic - 1])
|
||
# 断点往返(持有集+余库 relic_id 随行)
|
||
var sm_r = load("res://core/save_manager.gd").new("user://_smoke_relics_bp.json")
|
||
sm_r.load_from_disk()
|
||
a_r.set_save_manager(sm_r)
|
||
a_r._on_level_finished({"result": "victory", "hp": int(a_r.ledger.get("hero_hp", 26)), "node_index": 1, "node_id": 1, "node_type": "shop"})
|
||
var bp_r: Dictionary = sm_r.breakpoint_snapshot()
|
||
check((bp_r.get("relics", []) as Array).has("m3"), "断点附随件:relics 持有集入快照(迭代 12 T1 扩列)")
|
||
var bp_deck_m3 := 0
|
||
for t in (bp_r.get("bonus_deck", []) as Array):
|
||
if String((t as Dictionary).get("relic_id", "")) == "m3":
|
||
bp_deck_m3 += 1
|
||
check(bp_deck_m3 == 0, "断点余库:已兑付 relic 令牌逐枚还原(m3 零残留——续玩不重复获得)")
|
||
sm_r.clear_breakpoint()
|
||
_db.relics = live_rows # 还原现行表(共享 _db 防污染——后续冒烟段零感知)
|
||
# ④ T2 魔法流求值面(现行表 magic 四件——flags 组装直测;rider 落地行为=伤害控制器消费面,
|
||
# 既有战斗冒烟全绿=relic_flags 空 dict 零扰动实证;全链分岔读数归 T7 MC)
|
||
var orch = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(73))
|
||
orch._relic_flags_for(["arcane_ember", "mana_weaver", "azure_oath", "mana_resonance"])
|
||
var rf: Dictionary = orch._damage.relic_flags
|
||
check(int(rf.get("rider_flat", -1)) == 2 and bool(rf.get("rider_crit", false)),
|
||
"魔法流求值:rider_flat=余烬1+档一(4件)+1=2 · 档二 rider 可暴击(共鸣 rider 提升以奥术矢+档位承载——锚点双效果 provisional 披露)")
|
||
check(int(rf.get("clamp_magic_magic", -1)) == 1 and int(rf.get("clamp_magic_heavy", -1)) == 1
|
||
and bool(rf.get("first_dig_spell", false)) and int(rf.get("spell_value", -1)) == 2,
|
||
"魔法流求值:魔网织者 magic-vs-magic 罚修正 +1 · 湛蓝誓约 magic-vs-heavy +1 · 魔能共鸣奥术矢 value 2")
|
||
# ⑤ T3 标旗流求值面(现行表 magic 4+flag 4——混合持有:flag 档一 4 件成型)
|
||
orch._relic_flags_for(["arcane_ember", "mana_weaver", "azure_oath", "mana_resonance",
|
||
"mine_atlas", "nest_hunter", "perfect_defense", "war_banner"])
|
||
var rf8: Dictionary = orch._damage.relic_flags
|
||
check(int(rf8.get("flag_resource_extra", -1)) == 1 and int(rf8.get("nest_aura_value", -1)) == 1
|
||
and int(rf8.get("flag_all_hit_mult", -1)) == 2 and int(rf8.get("flag_strike_value", -1)) == 2,
|
||
"标旗流求值:雷区图谱额外资源 1 · 猎巢人光环 +1/巢 · 完美布防 ×2 · 战旗军规旗伤 2(复用 board_typed 零新配方)")
|
||
# ⑥ T4 穿刺/普攻流求值面(全 16 件持有——四流派档位成型)
|
||
orch._relic_flags_for(["arcane_ember", "mana_weaver", "azure_oath", "mana_resonance",
|
||
"mine_atlas", "nest_hunter", "perfect_defense", "war_banner",
|
||
"pierce_shot", "chain_arrows", "weak_instinct", "hunter_rapier",
|
||
"miner_stubborn", "miner_hammer", "focus_mind", "armor_crack"])
|
||
var rf16: Dictionary = orch._damage.relic_flags
|
||
check(bool(rf16.get("kill_overflow", false)) and int(rf16.get("chain_value", -1)) == 2
|
||
and int(rf16.get("discount_shift", -1)) == 1 and int(rf16.get("clamp_pierce_light", -1)) == 1,
|
||
"穿刺流求值:贯穿射击弹射旗 · 连珠矢段值 2 上限 2 · 猎弱本能折扣改善 1 档 · 猎手刺剑 pierce-vs-light +1")
|
||
check(int(rf16.get("round_dig_value", -1)) == 1 and int(rf16.get("hit_trade_atk", -1)) == 25
|
||
and int(rf16.get("hit_trade_hit", -1)) == 2 and int(rf16.get("armor_roll_rewrite", -1)) == 75
|
||
and int(rf16.get("crack_value", -1)) == 25,
|
||
"普攻流求值:执拗 +1/格上限 5 · 重锤交换 +25%/−2AC · 专注恒 75% 免掷骰 · 碎甲震击 off_guard 授予旗")
|
||
check(bool(rf16.get("rider_crit", false)) and bool(rf16.get("rider_crit", false)),
|
||
"档位质变:magic/pierce/normal/flag 四流派档二(16 件混合——同标签 ≥4 判档二确定性)")
|
||
# ⑦ T5 开格/蓄力流求值面(追加七件)
|
||
var ids23: Array = ["arcane_ember", "mana_weaver", "azure_oath", "mana_resonance",
|
||
"mine_atlas", "nest_hunter", "perfect_defense", "war_banner",
|
||
"pierce_shot", "chain_arrows", "weak_instinct", "hunter_rapier",
|
||
"miner_stubborn", "miner_hammer", "focus_mind", "armor_crack",
|
||
"greedy_lantern", "vein_intuition", "gambler_pact", "deep_drill",
|
||
"armory_prep", "iron_wall", "patient_burst"]
|
||
orch._relic_flags_for(ids23)
|
||
var rf23: Dictionary = orch._damage.relic_flags
|
||
check(int(rf23.get("dig_yield_pos_mult", -1)) == 85 and int(rf23.get("dig_yield_neg_mult", -1)) == 125
|
||
and int(rf23.get("combo_cap", -1)) == 5,
|
||
"开格流求值:正向乘区 25+10+50=85% · 负向加重 25+100=125% · 直觉连击上限 5(同 hook 累加——三件同持)")
|
||
check(int(rf23.get("deep_dig_cost", -1)) == 1 and int(rf23.get("deep_dig_cap", -1)) == 4
|
||
and int(rf23.get("ap_carry_value", -1)) == 1 and int(rf23.get("shield_per_layer", -1)) == 1
|
||
and int(rf23.get("spike_mult", -1)) == 2,
|
||
"深挖钻头=开掘 +1 AP/每 4 格深层产出(R4 甲非信息税对价)· 蓄力三件=层值 1/减伤 1/层/满层 ×2")
|
||
check(orch.action_economy.relic_ap_carry and orch.action_economy.relic_dig_extra == 1,
|
||
"蓄力/钻头 action_economy 注入(ap_carry 改写停手转存 + 付费开掘对价注入——关着 false/0 零扰动)")
|
||
# ⑦c 特性持有呈现段(2026-10-04 用户反馈补齐——C 区「特性」节:聚合行+成型档位;空态隐藏)
|
||
var panel = load("res://presentation/views/hero_info_panel_view.gd").new()
|
||
panel.set_relic_holdings([])
|
||
check(not panel._relic_section.visible, "特性段空态隐藏(视图空态纪律同特性段)")
|
||
panel.set_relic_holdings(["魔法 ×2 · 档一", "开格 ×1 · 散件"])
|
||
check(panel._relic_section.visible and panel._relic_list.get_child_count() == 2,
|
||
"特性段持有呈现(聚合行+成型档位常驻可读——「局内奖励选了什么看不到」缺口承载)")
|
||
# ⑦b 深挖钻头付费开掘回归(2026-10-04 试玩缺陷修复——对价挂付费侧):AP=1 降级照常开格
|
||
# (用户实测「AP=1 无法开格只能标旗」的回归闸);AP=2 付全价积付费计数(深层产出配额取数面)。
|
||
var ae_dd = load("res://core/action_economy.gd").new(_db)
|
||
ae_dd.relic_dig_extra = 1
|
||
ae_dd._phase = 1 if ae_dd.Phase.ACTIVE == 1 else ae_dd.Phase.ACTIVE # white-box 相位(冒烟专用)
|
||
ae_dd._ap = 1
|
||
check(ae_dd.request_dig(0, false) and ae_dd.deep_paid_count == 0,
|
||
"钻头降级:AP=1 照常开格(只付基础 1 不积付费——持有钻头永不锁死挖格,散件保底 B-2)")
|
||
ae_dd._ap = 2
|
||
ae_dd._phase = ae_dd.Phase.ACTIVE # 首挖 AP 归 0 已翻轮(ap_exhausted 仲裁)——白盒复位续测
|
||
check(ae_dd.request_dig(0, false) and ae_dd.deep_paid_count == 1,
|
||
"钻头付费开掘:AP=2 付全价(基础 1+对价 1——deep_paid_count 积数=深层产出配额取数面)")
|
||
# ⑧ T6 吞噬流求值面(27 件全量——七流派档位成型)
|
||
var ids27: Array = ids23.duplicate()
|
||
ids27.append_array(["ley_harvest", "thorn_weaver", "purge_sapper", "magma_body"])
|
||
orch._relic_flags_for(ids27)
|
||
var rf27: Dictionary = orch._damage.relic_flags
|
||
check(int(rf27.get("terrain_refund", -1)) == 1 and int(rf27.get("terrain_dig_double", -1)) == 1
|
||
and int(rf27.get("flag_neutralize", -1)) == 1 and int(rf27.get("variant_counter_value", -1)) == 1,
|
||
"吞噬流求值:地脉采撷补偿 1 · 荆棘编织双产出 · 净化工兵中和旗(🔴 仅伤害型——过滤在消费面)· 熔火反噬 1/层")
|
||
var orch0 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(74))
|
||
orch0._relic_flags_for([])
|
||
check((orch0._damage.relic_flags as Dictionary).is_empty(),
|
||
"零持有求值=零旗(relic 关着零扰动——伤害管线既有行为逐位不变)")
|
||
|
||
|
||
# 手工场景驱动:L1 全程 + L2 推进到商店房(_drive_level 复用 + 兑付确认/选路/大奖励确认手工编排)
|
||
func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool:
|
||
var guard := 24
|
||
while guard > 0 and String(st["result"]) == "":
|
||
guard -= 1
|
||
autoplay._drive_level(a.runner(), st, null, "normal", "min")
|
||
if String(st["result"]) != "":
|
||
return false
|
||
if a._pending_next_level:
|
||
# 三选一停点确认(迭代 10——确定性选件后推进,与 run_adventure 自动策略同口径;TP0 令牌化)
|
||
if not a._curio_pending.is_empty():
|
||
var bcands: Array = a._curio_pending["candidates"]
|
||
var bpick: Dictionary = autoplay._pick_token(bcands)
|
||
a.pick_curio(String(bpick.get("kind", "")), String(bpick.get("tier", "")), String(bpick.get("relic_id", "")))
|
||
a.continue_to_next_level()
|
||
continue
|
||
var r = a.runner()
|
||
if String(r.current_node().get("type", "")) == "shop":
|
||
return true
|
||
if r.awaiting_choice():
|
||
var ids: Array = r.next_candidate_ids()
|
||
ids.sort()
|
||
r.pick_next_node(int(ids[0]))
|
||
continue
|
||
var t := String(r.current_node().get("type", ""))
|
||
if t == "reward":
|
||
# 奖励节点三选一(迭代 10 追加批——确定性选件、替换放弃新件、兑付确认回图;TP0 令牌化)
|
||
var ndraw: Dictionary = a.node_curio_candidates()
|
||
var ncands: Array = ndraw.get("candidates", []) as Array
|
||
if not ncands.is_empty():
|
||
a.present_node_curio(ncands, ndraw.get("aux_detail", []) as Array)
|
||
var npick: Dictionary = autoplay._pick_token(ncands)
|
||
if a.apply_node_curio(String(npick.get("kind", "")), String(npick.get("tier", "")), String(npick.get("relic_id", ""))):
|
||
if not a._node_curio_pending.is_empty() and a._node_curio_pending.has("incoming"):
|
||
a.resolve_curio_replace(-1)
|
||
r.continue_after_node()
|
||
elif t == "rest":
|
||
r.continue_after_node()
|
||
return false
|
||
|
||
|
||
# 手工场景状态袋(_drive_level/_autoplay_battle 消费的最小字段族——run_adventure st 的同构子集)
|
||
func _adv_st() -> Dictionary:
|
||
return {"result": "", "hp_left": 0, "battles": 0, "rounds": 0, "hero_dmg": 0,
|
||
"rest_healed": 0, "hp_before_rest": -1, "defeat_node_index": -1, "defeat_node_type": "",
|
||
"boss_depth": -1, "hp_at_boss": -1, "rows": [], "timeout": false, "log_path": "",
|
||
"policy": "normal", "refreshes": 0, "mine_hits": 0, "visited_ids": [], "choices": 0}
|
||
|
||
|
||
|
||
# ---- 战斗屏棋盘布局·格边长公式回归(2026-09-19 真人试玩 bug⑥:L2 12×12 板下缘被裁约 1/6 格高——
|
||
# 旧公式 N×cell ≤ 可用高−12 未计 (N−1)×2px 网格间距,网格实高溢出被 clip_contents 裁掉;
|
||
# 修复 = 公式计入间距 + 垂直预留 24px〔上 12/下 12——底部不贴 D 区边框〕)----
|
||
func _smoke_layout_cell_formula() -> void:
|
||
print("--- 棋盘布局格边长公式(底部预留回归)---")
|
||
var board_generator = load("res://core/board_generator.gd").new()
|
||
var board_view = load("res://presentation/views/board_view.gd").new()
|
||
for L in [1, 2]:
|
||
var b = board_generator.generate(L, 0, _mk_stream("board_gen", 555), _db)
|
||
board_view.rebuild(b, null)
|
||
var avail := Vector2(1036, 460) # 受约束 D 区可用面积样本(1280×800 档量级——用户实测裁切场景)
|
||
board_view.set_cell_area(avail)
|
||
var n: int = maxi(b.width, b.height)
|
||
var cell: float = board_view.get("_cell")
|
||
var grid_h := n * int(cell) + (n - 1) * 2
|
||
check(grid_h <= int(avail.y) - 24, "L%d %dx%d 板网格高 %d ≤ 可用高 %d − 预留 24(格边 %dpx——下缘不贴边不裁切)" % [L, b.width, b.height, grid_h, int(avail.y), int(cell)])
|
||
check(cell >= 26.0 and cell <= 46.0, "L%d 格边在 26–46 档内(%dpx)" % [L, int(cell)])
|
||
board_view.free()
|
||
|
||
|
||
# ---- 地图战斗资源·身份面单元冒烟(格面资源批:roll 恰一次 / 雷安同池 / 幂等 / 清图批尾)----
|
||
|
||
func _smoke_reward_identity_unit() -> void:
|
||
print("--- 地图战斗资源·格面资源身份面 ---")
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(20260911)
|
||
var reward_economy_factory = load("res://core/reward_economy.gd")
|
||
var reward_economy = reward_economy_factory.new(_db, rng)
|
||
var eligible: Array = []
|
||
for i in range(6):
|
||
eligible.append({"cell": Vector2i(i, 0)})
|
||
check(reward_economy.roll_board(eligible), "roll_board 受理")
|
||
check(not reward_economy.roll_board(eligible), "重复 roll_board 被拒(恰一次——double-roll 守卫)")
|
||
# 身份查询投影 = {reward_id, cell_type, tier};五型封闭
|
||
var types_ok := true
|
||
var types_seen := {}
|
||
for e in eligible:
|
||
var ident: Dictionary = reward_economy.query_identity(e.cell)
|
||
if not (ident.has("reward_id") and ident.has("cell_type") and ident.has("tier")):
|
||
types_ok = false
|
||
if not RewardTable.CELL_TYPES.has(String(ident.cell_type)):
|
||
types_ok = false
|
||
types_seen[String(ident.cell_type)] = true
|
||
check(types_ok, "query_identity 投影 = {reward_id, cell_type, tier} 且五型封闭(样本 %s)" % str(types_seen.keys()))
|
||
# 清单外坐标 = null(E19——封锁格不入清单的同款出口)
|
||
check(reward_economy.query_identity(Vector2i(99, 99)) == null, "清单外坐标查询 = null(封锁格无身份同口径)")
|
||
# 同格重复受理幂等:同一快照逐字段相同
|
||
var snap1: Dictionary = reward_economy.grant(eligible[0].cell)
|
||
var snap2: Dictionary = reward_economy.grant(eligible[0].cell)
|
||
check(JSON.stringify(snap1) == JSON.stringify(snap2), "同格重复 grant 幂等(同一快照——受理恰一次)")
|
||
check(reward_economy.grant(Vector2i(99, 99)) == null, "清单外 grant 拒绝")
|
||
# 清图批尾身份:clear_default 唯一活行 clear_blast(blast 型正向);重调幂等
|
||
var cs1: Dictionary = reward_economy.grant_clear()
|
||
var cs2: Dictionary = reward_economy.grant_clear()
|
||
check(String(cs1.cell_type) == "blast" and String(cs1.reward_id) == "clear_blast", "清图批尾身份 = clear_blast(clear_default 唯一活行)")
|
||
var cb_range := [-1, -1]
|
||
for row in _db.reward.rows:
|
||
if String(row.reward_id) == "clear_blast":
|
||
cb_range = [int(row.get("power_min", row.power)), int(row.get("power_max", row.power))]
|
||
check(int(cs1.power) >= int(cb_range[0]) and int(cs1.power) <= int(cb_range[1]),
|
||
"清图奖励 power ∈ 表区间 [%d, %d](roll 值 %d——配置推导)" % [cb_range[0], cb_range[1], int(cs1.power)])
|
||
check(JSON.stringify(cs1) == JSON.stringify(cs2), "grant_clear 同批次重调幂等")
|
||
# 退役后受理拒绝
|
||
reward_economy.retire_mapping()
|
||
check(reward_economy.grant(eligible[0].cell) == null, "映射退役后 grant 拒绝(生命周期闭合)")
|
||
|
||
|
||
# ---- 地图战斗资源·伤害量值区间批冒烟(2026-09-14:身份批 roll 域内 + 中点锚 + 变异实际流动 + 定值行不受扰)----
|
||
|
||
|
||
# ---- 排雷玩法 旗动作耗尽末点 AP 回归(playtest 缺陷②——v1 冒烟盲区:自动玩家不用旗)----
|
||
|
||
func _smoke_flag_ap_exhausted() -> void:
|
||
print("--- 排雷玩法·旗动作耗尽末点 AP 回归 ---")
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(20260911)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
var rounds: Array = []
|
||
settlement.round_started.connect(func(ri): rounds.append(ri))
|
||
check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "回归战斗启动(Boss 行——D0 20HP 级会被盲挖粉尘秒杀,无法承载旗回归场景)")
|
||
var minesweeper = settlement.minesweeper
|
||
# 恰花 (AP-1) 点挖掘,留末点给旗(表驱动——2026-09-16 AP 压速批 8→6 不再写死)
|
||
var ap_total: int = int(_db.action_economy.ap_per_round)
|
||
for i in range(ap_total - 1):
|
||
if settlement.action_economy.phase != "Active" or settlement.action_economy.ap == 0:
|
||
break
|
||
var plan = AutoPlayer.choose_dig(minesweeper)
|
||
if plan.is_empty():
|
||
break
|
||
if plan.hero_cell != minesweeper.hero_pos():
|
||
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
|
||
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
|
||
check(settlement.action_economy.phase == "Active" and settlement.action_economy.ap == 1, "%d 次挖掘后恰剩 1 AP(phase=%s ap=%d)" % [ap_total - 1, str(settlement.action_economy.phase), settlement.action_economy.ap])
|
||
# 末点 AP 花在插旗——轮次必须终结并结算进入第 2 轮
|
||
# 旗目标 = 挖掘策略的下一目标格(构造保证:未开、与小人邻接——先把小人移到其邻接已开格)
|
||
var plan2 = AutoPlayer.choose_dig(minesweeper)
|
||
check(not plan2.is_empty(), "仍存在可挖目标(旗目标来源)")
|
||
var out: Array = []
|
||
if not plan2.is_empty():
|
||
if plan2.hero_cell != minesweeper.hero_pos():
|
||
minesweeper.settle_batch([{"action": "move", "cell": plan2.hero_cell}])
|
||
out = minesweeper.settle_batch([{"action": "flag_place", "cell": plan2.dig_cell}])
|
||
check(not out.is_empty() and bool(out[0].accepted), "末点 AP 插旗被受理")
|
||
check(rounds.size() >= 2, "旗动作耗尽 AP 触发轮末结算并进入第 2 轮(rounds=%s)" % str(rounds))
|
||
|
||
|
||
# ---- 关卡节点地图·线性推进冒烟(2026-09-11 关卡扩展批)----
|
||
|
||
func _smoke_level() -> void:
|
||
print("--- 关卡节点地图·线性推进(战×2→道具→战×2→恢复→Boss)---")
|
||
var autoplay = load("res://smoke/level_autoplay.gd")
|
||
var hero_base := {"atk": int(_db.hero.atk), "hp": int(_db.hero.max_hp), "ac": int(_db.hero.armor_class)} # 配置基线快照——⑤ 回基线对比用(改表自动跟随,不再硬编码)
|
||
# ① 满配通关路径(atk+200%/hp+100%——测试面板同源的属性覆盖)
|
||
var win = autoplay.run_level(_db, 42, 1, 200, 100)
|
||
check(win.result == "victory", "关卡·满配(atk+200/hp+100)通关(实测:%s @节点 %d)" % [win.result, win.defeat_node_index])
|
||
check(win.battles == 5, "战斗节点数 = 5(战×4 + Boss)")
|
||
check(win.boss_depth == 4, "Boss 节点以 Dmax=4 解析(棋盘生成 第三轴登记约定)")
|
||
check(win.hp_at_boss >= 1 and win.hp_at_boss <= int(_db.hero.max_hp) * 3, "进 Boss HP 携带在域内(%d)" % win.hp_at_boss)
|
||
# ② 恢复点:回复 = min(floor(生效max_hp×pct/100), 缺口)——hp+100% 档生效 max = 基线×2
|
||
# seed 42(2026-09-19 充能退役批换种:原 4242 因主角输出模型变化在恢复点前败北——探针目的 = 验回复公式,种子仅载体)
|
||
var rest = autoplay.run_level(_db, 42, 1, 100, 100)
|
||
if rest.result != "start_failed" and rest.hp_before_rest >= 0:
|
||
var eff_max := maxi(1, int(round(float(_db.hero.max_hp) * 2.0))) # hp_pct=100(run_level 已恢复基线)
|
||
var expect: int = mini(int(floor(float(eff_max) * 0.3)), eff_max - rest.hp_before_rest)
|
||
check(rest.rest_healed == expect, "恢复点回复 = floor(生效max_hp×30%%) 与缺口的较小值(%d/%d,生效 max %d)" % [rest.rest_healed, expect, eff_max])
|
||
else:
|
||
check(false, "恢复点未到达(seed 42 hp+100 在恢复点前败北?%s)" % rest.result)
|
||
# ③ 裸装败北路径(atk1/hp10——Roguelike 基线应难以通关)
|
||
var lose = autoplay.run_level(_db, 42, 1, -80, -80)
|
||
check(lose.result == "defeat", "关卡·裸装(atk≈1/hp≈10)败北路径可达(实测:%s)" % lose.result)
|
||
check(lose.defeat_node_index >= 0 and lose.defeat_node_index < 7, "败北节点记录在域内(节点 %d)" % lose.defeat_node_index)
|
||
# ④ 回放确定性:同种子两遍步骤流逐行相同
|
||
var rep1 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min", 0)
|
||
var rep2 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min", 0)
|
||
check(JSON.stringify(rep1.rows) == JSON.stringify(rep2.rows), "关卡回放断言:同种子两遍结算步骤流逐行相同")
|
||
check(rep1.rows.size() > 0, "关卡步骤流非空(%d 行)" % rep1.rows.size())
|
||
# ⑤ 属性覆盖恢复:模拟后 hero 表回基线(调整板不泄漏到后续会话;基线 = 函数入口的配置快照,改表自动跟随)
|
||
check(int(_db.hero.atk) == int(hero_base.atk) and int(_db.hero.max_hp) == int(hero_base.hp) \
|
||
and int(_db.hero.armor_class) == int(hero_base.ac),
|
||
"模拟后 hero 表恢复基线(atk=%d hp=%d ac=%d——对配置快照)" % [int(_db.hero.atk), int(_db.hero.max_hp), int(_db.hero.armor_class)])
|
||
|
||
|
||
# ---- 护甲等级掷点批·换装护栏(2026-09-16 用户拍板:百分比减伤行整体退役换装 AC,无双轨)----
|
||
# ① 换装在位:defense_up 状态行/池映射/奖励行三处全无残留,ac_up 三处就位(unit=ac,2026-09-20 改只续时不叠层);
|
||
# ② 免伤墙归零护栏泛化保留(BUG-0003 事故类永久免疫):hero_defense 池任何 percent 行
|
||
# magnitude × max_stack > pool_clamp_lo——单效果叠满不可触池底(off_guard 已于 2026-09-18
|
||
# 结构修复批退离 hero_defense 池换装 AC 轴,池内现无 percent 行——本断言防未来负向行回归时静默造墙);
|
||
# ③ 单位表 armor_class 字段:主角 10(暂定)/ 敌方行 0(暂定——正式值归怪物设定批);
|
||
# ④ 掷点参数域:档位边界升序/档位值降序无零档/骰面与暴击参数在域(加载校验双保险)。
|
||
|
||
func _smoke_ac_swap_guard() -> void:
|
||
print("--- 护甲等级掷点批·换装护栏 ---")
|
||
# ① 换装在位(三处无双轨)
|
||
check(_db.status_effect_row("defense_up").is_empty(), "状态表 defense_up 行已退役(无双轨)")
|
||
var ac_row: Dictionary = _db.status_effect_row("ac_up")
|
||
check(not ac_row.is_empty() and String(ac_row.get("target_side", "")) == "hero" \
|
||
and String(ac_row.get("unit", "")) == "ac" and int(ac_row.get("magnitude", 0)) == 1 \
|
||
and String(ac_row.get("stack_policy", "")) == "refresh",
|
||
"ac_up 行在位(hero · +1 ac · 只续时不叠层——2026-09-20 主角增益续时批)")
|
||
var ac_reward := {}
|
||
for row in _db.reward.rows:
|
||
if String(row.get("cell_type", "")) == "defense" and String(row.get("polarity", "")) == "positive":
|
||
ac_reward = row
|
||
check(String(ac_reward.get("effect_id", "")) == "ac_up" and String(ac_reward.get("display_name", "")) == "护甲强化",
|
||
"奖励表 defense 正向行 effect_id = ac_up(显示名暂定护甲强化,权重/tier 不变)")
|
||
check(not _db.settlement.effect_pool_map.has("defense_up") and not _db.settlement.effect_pool_map.has("ac_up"),
|
||
"effect_pool_map 无 defense_up/ac_up 键(减伤不入池——AC 不经乘区)")
|
||
# ② 免伤墙归零护栏(泛化——BUG-0003 免疫)
|
||
var wall_ok := true
|
||
var wall_detail := ""
|
||
for eff_id in _db.settlement.effect_pool_map.keys():
|
||
if String(_db.settlement.effect_pool_map[eff_id]) != "hero_defense":
|
||
continue
|
||
var row: Dictionary = _db.status_effect_row(eff_id)
|
||
if row.is_empty() or String(row.get("unit", "")) != "percent":
|
||
continue
|
||
var floor_v: int = int(row.get("magnitude", 0)) * int(row.get("max_stack", 1))
|
||
if floor_v <= int(_db.settlement.pool_clamp_lo):
|
||
wall_ok = false
|
||
wall_detail += " %s(%d)" % [eff_id, floor_v]
|
||
check(wall_ok, "hero_defense 池 percent 行叠满不可触池底(墙最深%s > clamp_lo %d——单效果归零结构性不可达)" % [
|
||
wall_detail if wall_detail != "" else "(池内现无负向行)", int(_db.settlement.pool_clamp_lo)])
|
||
# ③ 单位表 armor_class:域内校验(配置推导——正式投值归怪物/难度批,改表不断锚)
|
||
check(int(_db.hero.armor_class) >= 0 and int(_db.hero.armor_class) <= 30,
|
||
"主角 armor_class 在域内(%d——暂定基准随表走)" % int(_db.hero.armor_class))
|
||
var ac_band := true
|
||
var ac_detail := ""
|
||
var heavy_names := ["熊地精重击手", "灰矮人重装手"] # U2 裁 v1 硬梯度(2026-09-29):逐行手设值 8/9,非精英编码位
|
||
for row in _db.enemy.rows:
|
||
var ac: int = int(row.get("armor_class", -1))
|
||
var dname := String(row.get("display_name", "?"))
|
||
var boss_row: bool = String(row.get("enemy_id", "")).ends_with("_chief") or \
|
||
String(row.get("enemy_id", "")) == "drider" or String(row.get("enemy_id", "")) == "duergar_king"
|
||
var heavy_row: bool = dname in heavy_names
|
||
# 杂鱼 0~5;重击手/重装手 8~9(U2 返工批手设精英值);Boss 8~12 台阶
|
||
var lo := 8 if (boss_row or heavy_row) else 0
|
||
var hi := 12 if boss_row else (9 if heavy_row else 5)
|
||
if ac < lo or ac > hi:
|
||
ac_band = false
|
||
ac_detail += " %s:%d" % [dname, ac]
|
||
check(ac_band, "敌方 AC 行值在已裁带宽内(杂鱼 0~5·重击手/重装手 8~9·Boss 8~12——迭代 5 返工批 v1 重投,越带行%s)" % [
|
||
ac_detail if ac_detail != "" else "无"])
|
||
# ③b Boss 台阶断言(关卡即台阶——L1 8 < L2 10 < L3 12)+ 首遇位教学位低 AC(≤3)
|
||
var boss_ac := {}
|
||
for row in _db.enemy.rows:
|
||
var eid := String(row.get("enemy_id", ""))
|
||
if eid == "ogre_chief" or eid == "drider" or eid == "duergar_king":
|
||
boss_ac[int(row.get("level_index", 0))] = int(row.get("armor_class", 0))
|
||
check(int(boss_ac.get(1, 0)) < int(boss_ac.get(2, 0)) and int(boss_ac.get(2, 0)) < int(boss_ac.get(3, 0)),
|
||
"Boss AC 关卡台阶严格递增(%d < %d < %d)" % [int(boss_ac.get(1, 0)), int(boss_ac.get(2, 0)), int(boss_ac.get(3, 0))])
|
||
# ④ 掷点参数域(加载校验已过——此处钉形状语义)
|
||
var bounds: Array = _db.settlement.roll_tier_bounds
|
||
var discounts: Array = _db.settlement.roll_tier_discounts
|
||
check(int(_db.settlement.roll_faces) == 20 and int(_db.settlement.crit_roll) == 20 \
|
||
and int(_db.settlement.crit_multiplier) == 200 and int(_db.settlement.damage_floor) == 1 \
|
||
and int(_db.settlement.attack_bonus) == 0 and String(_db.settlement.armor_roll_stream) == "armor_roll",
|
||
"掷点参数拍板值在位(d20 / nat20 暴击 ×200%% / 下限 1 / attack_bonus 0 / armor_roll 流)")
|
||
check(bounds == [3, 7] and discounts == [75, 50, 25], "档位表拍板值在位(缺口 1-3 → ×0.75 · 4-7 → ×0.5 · 8+ → ×0.25——无零档)")
|
||
|
||
|
||
# ---- 护甲等级掷点批·行为面(2026-09-16 拍板验收:折扣档分布 / nat20 暴击 / 伤害下限 / 棋盘来源必中)----
|
||
|
||
func _smoke_armor_roll() -> void:
|
||
print("--- 护甲等级掷点(查档纯函数 + 暴击 + 伤害下限 + AC12 分布 + 棋盘必中)---")
|
||
# ① 查档纯函数:全掷点域逐位对照(AC10 / AC12)+ EV 锚(AC10 期望减免 21.25% —— registry 参考数)
|
||
var expect_ac10 := {100: 11, 75: 3, 50: 4, 25: 2} # 掷 10-20 全额 / 7-9 / 3-6 / 1-2
|
||
var expect_ac12 := {100: 9, 75: 3, 50: 4, 25: 4} # 掷 12-20 全额 / 9-11 / 5-8 / 1-4
|
||
for cfg in [{"ac": 10, "expect": expect_ac10}, {"ac": 12, "expect": expect_ac12}]:
|
||
var dist := {100: 0, 75: 0, 50: 0, 25: 0}
|
||
var tier_ok := true
|
||
for roll in range(1, 21):
|
||
var d: int = DamageMath.roll_discount_percent(roll, cfg.ac, _db.settlement.roll_tier_bounds, _db.settlement.roll_tier_discounts)
|
||
if not dist.has(d):
|
||
tier_ok = false
|
||
continue
|
||
dist[d] += 1
|
||
check(tier_ok and dist == cfg.expect, "查档 AC%d 全掷点域分布(100/75/50/25 = %s)" % [cfg.ac, str([dist[100], dist[75], dist[50], dist[25]])])
|
||
var ev_sum := 0
|
||
for roll in range(1, 21):
|
||
ev_sum += DamageMath.roll_discount_percent(roll, 10, _db.settlement.roll_tier_bounds, _db.settlement.roll_tier_discounts)
|
||
check(ev_sum == 1575, "AC10 EV 锚:期望系数合计 %d/2000 = 期望减免 21.25%%(nat20 恒全额含内)" % ev_sum)
|
||
# ② nat20 暴击(行为面):并流批(ADR-0007 裁丙)后掷点位随全局序漂移——探针改为按行为面扫描
|
||
# 「主角先手首掷 = 20」的种子,不再假设 armor_roll scope0 位
|
||
var crit_seed := -1
|
||
var crit_rows := {}
|
||
for s in range(1, 800):
|
||
crit_rows = _first_hero_attack_rows(s)
|
||
if int(crit_rows.get("roll", -1)) == int(_db.settlement.crit_roll):
|
||
crit_seed = s
|
||
break
|
||
check(crit_seed > 0, "nat20 种子可达(seed=%d——主角先手首掷命中 %d)" % [crit_seed, int(_db.settlement.crit_roll)])
|
||
check(int(crit_rows.get("roll", -1)) == int(_db.settlement.crit_roll) and bool(crit_rows.get("crit", false)),
|
||
"nat20 → 行留档 roll=20 + crit 标记(主角先手普通分量)")
|
||
check(int(crit_rows.get("normal", -1)) == 2 * int(_db.hero.atk) and int(crit_rows.get("value", -1)) == 2 * int(_db.hero.atk),
|
||
"nat20 → 普通分量 ×2(%d = 2×%d,暴击倍率只乘该分量,穿刺/魔法不吃)" % [int(crit_rows.get("normal", -1)), int(_db.hero.atk)])
|
||
# ③ 削弱续时(行为面):授予→衰减一轮→再授予 → 层数保持 1、剩余结算数重置(2026-09-18 结构
|
||
# 修复批叠层改续时——削弱 ×2 叠满池触底 ×0 的免伤轮结构性根治,旧 ×2 叠层采样通道随批退役)
|
||
var weaken_probe := _weaken_refresh_probe()
|
||
check(int(weaken_probe.get("layers", -1)) == 1 and int(weaken_probe.get("remaining", -1)) == 2,
|
||
"削弱重复授予 → 层数保持 1、剩余结算数重置 2(续时语义——结构性消饱和)")
|
||
check(int(weaken_probe.get("pool", -999)) == int(_db.status_effect_row("weaken").magnitude),
|
||
"削弱单层池读数 = 行值(enemy_output 池单层贡献——×2 叠满触底 ×0 不可达,免伤轮根治)")
|
||
# ④ AC11 折扣档分布(行为面):ac_up 逐轮授给(时效 1)→ Boss 行攻击拍逐拍判定
|
||
# (2026-09-20 主角增益续时批后 ac_up 层数恒 1——局内可达 AC 上限 10+1=11,探针随行下调)
|
||
var tiers := _ac11_tier_samples()
|
||
var tier_distinct := {}
|
||
var tiers_valid := true
|
||
for t in tiers:
|
||
tier_distinct[int(t)] = true
|
||
if not [100, 75, 50, 25].has(int(t)):
|
||
tiers_valid = false
|
||
check(tiers_valid and tiers.size() >= 3, "AC11 攻击拍逐拍判定(×%d 拍全部落档域 {100,75,50,25})" % tiers.size())
|
||
check(tier_distinct.size() >= 2, "折扣档实际流动(AC11 实测档位 %s——≥2 档可见)" % str(tier_distinct.keys()))
|
||
# ⑤ 棋盘来源必中 + 吃掷点行留档(全战斗流扫描)
|
||
var run = _run_battle(1, 0)
|
||
var board_ok := true
|
||
var attack_ok := true
|
||
var all_positive := true
|
||
for row in run.stream:
|
||
if String(row.get("type", "")) != "damage":
|
||
continue
|
||
if int(row.get("value", 0)) <= 0:
|
||
all_positive = false
|
||
var src := String(row.get("source_class", ""))
|
||
if src == "hero_attack" or src == "enemy_action":
|
||
if not (row.has("roll") and row.has("ac") and row.has("tier")):
|
||
attack_ok = false
|
||
elif row.has("roll"):
|
||
board_ok = false # 棋盘来源(爆裂/直伤/清障炸药)必中全额——不掷不留档
|
||
check(all_positive, "全战斗流零 v=0 伤害行(伤害下限全局生效)")
|
||
check(attack_ok, "hero_attack/enemy_action 伤害行掷点读数+档位随行留档(P2 归因)")
|
||
check(board_ok, "棋盘来源伤害行零掷点字段(爆裂/直伤/清障炸药/奖励即时必中全额)")
|
||
# ⑥ 全来源伤害下限(2026-09-20 用户拍板:任何伤害落地取值域 [1,+∞) 无上限——棋盘克制直伤此前
|
||
# 靠表值 2+(−1)=1 贴底无结构保证,反噬/地形变异/爆裂同为表值直落,本批全部收进 damage_floor 闸内)
|
||
var df: int = int(_db.settlement.damage_floor)
|
||
check(DamageMath.counter_component(2, -1, df) == 1, "棋盘克制直伤贴底 → 下限 1(点数 2 vs 不利修正 −1)")
|
||
check(DamageMath.counter_component(1, -3, df) == 1, "克制修正触底穿透 → 下限 1(点数 1 vs 修正 −3)")
|
||
check(DamageMath.counter_component(0, 5, df) == 0, "空桶不吃修正恒 0(零点数分量无伤害可落地——边界不变不受下限影响)")
|
||
|
||
|
||
# 定位种子下首场战斗第一行主角攻击伤害行(停手轮——零授予零充能,普通分量 = 攻击基线裸值)
|
||
func _first_hero_attack_rows(seed_val: int) -> Dictionary:
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(seed_val)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
var rows: Array = []
|
||
settlement.step_row.connect(func(row): rows.append(row))
|
||
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}):
|
||
return {}
|
||
settlement.minesweeper.settle_batch([{"action": "stop"}])
|
||
for row in rows:
|
||
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "hero_attack":
|
||
return row
|
||
return {}
|
||
|
||
|
||
# 削弱续时探测(2026-09-18 结构修复批——旧 _weaken_zero_round_rows ×2 叠层采样随批退役):
|
||
# 授予 → 衰减一轮 → 再授予,读 weaken 实例投影 {layers, remaining, pool}。
|
||
# 直授通道(来源格真实坐标惯例——沿前例);depth 2 板(D2 熊地精)仅作宿主,行为与敌种无关。
|
||
func _weaken_refresh_probe() -> Dictionary:
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(20260916)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}):
|
||
return {}
|
||
var src: Dictionary = {"source_class": "mine_hit", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index}
|
||
settlement._status_effects.apply_effect("weaken", src)
|
||
settlement._status_effects.decay_round() # remaining 2 → 1(先兑付后衰减——只借衰减器,不结算)
|
||
settlement._status_effects.apply_effect("weaken", src) # 续时:remaining ← 2,层数不动
|
||
for eff in settlement._status_effects.query_active("enemy"):
|
||
if String(eff.effect_id) == "weaken":
|
||
# T6 收口批:改走伤害控制器公有算术面 clamped_pool(原编排器 _clamped_pool 薄委托的直呼退役)
|
||
return {"layers": int(eff.layers), "remaining": int(eff.remaining_settlements), "pool": int(settlement._damage.clamped_pool("enemy_output"))}
|
||
return {}
|
||
|
||
|
||
# AC11 折扣档采样:每停手轮前直授 ac_up ×1(时效 1 逐轮衰减)——敌拍逐拍判定留档。
|
||
# (2026-09-20 主角增益续时批:ac_up 只续时不叠层、层数恒 1——原 ×2 授给达 AC12 的采样随批降为 AC11;
|
||
# 敌人技能池批:拍序改为摇号制,原 AC 批定影种子不再保证档位多样性——扫描种子取首个 ≥2 档样本集。)
|
||
func _ac11_tier_samples() -> Array:
|
||
for seed_offset in range(20):
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(20260917 + seed_offset)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
var rows: Array = []
|
||
settlement.step_row.connect(func(row): rows.append(row))
|
||
if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}):
|
||
continue
|
||
var guard := 12
|
||
while settlement.battle_result == "" and guard > 0:
|
||
guard -= 1
|
||
settlement._status_effects.apply_effect("ac_up", {"source_class": "safe_cell", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index})
|
||
settlement.minesweeper.settle_batch([{"action": "stop"}])
|
||
var tiers: Array = []
|
||
for row in rows:
|
||
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "enemy_action":
|
||
tiers.append(int(row.get("tier", -1)))
|
||
var distinct := {}
|
||
for t in tiers:
|
||
distinct[int(t)] = true
|
||
if tiers.size() >= 3 and distinct.size() >= 2:
|
||
return tiers
|
||
return []
|
||
|
||
|
||
# ---- 主角乘区接线 + 硬化折半方向(2026-09-18 结构修复批护栏)----
|
||
# 同种子停手轮恒同掷点(效果只改乘区、不动掷点流)→ 落地伤害精确关系:
|
||
# 增伤 = 基线 ×1.5、乏力 = ×0.75(hero_offense 池乘攻击合计——2026-09-19 充能退役后 = 单分量,直乘旁路退役);
|
||
# 硬化 = 基线 ×0.5 且 < 基线(enemy_defense 池负值折半——+100 翻倍符号 bug 随批纠正)。
|
||
# 基线构成(加法核 2026-09-18 + 充能退役 2026-09-19):基线 100——hero attack_type=normal 对轻甲中性 0 修正
|
||
# (目标 = L1 地精轻甲;AC 0 恒全额非暴击种子)。
|
||
func _smoke_pool_wiring() -> void:
|
||
print("--- 主角乘区接线 + 硬化折半方向 ---")
|
||
var base_row := _hero_attack_row_with({}, 100, 20260918)
|
||
var up_row := _hero_attack_row_with({"effects": ["attack_up"]}, 100, 20260918)
|
||
var sapped_row := _hero_attack_row_with({"effects": ["sapped"]}, 100, 20260918)
|
||
var fortify_row := _hero_attack_row_with({"effects": ["fortify"]}, 100, 20260918)
|
||
var base_v := int(base_row.get("value", -1))
|
||
check(int(base_row.get("normal", -1)) == base_v and base_v > 0, "单分量基线在位(normal 分量 = 落地值、基线 %d——充能退役批)" % base_v)
|
||
check(int(up_row.get("value", -1)) == int(floor(base_v * 1.5)),
|
||
"增伤经乘区加成合计(%d → %d = ×1.5——attack_up 直乘旁路退役,覆盖面=攻击合计)" % [base_v, int(up_row.get("value", -1))])
|
||
check(int(sapped_row.get("value", -1)) == int(floor(base_v * 0.75)),
|
||
"乏力经乘区削减合计(%d → %d = ×0.75)" % [base_v, int(sapped_row.get("value", -1))])
|
||
check(int(fortify_row.get("value", -1)) == int(floor(base_v * 0.5)) and int(fortify_row.get("value", -1)) < base_v,
|
||
"硬化折半方向(%d → %d = ×0.5 且 < 基线——敌方防御池负值减伤,翻倍 bug 退役)" % [base_v, int(fortify_row.get("value", -1))])
|
||
|
||
|
||
# 构造停手轮首行主角攻击行:opts = {effects: Array};hero_atk 覆盖 100 拉开量级
|
||
# (floor 关系可精确断言);效果直授沿 _weaken_refresh_probe 前例。
|
||
func _hero_attack_row_with(opts: Dictionary, hero_atk_val: int, seed_val: int) -> Dictionary:
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(seed_val)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
var rows: Array = []
|
||
settlement.step_row.connect(func(row): rows.append(row))
|
||
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_atk": hero_atk_val}):
|
||
return {}
|
||
for eff in opts.get("effects", []):
|
||
settlement._status_effects.apply_effect(String(eff), {"source_class": "mine_hit", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index})
|
||
settlement.minesweeper.settle_batch([{"action": "stop"}])
|
||
for row in rows:
|
||
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "hero_attack":
|
||
return row
|
||
return {}
|
||
|
||
|
||
func _smoke_board_generator() -> void:
|
||
print("--- 棋盘生成 ---")
|
||
var board_generator = load("res://core/board_generator.gd").new()
|
||
for L in [1, 2]:
|
||
var row = _db.resolve_board_gen(L, 0)
|
||
check(not row.is_empty(), "BoardGen 行解析 (%d,0)" % L)
|
||
if L == 2:
|
||
# R1 ceil 口径(2026-09-18 拍板):无精确行向上取整保证偏难——L2 深度 3 为恢复点无行,解析到深度 4
|
||
var ceil_row: Dictionary = _db.resolve_board_gen(2, 3)
|
||
check(int(ceil_row.get("node_depth", -1)) == 4, "R1 ceil 解析 (2,3)→(2,4)(向上取整偏难)")
|
||
var b1 = board_generator.generate(L, 0, _rng.open_stream("board_gen"), _db)
|
||
check(b1 != null, "L%d 生成产物非空" % L)
|
||
if b1 == null:
|
||
continue
|
||
check(board_generator.is_clearable(b1), "L%d clearable(安全格 8 邻连通)" % L)
|
||
check(board_generator.legality(b1, row, _db), "L%d legality 全过" % L)
|
||
var inf := float(b1.metrics.get("inferability", 0.0))
|
||
var mode := String(b1.metrics.get("accept_mode", ""))
|
||
print(" L%d 实测:inferability=%.3f / attempts_used=%d / accept_mode=%s / target=%.2f" % [
|
||
L, inf, int(b1.metrics.get("attempts_used", -1)), mode, float(row.inferability_target)])
|
||
# 出图验收(棋盘生成 登记口径):first_pass 必须 ≥ target;accept_best(重试耗尽接受最优)允许低于 target
|
||
# 但必须打印实测值(正常出口);accept_worst 由下方 illegal_product 断言承担。
|
||
check(inf >= float(row.inferability_target) or mode == "accept_best",
|
||
"L%d inferability %.3f %s target %.2f(accept_mode=%s)" % [L, inf, "≥" if inf >= float(row.inferability_target) else "<", float(row.inferability_target), mode])
|
||
check(b1.variants.is_empty(), "L%d 生成器产物零变体格(变异布置归战斗初始化第 4 步——生成器管线不布置,2026-09-19 用户拍板棋盘设置顺序)" % L)
|
||
check(b1.metrics.has("degraded_product") and b1.metrics.has("target_mines"), "L%d 指标集输出降级标记与目标雷数(三分出口表)" % L)
|
||
check(b1.degraded_product == (mode == "accept_best"), "L%d 降级产物标记 = (accept_mode==accept_best)(%s→%s)" % [L, mode, str(b1.degraded_product)])
|
||
check(b1.illegal_product == (mode == "accept_worst"), "L%d 非法产物标记 = (accept_mode==accept_worst)(%s→%s)" % [L, mode, str(b1.illegal_product)])
|
||
check(not b1.degraded_product or not b1.illegal_product, "L%d 降级/非法产物标记互斥(两出口不可同真)" % L)
|
||
check(int(b1.metrics.actual_mines) <= int(b1.metrics.target_mines), "L%d 产物断言:实际雷数 %d ≤ 目标雷数 N=%d(先布后删只减不增)" % [L, int(b1.metrics.actual_mines), int(b1.metrics.target_mines)])
|
||
check(b1.metrics.get("attempts_used", 99) >= 1 and b1.metrics.get("attempts_used", 99) <= int(row.max_gen_attempts), "L%d attempts_used ∈ [1,%d]" % [L, int(row.max_gen_attempts)])
|
||
check(not b1.illegal_product, "L%d 非法产物标记 = false" % L)
|
||
# 生成确定性:同种子逐格相同
|
||
var b2 = board_generator.generate(L, 0, _mk_stream("board_gen", 777), _db)
|
||
var b3 = board_generator.generate(L, 0, _mk_stream("board_gen", 777), _db)
|
||
check(_board_dump(b2) == _board_dump(b3), "L%d 同种子两次生成逐格相同" % L)
|
||
var b4 = board_generator.generate(L, 0, _mk_stream("board_gen", 778), _db)
|
||
check(_board_dump(b2) != _board_dump(b4), "L%d 异种子产物不同(退化护栏)" % L)
|
||
# ---- 初始展开收窄批断言(迭代14① T4)----
|
||
# ① 结构断言:R₀ ≤ cap(正式表行投值,合法性闸承载——出图必过);② r0_cap_rejected 尝试事实在位;
|
||
# ③ ring_removed 量级:剃雷域收窄后 ≤ 域内候选雷格数(5×5−出生 9=16——全域剃雷退役);
|
||
# ④ 闸压线行为:cap=9(出生保障面下限)时出图必 r0≤9 或 accept_worst 非法标记(三分出口诚实降级,玩家永不卡死)
|
||
var cap_val: int = int(row.get("r0_max_cap", 0))
|
||
check(cap_val >= BoardData.SPAWN_AREA_CELLS, "L%d r0_max_cap 正式行投值在位(%d ≥ 出生保障面 9——板面 30%% 锚)" % [L, cap_val])
|
||
check(int(b1.metrics.r0_count) <= cap_val, "L%d 结构断言 R₀=%d ≤ cap=%d(R₀≤cap 重掷闸承载)" % [L, int(b1.metrics.r0_count), cap_val])
|
||
check(int(b1.metrics.get("r0_cap_rejected", -1)) >= 0, "L%d r0_cap_rejected 尝试事实指标在位(本图 %d 次触发)" % [L, int(b1.metrics.get("r0_cap_rejected", -1))])
|
||
check(int(b1.metrics.get("ring_removed", 99)) <= (2 + int(row.get("shave_ring_radius", 1))) * (2 + int(row.get("shave_ring_radius", 1))) * 4 - 5, "L%d ring_removed=%d ≤ 剃雷域候选雷格数((3+2r)²−9——全域剃雷退役)" % [L, int(b1.metrics.get("ring_removed", 99))])
|
||
var row9: Dictionary = row.duplicate()
|
||
row9["r0_max_cap"] = BoardData.SPAWN_AREA_CELLS
|
||
row9["max_gen_attempts"] = 4
|
||
var stub9 := _BoardRowStub.new()
|
||
stub9.row = row9
|
||
var b9 = board_generator.generate(1, 0, _mk_stream("board_gen", 909), stub9)
|
||
check(b9 != null and (int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS or b9.illegal_product),
|
||
"L1 cap 压线 9:出图 R₀=%d %s(三分出口诚实降级——超阈即 accept_worst 非法标记,玩家永不卡死)" % [int(b9.metrics.r0_count), "≤9" if int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS else ">9→illegal_product"])
|
||
check(int(b9.metrics.get("r0_cap_rejected", 0)) >= 0 and String(b9.metrics.get("accept_mode", "")) != "", "L1 cap 压线出图指标集完整(accept_mode=%s / 闸触发 %d 次)" % [String(b9.metrics.get("accept_mode", "")), int(b9.metrics.get("r0_cap_rejected", 0))])
|
||
|
||
|
||
func _mk_stream(stream_name: String, scope: int) -> RandomNumberGenerator:
|
||
# 并流批(ADR-0007 裁丙):stream_name 降格为调用点自述标签(不参与派生),保留参数零改调用方
|
||
var rg := RandomNumberGenerator.new()
|
||
rg.seed = _rng.derive_at(scope)
|
||
return rg
|
||
|
||
|
||
# ---- 地形变异 框架面(首批类型定稿批 2026-09-19:四行两轴×两关 + 门控 + 跨字段互锁)----
|
||
|
||
func _smoke_variation_framework() -> void:
|
||
print("--- 地形变异 框架面(首批四类型 + 候选池门控 + 跨字段互锁)---")
|
||
check(_db.variation.rows.size() == 7, "变异清单 7 行(荆棘丛/藤蔓缠绕/岩浆/烬覆 + 落石/矿尘 + 贫矿脉——首批类型定稿批 + 第三关内容批 + 全局作用域变异首发批)")
|
||
check(_db.variation.validate().is_empty(), "变异表校验零错误(7 行全过域与跨字段互锁——含 scope=global 三互锁)")
|
||
var pool1_ids: Array = []
|
||
for r in _db.variant_pool(1, "dungeon"):
|
||
pool1_ids.append(String(r.variant_id))
|
||
check(pool1_ids == ["thorn", "vine"], "L1 dungeon 候选池 = 荆棘丛+藤蔓缠绕(unlock + board_types 双闸)")
|
||
var pool2_ids: Array = []
|
||
for r in _db.variant_pool(2, "underdark"):
|
||
pool2_ids.append(String(r.variant_id))
|
||
check(pool2_ids == ["magma", "ash"], "L2 underdark 候选池 = 岩浆+烬覆(第二关解锁)")
|
||
check(_db.variant_pool(1, "underdark").is_empty(), "unlock 门控:L1 不进 underdark 行(岩浆/烬覆第二关解锁)")
|
||
var pool2d_ids: Array = []
|
||
for r in _db.variant_pool(2, "dungeon"):
|
||
pool2d_ids.append(String(r.variant_id))
|
||
check(pool2d_ids == ["thorn", "vine"], "board_types 门控:L2 dungeon 板池 = 荆棘丛+藤蔓缠绕(解锁累积;岩浆/烬覆被 board_types ∅ 交集排除)")
|
||
check(_db.variation.row_by_id("nonexistent").is_empty(), "清单外 variant_id 返回空(读取通道在位)")
|
||
# 跨字段互锁负样本(加载期致命——枚举语义一致性闸)
|
||
var vt = load("res://data/schemas/variation_table.gd").new()
|
||
vt.rows.assign([{"variant_id": "bad1", "display_name": "坏行甲", "unlock_level": 1, "scope": "region",
|
||
"effect": "flat_damage", "effect_value": 1, "trigger": "on_open", "placement": "any",
|
||
"board_types": [], "counter_items": []}])
|
||
check(_errs_has_cross_field(vt.validate()), "跨字段互锁负样本①:flat_damage + placement=any 被拒(只出现在未打开的格子)")
|
||
vt.rows.assign([{"variant_id": "bad2", "display_name": "坏行乙", "unlock_level": 1, "scope": "region",
|
||
"effect": "hide_number", "effect_value": 0, "trigger": "on_open", "placement": "any",
|
||
"board_types": [], "counter_items": []}])
|
||
check(_errs_has_cross_field(vt.validate()), "跨字段互锁负样本②:hide_number + trigger≠on_number_shown 被拒")
|
||
|
||
|
||
func _errs_has_cross_field(errs: Array) -> bool:
|
||
for e in errs:
|
||
if String(e.get("category", "")) == "cross_field":
|
||
return true
|
||
return false
|
||
|
||
|
||
# ---- 全局作用域变异 首发批断言面(2026-10-04 迭代 14 ③号卡——T0-T5 集中:互锁/门控三维/
|
||
# 替换制/零取号/资源面偏斜守恒与合成序/快照投影/呈现面锚)----
|
||
|
||
# 取号计数桩(variant_place 零取号断言——子类拦截计数,派生与取数语义零改动)
|
||
class _CountVariantRng extends RngServiceCore:
|
||
var variant_calls := 0
|
||
func open_stream(purpose: String) -> RandomNumberGenerator:
|
||
if purpose == "variant_place":
|
||
variant_calls += 1
|
||
return super.open_stream(purpose)
|
||
|
||
|
||
# 板行覆盖桩(迭代14① T4——cap 压线行为断言;接口面 = resolve_board_gen 单点)
|
||
class _BoardRowStub:
|
||
var row: Dictionary
|
||
func resolve_board_gen(_L: int, _D: int) -> Dictionary:
|
||
return row
|
||
func status_effect_row(_id: String) -> Dictionary:
|
||
return {}
|
||
|
||
|
||
func _smoke_global_variant() -> void:
|
||
print("--- 全局作用域变异 首发批(门控三维 + 替换制 + 资源面偏斜)---")
|
||
# ① 跨字段互锁负样本(resource_face ⟺ scope=global 三条 + effect_value 新域 [0,50])
|
||
var vt = load("res://data/schemas/variation_table.gd").new()
|
||
vt.rows.assign([{"variant_id": "g1", "display_name": "负甲", "unlock_level": 2, "scope": "region",
|
||
"effect": "resource_face", "effect_value": 30, "trigger": "always", "placement": "any",
|
||
"board_types": [], "counter_items": []}])
|
||
check(_errs_has_cross_field(vt.validate()), "互锁负样本③:resource_face + scope=region 被拒(resource_face ⇒ global)")
|
||
vt.rows.assign([{"variant_id": "g2", "display_name": "负乙", "unlock_level": 2, "scope": "global",
|
||
"effect": "hide_number", "effect_value": 0, "trigger": "always", "placement": "any",
|
||
"board_types": [], "counter_items": []}])
|
||
check(_errs_has_cross_field(vt.validate()), "互锁负样本④:scope=global + effect=hide_number 被拒(global ⇒ resource_face)")
|
||
vt.rows.assign([{"variant_id": "g3", "display_name": "负丙", "unlock_level": 2, "scope": "global",
|
||
"effect": "resource_face", "effect_value": 30, "trigger": "on_open", "placement": "any",
|
||
"board_types": [], "counter_items": []}])
|
||
check(_errs_has_cross_field(vt.validate()), "互锁负样本⑤:scope=global + trigger=on_open 被拒(global ⇒ always 板级恒在)")
|
||
vt.rows.assign([{"variant_id": "g4", "display_name": "负丁", "unlock_level": 2, "scope": "global",
|
||
"effect": "resource_face", "effect_value": 30, "trigger": "always", "placement": "closed_only",
|
||
"board_types": [], "counter_items": []}])
|
||
check(_errs_has_cross_field(vt.validate()), "互锁负样本⑥:scope=global + placement=closed_only 被拒(global ⇒ any 无格子布置域)")
|
||
vt.rows.assign([{"variant_id": "g5", "display_name": "负戊", "unlock_level": 2, "scope": "global",
|
||
"effect": "resource_face", "effect_value": 51, "trigger": "always", "placement": "any",
|
||
"board_types": [], "counter_items": []}])
|
||
var vterr: Array = vt.validate()
|
||
var domain_hit := false
|
||
for e in vterr:
|
||
if String(e.get("category", "")) == "domain":
|
||
domain_hit = true
|
||
check(domain_hit, "互锁负样本⑦:resource_face effect_value=51 越域 [0,50] 被拒(不适用 flat_damage [1,9] 加法核)")
|
||
# ② 门控第三维(节点身份——global 行仅 boss/elite 入池;缺省/普通节点静默跳过零报错)
|
||
var pool_boss_ids: Array = []
|
||
for r in _db.variant_pool(2, "underdark", "boss"):
|
||
pool_boss_ids.append(String(r.variant_id))
|
||
check(pool_boss_ids == ["magma", "ash", "skewed_veins"], "门控三维:L2 underdark boss 池 = region 两行 + 贫矿脉(board_types ∅ 通配)")
|
||
var pool_battle_ids: Array = []
|
||
for r in _db.variant_pool(2, "underdark", "battle"):
|
||
pool_battle_ids.append(String(r.variant_id))
|
||
check(pool_battle_ids == ["magma", "ash"], "门控三维:battle 节点静默跳过 global 行(普通图零报错零警告)")
|
||
check(_db.variant_pool(2, "underdark").is_empty() == false and _db.variant_pool(2, "underdark").size() == 2,
|
||
"门控三维:缺省双参调用 = global 行跳过(既有调用口径零扰动)")
|
||
check(_db.variant_pool(1, "underdark", "boss").is_empty(), "L1 Boss 免疫:unlock_level=2 门控在 boss 节点同样生效(教学保护带 B)")
|
||
check(_db.global_variant_row(2, "underdark", "battle").is_empty(), "全局决议:battle 节点恒无全局型")
|
||
check(_db.global_variant_row(2, "underdark", "boss").get("variant_id", "") == "skewed_veins", "全局决议:L2 boss 取贫矿脉(确定性单行)")
|
||
check(_db.global_variant_row(1, "underdark", "boss").is_empty() and _db.global_variant_row(1, "dungeon", "boss").is_empty(),
|
||
"全局决议:L1 boss 全局池空 → 空行(兜底回落承载)")
|
||
check(_db.global_variant_row(2, "underdark", "elite").get("variant_id", "") == "skewed_veins", "elite 前向词位:boss 同池(首发零 elite 表行——词表位不造行)")
|
||
# ③ 替换制 + 零取号:L2 Boss 图恰一行板级变异 + 零 region 格 + variant_place 零消费
|
||
var crng = _CountVariantRng.new()
|
||
crng.set_master_seed(42)
|
||
var boss_settlement = load("res://core/battle_orchestrator.gd").new(_db, crng)
|
||
var boss_ok: bool = boss_settlement.begin_battle({"level_index": 2, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})
|
||
check(boss_ok, "L2 Boss 战斗启动(node_depth=4 boss 节点——替换制断言载体)")
|
||
if boss_ok:
|
||
var bb = boss_settlement.minesweeper.current_board()
|
||
check(_arr_str_eq(bb.board_variant_ids(), ["skewed_veins"]), "替换制:Boss 图 board_variants 恰一行 = 贫矿脉")
|
||
check(bb.variants.is_empty(), "替换制:Boss 图零 region 变异格(无格子级变异标记——呈现件闲置合法态)")
|
||
check(crng.variant_calls == 0, "零取号合法态:Boss 图 variant_place 零消费(全局取用确定性)")
|
||
var snap: Dictionary = boss_settlement.presentation_snapshot()
|
||
check(_arr_str_eq(snap.get("board_variants", []), ["skewed_veins"]), "快照投影:board_variants 呈现面(横幅/角标唯一读取面)")
|
||
# Boss 图确定性:同种子两遍逐位相同
|
||
var crng2 = _CountVariantRng.new()
|
||
crng2.set_master_seed(42)
|
||
var boss2 = load("res://core/battle_orchestrator.gd").new(_db, crng2)
|
||
if boss2.begin_battle({"level_index": 2, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}):
|
||
var bb2 = boss2.minesweeper.current_board()
|
||
check(_arr_str_eq(bb2.board_variant_ids(), ["skewed_veins"]) and crng2.variant_calls == 0,
|
||
"确定性:同种子 Boss 图两遍 board_variants/取号逐位相同")
|
||
# ④ 资源面偏斜投影(RewardEconomy 纯函数面——守恒/白名单/构造例/合成序)
|
||
var eco = load("res://core/reward_economy.gd").new(_db, load("res://core/rng_service.gd").new())
|
||
var base_rows: Array = eco._rollable_rows()
|
||
var base_sum: int = eco._pool_weight(base_rows)
|
||
check(base_sum == 77, "偏斜基线:正向池现行合计 77(挖掘风险批口径)")
|
||
eco.board_skew = 0
|
||
var off_rows: Array = eco._biased_cell_rows()
|
||
check(off_rows.size() == base_rows.size() and _rows_weights_equal(off_rows, base_rows), "off 态零扰动:board_skew=0 行集与基线逐位相同(不进投影域)")
|
||
eco.board_skew = 30
|
||
var on_rows: Array = eco._biased_cell_rows()
|
||
check(eco._pool_weight(on_rows) == 77, "合计守恒:偏斜后正向池合计 = 77(整数最大余数重归一)")
|
||
var wmap := _rows_weight_map(on_rows)
|
||
check(wmap.get("weaken", -1) == 8 and wmap.get("ap_up", -1) == 6 and wmap.get("gp_coin", -1) == 2,
|
||
"白名单豁免:weaken/gp/action 三行偏斜后逐位不动")
|
||
check(wmap.get("heal_minor", -1) == 3 and wmap.get("ac_up", -1) == 7, "生存族下调:heal 4→3 / defense 11→7(v=30 构造例)")
|
||
check(wmap.get("attack_up", -1) == 15 and wmap.get("spark_aoe", -1) == 18 and wmap.get("pierce_strike", -1) == 9 and wmap.get("magic_bolt", -1) == 9,
|
||
"进攻族上浮:attack 14→15 / blast 16→18 / pierce 8→9 / magic 8→9(v=30 构造例,等比上浮守恒)")
|
||
eco.board_skew = 30
|
||
eco.luck_bias = 2
|
||
var combo_rows: Array = eco._biased_cell_rows()
|
||
var cw := _rows_weight_map(combo_rows)
|
||
check(eco._pool_weight(combo_rows) == 77 + 4 * 2, "合成序:图级偏斜先(乘法面)件级 luck_bias 后(加法 +2×四稀有类)——合计 77+8")
|
||
check(cw.get("heal_minor", -1) == 5 and cw.get("gp_coin", -1) == 4 and cw.get("pierce_strike", -1) == 11 and cw.get("magic_bolt", -1) == 11,
|
||
"合成序构造例:skew(heal 3)+2=5 / gp 2+2=4 / pierce 9+2=11 / magic 9+2=11(图级定型后玩家构筑叠加)")
|
||
eco.luck_bias = 0
|
||
eco.board_skew = 50
|
||
var v50: Dictionary = _rows_weight_map(eco._biased_cell_rows())
|
||
check(eco._pool_weight(eco._biased_cell_rows()) == 77 and int(v50.get("heal_minor", -1)) > 0, "域上界 v=50:守恒成立且逐行权重恒正(域 [0,50] 安全)")
|
||
# 普通图摇号全链路确定性(off 路径)——同种子普通图资源映射逐位相同(golden 对拍另有批前快照)
|
||
var nrng = load("res://core/rng_service.gd").new()
|
||
nrng.set_master_seed(42)
|
||
var normal1 = load("res://core/battle_orchestrator.gd").new(_db, nrng)
|
||
normal1.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
|
||
var n1: Dictionary = _board_resource_dump(normal1)
|
||
var nrng2 = load("res://core/rng_service.gd").new()
|
||
nrng2.set_master_seed(42)
|
||
var normal2 = load("res://core/battle_orchestrator.gd").new(_db, nrng2)
|
||
normal2.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
|
||
check(n1 == _board_resource_dump(normal2) and not n1.is_empty(), "确定性:同种子普通图布置/摇号两遍逐位相同(off 全链路)")
|
||
|
||
|
||
func _arr_str_eq(a, b: Array) -> bool:
|
||
var arr: Array = a if a is Array else []
|
||
if arr.size() != b.size():
|
||
return false
|
||
for i in arr.size():
|
||
if String(arr[i]) != String(b[i]):
|
||
return false
|
||
return true
|
||
|
||
|
||
func _rows_weights_equal(a: Array, b: Array) -> bool:
|
||
if a.size() != b.size():
|
||
return false
|
||
for i in a.size():
|
||
if String(a[i].get("reward_id", "")) != String(b[i].get("reward_id", "")) or int(a[i].weight) != int(b[i].weight):
|
||
return false
|
||
return true
|
||
|
||
|
||
func _rows_weight_map(rows: Array) -> Dictionary:
|
||
var m := {}
|
||
for r in rows:
|
||
m[String(r.get("reward_id", ""))] = int(r.weight)
|
||
return m
|
||
|
||
|
||
func _board_resource_dump(settlement) -> Dictionary:
|
||
var b = settlement.minesweeper.current_board()
|
||
var dump := {}
|
||
for c in b.variants.keys():
|
||
dump["v:%d,%d" % [c.x, c.y]] = ",".join(b.variants[c])
|
||
for y in b.height:
|
||
for x in b.width:
|
||
var ident = settlement._reward_economy.query_identity(Vector2i(x, y))
|
||
if ident != null:
|
||
dump["r:%d,%d" % [x, y]] = String(ident.reward_id)
|
||
return dump
|
||
|
||
|
||
# ---- 地形变异 布置段(首批类型定稿批 2026-09-19——棋盘设置顺序第 4 步断言面)----
|
||
# 布置发生在编排器战斗初始化(install 初始展开 + roll_board 资源格之后),生成器产物零变异——
|
||
# 本段经 begin_battle 走完整初始化序后断言:门控一致性 / 布置域 / 数量公式 / 连通片上限 / 确定性。
|
||
|
||
func _smoke_variant_placement(L: int) -> void:
|
||
print("--- 地形变异 布置段(L%d)---" % L)
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(42)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.hero.max_hp}):
|
||
check(false, "L%d 布置段战斗启动失败" % L)
|
||
return
|
||
var b = settlement.minesweeper.current_board()
|
||
check(b != null, "L%d 布置段板非空" % L)
|
||
if b == null:
|
||
return
|
||
var pool_ids: Array = []
|
||
for r in _db.variant_pool(L, String(b.board_type)):
|
||
pool_ids.append(String(r.variant_id))
|
||
var vcells: Array = b.variants.keys()
|
||
var vid0 := ""
|
||
var single_type := true
|
||
var bad_id := false
|
||
for c in vcells:
|
||
var ids: Array = b.variants[c]
|
||
if ids.size() != 1:
|
||
single_type = false
|
||
for vid in ids:
|
||
if vid0 == "":
|
||
vid0 = String(vid)
|
||
elif vid0 != String(vid):
|
||
single_type = false
|
||
if not pool_ids.has(String(vid)):
|
||
bad_id = true
|
||
check(not bad_id, "L%d 变异 id 全在候选池(门控一致性:%s)" % [L, str(pool_ids)])
|
||
check(single_type, "L%d 每图至多一种变异类型(单格单 id + 全图同 id)" % L)
|
||
var r0 := {}
|
||
for c in b.compute_r0():
|
||
r0[c] = true
|
||
var domain_ok := true
|
||
for c in vcells:
|
||
if r0.has(c) or b.is_mine(c):
|
||
domain_ok = false
|
||
check(domain_ok, "L%d 变异格全在初始展开区外且不吃雷格(布置域结构保障——closed_only)" % L)
|
||
var cand_count := 0
|
||
for c in b.safe_cells():
|
||
if not r0.has(c):
|
||
cand_count += 1
|
||
var expect_cap: int = int(round(float(cand_count) * float(_db.variation.percent_of_cells) / 100.0)) \
|
||
+ (L - 1) * int(_db.variation.per_level_bonus)
|
||
check(vcells.size() <= expect_cap, "L%d 变异格数 %d ≤ 公式上限 %d(可挖池百分比 + 关卡递增)" % [L, vcells.size(), expect_cap])
|
||
# 数量公式量随候选池缩放(2026-09-21 基数修正批):L1 无关卡加成,极小池(<10 格)可 round 至 0——
|
||
# 零布置为合法态(同池空口径);非零断言改为条件式,公式量>0 时才要求非零(L2/L3 关卡加成保底恒>0)
|
||
check(expect_cap == 0 or vcells.size() > 0, "L%d 公式量>0 时非零布置(极小池 round 至 0 = 合法零布置——2026-09-21 基数修正批)" % L)
|
||
var pack_max: int = int(_db.variation.pack_max)
|
||
var vset := {}
|
||
for c in vcells:
|
||
vset[c] = true
|
||
var seen := {}
|
||
var pack_ok := true
|
||
for c in vcells:
|
||
if seen.has(c):
|
||
continue
|
||
seen[c] = true
|
||
var comp_size := 1
|
||
var q: Array = [c]
|
||
while q.size() > 0:
|
||
var x: Vector2i = q.pop_front()
|
||
for n in b.neighbors8(x):
|
||
if vset.has(n) and not seen.has(n):
|
||
seen[n] = true
|
||
comp_size += 1
|
||
q.append(n)
|
||
if comp_size > pack_max:
|
||
pack_ok = false
|
||
check(pack_ok, "L%d 全部连通片 ≤ %d 格(全连通生长、2×2/1×4/1×3+1 等形)" % [L, pack_max])
|
||
# 布置确定性:全新 RngService 同种子重开,变异布置逐格相同(variant_place 流确定性)
|
||
var rng2 = load("res://core/rng_service.gd").new()
|
||
rng2.set_master_seed(42)
|
||
var s2 = load("res://core/battle_orchestrator.gd").new(_db, rng2)
|
||
s2.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.hero.max_hp})
|
||
var b2 = s2.minesweeper.current_board()
|
||
check(b2 != null and _variant_dump(b2) == _variant_dump(b), "L%d 同种子变异布置逐格相同(variant_place 流确定性)" % L)
|
||
|
||
|
||
func _variant_dump(b) -> String:
|
||
var keys: Array = b.variants.keys()
|
||
keys.sort_custom(func(a, b2): return a.y * 1000 + a.x < b2.y * 1000 + b2.x)
|
||
var parts: Array = []
|
||
for c in keys:
|
||
parts.append("%d,%d:%s" % [c.x, c.y, str(b.variants[c])])
|
||
return "|".join(parts)
|
||
|
||
|
||
func _board_dump(b) -> String:
|
||
var parts: Array = [str(b.width), str(b.height), str(b.spawn_center)]
|
||
for y in b.height:
|
||
for x in b.width:
|
||
var c := Vector2i(x, y)
|
||
parts.append("%d:%d:%d:%s" % [x, y, 1 if b.is_mine(c) else 0, str(b.variants.get(c, []))])
|
||
return "\n".join(parts)
|
||
|
||
|
||
# ---- 完整战斗回放(自动玩家,确定性)——回放断言:同种子两遍步骤流逐行相同 ----
|
||
|
||
func _smoke_full_battle(L: int, D: int, label: String) -> void:
|
||
print("--- 完整战斗回放:%s ---" % label)
|
||
var run1 = _run_battle(L, D)
|
||
var run2 = _run_battle(L, D)
|
||
check(run1.terminated, "%s 战斗在轮次上限内终局(%d 轮,%s)" % [label, run1.rounds, run1.result])
|
||
if JSON.stringify(run1.stream) != JSON.stringify(run2.stream):
|
||
for i in range(maxi(run1.stream.size(), run2.stream.size())):
|
||
var a := JSON.stringify(run1.stream[i]) if i < run1.stream.size() else "<none>"
|
||
var b := JSON.stringify(run2.stream[i]) if i < run2.stream.size() else "<none>"
|
||
if a != b:
|
||
print(" 流差异 @%d:\n run1=%s\n run2=%s" % [i, a, b])
|
||
break
|
||
check(JSON.stringify(run1.stream) == JSON.stringify(run2.stream), "%s 回放断言:结算步骤流逐行相同" % label)
|
||
check(JSON.stringify(run1.events) == JSON.stringify(run2.events), "%s 回放断言:事件流逐位相同" % label)
|
||
check(run1.stream.size() > 0, "%s 结算步骤流非空(%d 行)" % [label, run1.stream.size()])
|
||
var has_verdict := false
|
||
var hero_attacks := 0
|
||
var hero_breakdown_ok := true
|
||
for row in run1.stream:
|
||
if row.get("type", "") == "verdict":
|
||
has_verdict = true
|
||
if row.get("source_class", "") == "hero_attack":
|
||
hero_attacks += 1
|
||
# 单分量伤害构成行(2026-09-19 充能退役批):hero_attack 行必须携带实际攻击类型键 + 掷点读数(P2 归因)
|
||
var atk_t: String = String(_db.hero.attack_type)
|
||
if not (row.has(atk_t) and row.has("roll") and row.has("ac") and row.has("tier")):
|
||
hero_breakdown_ok = false
|
||
check(has_verdict, "%s 步骤流含判定行" % label)
|
||
# 伤害道具批(2026-09-18):单弱敌遭遇可被挖掘期兑付(穿刺弹/魔法矢/爆裂粉尘)在授予段单独击杀——
|
||
# 敌全灭后战斗段整段跳过、零先手行为合法结局;先手规则本身由强敌场景(L1/L2 深节点)继续守护。
|
||
check(hero_attacks >= 1 or run1.terminated, "%s 主角先手攻击行存在或授予段终局(2026-09-11 拍板① + 伤害道具批)×%d" % [label, hero_attacks])
|
||
check(hero_breakdown_ok, "%s 主角攻击行携带类型键与掷点读数(单分量——充能退役批)" % label)
|
||
|
||
|
||
func _run_battle(L: int, D: int) -> Dictionary:
|
||
# 每遍全新 RngService(同 master 种子)——回放断言的隔离前提(地图战斗资源 AC"两套全新 RngService+地图战斗资源"同款)
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(42)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
var out := {"stream": [], "events": [], "rounds": 0, "result": "", "terminated": false}
|
||
settlement.step_row.connect(func(row): out.stream.append(row))
|
||
settlement.battle_finished.connect(func(payload): out.result = payload.get("result", ""))
|
||
settlement.minesweeper.cell_revealed.connect(func(evt): out.events.append(evt))
|
||
settlement.minesweeper.flag_judged.connect(func(evt): out.events.append(evt))
|
||
settlement.minesweeper.round_end_board.connect(func(p): out.events.append({"ev": "round_end_board", "path_tag": p.path_tag}))
|
||
settlement.action_economy.round_end_stopped.connect(func(p): out.events.append({"ev": "round_end_stopped", "path_tag": p.path_tag}))
|
||
var minesweeper = settlement.minesweeper
|
||
var action_economy = settlement.action_economy
|
||
if not settlement.begin_battle({"level_index": L, "node_depth": D, "hero_hp": _db.hero.max_hp}):
|
||
out.stream.append({"type": "verdict", "value": "begin_failed"})
|
||
return out
|
||
var max_rounds := 40
|
||
while out.result == "" and out.rounds < max_rounds:
|
||
out.rounds += 1
|
||
_auto_play_round(settlement, out)
|
||
if out.result == "":
|
||
out.result = "timeout"
|
||
out.terminated = out.result == "victory" or out.result == "defeat"
|
||
if out.terminated:
|
||
# P0-3 回归断言(2026-10-02 修复批):notify_combat_end 曾被并进行尾注释从不执行——
|
||
# 战斗收束后相位必须回 Idle(RAP 清零),任何「相位卡 Waiting」回归在此被抓
|
||
check(action_economy.phase == "Idle",
|
||
"战斗收束后行动经济相位回 Idle(notify_combat_end 生效,实测=%s)" % String(action_economy.phase))
|
||
return out
|
||
|
||
|
||
func _auto_play_round(settlement, out: Dictionary) -> void:
|
||
var minesweeper = settlement.minesweeper
|
||
var action_economy = settlement.action_economy
|
||
var start_round: int = settlement.round_index
|
||
# 确定性自动玩家:挖最近的已探明邻接未开格;无可挖则停手(结算窗口由信号回调提交)
|
||
var guard := 400
|
||
while action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0 and out.result == "":
|
||
guard -= 1
|
||
var plan := AutoPlayer.choose_dig(minesweeper)
|
||
if plan.is_empty():
|
||
break
|
||
if plan.hero_cell != minesweeper.hero_pos():
|
||
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
|
||
if action_economy.phase != "Active" or settlement.round_index != start_round:
|
||
break
|
||
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
|
||
if action_economy.phase == "Active" and settlement.round_index == start_round and out.result == "":
|
||
minesweeper.settle_batch([{"action": "stop"}])
|
||
|
||
|
||
|
||
# ---- 地图战斗资源·伤害量值区间批冒烟(2026-09-14:身份批 roll 域内 + 中点锚 + 变异实际流动 + 定值行不受扰)----
|
||
|
||
func _smoke_power_interval() -> void:
|
||
print("--- 地图战斗资源·伤害量值区间(上阶/下阶随机)---")
|
||
# ① 表侧静态锚(配置推导——2026-09-23 重构,改表不再断锚):凡带 power_min/power_max 的行 = 区间行,
|
||
# 校验配对齐全(lo ≤ hi)+ power = floor 中点(行动经济 条款①期望当量锚);其余行必须零区间字段。
|
||
# 行清单与区间值全部随表走——现行区间行 heal_minor/clear_blast 只是恰然,不再是锚。
|
||
var interval_rows := {} # rid → [lo, hi](读表)
|
||
var anchors_ok := true
|
||
for row in _db.reward.rows:
|
||
var rid := String(row.reward_id)
|
||
if row.has("power_min") or row.has("power_max"):
|
||
var lo: int = int(row.get("power_min", -1))
|
||
var hi: int = int(row.get("power_max", -1))
|
||
interval_rows[rid] = [lo, hi]
|
||
if lo > hi or int(row.power) != (lo + hi) / 2:
|
||
anchors_ok = false
|
||
check(anchors_ok and interval_rows.size() >= 1,
|
||
"区间行配对齐全且 power = floor 中点(配置推导 %d 行:%s)" % [interval_rows.size(), str(interval_rows.keys())])
|
||
# ② 运行侧:24 板 roll——区间行快照 power 全域内 + 上下阶实际流动 + 定值行不受扰
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(20260914)
|
||
var reward_economy_factory = load("res://core/reward_economy.gd")
|
||
var eligible: Array = []
|
||
for i in range(6):
|
||
eligible.append({"cell": Vector2i(i, 0)})
|
||
var seen := {}
|
||
for rid in interval_rows.keys():
|
||
seen[rid] = {} # rid → {roll 值集}(区间行随表注册)
|
||
var in_domain := true
|
||
var fixed_ok := true
|
||
for trial in range(24):
|
||
var reward_economy = reward_economy_factory.new(_db, rng)
|
||
if not reward_economy.roll_board(eligible):
|
||
continue
|
||
for e in eligible:
|
||
var snap: Dictionary = reward_economy.query_full_snapshot(e.cell)
|
||
var rid := String(snap.reward_id)
|
||
if interval_rows.has(rid):
|
||
var v := int(snap.power)
|
||
seen[rid][v] = true
|
||
if v < interval_rows[rid][0] or v > interval_rows[rid][1]:
|
||
in_domain = false
|
||
elif int(snap.power) != _row_power(rid):
|
||
fixed_ok = false # 定值行快照 power 必须仍是表值
|
||
var cs: Dictionary = reward_economy.grant_clear()
|
||
var cv := int(cs.power)
|
||
var c_rid := String(cs.reward_id)
|
||
if not seen.has(c_rid):
|
||
seen[c_rid] = {}
|
||
seen[c_rid][cv] = true
|
||
if interval_rows.has(c_rid) and (cv < interval_rows[c_rid][0] or cv > interval_rows[c_rid][1]):
|
||
in_domain = false
|
||
check(in_domain, "24 板全区间行快照 power ∈ [power_min, power_max](含清图槽)")
|
||
check(fixed_ok, "定值行快照 power 恒等表值——区间批零外溢")
|
||
# 定值行(区间 lo==hi 的行,如 heal_minor)roll 恒等表值;区间行(lo<hi,如 clear_blast)须实际流动。
|
||
# 全部按 interval_rows 配置推导(2026-09-23 重构)——新增/改区间行自动纳入,不再逐行硬编码。
|
||
var variance_ok := true
|
||
var fixed_rows_ok := true
|
||
for rid in interval_rows.keys():
|
||
var lo: int = interval_rows[rid][0]
|
||
var hi: int = interval_rows[rid][1]
|
||
var samples: int = seen.get(rid, {}).size()
|
||
if lo == hi:
|
||
if samples > 0 and not (samples == 1 and seen[rid].has(lo)):
|
||
fixed_rows_ok = false
|
||
elif samples >= 2 and seen[rid].size() < 2:
|
||
variance_ok = false
|
||
check(variance_ok, "区间行上下阶实际流动(≥2 样本的区间行须 ≥2 个不同 roll 值)")
|
||
check(fixed_rows_ok, "定值行(lo=hi)roll 恒等表值")
|
||
|
||
|
||
func _row_power(rid: String) -> int:
|
||
for row in _db.reward.rows:
|
||
if String(row.reward_id) == rid:
|
||
return int(row.power)
|
||
return -1
|
||
|
||
|
||
|
||
|
||
# ---- 行动经济·轮内刷新冒烟(2026-09-12 方案 A 用户拍板:Active 相位当场换图继续挖)----
|
||
|
||
func _smoke_active_refresh() -> void:
|
||
print("--- 行动经济·轮内刷新(方案 A)---")
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(20260912)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
# 计数器用容器(GDScript lambda 对局部 int 按值捕获——int 计数在 lambda 内加无效)
|
||
var ct := {"board_swaps": 0, "safe_events": 0, "safe_rows": 0, "waiting_refresh_rejected": true, "waiting_probe_hit": false}
|
||
settlement.board_changed.connect(func(): ct["board_swaps"] += 1)
|
||
settlement.minesweeper.cell_revealed.connect(func(evt):
|
||
if evt.kind == "safe" and evt.channel != "initial":
|
||
ct["safe_events"] += 1)
|
||
settlement.step_row.connect(func(row):
|
||
if row.source_class == "safe_cell":
|
||
ct["safe_rows"] += 1 # 结算流中 safe_cell 来源行(含兑付/未生效——每格必有行)
|
||
# 结算中(Waiting 相位——段内)发起刷新必须被拒(等待期换图通道 2026-09-15 整体移除的回归探针:
|
||
# 衰减行恒在每结算出现,借其在段内同步探测一次)
|
||
if String(row.get("type", "")) == "decay" and not bool(ct["waiting_probe_hit"]):
|
||
ct["waiting_probe_hit"] = true
|
||
ct["waiting_refresh_rejected"] = not settlement.player_request_refresh())
|
||
check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "轮内刷新战斗启动(Boss 行 150HP——承载多轮样本)")
|
||
var minesweeper = settlement.minesweeper
|
||
# 轮 1–3:零挖掘停手攒 RAP = 3×AP(每轮全转存;表驱动——2026-09-16 AP 压速批 8→6 不再写死)
|
||
for i in range(3):
|
||
minesweeper.settle_batch([{"action": "stop"}])
|
||
var rap_banked: int = 3 * int(_db.action_economy.ap_per_round)
|
||
check(settlement.round_index == 4 and settlement.action_economy.rap == rap_banked, "三轮停手攒 RAP=%d(round=%d rap=%d)" % [rap_banked, settlement.round_index, settlement.action_economy.rap])
|
||
check(settlement.action_economy.phase == "Active", "第 4 轮 Active 相位")
|
||
# 挖 1 格 → 刷 → 挖 1 格 → 刷 → 挖 1 格 → 第 3 刷拒(上限)
|
||
var plan = AutoPlayer.choose_dig(minesweeper)
|
||
if plan.hero_cell != minesweeper.hero_pos():
|
||
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
|
||
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
|
||
var ap_before: int = settlement.action_economy.ap
|
||
var board_before = minesweeper.current_board()
|
||
var swaps_before: int = ct["board_swaps"]
|
||
var ok1: bool = settlement.player_request_refresh()
|
||
check(ok1, "轮内第 1 次刷新受理")
|
||
check(settlement.action_economy.phase == "Active" and settlement.action_economy.ap == ap_before, "相位保持 Active、AP 保留不重置(ap=%d)" % settlement.action_economy.ap)
|
||
check(settlement.action_economy.rap == rap_banked - int(_db.action_economy.rap_refresh_cost), "RAP -刷新价(%d)" % settlement.action_economy.rap)
|
||
check(int(ct["board_swaps"]) == swaps_before + 1 and minesweeper.current_board() != board_before, "当场换新图(board_changed +1)")
|
||
var plan2 = AutoPlayer.choose_dig(minesweeper)
|
||
if plan2.hero_cell != minesweeper.hero_pos():
|
||
minesweeper.settle_batch([{"action": "move", "cell": plan2.hero_cell}])
|
||
minesweeper.settle_batch([{"action": "dig", "cell": plan2.dig_cell}])
|
||
var ok2: bool = settlement.player_request_refresh()
|
||
check(ok2, "轮内第 2 次刷新受理(RAP=%d)" % settlement.action_economy.rap)
|
||
var plan3 = AutoPlayer.choose_dig(minesweeper)
|
||
if plan3.hero_cell != minesweeper.hero_pos():
|
||
minesweeper.settle_batch([{"action": "move", "cell": plan3.hero_cell}])
|
||
minesweeper.settle_batch([{"action": "dig", "cell": plan3.dig_cell}])
|
||
var ok3: bool = settlement.player_request_refresh()
|
||
check(not ok3, "第 3 次刷新被拒(每轮兜底上限 2——RAP %d 足额仍拒)" % settlement.action_economy.rap)
|
||
# 跨图 facts 完整兑现:拒绝后挖到 AP 耗尽自动结算(扩大跨图样本——三段挖掘分属三张图)
|
||
var dig_guard := 20
|
||
while settlement.action_economy.phase == "Active" and settlement.round_index == 4 and dig_guard > 0:
|
||
dig_guard -= 1
|
||
var p = AutoPlayer.choose_dig(minesweeper)
|
||
if p.is_empty():
|
||
minesweeper.settle_batch([{"action": "stop"}])
|
||
break
|
||
if p.hero_cell != minesweeper.hero_pos():
|
||
minesweeper.settle_batch([{"action": "move", "cell": p.hero_cell}])
|
||
minesweeper.settle_batch([{"action": "dig", "cell": p.dig_cell}])
|
||
check(settlement.round_index == 5 and settlement.battle_result == "", "轮 4 挖至 AP 耗尽自动结算(round=%d result='%s')" % [settlement.round_index, settlement.battle_result])
|
||
check(int(ct["safe_rows"]) == int(ct["safe_events"]), "跨图 facts 完整兑现(safe_cell 结算行 %d == safe 事件 %d——快照批次隔离)" % [int(ct["safe_rows"]), int(ct["safe_events"])])
|
||
check(int(ct["safe_events"]) >= 3, "跨图挖掘 safe 事件 ≥3(%d——断言非空转)" % int(ct["safe_events"]))
|
||
check(bool(ct["waiting_refresh_rejected"]) and bool(ct["waiting_probe_hit"]), "结算段内(Waiting 相位)刷新被拒(等待期换图通道已移除)")
|
||
# 回放确定性:同种子两遍步骤流逐行相同(含两次轮内刷新取号序)
|
||
var rep1 = _active_refresh_replay()
|
||
var rep2 = _active_refresh_replay()
|
||
check(JSON.stringify(rep1) == JSON.stringify(rep2), "轮内刷新回放断言:同种子两遍步骤流逐行相同")
|
||
check(rep1.size() > 0, "轮内刷新步骤流非空(%d 行)" % rep1.size())
|
||
|
||
|
||
func _active_refresh_replay() -> Array:
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(20260912)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
var rows: Array = []
|
||
settlement.step_row.connect(func(row): rows.append(row))
|
||
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}):
|
||
return rows
|
||
var minesweeper = settlement.minesweeper
|
||
for i in range(3):
|
||
minesweeper.settle_batch([{"action": "stop"}])
|
||
for attempt in range(3):
|
||
var plan = AutoPlayer.choose_dig(minesweeper)
|
||
if not plan.is_empty():
|
||
if plan.hero_cell != minesweeper.hero_pos():
|
||
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
|
||
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
|
||
settlement.player_request_refresh()
|
||
minesweeper.settle_batch([{"action": "stop"}])
|
||
return rows
|
||
|
||
|
||
# ---- 难度曲线与Boss DifficultyTable 结构位冒烟(AC-10:空表零行为 + 套乘点 = 行值快照前)----
|
||
|
||
func _smoke_difficulty_table() -> void:
|
||
print("--- 难度曲线与Boss·DifficultyTable 结构位 ---")
|
||
check(_db.difficulty != null and _db.difficulty.rows.is_empty(), "DifficultyTable MVP 空表加载(结构位)")
|
||
# ① 空表零行为:敌人系统.spawn 敌值 = EnemyTable 行值原样
|
||
var enemy_system = load("res://core/unit_container.gd").new(_db)
|
||
check(enemy_system.spawn(1, 0, int(_db.hero.max_hp)), "spawn 受理")
|
||
var unit = enemy_system.all_enemies()[0]
|
||
var d0: Dictionary = _db.resolve_enemy_rowset(1, 0)
|
||
check(int(unit.hp) == int(d0.hp) and int(unit.atk) == int(d0.atk), "空表零修饰(hp=%d atk=%d = 行值 %d/%d)" % [int(unit.hp), int(unit.atk), int(d0.hp), int(d0.atk)])
|
||
# ② 修饰行运行时注入(debug 通道——R3 override 同族):套乘生效于 hp/atk
|
||
_db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 1.5, "atk_mult": 2.0})
|
||
var enemy_system_b = load("res://core/unit_container.gd").new(_db)
|
||
check(enemy_system_b.spawn(1, 0, int(_db.hero.max_hp)), "修饰后 spawn 受理")
|
||
var g2 = enemy_system_b.all_enemies()[0]
|
||
check(int(g2.hp) == int(round(float(d0.hp) * 1.5)) and int(g2.max_hp) == int(g2.hp), "hp 修饰 ×1.5(%d→%d,max_hp 同步)" % [int(d0.hp), int(g2.hp)])
|
||
check(int(g2.atk) == int(d0.atk) * 2, "atk 修饰 ×2.0(%d→%d)" % [int(d0.atk), int(g2.atk)])
|
||
# ③ 越域 clamp:乘数 50 → clamp 10
|
||
_db.difficulty.rows.clear()
|
||
_db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 50.0, "atk_mult": 1.0})
|
||
var enemy_system_c = load("res://core/unit_container.gd").new(_db)
|
||
check(enemy_system_c.spawn(1, 0, int(_db.hero.max_hp)), "越域修饰 spawn 受理")
|
||
check(int(enemy_system_c.all_enemies()[0].hp) == int(d0.hp) * 10, "越域乘数 clamp 至 10(%d→%d)" % [int(d0.hp), int(enemy_system_c.all_enemies()[0].hp)])
|
||
_db.difficulty.rows.clear() # 还原空表(后续冒烟/关卡断言基线)
|
||
|
||
|
||
# ---- 攻击类型 × 护甲克制 + 标错惩罚冒烟(2026-09-12 克制批:护甲快照/克制矩阵/充能/反噬三选一)----
|
||
|
||
func _smoke_damage_types() -> void:
|
||
print("--- 攻击类型 × 护甲克制 + 标错惩罚 ---")
|
||
# ① 护甲类型快照传播(spawn 行值 → 单位字典)
|
||
var enemy_system = load("res://core/unit_container.gd").new(_db)
|
||
check(enemy_system.spawn(1, 0, int(_db.hero.max_hp)) and String(enemy_system.all_enemies()[0].armor_type) == "light", "goblin 护甲 = light(轻甲——教学位)")
|
||
var enemy_system_b = load("res://core/unit_container.gd").new(_db)
|
||
check(enemy_system_b.spawn(1, 2, int(_db.hero.max_hp)) and String(enemy_system_b.all_enemies()[0].armor_type) == "light", "bugbear 护甲 = light(护甲类型重配批 2026-09-18:关卡 1 全轻甲)")
|
||
var enemy_system_c = load("res://core/unit_container.gd").new(_db)
|
||
check(enemy_system_c.spawn(1, 4, int(_db.hero.max_hp)) and String(enemy_system_c.all_enemies()[0].armor_type) == "light", "ogre_chief 护甲 = light(护甲类型重配批 2026-09-18:关卡 1 全轻甲)")
|
||
# ② 克制矩阵表值在位(2026-09-18 加法核换装:三档点数 克制 +1 / 中性 0 / 被克 −1——percent 量纲退役)
|
||
var am: Dictionary = _db.settlement.armor_effectiveness
|
||
check(int(am.normal.light) == 0 and int(am.normal.ward) == 1 and int(am.normal.heavy) == -1, "普通行:轻甲 0 / 魔法护甲 +1 / 重甲 −1(加法核)")
|
||
check(int(am.pierce.light) == -1 and int(am.pierce.ward) == 1 and int(am.pierce.heavy) == 0, "穿刺行:轻甲 −1 / 魔法护甲 +1 / 重甲 0 中性(用户拍板)")
|
||
check(int(am.magic.light) == 0 and int(am.magic.ward) == -1 and int(am.magic.heavy) == 1, "魔法行:轻甲 0 / 魔法护甲 −1 / 重甲 +1(加法核)")
|
||
# ③ 标错(类型化负向兑付)+ 标中正向兑付(2026-09-19 充能退役后标中旗只兑格面资源正向;Boss 行多轮样本;板真相直读保证 hit/miss 确定性)
|
||
var run1 = _run_flag_backlash_round(20260912)
|
||
var miss_rows := 0
|
||
var negative_rows := 0
|
||
var hit_rows := 0
|
||
for row in run1.rows:
|
||
if row.get("type", "") == "flag_miss":
|
||
miss_rows += 1
|
||
if String(row.get("source_class", "")) == "flag_miss" and String(row.get("type", "")) in ["effect", "damage"]:
|
||
negative_rows += 1 # 标错的负向兑付行(该安全格资源的负向字典行——effect/直伤)
|
||
if String(row.get("source_class", "")) == "flag_hit":
|
||
hit_rows += 1 # 标中的正向兑付行(该雷格资源的正向字典行——2026-09-19 充能退役后标中只走格面资源正向)
|
||
check(miss_rows >= 1, "标错旗产生 flag_miss 归因行(×%d)" % miss_rows)
|
||
check(negative_rows >= 1, "标错旗兑付该安全格资源的负向字典行(×%d——attack/defense/weaken 效果行或 heal 直伤行)" % negative_rows)
|
||
check(hit_rows >= 1, "标中旗兑付该雷格资源的正向字典行(×%d——充能退役后无魔法分量,正向=格面资源)" % hit_rows)
|
||
# ④ 同种子回放(极性路由零随机——同种子逐行相同)
|
||
var run2 = _run_flag_backlash_round(20260912)
|
||
check(JSON.stringify(run1.rows) == JSON.stringify(run2.rows), "标错惩罚轮回放断言:同种子步骤流逐行相同(类型化负向兑付确定性)")
|
||
|
||
|
||
# 构造轮:标 1 面"安全格旗"(必 miss → 该格资源负向兑付)+ 标 1 面"雷旗"(必 hit → 正向兑付 + 充能)后停手收轮。
|
||
# 返回 flagged safe 格与其 cell_type(Ready 域内查询——类型化断言的数据面)。
|
||
func _run_flag_backlash_round(seed_val: int) -> Dictionary:
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(seed_val)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
var rows: Array = []
|
||
settlement.step_row.connect(func(row): rows.append(row))
|
||
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}):
|
||
return {"rows": rows, "safe_cell": Vector2i(-1, -1), "safe_type": ""}
|
||
var minesweeper = settlement.minesweeper
|
||
var safe_t := _find_flag_target(minesweeper, false)
|
||
var mine_t := _find_flag_target(minesweeper, true)
|
||
var safe_type := ""
|
||
if not safe_t.is_empty():
|
||
var ident = settlement.query_reward_identity(safe_t.flag_cell)
|
||
safe_type = String(ident.get("cell_type", "")) if ident != null else ""
|
||
if safe_t.hero_cell != minesweeper.hero_pos():
|
||
minesweeper.settle_batch([{"action": "move", "cell": safe_t.hero_cell}])
|
||
minesweeper.settle_batch([{"action": "flag_place", "cell": safe_t.flag_cell}])
|
||
if not mine_t.is_empty():
|
||
if mine_t.hero_cell != minesweeper.hero_pos():
|
||
minesweeper.settle_batch([{"action": "move", "cell": mine_t.hero_cell}])
|
||
minesweeper.settle_batch([{"action": "flag_place", "cell": mine_t.flag_cell}])
|
||
minesweeper.settle_batch([{"action": "stop"}])
|
||
return {"rows": rows, "safe_cell": safe_t.get("flag_cell", Vector2i(-1, -1)), "safe_type": safe_type}
|
||
|
||
|
||
# 板真相直读找旗目标:未开 + 指定雷/安全属性 + 存在可达已开邻格(测试允许读板真相——排雷玩法 冒烟同口径)
|
||
func _find_flag_target(minesweeper, want_mine: bool) -> Dictionary:
|
||
var revealed: Array = minesweeper.revealed_cells()
|
||
var revealed_set := {}
|
||
for c in revealed:
|
||
revealed_set[c] = true
|
||
for y in minesweeper.current_board().height:
|
||
for x in minesweeper.current_board().width:
|
||
var c := Vector2i(x, y)
|
||
if minesweeper.is_revealed(c) or minesweeper.has_flag(c):
|
||
continue
|
||
if minesweeper.current_board().is_mine(c) != want_mine:
|
||
continue
|
||
for n in minesweeper.current_board().neighbors8(c):
|
||
if revealed_set.has(n) and (minesweeper.compute_move_path(n).size() > 0 or n == minesweeper.hero_pos()):
|
||
return {"flag_cell": c, "hero_cell": n}
|
||
return {}
|
||
|
||
|
||
# ---- 插旗不可逆四情况冒烟(2026-09-19 用户拍板全套落地:禁拔旗 + 挖旗格兑付改判)----
|
||
# 四情况:① 插雷格不动 = 标中奖励照常;② 插雷格再挖开 = 只受挖雷一层惩罚(mine_hit 照常)标中不发;
|
||
# ③ 插安全格不动 = 标空惩罚照常;④ 插安全格再挖开 = 零惩罚零奖励(正向兑付作废、标空不判)。
|
||
# 退役面:flag_pull 出目录(out_of_catalog);重复插旗仍 flag_present 拒绝。
|
||
# 场景 A(④ + 退役面):标安全格 → flag_pull 拒绝 → 重复插旗拒绝 → 直接挖开 → 收轮。
|
||
# 场景 B(②):标雷格 → 直接挖开 → 收轮。
|
||
# 场景 C(①③):标安全格 + 标雷格 → 不挖收轮 → 标空/标中判定照常。
|
||
func _smoke_flag_irreversible_quadrants() -> void:
|
||
print("--- 插旗不可逆四情况(禁拔旗 + 挖旗格兑付改判)---")
|
||
# 场景 A:安全格直挖(情况④)+ flag_pull/重复插旗退役面
|
||
var rng_a = load("res://core/rng_service.gd").new()
|
||
rng_a.set_master_seed(20260919)
|
||
var settle_a = load("res://core/battle_orchestrator.gd").new(_db, rng_a)
|
||
var rows_a: Array = []
|
||
settle_a.step_row.connect(func(row): rows_a.append(row))
|
||
check(settle_a.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "四情况 A:开局受理")
|
||
var ms_a = settle_a.minesweeper
|
||
var t_a := _find_flag_target(ms_a, false)
|
||
check(not t_a.is_empty(), "四情况 A:找到安全格旗目标")
|
||
if not t_a.is_empty():
|
||
if t_a.hero_cell != ms_a.hero_pos():
|
||
ms_a.settle_batch([{"action": "move", "cell": t_a.hero_cell}])
|
||
check(ms_a.settle_batch([{"action": "flag_place", "cell": t_a.flag_cell}])[0].accepted, "四情况 A:插旗受理")
|
||
var pull_out: Array = ms_a.settle_batch([{"action": "flag_pull", "cell": t_a.flag_cell}])
|
||
check(not pull_out.is_empty() and not bool(pull_out[0].get("accepted", true)) and String(pull_out[0].get("sub_reason", "")) == "out_of_catalog",
|
||
"四情况 A:flag_pull 出目录拒绝(拔旗退役)")
|
||
var dup_place: Array = ms_a.settle_batch([{"action": "flag_place", "cell": t_a.flag_cell}])
|
||
check(not dup_place.is_empty() and not bool(dup_place[0].get("accepted", true)) and String(dup_place[0].get("sub_reason", "")) == "flag_present",
|
||
"四情况 A:重复插旗仍被拒(flag_present)")
|
||
var dig_a: Array = ms_a.settle_batch([{"action": "dig", "cell": t_a.flag_cell}])
|
||
check(not dig_a.is_empty() and bool(dig_a[0].get("accepted", false)), "四情况 A:旗格直接挖开被受理(不再须先拔旗)")
|
||
check(not ms_a.has_flag(t_a.flag_cell), "四情况 A:旗随挖掘隐式退役(has_flag = false)")
|
||
ms_a.settle_batch([{"action": "stop"}])
|
||
var target_a: Array = [t_a.flag_cell.x, t_a.flag_cell.y]
|
||
var miss_a := 0
|
||
var grant_a := 0
|
||
var void_a := 0
|
||
for row in rows_a:
|
||
if row.get("origin_cell", [0, 0]) != target_a:
|
||
continue
|
||
if row.get("type", "") == "flag_miss" or String(row.get("source_class", "")) == "flag_miss":
|
||
miss_a += 1
|
||
if String(row.get("source_class", "")) == "safe_cell" and row.get("type", "") != "reward_ineffective":
|
||
grant_a += 1
|
||
if row.get("type", "") == "reward_ineffective":
|
||
void_a += 1
|
||
check(miss_a == 0, "四情况 A:标空判定零兑付(×%d——旗已擦除不入轮末基线)" % miss_a)
|
||
check(grant_a == 0, "四情况 A:安全格正向兑付作废(×%d——零奖励)" % grant_a)
|
||
check(void_a == 1, "四情况 A:兑付作废归因行恰一次(×%d——零惩罚零奖励留痕)" % void_a)
|
||
# 场景 B:雷格直挖(情况②)
|
||
var rng_b = load("res://core/rng_service.gd").new()
|
||
rng_b.set_master_seed(20260919)
|
||
var settle_b = load("res://core/battle_orchestrator.gd").new(_db, rng_b)
|
||
var rows_b: Array = []
|
||
settle_b.step_row.connect(func(row): rows_b.append(row))
|
||
check(settle_b.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "四情况 B:开局受理")
|
||
var ms_b = settle_b.minesweeper
|
||
var t_b := _find_flag_target(ms_b, true)
|
||
check(not t_b.is_empty(), "四情况 B:找到雷格旗目标")
|
||
if not t_b.is_empty():
|
||
if t_b.hero_cell != ms_b.hero_pos():
|
||
ms_b.settle_batch([{"action": "move", "cell": t_b.hero_cell}])
|
||
check(ms_b.settle_batch([{"action": "flag_place", "cell": t_b.flag_cell}])[0].accepted, "四情况 B:插旗受理")
|
||
var dig_b: Array = ms_b.settle_batch([{"action": "dig", "cell": t_b.flag_cell}])
|
||
check(not dig_b.is_empty() and bool(dig_b[0].get("accepted", false)), "四情况 B:旗雷格直接挖开被受理")
|
||
check(ms_b.is_revealed(t_b.flag_cell) and ms_b.current_board().is_mine(t_b.flag_cell), "四情况 B:雷格已揭示(挖雷本体照常)")
|
||
ms_b.settle_batch([{"action": "stop"}])
|
||
var target_b: Array = [t_b.flag_cell.x, t_b.flag_cell.y]
|
||
var mine_hit_b := 0
|
||
var hit_b := 0
|
||
for row in rows_b:
|
||
if row.get("origin_cell", [0, 0]) != target_b:
|
||
continue
|
||
if String(row.get("source_class", "")) == "mine_hit":
|
||
mine_hit_b += 1
|
||
if row.get("type", "") == "flag_hit" or String(row.get("source_class", "")) == "flag_hit":
|
||
hit_b += 1
|
||
check(mine_hit_b >= 1, "四情况 B:挖雷惩罚照常兑付(×%d——只受挖雷一层惩罚)" % mine_hit_b)
|
||
check(hit_b == 0, "四情况 B:标中不发(×%d——旗已擦除不判)" % hit_b)
|
||
# 场景 C:不挖收轮(①③判定照常)
|
||
var rng_c = load("res://core/rng_service.gd").new()
|
||
rng_c.set_master_seed(20260919)
|
||
var settle_c = load("res://core/battle_orchestrator.gd").new(_db, rng_c)
|
||
var rows_c: Array = []
|
||
settle_c.step_row.connect(func(row): rows_c.append(row))
|
||
check(settle_c.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "四情况 C:开局受理")
|
||
var ms_c = settle_c.minesweeper
|
||
var t_safe := _find_flag_target(ms_c, false)
|
||
var t_mine := _find_flag_target(ms_c, true)
|
||
check(not t_safe.is_empty() and not t_mine.is_empty(), "四情况 C:安全格+雷格旗目标齐备")
|
||
if not t_safe.is_empty() and not t_mine.is_empty():
|
||
for t in [t_safe, t_mine]:
|
||
if t.hero_cell != ms_c.hero_pos():
|
||
ms_c.settle_batch([{"action": "move", "cell": t.hero_cell}])
|
||
ms_c.settle_batch([{"action": "flag_place", "cell": t.flag_cell}])
|
||
ms_c.settle_batch([{"action": "stop"}]) # 不挖收轮——两旗均在场基线
|
||
var safe_c: Array = [t_safe.flag_cell.x, t_safe.flag_cell.y]
|
||
var mine_c: Array = [t_mine.flag_cell.x, t_mine.flag_cell.y]
|
||
var miss_c := 0
|
||
var miss_neg_c := 0
|
||
var hit_c := 0
|
||
for row in rows_c:
|
||
if row.get("origin_cell", [0, 0]) == safe_c and row.get("type", "") == "flag_miss":
|
||
miss_c += 1
|
||
if row.get("origin_cell", [0, 0]) == safe_c and String(row.get("source_class", "")) == "flag_miss" and row.get("type", "") != "flag_miss":
|
||
miss_neg_c += 1
|
||
if row.get("origin_cell", [0, 0]) == mine_c and (row.get("type", "") == "flag_hit" or String(row.get("source_class", "")) == "flag_hit"):
|
||
hit_c += 1
|
||
check(miss_c >= 1 and miss_neg_c >= 1, "四情况 C:标空惩罚照常(归因×%d 负向兑付×%d)" % [miss_c, miss_neg_c])
|
||
check(hit_c >= 1, "四情况 C:标中奖励照常(×%d)" % hit_c)
|
||
|
||
|
||
# ---- 试玩日志器冒烟(结算快照 JSONL + 诊断转储)----
|
||
func _smoke_playtest_logger() -> void:
|
||
print("--- PlaytestLogger(结算快照 JSONL + 诊断转储)---")
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(20260912)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
var logger = load("res://presentation/playtest_logger.gd").new()
|
||
var path: String = logger.start_session(20260912)
|
||
logger.attach_battle(settlement)
|
||
var snaps: Array = []
|
||
settlement.settlement_snapshot.connect(func(snap): snaps.append(snap))
|
||
check(path.contains("playtest_20260912"), "会话文件开启(%s)" % path)
|
||
check(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}), "快照战斗启动")
|
||
var minesweeper = settlement.minesweeper
|
||
for i in range(2):
|
||
minesweeper.settle_batch([{"action": "stop"}])
|
||
check(snaps.size() == 2, "每结算一快照(%d 次结算 → %d 快照)" % [2, snaps.size()])
|
||
var s1: Dictionary = snaps[0]
|
||
var required := ["kind", "level", "depth", "round", "verdict", "hero", "economy", "board", "enemies", "buffs", "round_facts", "spawn_count"]
|
||
var missing := []
|
||
for k in required:
|
||
if not s1.has(k):
|
||
missing.append(k)
|
||
check(missing.is_empty(), "快照字段齐备(缺 %s)" % str(missing))
|
||
check(int(s1.hero.max_hp) == int(_db.hero.max_hp) and s1.economy.has("phase") and s1.board.has("seq"), "快照含英雄/经济/板序号(板 seq=%d)" % int(s1.board.seq))
|
||
# spawn_count 回归(playtest-2026-09-12 报告 Bug#1):停手两轮零击杀(主角攻击 6×2 < 24 HP 确定)→
|
||
# unit_count 必须 == spawn_count(全灭才允许 <);快照级区分「首轮全灭」vs「空场」的数据面在位
|
||
check(int(s1.get("spawn_count", -1)) >= 1 and int(s1.get("spawn_count", -1)) == int(s1.get("unit_count", -2)),
|
||
"快照 spawn_count 在位且 == unit_count(无击杀轮:%d/%d——全灭/空场可判别)" % [int(s1.get("spawn_count", -1)), int(s1.get("unit_count", -1))])
|
||
logger.log_action("dig", Vector2i(1, 1), true)
|
||
var diag: String = logger.dump_diagnosis({"test": true})
|
||
check(diag.contains("diag_"), "诊断转储文件生成(%s)" % diag)
|
||
# JSONL 文件内容验证(重新读取)
|
||
var f := FileAccess.open(path, FileAccess.READ)
|
||
var lines := 0
|
||
var parse_ok := true
|
||
while not f.eof_reached():
|
||
var line := f.get_line()
|
||
if line.strip_edges() == "":
|
||
continue
|
||
lines += 1
|
||
var parsed = JSON.parse_string(line)
|
||
if parsed == null:
|
||
parse_ok = false
|
||
check(lines >= 3 and parse_ok, "JSONL 全部行可解析(%d 行 = session + 2 结算)" % lines)
|
||
logger.close_session()
|
||
|
||
|
||
# ---- 遭遇随机化冒烟(难度曲线与Boss Core Rule 8:区间骰/encounter 流/可回放/空表定值路径)----
|
||
|
||
func _smoke_encounter_roll() -> void:
|
||
print("--- 遭遇随机化(unit_count 区间骰)---")
|
||
var counts: Array = []
|
||
for i in range(20):
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(30000 + i)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
check(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}), "遭遇扫描战斗启动(quiet 计数档——计数入总账,成败由后续 spawn 断言归因)", true)
|
||
counts.append(settlement.presentation_snapshot().enemies.size())
|
||
# 区间生效:20 个种子应出现 1 和 2(D0 [1,2])
|
||
check(counts.has(1) and counts.has(2), "D0 区间 [1,2] 两值均出现(分布 %s)" % str(counts))
|
||
# 域内断言带标签(原恒真 check(true) 已废——断言必须能抓住失效,code-review 2026-09-12)
|
||
var all_in_domain := true
|
||
for c in counts:
|
||
if c < 1 or c > 2:
|
||
all_in_domain = false
|
||
check(all_in_domain, "全部计数在域内 [1,2](分布 %s)" % str(counts))
|
||
# 同种子可回放:两遍同数量(encounter 流确定性)
|
||
var r1 := _encounter_replay(30000)
|
||
var r2 := _encounter_replay(30000)
|
||
check(r1 == r2, "同种子遭遇骰可回放(%s == %s)" % [str(r1), str(r2)])
|
||
# D1 [2,3]:seed 30005 路径(任意种子域内即可)
|
||
var rng3 = load("res://core/rng_service.gd").new()
|
||
rng3.set_master_seed(31000)
|
||
var settlement_d1 = load("res://core/battle_orchestrator.gd").new(_db, rng3)
|
||
check(settlement_d1.begin_battle({"level_index": 1, "node_depth": 1, "hero_hp": int(_db.hero.max_hp)}), "D1 战斗启动")
|
||
var n1: int = settlement_d1.presentation_snapshot().enemies.size()
|
||
check(n1 >= 2 and n1 <= 3, "D1 区间 [2,3] 域内(%d 只)" % n1)
|
||
# D2 固定(教学位不加骰)
|
||
var rng4 = load("res://core/rng_service.gd").new()
|
||
rng4.set_master_seed(31000)
|
||
var settlement_d2 = load("res://core/battle_orchestrator.gd").new(_db, rng4)
|
||
settlement_d2.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)})
|
||
check(settlement_d2.presentation_snapshot().enemies.size() == 1, "D2 熊地精固定 1(首遇教学位)")
|
||
# 全深度 × 多种子域内扫描(playtest-2026-09-12 报告 Bug#1 回归——区间下限 1 不可掷 0:
|
||
# 战斗结算 roll_lo 下限 + 敌人系统 spawn count<1 fail-fast 双闸;单深度单种子是盲区,全域扫描才钉死不变式)
|
||
var depths: Array = []
|
||
for node in _level_battle_nodes(1):
|
||
depths.append(int(node.get("node_depth", -1)))
|
||
var scan_bad := 0
|
||
var scan_total := 0
|
||
for i in range(12):
|
||
var rngs = load("res://core/rng_service.gd").new()
|
||
rngs.set_master_seed(32000 + i)
|
||
for depth_v in depths:
|
||
scan_total += 1
|
||
var settlement_scan = load("res://core/battle_orchestrator.gd").new(_db, rngs)
|
||
if not settlement_scan.begin_battle({"level_index": 1, "node_depth": int(depth_v), "hero_hp": int(_db.hero.max_hp)}):
|
||
scan_bad += 1
|
||
continue
|
||
var n: int = settlement_scan.presentation_snapshot().enemies.size()
|
||
var erow2: Dictionary = _db.resolve_enemy_rowset(1, int(depth_v))
|
||
var lo2: int = int(erow2.get("unit_count", 1))
|
||
var hi2: int = int(erow2.get("unit_count_max", lo2))
|
||
if n < 1 or n < lo2 or n > hi2:
|
||
scan_bad += 1
|
||
check(scan_bad == 0, "全深度 × 12 种子 spawn 全域内(%d 场,∈[unit_count, unit_count_max] 且 ≥1)——空遭遇不可达(Bug#1 回归)" % scan_total)
|
||
|
||
|
||
# 模板感知取节点表(M-3 迭代 5 T5——row.templates 非空取模板 0=现行固定图,保持既有断言语义)
|
||
func _level_active_nodes(level_index: int) -> Array:
|
||
for row in _db.level.rows:
|
||
if int(row.level_index) == level_index:
|
||
var tpls: Array = row.get("templates", [])
|
||
if not tpls.is_empty():
|
||
return tpls[0].get("nodes", [])
|
||
return row.get("nodes", [])
|
||
return []
|
||
|
||
|
||
func _level_battle_nodes(level_index: int) -> Array:
|
||
var out: Array = []
|
||
for row in _db.level.rows:
|
||
if int(row.level_index) == level_index:
|
||
for node in _level_active_nodes(level_index):
|
||
var t := String(node.get("type", ""))
|
||
if t == "battle" or t == "boss":
|
||
out.append(node)
|
||
return out
|
||
|
||
|
||
# ---- 战斗段 fresh 池攻击链回归(playtest-2026-09-12 报告 Bug#2 沉淀——
|
||
# 「Boss 攻击拍零兑现」实为旧 defense_up 数据 ×2 叠满 = 池触底 ×0(设计语义);本断言钉死:
|
||
# 池为空时攻击链必须按掷点档位足额兑现——2026-09-16 护甲等级掷点批改口径:
|
||
# d_tick = max(damage_floor, pool_damage(floor(基线×档位%), 0, 0)),档位读数随行重建可归因)----
|
||
|
||
func _smoke_attack_chain_fresh_pool() -> void:
|
||
print("--- 战斗结算·fresh 池攻击链回归(Bug#2·掷点口径)---")
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(20260913)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
var ticks: Array = [] # {baseline, attach, roll, tier, crit, tick}——敌行动伤害行重建
|
||
settlement.step_row.connect(func(row):
|
||
if row.type == "damage" and row.source_class == "enemy_action":
|
||
ticks.append({"baseline": int(row.baseline), "attach": int(row.get("attach", 0)), "roll": int(row.roll),
|
||
"tier": int(row.tier), "crit": bool(row.get("crit", false)), "tick": int(row.value)}))
|
||
check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "fresh 池回归战斗启动(Boss 行)")
|
||
# 停手轮:零挖掘零旗 → 零授予 → 池恒空;Boss 拍 = 开轮摇号结果(伤害技/普攻兑付伤害行、硬化施放行不计)
|
||
for i in range(6):
|
||
if settlement.battle_result != "":
|
||
break
|
||
settlement.minesweeper.settle_batch([{"action": "stop"}])
|
||
check(ticks.size() >= 2, "非施放攻击拍采样充足(%d 拍——增益施放行零兑付为设计语义不计)" % ticks.size())
|
||
var all_full := true
|
||
for t in ticks:
|
||
var base_factor: float = float(t.tier) * (float(_db.settlement.crit_multiplier) / 100.0 if t.crit else 1.0)
|
||
var rolled_base := int(floor(float(t.baseline) * base_factor / 100.0))
|
||
var expected: int = maxi(int(_db.settlement.damage_floor), rolled_base + int(t.get("attach", 0)))
|
||
if int(t.tick) != expected or int(t.tick) <= 0:
|
||
all_full = false
|
||
check(all_full, "fresh 池非蓄力拍 d_tick = max(1, 掷点档位基线)(Boss 攻击链健康——Bug#2 回归断言)")
|
||
|
||
func _encounter_replay(seed_val: int) -> Array:
|
||
var out: Array = []
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(seed_val)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
|
||
out.append(settlement.presentation_snapshot().enemies.size())
|
||
return out
|
||
|
||
|
||
# ==================== 清图奖励双份兑付链 / 轮内刷新 provenance 守卫 / 窗口锚行(2026-09-15 补覆盖批) ====================
|
||
# 清图 = 全安全格开完。清图测试用"真雷旗(零误标→完美排雷)+ AutoPlayer 逐轮清图"真实流驱动;
|
||
# DifficultyTable 注入 hp_mult=10 把 Boss 血量抬到 1500,保证清图批次发生在战斗结束之前(测试台账,跑完即还原)。
|
||
|
||
const CLEAR_SMOKE_DEPTH := 4
|
||
const CLEAR_SMOKE_HERO_HP := 100000
|
||
var _regular_clear_reward_rows := 0 # 常规(非刷新图)清图的 clear_reward 行数——刷新守卫对照基线
|
||
|
||
|
||
func _smoke_clear_reward_chain() -> void:
|
||
print("--- 清图奖励·双份兑付链(完美排雷 / 零兑付 / 第二份被拒 / 高光记实)---")
|
||
# ① 真实流完美排雷:标中一面真雷旗 + 零误标 + 清全图 → grant_clear 身份(clear_blast)双份真实兑付
|
||
var rows: Array = []
|
||
var run := _run_full_clear(20260915, false, rows)
|
||
check(bool(run.cleared), "① 完美排雷清图达成(%d 轮)" % int(run.rounds))
|
||
var dmg := []
|
||
for r in rows:
|
||
if String(r.get("type", "")) == "damage" and String(r.get("source_class", "")) == "clear_reward":
|
||
dmg.append(r)
|
||
var hl := _rows_of_type(rows, "highlight")
|
||
_regular_clear_reward_rows = dmg.size()
|
||
check(dmg.size() == 2, "① 清图奖励双份真实兑付(clear_reward 伤害行 ×%d——clear_blast 身份两份各打一发)" % dmg.size())
|
||
if dmg.size() == 2:
|
||
check(String(dmg[0].display_name).contains("清图奖励") and String(dmg[1].display_name).contains("完美第二份"),
|
||
"① 第二份按身份类型走真实兑付(首份『清图奖励』/ 第二份『完美第二份』)")
|
||
check(hl.size() == 1 and int(hl[0].value) == 2, "④ 高光行 value = 实际有效份数 2(不记名义值;行数 %d)" % hl.size())
|
||
if hl.size() == 1:
|
||
check(String(hl[0].get("highlight", "")) == "perfect_sweep", "高光标记 = perfect_sweep")
|
||
# ②/③ 弱形式:正常战斗流不可达的零兑付分支(见函数头注)
|
||
_smoke_clear_reward_zero_paths()
|
||
|
||
|
||
# 弱形式登记:正常战斗流下"授段时刻无存活敌"不可达——敌全灭当轮判定段即判胜并终止战斗,而清图批在判定段之前,
|
||
# 恒有存活敌;故本组直调编排私有面构造状态,只断言"路径存在 + 行为符合契约"(构造手法见各行注释)。
|
||
func _smoke_clear_reward_zero_paths() -> void:
|
||
# ②-a 首份零兑付判定(无存活敌)→ first_ok=false + 未生效行
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(20260915)
|
||
var s = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
var rows: Array = []
|
||
s.step_row.connect(func(row): rows.append(row))
|
||
check(s.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80}),
|
||
"零兑付构造战斗启动")
|
||
for u in s._enemy_system.all_enemies():
|
||
s._enemy_system.apply_damage(u, int(u.hp)) # 测试通道:构造"无存活敌"(弱形式——全灭而非 despawn)
|
||
var snap = s._reward_economy.grant_clear()
|
||
check(snap != null and String(snap.cell_type) == "blast",
|
||
"清图槽身份 = blast 型(clear_default 唯一活行 clear_blast——第二份同身份兑付)")
|
||
rows.clear()
|
||
var first_ok: bool = s._grant_clear_identity(snap, 1, "(清图奖励)")
|
||
check(not first_ok, "② 即时类首份零兑付(无存活敌)→ first_ok=false")
|
||
check(_has_source_row(rows, "reward_ineffective", "clear_reward", "clear_blast"),
|
||
"② 零兑付产出 reward_ineffective 行(batch_key=clear_blast)")
|
||
# ②-b 高光守卫:perfect 谓词成立但首份零兑付 → 高光行不发(不记名义值)
|
||
var rng_b = load("res://core/rng_service.gd").new()
|
||
rng_b.set_master_seed(20260915)
|
||
var s_b = load("res://core/battle_orchestrator.gd").new(_db, rng_b)
|
||
var rows_b: Array = []
|
||
s_b.step_row.connect(func(row): rows_b.append(row))
|
||
check(s_b.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80}),
|
||
"高光守卫构造战斗启动")
|
||
for u in s_b._enemy_system.all_enemies():
|
||
s_b._enemy_system.apply_damage(u, int(u.hp))
|
||
s_b._perfect_hit_true = 1 # 测试通道:构造完美谓词成立(真值在正常流由旗判定累加)
|
||
s_b._perfect_hit_false = 0
|
||
rows_b.clear()
|
||
s_b._grant_clear_reward(1)
|
||
check(_has_source_row(rows_b, "reward_ineffective", "clear_reward", "clear_blast"),
|
||
"② _grant_clear_reward 守卫路径重走首份 → 未生效行仍在案")
|
||
check(_rows_of_type(rows_b, "highlight").is_empty(), "② 首份零兑付 → 高光行不发(不记名义值 2)")
|
||
# ③ 第二份被拒:唯一存活敌 HP ≤ 首份 aoe 量 → 首份击杀 → 第二份无存活敌(zero + 未生效行)
|
||
var rng2 = load("res://core/rng_service.gd").new()
|
||
rng2.set_master_seed(20260915)
|
||
var s2 = load("res://core/battle_orchestrator.gd").new(_db, rng2)
|
||
var rows2: Array = []
|
||
s2.step_row.connect(func(row): rows2.append(row))
|
||
check(s2.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80}),
|
||
"第二份被拒构造战斗启动")
|
||
var boss = s2._enemy_system.all_enemies()[0]
|
||
boss.hp = 1 # 测试通道:压低至首份 aoe 必杀(弱形式——正常流 Boss 血量不可控)
|
||
s2._perfect_hit_true = 1
|
||
s2._perfect_hit_false = 0
|
||
rows2.clear()
|
||
s2._grant_clear_reward(1)
|
||
check(_has_source_row(rows2, "damage", "clear_reward"), "③ 首份 aoe 真实兑付(击杀唯一存活敌)")
|
||
check(_has_source_row(rows2, "reward_ineffective", "clear_reward", "clear_blast"),
|
||
"③ 第二份被拒产出 reward_ineffective 行(零兑付)")
|
||
var hl2 := _rows_of_type(rows2, "highlight")
|
||
check(hl2.size() == 1 and int(hl2[0].value) == 1, "④ 高光行 value = 实际有效份数 1(不记名义值 2)")
|
||
|
||
|
||
# 轮内刷新 provenance 守卫:刷新换来的图同轮清图不发清图奖励(_board_from_midround_refresh 守卫)
|
||
func _smoke_refresh_provenance_guard() -> void:
|
||
print("--- 轮内刷新 provenance 守卫(刷新图清图不发清图奖励)---")
|
||
var rows: Array = []
|
||
var run := _run_full_clear(20260916, true, rows)
|
||
check(bool(run.refreshed), "轮内刷新受理(三轮停手攒 RAP 后 Active 相位发起)")
|
||
check(bool(run.cleared), "刷新图清图达成(%d 轮)" % int(run.rounds))
|
||
var cr := 0
|
||
var hl := 0
|
||
for r in rows:
|
||
if String(r.get("source_class", "")) == "clear_reward":
|
||
cr += 1
|
||
if String(r.get("type", "")) == "highlight":
|
||
hl += 1
|
||
check(cr == 0, "刷新图清图 → 无 clear_reward 行(守卫在位——实测 %d 行)" % cr)
|
||
check(hl == 0, "刷新图清图 → 无高光行(守卫在位——实测 %d 行)" % hl)
|
||
check(_regular_clear_reward_rows > 0, "对照基线:常规换图清图有 clear_reward 行(×%d——守卫非全局静默)" % _regular_clear_reward_rows)
|
||
|
||
|
||
# ==================== 格面资源批新增覆盖(极性路由 / 负向字典 / 资源面 / 五段全序) ====================
|
||
# 极性路由断言(战斗结算 验收):挖安全格/标中旗只出正向、挖雷格/标错旗只出负向、兑付行指回来源格。
|
||
# 正向字典效果集 = {attack_up, ac_up, weaken}(ac_up = 2026-09-16 护甲等级掷点批换装,原 defense_up)
|
||
# + 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"]
|
||
|
||
|
||
# 构造验收谓词(hunt 用——并流批后种子按行为面扫描:四触达 + 标空旗负向兑付行在案)
|
||
func _polarity_probe_accepts(probe: Dictionary) -> bool:
|
||
if not (bool(probe["flag_hit_done"]) and bool(probe["flag_miss_done"])):
|
||
return false
|
||
if int(probe["mine_events"]) < 1 or int(probe["safe_events"]) < 1:
|
||
return false
|
||
var miss_rows := _rows_at(probe["rows"], "flag_miss", probe["miss_cell"])
|
||
if miss_rows.is_empty():
|
||
return false
|
||
for r in miss_rows:
|
||
if String(r.get("type", "")) == "flag_miss":
|
||
continue
|
||
var eff := String(r.get("effect_id", ""))
|
||
if eff != "" and not NEGATIVE_EFFECTS.has(eff):
|
||
return false
|
||
return true
|
||
|
||
|
||
func _smoke_polarity_routing() -> void:
|
||
print("--- 极性路由(挖安全格正向 / 挖雷格负向 / 标中正向 / 标空负向)---")
|
||
# 并流批(ADR-0007 裁丙)后战斗随机值面漂移——构造种子改行为面扫描(谓词见 _polarity_probe_accepts)
|
||
var probe := {}
|
||
var polarity_seed := -1
|
||
for s in [20260918, 20260919, 20260920, 20260921, 42, 4242, 7, 100, 20260922, 123, 2024, 31337, 555, 1111, 2222, 20260923, 999, 777]:
|
||
probe = _run_polarity_probe(s)
|
||
if _polarity_probe_accepts(probe):
|
||
polarity_seed = s
|
||
break
|
||
check(polarity_seed > 0, "极性路由构造种子可达(seed=%d——并流后行为面重锚)" % polarity_seed)
|
||
check(bool(probe["flag_hit_done"]) and bool(probe["flag_miss_done"]), "构造触达:标中真雷 + 标空安全格均入队")
|
||
check(int(probe["mine_events"]) >= 1, "构造触达:挖到雷格 ≥1(%d)" % int(probe["mine_events"]))
|
||
check(int(probe["safe_events"]) >= 1, "构造触达:挖到安全格 ≥1(%d)" % int(probe["safe_events"]))
|
||
# 挖到的安全格:只出正向——其全部来源行必存在且 effect 行 ∈ 正向集
|
||
# (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]
|
||
var rows := _rows_at(probe["rows"], "safe_cell", cell_arr)
|
||
if rows.is_empty():
|
||
safe_all_pos = false # 每个资源格必有 ≥1 兑付行(每格必有货 → 每格必有行)
|
||
for r in rows:
|
||
var eff := String(r.get("effect_id", ""))
|
||
if eff == "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 行;
|
||
# action 型排队行同按行值符号辨极性:雷格恒 −power)
|
||
var mine_all_neg := true
|
||
for e in probe["mine_cells"].keys():
|
||
var cell_arr: Array = [e.x, e.y]
|
||
var rows := _rows_at(probe["rows"], "mine_hit", cell_arr)
|
||
if rows.is_empty():
|
||
mine_all_neg = false
|
||
for r in rows:
|
||
var eff := String(r.get("effect_id", ""))
|
||
if eff == "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())
|
||
# 标中旗:该雷格正向兑付行(flag_hit 来源)+ 效果 ∈ 正向字典
|
||
var hit_ok := false
|
||
for r in probe["rows"]:
|
||
if String(r.get("source_class", "")) == "flag_hit" and r.get("origin_cell", [0, 0]) == probe["hit_cell"]:
|
||
hit_ok = true
|
||
var eff := String(r.get("effect_id", ""))
|
||
if eff != "" and not POSITIVE_EFFECTS.has(eff):
|
||
hit_ok = false
|
||
check(hit_ok, "标中旗 → 该雷格资源的正向兑付行(origin 指回雷格,效果 ∈ 正向字典)")
|
||
# 条款3 条件二旗溢价断言(迭代14② T4——标中兑付行数值 = 同型正向 ×(1+δ)):
|
||
# δ 读 settlement.flag_hit_premium(125 provisional);九型正向行值全固定(唯一区间行 clear_blast
|
||
# 系清图槽不入格池)⇒ 逐型期望全确定。整数量级四舍五入域注记:ac_up/action 1 点量纲取整后不变
|
||
# =整数粒度地板——等号压线判读在当量聚合面(audit 工具/registry clause3_inequality),非断言面。
|
||
var prem_pct: int = int(_db.settlement.flag_hit_premium)
|
||
check(prem_pct == 125, "旗溢价配置在位(flag_hit_premium=125 provisional——形态 a 兑付溢价)")
|
||
var prem_rows := 0
|
||
var prem_ok := true
|
||
for r in probe["rows"]:
|
||
if String(r.get("source_class", "")) != "flag_hit" or String(r.get("highlight", "")) != "flag_hit":
|
||
continue
|
||
prem_rows += 1
|
||
var rtype := String(r.get("type", ""))
|
||
var v := int(r.get("value", 0))
|
||
var eff := String(r.get("effect_id", ""))
|
||
if rtype == "effect":
|
||
if eff == "gp_gain":
|
||
prem_ok = prem_ok and v == int(round(3 * prem_pct / 100.0)) # 金币袋 3→4
|
||
elif eff == "ap_delta":
|
||
prem_ok = prem_ok and v == 1 # +1AP:1 点量纲(round(1.25)=1——整数粒度地板)
|
||
else:
|
||
var mag: int = int(_db.status_effect_row(eff).get("magnitude", -999))
|
||
prem_ok = prem_ok and v == int(round(mag * prem_pct / 100.0)) # 增伤50→63/削弱65→81/护甲1→1
|
||
elif rtype == "heal":
|
||
# 急救 2→3(clamp 封顶只降不升:满血走 reward_ineffective 不入本行——区间断言)
|
||
prem_ok = prem_ok and v >= 1 and v <= int(round(2 * prem_pct / 100.0))
|
||
elif rtype == "damage":
|
||
var at := String(r.get("attack_type", ""))
|
||
if at == "":
|
||
prem_ok = prem_ok and v == int(round(1 * prem_pct / 100.0)) # 爆裂粉尘 1→1(群伤行值=表值直付)
|
||
else:
|
||
# 穿刺/魔法 2→3(行值=落地伤=基准+克制点数——反推基准核对;damage_floor 兜底下成立)
|
||
prem_ok = prem_ok and (v - int(r.get("effectiveness", 0))) == int(round(2 * prem_pct / 100.0))
|
||
check(prem_rows >= 1 and prem_ok, "标中兑付溢价(×%d 行逐型=同型正向 ×%d/100——等号压线注记:δ=1.25 时条件二 E[标中]=1.25·E_dig 恰等成立)" % [prem_rows, prem_pct])
|
||
# 标空不乘溢价:负向直伤行(反噬6/陷阱8/魔法反噬8 固定表值)零溢价污染——溢价值挂标中正向单点
|
||
var miss_flat_ok := true
|
||
for r in _rows_at(probe["rows"], "flag_miss", probe["miss_cell"]):
|
||
if String(r.get("type", "")) == "damage":
|
||
miss_flat_ok = miss_flat_ok and int(r.get("value", 0)) in [6, 8]
|
||
check(miss_flat_ok, "标空惩罚零溢价(负向直伤行=固定表值原值——溢价只挂标中正向)")
|
||
# 标空旗:归因行 + 该安全格负向兑付行(flag_miss 来源)
|
||
var miss_rows := _rows_at(probe["rows"], "flag_miss", probe["miss_cell"])
|
||
var miss_neg_ok := false
|
||
for r in miss_rows:
|
||
if String(r.get("type", "")) == "flag_miss":
|
||
miss_neg_ok = true
|
||
else:
|
||
var eff := String(r.get("effect_id", ""))
|
||
if eff != "" and not NEGATIVE_EFFECTS.has(eff):
|
||
miss_neg_ok = false
|
||
check(not miss_rows.is_empty() and miss_neg_ok, "标空旗 → 标旗未中归因行 + 该安全格资源负向兑付(同格同批)")
|
||
# 抵消过滤既有语义:标旗后被撤旗不判(发射面基线过滤)——由排雷玩法 冒烟与旗判定基线覆盖,此处核兑付行零泄漏
|
||
var flag_srcs := {}
|
||
for r in probe["rows"]:
|
||
if String(r.get("source_class", "")) in ["flag_hit", "flag_miss"]:
|
||
flag_srcs[r.get("origin_cell")] = true
|
||
check(int(flag_srcs.size()) <= 2, "旗判定兑付仅旗判定批两旗在案(撤旗抵消过滤零泄漏——%d 格)" % flag_srcs.size())
|
||
|
||
|
||
# 极性路由探针驱动:标 1 面真雷旗(标中)+ 1 面安全格旗(标空)→ AutoPlayer 挖到战斗终局(多轮样本)。
|
||
# 收集全部 cell_revealed / flag_judged 事件与结算行;返回格子集合与旗格坐标。
|
||
func _run_polarity_probe(seed_val: int) -> Dictionary:
|
||
_db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 10.0, "atk_mult": 0.5}) # 抬敌血压低敌攻——多轮挖掘样本不被终局截断
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(seed_val)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
var probe := {"rows": [], "safe_cells": {}, "mine_cells": {}, "hit_cell": [], "miss_cell": [],
|
||
"mine_events": 0, "safe_events": 0, "flag_hit_done": false, "flag_miss_done": false}
|
||
settlement.step_row.connect(func(row): probe["rows"].append(row))
|
||
settlement.minesweeper.cell_revealed.connect(func(evt):
|
||
if String(evt.get("channel", "")) == "initial":
|
||
return
|
||
if evt.kind == "mine":
|
||
probe["mine_cells"][evt.cell] = true
|
||
probe["mine_events"] = int(probe["mine_events"]) + 1
|
||
elif evt.kind == "safe":
|
||
probe["safe_cells"][evt.cell] = true
|
||
probe["safe_events"] = int(probe["safe_events"]) + 1)
|
||
settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 300, "hero_max_hp": 300})
|
||
var ms = settlement.minesweeper
|
||
# 旗 1:真雷(标中 → 正向);旗 2:安全格(标空 → 负向)
|
||
var mine_t := _find_flag_target(ms, true)
|
||
var safe_t := _find_flag_target(ms, false)
|
||
if not mine_t.is_empty():
|
||
if mine_t.hero_cell != ms.hero_pos():
|
||
ms.settle_batch([{"action": "move", "cell": mine_t.hero_cell}])
|
||
ms.settle_batch([{"action": "flag_place", "cell": mine_t.flag_cell}])
|
||
probe["hit_cell"] = [mine_t.flag_cell.x, mine_t.flag_cell.y]
|
||
probe["flag_hit_done"] = true
|
||
if not safe_t.is_empty():
|
||
if safe_t.hero_cell != ms.hero_pos():
|
||
ms.settle_batch([{"action": "move", "cell": safe_t.hero_cell}])
|
||
ms.settle_batch([{"action": "flag_place", "cell": safe_t.flag_cell}])
|
||
probe["miss_cell"] = [safe_t.flag_cell.x, safe_t.flag_cell.y]
|
||
probe["flag_miss_done"] = true
|
||
# 挖到终局(旗挡链不影响 AutoPlayer——choose_dig 跳过旗格)
|
||
var out := {"result": "", "rounds": 0, "terminated": false, "stream": []}
|
||
var guard := 40
|
||
while settlement.battle_result == "" and guard > 0:
|
||
guard -= 1
|
||
_auto_play_round(settlement, out)
|
||
_db.difficulty.rows.clear() # 测试台账还原
|
||
return probe
|
||
|
||
|
||
func _rows_at(rows: Array, source_class: String, cell) -> Array:
|
||
var out: Array = []
|
||
for r in rows:
|
||
if String(r.get("source_class", "")) == source_class and r.get("origin_cell", [9, 9]) == cell:
|
||
out.append(r)
|
||
return out
|
||
|
||
|
||
# 负向字典逐型直验(类型化兑付行协议):找真实板上各型非封锁格,直调 _grant_routed 负向路由,
|
||
# 断言每型的兑付形态(enrage/off_guard/sapped 效果行、heal 直伤行、blast 警觉行、action 行动点排队)。
|
||
func _smoke_negative_dictionary() -> void:
|
||
print("--- 负向字典逐型直验(sapped 在位 / heal 直伤 / blast 警觉 / action 行动点)---")
|
||
# 数据面:sapped 行在位(状态表七行 + effect_pool_map 收编)
|
||
var sapped: Dictionary = _db.status_effect_row("sapped")
|
||
check(not sapped.is_empty() and String(sapped.get("target_side", "")) == "hero" \
|
||
and int(sapped.get("magnitude", 0)) == -25 and int(sapped.get("duration_settlements", 0)) == 1 \
|
||
and int(sapped.get("max_stack", 0)) == 16 and String(sapped.get("stack_policy", "")) == "stack",
|
||
"sapped 行在位(hero · −25 percent · dur 1 · max 16 · stack——weaken 型负向)")
|
||
check(String(_db.settlement.effect_pool_map.get("sapped", "")) == "hero_offense",
|
||
"effect_pool_map 收编 sapped → hero_offense(attack_up 镜像池)")
|
||
# 行为面:逐型负向兑付形态(真实板 + 真实路由路径)
|
||
# 数值压缩批(2026-09-17):heal 权重 15→3 后单板五型齐备不再必然(无 heal 板概率 ≈1/3)——
|
||
# 改多板扫描,取第一块五型齐备的板做行为断言。
|
||
var settlement = null
|
||
var board = null
|
||
var by_type := {} # cell_type → 首个非封锁格(query_identity 雷安同池——雷格可查)
|
||
var rows: Array = []
|
||
for seed_i in range(6):
|
||
# 每板独立 rng 实例(迭代 7 T1 修正——复用实例重播种触发 set_master_seed 的 _counter==0 守卫;
|
||
# 原版第一板即 break 掩盖了该违例,aux 供给挤占后多板扫描暴露)
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(20260919 + seed_i)
|
||
settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
rows = []
|
||
settlement.step_row.connect(func(row): rows.append(row))
|
||
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_max_hp": int(_db.hero.max_hp)}):
|
||
continue
|
||
board = settlement.minesweeper.current_board()
|
||
var r0 := {}
|
||
for c in board.compute_r0():
|
||
r0[c] = true
|
||
by_type = {}
|
||
for y in board.height:
|
||
for x in board.width:
|
||
var c := Vector2i(x, y)
|
||
if r0.has(c) or by_type.size() == 5:
|
||
continue
|
||
var ident = settlement.query_reward_identity(c)
|
||
# aux 身份不入五型轴(秘宝内容批 2026-09-29——五型齐备=格面资源历史断言轴,
|
||
# aux 供给挤占不改变本断言语义;扫描排除 aux 身份格)
|
||
if ident != null and String(ident.cell_type) != RewardTable.TREASURE_ITEM_TYPE and not by_type.has(String(ident.cell_type)):
|
||
by_type[String(ident.cell_type)] = c
|
||
# break 条件=percent_map 三型齐备(迭代 7 T1——aux 供给挤占下 weaken/defense 收集时机漂移,
|
||
# 直验可达性优先;五型多样性 check 保留不动)
|
||
if by_type.has("attack") and by_type.has("defense") and by_type.has("weaken"):
|
||
break
|
||
check(by_type.size() == 5, "扫描 6 板内出现五型齐备板(%s)" % str(by_type.keys()))
|
||
# attack/defense/weaken:负向字典行走 apply_effect(off_guard 2026-09-18 换装 ac 轴——兑付形态不变)
|
||
var percent_map := {"attack": "enrage", "defense": "off_guard", "weaken": "sapped"}
|
||
for ctype in percent_map.keys():
|
||
if not by_type.has(ctype):
|
||
continue
|
||
rows.clear()
|
||
var snap: Dictionary = settlement._reward_economy.query_full_snapshot(by_type[ctype])
|
||
settlement._grant_routed(snap, "negative", "mine_hit", by_type[ctype], 1)
|
||
var eff: String = percent_map[ctype]
|
||
check(_has_source_row(rows, "effect", "mine_hit") and _has_effect_id(rows, eff),
|
||
"%s 型负向 → %s 效果行(mine_hit 来源指回该格)" % [ctype, eff])
|
||
# heal:负向 = 主角直伤(表值——数值压缩批 4→1;不经增益状态)
|
||
if by_type.has("heal"):
|
||
rows.clear()
|
||
var hp_before: int = int(settlement.presentation_snapshot().hero.hp)
|
||
var snap_h: Dictionary = settlement._reward_economy.query_full_snapshot(by_type["heal"])
|
||
settlement._grant_routed(snap_h, "negative", "mine_hit", by_type["heal"], 1)
|
||
var hp_after: int = int(settlement.presentation_snapshot().hero.hp)
|
||
var backlash_power: int = 0
|
||
for row in _db.reward.rows:
|
||
if String(row.get("reward_id", "")) == "heal_backlash":
|
||
backlash_power = int(row.power)
|
||
check(hp_after == hp_before - backlash_power, "heal 型负向 → 主角直伤 %d(%d → %d,不经增益状态)" % [backlash_power, hp_before, hp_after])
|
||
# blast:负向 = 警觉(敌方护甲等级 +1 percent 行——2026-09-20 伏击退役换装)
|
||
if by_type.has("blast"):
|
||
rows.clear()
|
||
var snap_b: Dictionary = settlement._reward_economy.query_full_snapshot(by_type["blast"])
|
||
settlement._grant_routed(snap_b, "negative", "mine_hit", by_type["blast"], 1)
|
||
var alert_ok: bool = false
|
||
for eff_a in settlement._status_effects.query_active("enemy"):
|
||
if String(eff_a.effect_id) == "alert":
|
||
alert_ok = true
|
||
check(alert_ok, "blast 型负向 → 警觉(敌方护甲等级 +1 状态在位)")
|
||
# action:负向 = 下轮行动点 −1 / 正向 = +1(排队,轮开始兑现——八型批 2026-09-20;确定性直验不依赖板面摇号)
|
||
rows.clear()
|
||
var d0: int = settlement.action_economy.pending_ap_delta()
|
||
settlement._grant_routed({"reward_id": "ap_drain", "cell_type": "action", "polarity": "negative", "display_name": "迟滞", "power": 1}, "negative", "mine_hit", Vector2i(0, 0), 1)
|
||
check(settlement.action_economy.pending_ap_delta() == d0 - 1, "action 型负向 → 下轮行动点 −1(排队)")
|
||
settlement._grant_routed({"reward_id": "ap_up", "cell_type": "action", "polarity": "positive", "display_name": "行动点", "power": 1}, "positive", "safe_open", Vector2i(0, 0), 1)
|
||
check(settlement.action_economy.pending_ap_delta() == d0, "action 型正负抵消 → 排队净值归零")
|
||
|
||
|
||
# 资源面断言:chain/dig 事件恒携带 resource=true(雷格同权);initial 恒 false(封锁格);
|
||
# 封锁格查询 = null、雷格未开恒可查。
|
||
func _smoke_resource_surface() -> void:
|
||
print("--- 格面资源资格面(雷格携带 / 封锁格无资源)---")
|
||
var run1 = _run_battle(1, 0)
|
||
var run2 = _run_battle(2, 0)
|
||
var all_events: Array = run1.events + run2.events
|
||
var digchain_bad := 0
|
||
var initial_bad := 0
|
||
var mine_with_resource := 0
|
||
var initial_count := 0
|
||
for evt in all_events:
|
||
if not evt.has("resource"):
|
||
continue # 非揭示事件(round_end 标记等)
|
||
if String(evt.get("channel", "")) == "initial":
|
||
initial_count += 1
|
||
if bool(evt.resource):
|
||
initial_bad += 1
|
||
else:
|
||
if not bool(evt.resource):
|
||
digchain_bad += 1
|
||
if evt.kind == "mine":
|
||
mine_with_resource += 1
|
||
check(digchain_bad == 0, "chain/dig 揭示事件 resource 恒 true(雷安同池——违例 %d)" % digchain_bad)
|
||
check(initial_bad == 0 and initial_count >= 1, "initial 揭示事件(封锁格)resource 恒 false(×%d 事件)" % initial_count)
|
||
check(mine_with_resource >= 1, "雷格揭示携带资源(mine ×resource=true %d——雷格不再恒无)" % mine_with_resource)
|
||
# 封锁格无身份 / 雷格未开恒可查(未开格恒可查——秘宝 桥接结构前提)
|
||
var rng3 = load("res://core/rng_service.gd").new()
|
||
rng3.set_master_seed(20260920)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng3)
|
||
settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
|
||
var board = settlement.minesweeper.current_board()
|
||
var r0_first: Variant = null
|
||
for c in board.compute_r0():
|
||
r0_first = c
|
||
break
|
||
var mine_unrevealed = null
|
||
for c in board.mines.keys():
|
||
if not settlement.minesweeper.is_revealed(c):
|
||
mine_unrevealed = c
|
||
break
|
||
check(settlement.query_reward_identity(r0_first) == null, "封锁格(R₀)身份查询 = null(不入摇号清单)")
|
||
check(mine_unrevealed != null and settlement.query_reward_identity(mine_unrevealed) != null,
|
||
"未开雷格身份恒可查({reward_id, cell_type, tier}——秘宝 桥接前提)")
|
||
|
||
|
||
# 五段全序断言(战斗结算 验收"五段全序"+ 行型封闭 10 类):逐轮行相位序 = 授予 → 增益/减益 → 战斗 → 衰减 → 判定。
|
||
func _smoke_settlement_segments() -> void:
|
||
print("--- 五段全序 + 行型封闭 ---")
|
||
check(Array(_db.settlement.segment_order) == SettlementTable.FROZEN_SEGMENT_ORDER
|
||
and SettlementTable.FROZEN_SEGMENT_ORDER == ["grant", "buff_debuff", "combat", "decay", "verdict"],
|
||
"segment_order 五段冻结在位(表值 = 拍板序恒等)")
|
||
var run = _run_battle(1, 0)
|
||
var legacy := {"item": 0, "consumed_ineffective": 0, "use_rejected": 0, "window_open": 0, "window_skip": 0}
|
||
for row in run.stream:
|
||
if legacy.has(String(row.get("type", ""))):
|
||
legacy[String(row.get("type", ""))] += 1
|
||
var legacy_total := 0
|
||
for k in legacy:
|
||
legacy_total += int(legacy[k])
|
||
check(legacy_total == 0, "行型封闭 10 类(删五类零残留:item/consumed_ineffective/use_rejected/window_open/window_skip)")
|
||
var order_ok := true
|
||
var decay_verdict_ok := true
|
||
var by_round := {}
|
||
for row in run.stream:
|
||
var ri: int = int(row.get("round_index", 0))
|
||
if not by_round.has(ri):
|
||
by_round[ri] = []
|
||
by_round[ri].append(row)
|
||
for ri in by_round.keys():
|
||
var phases: Array = []
|
||
for row in by_round[ri]:
|
||
phases.append(_row_phase(row, ri))
|
||
var max_phase := 0
|
||
for p in phases:
|
||
if int(p) < max_phase:
|
||
order_ok = false
|
||
max_phase = maxi(max_phase, int(p))
|
||
var decays := 0
|
||
var verdicts := 0
|
||
for row in by_round[ri]:
|
||
if String(row.get("type", "")) == "decay":
|
||
decays += 1
|
||
if String(row.get("type", "")) == "verdict":
|
||
verdicts += 1
|
||
if decays != 1 or verdicts != 1:
|
||
decay_verdict_ok = false # 衰减/判定每结算恰一次
|
||
check(order_ok, "每轮行相位单调(授予→增益/减益→战斗→衰减→判定)")
|
||
check(decay_verdict_ok, "衰减/判定段每结算恰一段(先兑付后衰减——含末轮)")
|
||
|
||
|
||
# 行相位归类(五段序断言用):0=授予(含高光/归因)1=增益减益(续存组合行)2=战斗 3=衰减 4=判定
|
||
func _row_phase(row: Dictionary, ri: int) -> int:
|
||
match String(row.get("type", "")):
|
||
"decay":
|
||
return 3
|
||
"verdict":
|
||
return 4
|
||
"cast":
|
||
return 2
|
||
"damage":
|
||
if String(row.get("source_class", "")) in ["hero_attack", "enemy_action"]:
|
||
return 2
|
||
return 0 # 授予段 flat 兑付(爆裂/直伤)
|
||
match String(row.get("source_class", "")):
|
||
"safe_cell", "mine_hit", "flag_hit", "flag_miss", "clear_reward":
|
||
if int(row.get("round_index", ri)) < ri:
|
||
return 1 # 前轮授予实例的续存组合行(增益/减益段)
|
||
return 0
|
||
_:
|
||
return 0
|
||
|
||
|
||
# 清图驱动(真实流):标一面真雷旗(完美前置)→ 逐轮 AutoPlayer 挖到轮末;board_seq 递增 = 清图换图。
|
||
# from_refresh=true 时先三轮停手攒 RAP、第 4 轮轮内刷新换图,再清刷新图。
|
||
func _run_full_clear(seed_val: int, from_refresh: bool, rows: Array) -> Dictionary:
|
||
_db.difficulty.rows.append({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hp_mult": 10.0, "atk_mult": 1.0})
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(seed_val)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
settlement.step_row.connect(func(row): rows.append(row))
|
||
var res := {"cleared": false, "refreshed": false, "rounds": 0}
|
||
if not settlement.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH,
|
||
"hero_hp": CLEAR_SMOKE_HERO_HP, "hero_max_hp": CLEAR_SMOKE_HERO_HP}):
|
||
_db.difficulty.rows.clear()
|
||
return res
|
||
if from_refresh:
|
||
for i in range(3):
|
||
settlement.minesweeper.settle_batch([{"action": "stop"}])
|
||
res["refreshed"] = settlement.player_request_refresh()
|
||
var drive := _drive_clear(settlement)
|
||
res["cleared"] = drive.cleared
|
||
res["rounds"] = drive.rounds
|
||
_db.difficulty.rows.clear() # 测试台账还原(后续冒烟基线)
|
||
return res
|
||
|
||
|
||
func _drive_clear(settlement) -> Dictionary:
|
||
var flag_done := false
|
||
var rounds := 0
|
||
while rounds < 60:
|
||
rounds += 1
|
||
var ms = settlement.minesweeper
|
||
if not flag_done:
|
||
var t := _find_flag_target(ms, true)
|
||
if not t.is_empty():
|
||
if t.hero_cell != ms.hero_pos():
|
||
ms.settle_batch([{"action": "move", "cell": t.hero_cell}])
|
||
ms.settle_batch([{"action": "flag_place", "cell": t.flag_cell}])
|
||
flag_done = true # 一面真雷旗 = 完美排雷前半(零误标),后半靠 AutoPlayer 清图
|
||
var seq_before: int = int(settlement.diagnostic_snapshot().board_seq)
|
||
var out := {"result": "", "rounds": 0, "terminated": false, "stream": []}
|
||
_auto_play_round(settlement, out)
|
||
if int(settlement.diagnostic_snapshot().board_seq) > seq_before:
|
||
return {"cleared": true, "rounds": rounds} # 清图换图 = board_seq 递增
|
||
if settlement.battle_result != "":
|
||
return {"cleared": false, "rounds": rounds}
|
||
return {"cleared": false, "rounds": rounds}
|
||
|
||
|
||
func _rows_of_type(rows: Array, t: String) -> Array:
|
||
var out: Array = []
|
||
for r in rows:
|
||
if String(r.get("type", "")) == t:
|
||
out.append(r)
|
||
return out
|
||
|
||
|
||
func _has_source_row(rows: Array, t: String, source: String, batch_key: String = "") -> bool:
|
||
for r in rows:
|
||
if String(r.get("type", "")) != t or String(r.get("source_class", "")) != source:
|
||
continue
|
||
if batch_key == "" or String(r.get("batch_key", "")) == batch_key:
|
||
return true
|
||
return false
|
||
|
||
|
||
func _has_effect_id(rows: Array, effect_id: String) -> bool:
|
||
for r in rows:
|
||
if String(r.get("effect_id", "")) == effect_id:
|
||
return true
|
||
return false
|
||
|
||
|
||
# 手搓战斗状态袋单点(T6 收口批——7 处字面量收编;_autoplay_battle 直接消费的最小字段族。
|
||
# 大字段族同构袋仍走 _adv_st()——_drive_level/_autoplay_battle 全字段消费面)
|
||
func _battle_st(policy: String) -> Dictionary:
|
||
return {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": policy}
|
||
|
||
|
||
# ==================== 敌人技能池批(2026-09-16 用户拍板——开轮摇号出招系统) ====================
|
||
# 六项验收断言(docs/proposal-skill-pool.md §8 批 1):同种子回放逐位复现 / 单敌轨迹独立复放 /
|
||
# EV 抽样对锚 / 死亡打断作废 / 空池不可能 / 加载校验拒绝。
|
||
|
||
func _smoke_skill_pool() -> void:
|
||
print("--- 敌人技能池(开轮摇号 · 冷却/保底两闸 · 三乘法链 · 独立子流)---")
|
||
_smoke_skill_pool_data()
|
||
_smoke_skill_pool_replay_independence()
|
||
_smoke_skill_pool_ev_anchor()
|
||
_smoke_skill_pool_death_void()
|
||
_smoke_skill_pool_validation_reject()
|
||
_smoke_debuff_rider()
|
||
|
||
|
||
# ① 数据面:技能表行集 + 普攻锁死 + 建议池行值 + 空池不可能(结构断言)
|
||
func _smoke_skill_pool_data() -> void:
|
||
check(_db.skill.rows.size() == 12, "技能表十二行(普攻/突刺/蓄力/重击/硬化 + 撕咬/毒爆/蛛网束缚 + 大地脉动 + 震地怒击/遏毒/岩崩——前三批 + Boss 半血解锁技三行〔迭代14④ T5〕)")
|
||
var basic: Dictionary = _db.skill.row_by_id("attack")
|
||
check(not basic.is_empty() and String(basic.type) == "basic" and int(basic.cooldown_beats) == 0
|
||
and (not basic.has("damage_mult") or is_equal_approx(float(basic.damage_mult), 1.0)),
|
||
"普攻行:倍率恒 1 · 冷却恒 0(域校验锁死——池恒非空的结构前提)")
|
||
var lunge: Dictionary = _db.skill.row_by_id("lunge")
|
||
check(not lunge.is_empty() and is_equal_approx(float(lunge.damage_mult), 1.5) and int(lunge.cooldown_beats) == 2,
|
||
"突刺行(伤害 ×1.5 · 冷却 2——提案 §2.5 建议池新行)")
|
||
var charge_row: Dictionary = _db.skill.row_by_id("charge")
|
||
check(not charge_row.is_empty() and String(charge_row.buff_ref) == "empower",
|
||
"蓄力行 = 纯攻击增益技(0 伤害、buff_ref → empower——与重击无组合语义)")
|
||
var bite: Dictionary = _db.skill.row_by_id("bite")
|
||
check(not bite.is_empty() and is_equal_approx(float(bite.damage_mult), 1.5) and int(bite.cooldown_beats) == 1,
|
||
"撕咬行(伤害 ×1.5 · 冷却 1——巨蜘蛛池载体,与突刺同倍率错开冷却轴)")
|
||
var venom: Dictionary = _db.skill.row_by_id("venom_burst")
|
||
check(not venom.is_empty() and is_equal_approx(float(venom.damage_mult), 2.5) and int(venom.cooldown_beats) == 4
|
||
and is_equal_approx(float(venom.rider_mult), 0.75),
|
||
"毒爆行(伤害 ×2.5 · 冷却 4 · 必中附加 ×0.75——蛛化卓尔精灵专属池独占技,新效应词汇批)")
|
||
var venom_sole_ok := true
|
||
for row in _db.enemy.rows:
|
||
for entry in row.pool:
|
||
if String(entry.skill_id) == "venom_burst" and String(row.enemy_id) != "drider":
|
||
venom_sole_ok = false
|
||
check(venom_sole_ok, "毒爆独占性(仅蛛化卓尔精灵池引用——Boss 专属池独占技纪律)")
|
||
# 新效应词汇批(2026-09-19):妨害技首发行 + off_guard 落点 + 实装面收窄
|
||
var snare: Dictionary = _db.skill.row_by_id("web_snare")
|
||
check(not snare.is_empty() and String(snare.type) == "debuff" and String(snare.buff_ref) == "off_guard"
|
||
and int(snare.buff_duration) == 2 and int(snare.cooldown_beats) == 4,
|
||
"蛛网束缚行(妨害型 · buff_ref → off_guard 失衡 · duration 2 = 1 有效拍 · 冷却 4——新效应词汇批首发)")
|
||
var snare_sole_ok := true
|
||
for row in _db.enemy.rows:
|
||
for entry in row.pool:
|
||
if String(entry.skill_id) == "web_snare" and String(row.enemy_id) != "drider":
|
||
snare_sole_ok = false
|
||
check(snare_sole_ok, "蛛网束缚独占性(仅蛛化卓尔精灵池引用——实装面收窄,其余敌种不动)")
|
||
var off_guard_row: Dictionary = _db.status_effect_row("off_guard")
|
||
check(not off_guard_row.is_empty() and String(off_guard_row.target_side) == "hero" and String(off_guard_row.unit) == "ac",
|
||
"失衡状态行(hero · ac 轴——妨害技落点,ConfigDB side 守卫正例)")
|
||
# 行集键 = (level_index, node_depth)——分支节点图批起 bugbear 等敌种跨关异池,enemy_id 不再唯一
|
||
var expect_pools := {
|
||
"1_0": {"attack": 9, "lunge": 1},
|
||
"1_1": {"attack": 9, "lunge": 1},
|
||
"1_2": {"attack": 6, "charge": 2, "heavy": 2},
|
||
"1_3": {"attack": 6, "charge": 2, "heavy": 2},
|
||
"1_4": {"attack": 6, "heavy": 2, "harden": 2, "quake_smash": 2},
|
||
"2_0": {"attack": 6, "lunge": 1, "heavy": 2},
|
||
"2_1": {"attack": 6, "lunge": 1, "heavy": 2},
|
||
"2_4": {"attack": 6, "bite": 2, "charge": 1},
|
||
"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_9": {"attack": 6, "heavy": 2, "venom_burst": 2, "web_snare": 2, "venom_curb": 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_9": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
|
||
}
|
||
var pools_ok := true
|
||
for row in _db.enemy.rows:
|
||
var rk := "%d_%d" % [int(row.level_index), int(row.node_depth)]
|
||
if not expect_pools.has(rk):
|
||
pools_ok = false
|
||
continue
|
||
var got := {}
|
||
for entry in row.pool:
|
||
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〔(2,9)——商店房批 Boss 重编号〕;L3 灰矮人 6/2/2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2 · 石君王 6/2/2〔earth_pulse·(3,9)〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕)")
|
||
var basic_count_ok := true
|
||
for row in _db.enemy.rows:
|
||
var n := 0
|
||
for entry in row.pool:
|
||
var srow: Dictionary = _db.skill_row(String(entry.skill_id))
|
||
if not srow.is_empty() and String(srow.type) == "basic":
|
||
n += 1
|
||
if n != 1:
|
||
basic_count_ok = false
|
||
check(basic_count_ok, "每池恰一行普攻(空池不可能的加载校验同款)")
|
||
# Boss 半血解锁技·编排集成断言(迭代14④ T6——真实编排回路:半血 → 下轮开轮闩检查 → 解锁行恰一次)
|
||
var st_unlock: Dictionary = {"rows": []}
|
||
var sett_u = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(4242))
|
||
sett_u.step_row.connect(func(row): st_unlock["rows"].append(row))
|
||
check(sett_u.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp)}), "前置:卓尔 Boss 战受理(2,9——解锁技 venom_curb 门槛 50)")
|
||
var boss_u = sett_u._enemy_system.all_enemies()[0] # 白盒夹具(smoke 惯例——autoplay 同族私有面)
|
||
sett_u._enemy_system.apply_damage(boss_u, (int(boss_u.max_hp) - 65))
|
||
check(int(boss_u.hp) == 65, "前置:Boss 压到阈值 65(=round(130×0.5))")
|
||
sett_u.minesweeper.settle_batch([{"action": "stop"}]) # 收轮 → 结算 → 次轮开轮闩检查
|
||
var unlock_rows: Array = []
|
||
for r in st_unlock["rows"]:
|
||
if String(r.get("highlight", "")) == "skill_unlock":
|
||
unlock_rows.append(r)
|
||
check(unlock_rows.size() == 1, "解锁归因行恰一次(×%d——闩去重,二次检查零行)" % unlock_rows.size())
|
||
if unlock_rows.size() == 1:
|
||
check(String(unlock_rows[0].get("display_name", "")) == "蛛化卓尔精灵 解锁了 遏毒",
|
||
"解锁行文本 =「蛛化卓尔精灵 解锁了 遏毒」(高光键 skill_unlock——呈现层一次性技能名 toast 路由键)")
|
||
check(String(unlock_rows[0].get("type", "")) == "highlight", "解锁行型 = highlight 既有行型(零行型新增——行协议封闭)")
|
||
var rollable_ok := true
|
||
for depth in [0, 1, 2, 3, 4]:
|
||
var es = load("res://core/unit_container.gd").new(_db)
|
||
es.spawn(1, int(depth), int(_db.hero.max_hp))
|
||
for u in es.all_enemies():
|
||
if es.rollable_skills(u).is_empty():
|
||
rollable_ok = false
|
||
for depth in [0, 1, 4, 5, 6, 7, 8]:
|
||
var es2 = load("res://core/unit_container.gd").new(_db)
|
||
es2.spawn(2, int(depth), int(_db.hero.max_hp))
|
||
for u in es2.all_enemies():
|
||
if es2.rollable_skills(u).is_empty():
|
||
rollable_ok = false
|
||
check(rollable_ok, "全深度实例 spawn 后可摇集合恒非空(普攻恒冷却 0——容器级断言,L1/L2 双关卡)")
|
||
# 状态表新行 + 敌方防御池收编
|
||
var empower: Dictionary = _db.status_effect_row("empower")
|
||
var fortify: Dictionary = _db.status_effect_row("fortify")
|
||
check(not empower.is_empty() and String(empower.target_side) == "enemy" and int(empower.magnitude) == 100,
|
||
"蓄力状态行 empower(enemy +100 percent——敌方输出 ×2 走既有 enemy_output 池)")
|
||
check(not fortify.is_empty() and String(fortify.target_side) == "enemy" and int(fortify.magnitude) == -50,
|
||
"硬化状态行 fortify(enemy −50 percent——敌方防御池负值 = 主角攻击折半;+100 符号 bug 2026-09-18 纠正,provisional 归 状态层重设计批)")
|
||
check(String(_db.settlement.effect_pool_map.get("empower", "")) == "enemy_output" \
|
||
and String(_db.settlement.effect_pool_map.get("fortify", "")) == "enemy_defense",
|
||
"effect_pool_map 收编 empower/fortify(敌方防御池 enemy_defense 入表)")
|
||
|
||
|
||
# ② 同种子回放逐位复现(D3 大地精——原熊地精双雄,多实例同种)
|
||
# ③「单敌轨迹独立复放」随并流批退役(ADR-0007 裁丙 2026-09-27):子流派生向量退役后单敌轨迹无法
|
||
# 脱离全序重建(位置与其它流取数交错);同种子全战斗逐位复现由 ② 全对拍承载,单点定位归事件日志。
|
||
func _smoke_skill_pool_replay_independence() -> void:
|
||
var seed_val := -1
|
||
for s in range(1, 60):
|
||
var seqs := _record_pool_trajectories(s, 1)
|
||
if not seqs.is_empty() and int(seqs.count) >= 2:
|
||
seed_val = s
|
||
break
|
||
check(seed_val > 0, "多实例种子可达(seed=%d——D3 遭遇 ≥2 实例)" % seed_val)
|
||
# 数值压缩批(2026-09-17):敌 hp 18→6 后 hero_atk=1 十轮内必然击杀(实例错位、轨迹断裂)——
|
||
# 观测窗 10 轮→5 轮(5 拍 ×1 伤 < 6 HP 全员存活;仍覆盖保底 M=4 的触发窗)。
|
||
var rounds := 5
|
||
var run1 := _record_pool_trajectories(seed_val, rounds)
|
||
var run2 := _record_pool_trajectories(seed_val, rounds)
|
||
check(not run1.is_empty() and JSON.stringify(run1.seqs) == JSON.stringify(run2.seqs),
|
||
"同种子回放:各实例摇定动作序列逐位相同(×%d 轮 × %d 实例——子流独立复放面随并流退役)" % [rounds, int(run1.count)])
|
||
|
||
|
||
# 多敌战逐轮记录各实例摇定动作(hero_atk=1/hp=300 构造全员存活满 N 轮——轨迹长度 = 轮数)
|
||
func _record_pool_trajectories(seed_val: int, rounds: int) -> Dictionary:
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(seed_val)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
if not settlement.begin_battle({"level_index": 1, "node_depth": 3, "hero_hp": 300, "hero_max_hp": 300, "hero_atk": 1}):
|
||
return {}
|
||
var seqs: Array = []
|
||
for u in settlement._enemy_system.all_enemies():
|
||
seqs.append([])
|
||
for r in rounds:
|
||
var handles: Array = settlement._enemy_system.all_enemies()
|
||
for i in handles.size():
|
||
seqs[i].append(settlement._enemy_system.pending_action(handles[i]))
|
||
settlement.minesweeper.settle_batch([{"action": "stop"}])
|
||
if settlement.battle_result != "":
|
||
return {}
|
||
return {"count": seqs.size(), "seqs": seqs}
|
||
|
||
|
||
# ④ EV 抽样对锚(含闸长期速率 vs 静态锚——proposal §2.5;精确重锚归 难度曲线与Boss)。
|
||
# 带宽口径:熊地精/酋长 EV 结构性恒 1(普攻 1 + 重击 2 与蓄力/硬化 0 加权守恒)→ 窄带;
|
||
# 地精突刺池受保底闸抬升(唯一特殊技 w=1/10,保底 M=4 把突刺率从自然 10% 抬到 ~20%+)→ 宽带登记。
|
||
func _smoke_skill_pool_ev_anchor() -> void:
|
||
var bands := {"goblin": [1.00, 1.20], "bugbear": [0.97, 1.03], "ogre_chief": [1.10, 1.30]} # 酋长重锚〔迭代14④ T5——震地怒击半血解锁入池,含闸长期速率上移:实测 1.1948;EV 锚批注 enemy.tres 同批换代〕
|
||
var species := ["goblin", "bugbear", "ogre_chief"]
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(20260921)
|
||
var n_beats := 5000
|
||
var pity_m: int = int(_db.skill.pity_threshold)
|
||
for si in species.size():
|
||
var eid: String = species[si]
|
||
var template: Array = []
|
||
for row in _db.enemy.rows:
|
||
if String(row.enemy_id) == eid:
|
||
template = row.pool
|
||
break
|
||
var pool: Array = []
|
||
for entry in template:
|
||
pool.append({"skill_id": String(entry.skill_id), "weight": int(entry.weight), "cooldown_left": 0})
|
||
var streak := 0
|
||
var ev_sum := 0.0
|
||
for k in n_beats:
|
||
var entries: Array = []
|
||
for e in pool:
|
||
if int(e.cooldown_left) <= 0:
|
||
entries.append({"skill_id": e.skill_id, "weight": e.weight})
|
||
if streak >= pity_m:
|
||
var specials: Array = []
|
||
for e in pool:
|
||
var sr: Dictionary = _db.skill_row(String(e.skill_id))
|
||
if not sr.is_empty() and String(sr.type) != "basic":
|
||
specials.append({"skill_id": e.skill_id, "weight": e.weight})
|
||
if not specials.is_empty():
|
||
entries = specials
|
||
var sid := SkillLottery.draw(entries, rng.open_stream("behavior_roll"))
|
||
var srow: Dictionary = _db.skill_row(sid)
|
||
var mult := 0.0
|
||
if String(srow.type) == "basic":
|
||
mult = 1.0
|
||
elif String(srow.type) == "damage":
|
||
mult = float(srow.get("damage_mult", 1.0))
|
||
ev_sum += mult
|
||
for e in pool:
|
||
e.cooldown_left = maxi(0, int(e.cooldown_left) - 1)
|
||
if String(e.skill_id) == sid:
|
||
e.cooldown_left = int(srow.cooldown_beats)
|
||
streak = streak + 1 if String(srow.type) == "basic" else 0
|
||
var measured: float = ev_sum / float(n_beats)
|
||
var band: Array = bands[eid]
|
||
var in_band: bool = measured >= float(band[0]) and measured <= float(band[1])
|
||
check(in_band, "EV 抽样对锚 %s(实测 %.4f ∈ [%.2f, %.2f] 含闸长期速率,×%d 拍)" % [eid, measured, float(band[0]), float(band[1]), n_beats])
|
||
|
||
|
||
# ⑤ 死亡打断作废(摇定后、兑现前死亡 → 本拍技能作废、不动手、不进账)
|
||
func _smoke_skill_pool_death_void() -> void:
|
||
var void_seed := -1
|
||
for s in range(1, 400):
|
||
var rg = load("res://core/rng_service.gd").new()
|
||
rg.set_master_seed(s)
|
||
var erow: Dictionary = _db.resolve_enemy_rowset(1, 2)
|
||
var entries: Array = []
|
||
for entry in erow.pool:
|
||
entries.append({"skill_id": String(entry.skill_id), "weight": int(entry.weight)})
|
||
var sid := SkillLottery.draw(entries, rg.open_stream("behavior_roll"))
|
||
if String(_db.skill_row(sid).type) != "basic":
|
||
void_seed = s
|
||
break
|
||
check(void_seed > 0, "死亡打断种子可达(seed=%d——D2 首拍摇中特殊技)" % void_seed)
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(void_seed)
|
||
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
|
||
var rows: Array = []
|
||
settlement.step_row.connect(func(row): rows.append(row))
|
||
check(settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}),
|
||
"死亡打断战斗启动(hero_atk 999——先手必杀 40HP 熊地精)")
|
||
var u: Dictionary = settlement._enemy_system.all_enemies()[0] # 句柄预捕获(胜利 despawn 后容器已清,字典引用仍可读)
|
||
var pending: String = settlement._enemy_system.pending_action(u)
|
||
check(pending != "", "开轮已摇定本拍动作(待发 = %s)" % pending)
|
||
settlement.minesweeper.settle_batch([{"action": "stop"}])
|
||
var enemy_rows := 0
|
||
for row in rows:
|
||
if String(row.get("source_class", "")) == "enemy_action":
|
||
enemy_rows += 1
|
||
check(enemy_rows == 0, "先手击杀 → 摇定技能作废(第 1 轮零敌方行动行——死亡即不动手)")
|
||
var cd_sum := 0
|
||
for e in u.pool:
|
||
cd_sum += int(e.cooldown_left)
|
||
check(cd_sum == 0 and int(u.basic_streak) == 0,
|
||
"作废不进账(全池冷却 0 · 保底计数 0——执行上报未发生)")
|
||
|
||
|
||
# ⑥ 加载校验拒绝(单表域校验 + 跨表池构成纪律)
|
||
func _smoke_skill_pool_validation_reject() -> void:
|
||
var skill_table = load("res://data/schemas/skill_table.gd").new()
|
||
var enemy_table = load("res://data/schemas/enemy_table.gd").new()
|
||
enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "ghost", "weight": 1}]}])
|
||
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "dangling_skill"),
|
||
"加载校验拒绝:池条目 skill_id 悬空致命")
|
||
skill_table.rows.assign([{"skill_id": "a", "display_name": "a", "type": "damage", "damage_mult": 1.5, "cooldown_beats": 2}])
|
||
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"),
|
||
"加载校验拒绝:池普攻行数 ≠ 1(空池不可能前提)")
|
||
skill_table.rows.assign([
|
||
{"skill_id": "b1", "display_name": "b1", "type": "buff", "buff_ref": "empower", "buff_duration": 2, "cooldown_beats": 3},
|
||
{"skill_id": "b2", "display_name": "b2", "type": "buff", "buff_ref": "fortify", "buff_duration": 2, "cooldown_beats": 4}])
|
||
enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "b1", "weight": 1}, {"skill_id": "b2", "weight": 1}]}])
|
||
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"),
|
||
"加载校验拒绝:池增益技 > 1(每池增益 ≤1 纪律)")
|
||
skill_table.rows.assign([{"skill_id": "atk", "display_name": "a", "type": "basic", "damage_mult": 2.0, "cooldown_beats": 0}])
|
||
check(not skill_table.validate().is_empty(), "加载校验拒绝:普攻配倍率 ≠ 1(域校验锁死)")
|
||
skill_table.rows.assign([{"skill_id": "atk", "display_name": "a", "type": "basic", "cooldown_beats": 2}])
|
||
check(not skill_table.validate().is_empty(), "加载校验拒绝:普攻配冷却 ≠ 0(域校验锁死)")
|
||
skill_table.rows.assign([{"skill_id": "d", "display_name": "d", "type": "damage", "damage_mult": 30.0, "cooldown_beats": 1}])
|
||
check(not skill_table.validate().is_empty(), "加载校验拒绝:damage_mult 越域 [1.1,16]")
|
||
var et2 = load("res://data/schemas/enemy_table.gd").new()
|
||
et2.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "a", "weight": 0}]}])
|
||
check(not et2.validate().is_empty(), "加载校验拒绝:池权重 ≤ 0")
|
||
et2.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": []}])
|
||
check(not et2.validate().is_empty(), "加载校验拒绝:空池(禁)")
|
||
# 新效应词汇批(2026-09-19):妨害 / 必中附加分量校验组
|
||
skill_table.rows.assign([
|
||
{"skill_id": "w1", "display_name": "w1", "type": "debuff", "buff_ref": "off_guard", "buff_duration": 2, "cooldown_beats": 4},
|
||
{"skill_id": "w2", "display_name": "w2", "type": "debuff", "buff_ref": "off_guard", "buff_duration": 2, "cooldown_beats": 4}])
|
||
enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "w1", "weight": 1}, {"skill_id": "w2", "weight": 1}]}])
|
||
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"),
|
||
"加载校验拒绝:池妨害技 > 1(每池妨害 ≤1 纪律)")
|
||
skill_table.rows.assign([{"skill_id": "w1", "display_name": "w1", "type": "debuff", "cooldown_beats": 4}])
|
||
check(not skill_table.validate().is_empty(), "加载校验拒绝:妨害技缺 buff_ref(效果引用)")
|
||
skill_table.rows.assign([{"skill_id": "d", "display_name": "d", "type": "damage", "damage_mult": 1.5, "rider_mult": 1.5, "cooldown_beats": 1}])
|
||
check(not skill_table.validate().is_empty(), "加载校验拒绝:rider_mult 越域 (0,1]")
|
||
skill_table.rows.assign([{"skill_id": "b", "display_name": "b", "type": "buff", "buff_ref": "empower", "buff_duration": 2, "rider_mult": 0.5, "cooldown_beats": 1}])
|
||
check(not skill_table.validate().is_empty(), "加载校验拒绝:非伤害技带必中附加分量")
|
||
# 施放类 side 守卫:妨害技引用敌方侧状态 → 拒绝(增益技引用主角侧同罪,本组以妨害反例覆盖)
|
||
skill_table.rows.assign([
|
||
{"skill_id": "atk", "display_name": "a", "type": "basic", "cooldown_beats": 0},
|
||
{"skill_id": "w1", "display_name": "w1", "type": "debuff", "buff_ref": "empower", "buff_duration": 2, "cooldown_beats": 4}])
|
||
enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "atk", "weight": 1}, {"skill_id": "w1", "weight": 1}]}])
|
||
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table, _db.status_effects), "cast_side"),
|
||
"加载校验拒绝:妨害技引用敌方侧状态(施放类 side 守卫)")
|
||
|
||
|
||
func _has_err_category(errs: Array, category: String) -> bool:
|
||
for e in errs:
|
||
if String(e.get("category", "")) == category:
|
||
return true
|
||
return false
|
||
|
||
|
||
# ⑦ 新效应词汇批实装探针(2026-09-19——妨害技蛛网束缚 + 毒爆必中附加分量,实装面 = L2 Boss 卓尔):
|
||
# 停手轮驱动 (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
|
||
var venom_rows_seen := 0
|
||
var snare_seed := -1
|
||
var base_ac: int = int(_db.hero.armor_class)
|
||
var off_m: int = int(_db.status_effect_row("off_guard").magnitude)
|
||
var floor_v: int = int(_db.settlement.damage_floor)
|
||
for s in range(1, 401):
|
||
if venom_seed > 0 and snare_seed > 0:
|
||
break
|
||
var rg = load("res://core/rng_service.gd").new()
|
||
rg.set_master_seed(s)
|
||
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": 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):
|
||
if settlement.battle_result != "":
|
||
break
|
||
settlement.minesweeper.settle_batch([{"action": "stop"}])
|
||
var has_venom := false
|
||
var has_snare := false
|
||
var snare_round := -1
|
||
for row in rows:
|
||
if String(row.get("type", "")) != "damage" or String(row.get("source_class", "")) != "enemy_action":
|
||
if String(row.get("type", "")) == "cast" and String(row.get("skill_id", "")) == "web_snare":
|
||
has_snare = true
|
||
snare_round = int(row.get("round_index", -1))
|
||
continue
|
||
if String(row.get("skill_id", "")) == "venom_burst":
|
||
has_venom = true
|
||
venom_rows_seen += 1
|
||
var rider_v: int = int(row.get("rider", -1))
|
||
if rider_v != maxi(floor_v, int(floor(float(boss_atk) * 0.75))):
|
||
venom_check_ok = false
|
||
# 终值精确复算(停手轮池恒空):主分量链结果 + 必中附加后置
|
||
var base_factor: float = float(int(row.tier)) * (float(_db.settlement.crit_multiplier) / 100.0 if bool(row.get("crit", false)) else 1.0)
|
||
var rolled_main: int = int(floor(float(int(row.baseline)) * base_factor / 100.0))
|
||
var expected: int = maxi(floor_v, rolled_main + int(row.get("attach", 0))) + rider_v
|
||
if int(row.value) != expected:
|
||
venom_check_ok = false
|
||
elif row.has("rider"):
|
||
venom_check_ok = false # rider 键只允许出现在带 rider_mult 的技能行
|
||
if has_snare:
|
||
# 施放后主角掷点阈值下调(off_guard ac 轴):同轮起的伤害行阈值读数 = 基准 AC + 失衡幅度。
|
||
# 只取"可观测"种子(施放轮后仍有敌方伤害行——施放在末轮随战斗终局属合法不可观测,不作失败)。
|
||
var seen_lowered := false
|
||
for row in rows:
|
||
if String(row.get("type", "")) != "damage" or String(row.get("source_class", "")) != "enemy_action":
|
||
continue
|
||
if int(row.get("round_index", -1)) >= snare_round and int(row.get("ac", -999)) == base_ac + off_m:
|
||
seen_lowered = true
|
||
break
|
||
if seen_lowered and snare_seed < 0:
|
||
snare_seed = s
|
||
if has_venom and venom_seed < 0:
|
||
venom_seed = s
|
||
check(venom_seed > 0 and venom_rows_seen >= 1, "毒爆必中附加分量种子可达(seed=%d,毒爆伤害行 ×%d)" % [venom_seed, venom_rows_seen])
|
||
check(venom_check_ok, "毒爆伤害行逐拍精确复算:终值 = max(1, 掷点档位主分量) + 必中附加(rider 只随 rider_mult 行出现)")
|
||
check(snare_seed > 0, "蛛网束缚落主角:施放后掷点阈值 = 基准 AC + 失衡幅度(off_guard ac 轴端到端,seed=%d)" % snare_seed)
|
||
|
||
|
||
# ---- 分支节点图批冒烟:图合法性校验 + L2 幽暗地域图版行进(2026-09-17——关卡节点地图 v1.0 图版落地)----
|
||
|
||
func _smoke_level_graph() -> void:
|
||
print("--- 关卡节点地图·分支节点图(L2 幽暗地域)---")
|
||
var autoplay = load("res://smoke/level_autoplay.gd")
|
||
# ① L1 单链与 L2 图经同一套加载校验(单链 = 出边恰一的退化形态——load_tables 全绿已含,显式复述)
|
||
check(_db.level.validate().is_empty(), "LevelTable 图合法性校验:L1 单链 + L2 图同套算法零错误")
|
||
# ② L2 行集结构(网状图建议规模)
|
||
var l2 := {}
|
||
for row in _db.level.rows:
|
||
if int(row.level_index) == 2:
|
||
l2 = row
|
||
check(not l2.is_empty() and _level_active_nodes(2).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 _level_active_nodes(2):
|
||
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 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()
|
||
var good := [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [2]},
|
||
{"node_depth": 2, "type": "boss"}]
|
||
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": good}])
|
||
check(lt.validate().is_empty(), "图校验正例:两岔合流小图零错误")
|
||
var dup = JSON.parse_string(JSON.stringify(good))
|
||
dup[1].node_depth = 0
|
||
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dup}])
|
||
check(_has_err_category(lt.validate(), "dup_key"), "图校验拒绝:节点编号重复(关卡内唯一定位地址)")
|
||
var dual = JSON.parse_string(JSON.stringify(good))
|
||
dual.append({"node_depth": 9, "type": "battle", "next": [2]})
|
||
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dual}])
|
||
check(_has_err_category(lt.validate(), "structural"),
|
||
"图校验拒绝:第二无入边节点(入口唯一违例——入口节点数 = 3)")
|
||
var boss_out = JSON.parse_string(JSON.stringify(good))
|
||
boss_out[2].next = [1]
|
||
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": boss_out}])
|
||
check(_has_err_category(lt.validate(), "structural"), "图校验拒绝:Boss 带出边(终点不可外出)")
|
||
var cyc = JSON.parse_string(JSON.stringify(good))
|
||
cyc[1].next = [0]
|
||
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": cyc}])
|
||
check(_has_err_category(lt.validate(), "structural"), "图校验拒绝:回边 1→0(连线只朝前进方向 ⇒ 无环)")
|
||
var dang = JSON.parse_string(JSON.stringify(good))
|
||
dang[0].next = [1, 7]
|
||
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dang}])
|
||
check(_has_err_category(lt.validate(), "dangling_next"), "图校验拒绝:出边指向不存在编号(悬空致命)")
|
||
var orphan = JSON.parse_string(JSON.stringify(good))
|
||
orphan[0].next = [1]
|
||
orphan[1].next = [2]
|
||
orphan.append({"node_depth": 5, "type": "battle", "next": [2]})
|
||
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": orphan}])
|
||
var orphan_errs: Array = lt.validate()
|
||
check(_has_err_category(orphan_errs, "structural"), "图校验拒绝:孤儿节点(从入口不可达 / 双入口同体违例)")
|
||
# ④ L2 选路推进:min / max 双路径全通(确定性路径轴——零随机流自开)
|
||
# 并流批(ADR-0007 裁丙)后战斗随机值面漂移——max 路径种子改行为面扫描(路线形状/商店/选路口为断言本体)
|
||
var win_min = autoplay.run_level(_db, 42, 2, 200, 100, false, null, "normal", "min", 0)
|
||
check(win_min.result == "victory", "L2·满配 min 路径通关(实测:%s)" % win_min.result)
|
||
check(win_min.boss_depth == 9, "L2 Boss 以编号 9 解析(终点编号最大——商店房批重编号 8→9)")
|
||
check(win_min.visited_ids == [0, 1, 3, 7, 8, 9], "min 路径 = 0→1→3(休整)→7→8(商店零购离开)→9(实测 %s——T17 出度≤2 拍板模板 0 重建)" % str(win_min.visited_ids))
|
||
check(win_min.choices >= 3, "选路口发生 %d 次(每节点兑付后——零自动推进)" % win_min.choices)
|
||
# max 路径 = 存储种子(T17 改造——废除逐种子扫描):本项目核心承诺=确定性复放(同代码同表
|
||
# 同种子逐位一致),「能赢且路径符合的种子」是恒定事实,采一次存死即可。旧形态 range(42,300)
|
||
# 逐种子全关卡通关扫描找命中,期望路径过期时 258 次模拟撞 180s 闸(台账 257 + 2026-10-02
|
||
# 出度≤2 重建两次实证)。存储种子常态一次跑完;图表/数值有意变更致本断言红=快速失败(一次
|
||
# 即红非扫描超时),重采=临时脚本跑同款 run_level 循环取首命中种子回填(离线一次性)。
|
||
const MAX_PATH_SEED := 43 # 2026-10-05 判式换代批重采(旗溢价 δ=1.25 落地致种子 42 战局位移=败于终点;同款循环首命中——断言注释既定程序,临时脚本用后即删)
|
||
var win_max: Dictionary = autoplay.run_level(_db, MAX_PATH_SEED, 2, 200, 100, false, null, "normal", "max", 0)
|
||
check(win_max.result == "victory" and win_max.visited_ids == [0, 2, 6, 7, 8, 9],
|
||
"L2·满配 max 路径(max 取最大编号候选)通关 = 0→2(奖励)→6→7→8(商店零购离开)→9(存储种子 %d——T17 出度≤2 拍板模板 0 重建)" % MAX_PATH_SEED)
|
||
# ⑤ 零自动推进状态机(手动驱动——兑付确认 / 选路拒绝语义逐拍断言)
|
||
var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42))
|
||
runner.set_hero_override(200, 100)
|
||
var choice_sizes: Array = []
|
||
runner.choices_presented.connect(func(c): choice_sizes.append(c.size()))
|
||
check(runner.start_level(2, 42, {}, 0), "手动驱动:L2 启动(入口战斗直接开打——入口无前置兑付;template_pick=0 固定现行图——精确选路断言)")
|
||
var st = _battle_st("normal")
|
||
var first = runner.current_battle()
|
||
var res_box := {} # 信号写回容器(lambda 按值捕获局部变量——Dictionary 引用可变)
|
||
first.battle_finished.connect(func(p): res_box["result"] = String(p.get("result", "")))
|
||
while runner.current_battle() == first and not res_box.has("result"):
|
||
autoplay._autoplay_battle(runner, first, st, null)
|
||
check(String(res_box.get("result", "")) == "victory" and runner.current_battle() == null and runner.awaiting_choice(),
|
||
"首战胜利后:settlement 释放 + 进选路态(战斗胜利不再自动推进——接口漂移清偿)")
|
||
check(runner.next_candidate_ids() == [1, 2], "入口岔路候选 = [1, 2](T17 出度≤2 拍板模板 0 宽廊双干——层宽 4 上限随出度约束)")
|
||
check(not runner.pick_next_node(8) and runner.awaiting_choice(), "候选外编号拒绝且仍待选(拒绝语义)")
|
||
check(runner.pick_next_node(2), "选编号 2(奖励点)受理")
|
||
check(String(runner.current_node().get("type", "")) == "reward" and not runner.awaiting_choice(),
|
||
"到达奖励点:直通兑付(确认前不出选路口)")
|
||
check(runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [5, 6],
|
||
"兑付确认后才出现选路口(continue_after_node 泛化语义)候选 = [5, 6]")
|
||
check(runner.pick_next_node(6) and runner.current_battle() != null, "选编号 6(巨蜘蛛巢群)开战")
|
||
# ⑥ L2 锚行首遇战:基线 0 加成 · 完美档(零盲挖 = 零踩雷上限口径)多种子扫描
|
||
# (Package B 第 7 轮阶段拔高后锚行 45→50 退出绿档——13/12 全胜改多数胜 + 零踩雷恒要求,
|
||
# 2026-09-23 拍板 B 接受现曲线;还原绿档需回调锚行血量,归用户决策)
|
||
var green_ok := true
|
||
var green_min_hp := 99999
|
||
var green_wins := 0
|
||
for i in range(12):
|
||
var g_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(9100 + i))
|
||
g_runner.start_level(2, 9100 + i)
|
||
var g_st = _battle_st("perfect")
|
||
var g_first = g_runner.current_battle()
|
||
var g_box := {}
|
||
g_first.battle_finished.connect(func(p):
|
||
g_box["result"] = String(p.get("result", ""))
|
||
g_box["hero_hp"] = int(p.get("hero_hp", 0)))
|
||
var g_guard := 6
|
||
while g_runner.current_battle() == g_first and not g_box.has("result") and g_guard > 0:
|
||
g_guard -= 1
|
||
autoplay._autoplay_battle(g_runner, g_first, g_st, null)
|
||
if int(g_st.mine_hits) > 0:
|
||
green_ok = false
|
||
if String(g_box.get("result", "")) == "victory":
|
||
green_wins += 1
|
||
if int(g_box.get("hero_hp", 0)) > 0:
|
||
green_min_hp = mini(green_min_hp, int(g_box.hero_hp))
|
||
check(green_ok, "首遇位零踩雷:完美档 12 种子(节点 0 熊地精)恒零踩雷")
|
||
check(green_wins >= 7, "首遇战胜局 ≥7/12(实际 %d——阶段拔高后锚行退出绿档,多数胜口径)" % green_wins)
|
||
check(green_min_hp > 0, "首遇战胜后 HP 下探读数 %d(>0——绿档记录)" % green_min_hp)
|
||
# ⑦ 跨表行完整:L2 每战斗/Boss 节点 (2, 编号) 在 board_gen 与 enemy 精确行(加载校验同款显式复述)
|
||
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
|
||
var cross_ok := true
|
||
for node in _level_active_nodes(2):
|
||
var t := String(node.get("type", ""))
|
||
if t != "battle" and t != "boss":
|
||
continue
|
||
var key := "2_%d" % int(node.node_depth)
|
||
if not bg_keys.has(key) or not en_keys.has(key):
|
||
cross_ok = false
|
||
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, 9)
|
||
check(String(l2_boss.get("enemy_id", "")) == "drider" and String(l2_boss.get("armor_type", "")) == "ward",
|
||
"蛛化卓尔精灵 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 _level_active_nodes(3).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 _level_active_nodes(3):
|
||
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 and int(tcount3.shop) == 1, "L3 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)")
|
||
check(branch3 == 3, "L3 真实岔路 = %d 处(0→[1,2,3,4,5] · 2→[6,7] · 5→[6,7]——U2 返工批五岔环廊层宽 5)" % branch3)
|
||
# ⑨ 跨表行完整:L3 每战斗/Boss 节点 (3, 编号) 在 board_gen 与 enemy 精确行(⑦ 的 bg_keys/en_keys 复用)
|
||
var cross_ok3 := true
|
||
for node in _level_active_nodes(3):
|
||
var t3 := String(node.get("type", ""))
|
||
if t3 != "battle" and t3 != "boss":
|
||
continue
|
||
var key3 := "3_%d" % int(node.node_depth)
|
||
if not bg_keys.has(key3) or not en_keys.has(key3):
|
||
cross_ok3 = false
|
||
check(cross_ok3, "跨表行完整:L3 全部战斗/Boss 节点在 board_gen + enemy 精确行(7 节点)")
|
||
# ⑩ 定点:L3 首遇行集与 Boss 行集(克制面重轻错开——重甲灰矮人首次登台)
|
||
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, 9)
|
||
check(String(l3_king.get("enemy_id", "")) == "duergar_king" and String(l3_king.get("armor_type", "")) == "heavy",
|
||
"灰矮人石君王 Boss 行集 (3,9) = duergar_king(重甲收束「轻甲→魔法甲→重甲」教学梯度;商店房批重编号 (3,8)→(3,9))")
|
||
# ⑪ L3 成长代理档首遇绿档:模拟 GP 商店成长后的入关参数(atk_pct=200 / hp_pct=100)· 完美档多种子首遇战稳过
|
||
# (零加成基线红档属预期——断言口径 = 成长代理档,非基线;跑法同 ⑥)
|
||
var green3_ok := true
|
||
var green3_min_hp := 99999
|
||
for i in range(12):
|
||
var g3_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(9100 + i))
|
||
g3_runner.set_hero_override(200, 100)
|
||
g3_runner.start_level(3, 9100 + i)
|
||
var g3_st = _battle_st("perfect")
|
||
var g3_first = g3_runner.current_battle()
|
||
var g3_box := {}
|
||
g3_first.battle_finished.connect(func(p):
|
||
g3_box["result"] = String(p.get("result", ""))
|
||
g3_box["hero_hp"] = int(p.get("hero_hp", 0)))
|
||
var g3_guard := 6
|
||
while g3_runner.current_battle() == g3_first and not g3_box.has("result") and g3_guard > 0:
|
||
g3_guard -= 1
|
||
autoplay._autoplay_battle(g3_runner, g3_first, g3_st, null)
|
||
if String(g3_box.get("result", "")) != "victory" or int(g3_st.mine_hits) > 0:
|
||
green3_ok = false
|
||
elif int(g3_box.get("hero_hp", 0)) > 0:
|
||
green3_min_hp = mini(green3_min_hp, int(g3_box.hero_hp))
|
||
check(green3_ok, "L3 首遇位绿档:成长代理档口径(atk_pct=200/hp_pct=100)完美档 12 种子首遇战(节点 0 灰矮人)全胜且零踩雷")
|
||
check(green3_min_hp > 0, "L3 首遇战胜后 HP 下探读数 %d(>0——绿档记录)" % green3_min_hp)
|
||
|
||
|
||
# 冒烟隔离用全新 RngService(同 master 种子——回放确定性纪律)
|
||
func _fresh_rng(seed_val: int):
|
||
var rng = load("res://core/rng_service.gd").new()
|
||
rng.set_master_seed(seed_val)
|
||
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 := _battle_st("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(900, 400) # 行进段快速通关档(购买断言与百分比轴正交——加算区单独断言)
|
||
runner.start_level(2, s, {}, 0) # 固定模板 0——商店经济断言按现行图 GP 账本
|
||
var st := _battle_st("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 档,现价现算)")
|
||
# 行进段百分比归零(购买/生效断言按加算区裸值口径——调试浮窗百分比后乘;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
|
||
var hp_buys := 1 # 夹具前段已购 1 笔(购买 hp_up 受理断言)——总数 = 前段 + 本循环
|
||
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
|
||
hp_buys += 1
|
||
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()
|
||
# max_hp 期望按实际购买笔数推导(迭代14① T4 断言基线复核——棋盘换代改变战局 GP 流,可购笔数
|
||
# 随经济漂移;断言语义=「加算区笔数×3 全额入战斗开始参数」,不再锚死恰好 1 笔)
|
||
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 * hp_buys,
|
||
"战斗开始参数 = 基础表值 + 加算区(atk %d = 2+1 / max_hp %d = %d+%d×3)" % [int(snap["hero"].get("atk", -1)), int(snap["hero"].get("max_hp", -1)), int(_db.hero.max_hp), hp_buys])
|
||
# 冒险收束(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, _battle_st("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 _level_active_nodes(L):
|
||
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(_level_active_nodes(L))
|
||
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)——教学期最小修订面)")
|
||
|
||
|
||
# ---- ⑤ 商店共享池(M-4 迭代 11 第②批 T5——三选一同构/乘法价/跨渠道共账/切边/断点扩键)----
|
||
|
||
func _smoke_shop_pool() -> void:
|
||
print("--- 商店共享池(抽3买1全重抽 · 乘法定价 ceil · 池空硬闸 · 切边零入店)---")
|
||
var autoplay = load("res://smoke/level_autoplay.gd")
|
||
var shelf_kinds: Array = []
|
||
for row in _db.shop_pool_rows():
|
||
shelf_kinds.append(String(row.get("kind_id", "")))
|
||
# ① 池模式场景可达(_adv_drive_to_shop 复用——L1 全程+L2 推进到商店房;pool 模式进店即抽架)
|
||
var scen := {}
|
||
var first_shelf_a: Array = []
|
||
var first_shelf_b: Array = []
|
||
for s in range(42, 92):
|
||
var a8 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(s))
|
||
a8.set_hero_override(900, 400)
|
||
if not a8.start_adventure(s, {"first_script": true, "shop_mode": "pool"}):
|
||
continue
|
||
if not _adv_drive_to_shop(a8, _adv_st(), autoplay):
|
||
continue
|
||
scen = {"a": a8, "s": s, "r": a8.runner()}
|
||
first_shelf_a = (a8.runner().shop_shelf() as Array).duplicate(true)
|
||
break
|
||
check(not scen.is_empty(), "池模式商店房场景可达(seed=%s)" % str(scen.get("s", -1)))
|
||
if not scen.is_empty():
|
||
# 同种子再跑一遍采第二份首架(确定性对拍——断点货架续玩=确定性重放承载的实证面)
|
||
var a8b = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(int(scen["s"])))
|
||
a8b.set_hero_override(900, 400)
|
||
if a8b.start_adventure(int(scen["s"]), {"first_script": true, "shop_mode": "pool"}) \
|
||
and _adv_drive_to_shop(a8b, _adv_st(), autoplay):
|
||
first_shelf_b = (a8b.runner().shop_shelf() as Array).duplicate(true)
|
||
check(first_shelf_a.size() == 3, "进店抽 3(货架 %d 卡——同构交互)" % first_shelf_a.size())
|
||
# MC-1 商店独立刷新(P-C2+Q2 裁——基础免费 0;GP 刷新 ceil(20×1.8^n) provisional;重抽同源)
|
||
if not scen.is_empty():
|
||
var r8 = scen["r"]
|
||
var gp0: int = int(r8.gp)
|
||
var cost0: int = r8.shop_refresh_cost()
|
||
check(cost0 == int(_db.adventure.shop_refresh_cost_base), "刷新首价=基价(%d)" % cost0)
|
||
check(r8.shop_refresh_free_left() == 0, "基础免费 0 次(Q2——天赋增益位后续添加)")
|
||
var shelf_pre: Array = (r8.shop_shelf() as Array).duplicate(true)
|
||
check(r8.shop_refresh(), "GP 刷新受理(gp=%d cost=%d)" % [gp0, cost0])
|
||
check(int(r8.gp) == gp0 - cost0, "GP 刷新扣费(%d→%d)" % [gp0, int(r8.gp)])
|
||
check(int(r8.shop_refresh_cost()) == int(ceil(float(cost0) * float(_db.adventure.shop_refresh_cost_curve))), "刷新爬价 ×curve(%d)" % int(r8.shop_refresh_cost()))
|
||
var same_shelf := first_shelf_a.size() == first_shelf_b.size()
|
||
if same_shelf:
|
||
for i in first_shelf_a.size():
|
||
if String(first_shelf_a[i].get("kind_id", "")) != String(first_shelf_b[i].get("kind_id", "")) \
|
||
or int(first_shelf_a[i].get("price", -1)) != int(first_shelf_b[i].get("price", -1)):
|
||
same_shelf = false
|
||
break
|
||
check(same_shelf, "同种子同货架逐位(确定性重放承载——断点货架续玩语义)")
|
||
if scen.is_empty():
|
||
return
|
||
var a8 = scen["a"]
|
||
var r8 = scen["r"]
|
||
# ② 货架投影形制:kind 词表封闭 + 现价公式推导(ceil(base×b^(关序−1)×A^0)——核心现算,呈现零复算)
|
||
var l2 := int(r8.level_row.level_index)
|
||
for c in r8.shop_shelf():
|
||
# T6 商店保底后 relic 卡合法入架(保底占位)——词表断言放行 relic;价格断言分叉
|
||
# (数值卡走 shop_pool_row 推导 / relic 卡走 15 固定锚——同公式不同基价)
|
||
var ckind := String(c.get("kind_id", ""))
|
||
var is_relic := ckind == "relic"
|
||
check(is_relic or shelf_kinds.has(ckind), "货架 kind 在封闭词表或保底 relic 卡(%s)" % ckind)
|
||
var expect_p: int
|
||
if is_relic:
|
||
expect_p = int(ceil(15.0 * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0))
|
||
else:
|
||
var prow: Dictionary = _db.shop_pool_row(ckind)
|
||
expect_p = int(ceil(float(int(prow.get("base_price", 0))) * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0))
|
||
check(int(c.get("price", -1)) == expect_p,
|
||
"首购现价 = ceil(基价×b^(关序−1))(%s: %d = %d 推导)" % [ckind, int(c.get("price", -1)), expect_p])
|
||
# ③ 买 1 全重抽:出账+加算区+账本三落点 + 现价 A 链递增(买后 visit 计数=1;TP0——消费在架令牌本体)
|
||
var buy_c: Dictionary = {}
|
||
for c in r8.shop_shelf():
|
||
if int(c.get("price", 1 << 30)) <= int(r8.gp):
|
||
buy_c = c
|
||
break
|
||
check(not buy_c.is_empty(), "可购卡在架(GP %d——种子扫描保证首购空间)" % int(r8.gp))
|
||
if not buy_c.is_empty():
|
||
var kind := String(buy_c.get("kind_id", ""))
|
||
var tier := String(buy_c.get("tier", ""))
|
||
var val_map := {"attack": "atk", "max_hp": "max_hp", "armor": "armor_class", "ap": "ap"}
|
||
var gp0: int = int(r8.gp)
|
||
var taken0: int = int(a8._curio_taken.get(kind, 0))
|
||
var val0: int = int(a8.bonus.get(String(val_map[kind]), -999))
|
||
check(r8.buy_pool_item(kind), "购买受理(pool 口——%s|%s)" % [kind, tier])
|
||
check(int(r8.gp) == gp0 - int(buy_c.get("price", 0)), "钱包扣现价(%d → %d)" % [gp0, int(r8.gp)])
|
||
check(int(a8.bonus.get(String(val_map[kind]), -999)) == val0 + int(_db.adventure.curio_value(kind, tier)),
|
||
"加算区 +令牌档位件值(池件=奖励件本体——%s|%s %+d)" % [kind, tier, int(_db.adventure.curio_value(kind, tier))])
|
||
check(int(a8._curio_taken.get(kind, 0)) == taken0 + 1, "共享池账本 -1(跨渠道单点——taken %d → %d)" % [taken0, int(a8._curio_taken.get(kind, 0))])
|
||
# A 链:同visit 第二购现价 = ceil(base×b^(l2−1)×A^1)(货架已全重抽——新卡新价同公式)
|
||
var second: Dictionary = {}
|
||
for c in r8.shop_shelf():
|
||
if int(c.get("price", 1 << 30)) <= int(r8.gp):
|
||
second = c
|
||
break
|
||
if not second.is_empty():
|
||
var prow2: Dictionary = _db.shop_pool_row(String(second.get("kind_id", "")))
|
||
var expect_p2: int = int(ceil(float(int(prow2.get("base_price", 0))) * pow(_db.shop_price_b(), float(l2 - 1)) * _db.shop_price_a()))
|
||
check(int(second.get("price", -1)) == expect_p2,
|
||
"A 链爬价 = ceil(base×b^(关序−1)×A^1)(进店第 2 购——%d)" % expect_p2)
|
||
check(r8.buy_pool_item(String(second.get("kind_id", ""))), "同 visit 第二购受理(A 链软闸在价不在限)")
|
||
# ④ 切边零入店(白-box 重抽 40 次——GP 袋/规则改写型特性永不在架;重抽消费 shop_draw 签,场景尾不回写主时间线)
|
||
var cut_seen := false
|
||
for i in 40:
|
||
r8._shop_draw_shelf()
|
||
for c in r8.shop_shelf():
|
||
if String(c.get("kind_id", "")) == "gp" or String(c.get("kind_id", "")) == "treasure_item":
|
||
cut_seen = true
|
||
check(not cut_seen, "切边件零入店(40 次重抽零 gp/aux——负交易切边+三轴 doctrine)")
|
||
# ⑤ 池空硬闸(库存尽剔出牌库——TP0:white-box 耗尽 attack 令牌后货架零 attack + 消费口拒绝)
|
||
for di8 in range(a8._curio_deck.size() - 1, -1, -1):
|
||
if String((a8._curio_deck[di8] as Dictionary).get("kind", "")) == "attack":
|
||
a8._curio_deck.remove_at(di8)
|
||
var attack_seen := false
|
||
for i in 40:
|
||
r8._shop_draw_shelf()
|
||
if (r8.shop_shelf() as Array).any(func(c): return String(c.get("kind_id", "")) == "attack"):
|
||
attack_seen = true
|
||
break
|
||
check(not attack_seen, "库存尽剔牌库(attack 令牌耗尽——池消耗即上限跨渠道共库 reward→shop 向)")
|
||
var stock_before: int = a8.pool_stock("attack")
|
||
check(not a8._consume_pool_purchase("attack", "common") and a8.pool_stock("attack") == stock_before,
|
||
"池空消费拒绝(handler 零副作用——买光即枯竭硬闸)")
|
||
# ⑥ 跨渠道共库 shop→reward 向:回填一枚 attack 令牌 → 消费口受理 → 奖励牌库同步摘牌
|
||
a8._curio_deck.append({"kind": "attack", "tier": "common"})
|
||
check(a8._consume_pool_purchase("attack", "common"), "商店消费口受理(_apply_curio_pick 同口——真单点库)")
|
||
var reward_attack_seen := false
|
||
for t6 in a8._draw_curio_candidates():
|
||
if String((t6 as Dictionary).get("kind", "")) == "attack":
|
||
reward_attack_seen = true
|
||
break
|
||
check(not reward_attack_seen,
|
||
"商店购买进奖励牌库摘牌(买一件=奖励面少一件——shop→reward 向共库)")
|
||
# ⑦ 断点扩键(P2 甲——save v4 域内:shop_shelf/shop_visit_buys 入快照;离开店=空架合法态)
|
||
var sm = load("res://core/save_manager.gd").new("user://_smoke_shop_pool_save.json")
|
||
sm.load_from_disk()
|
||
a8.set_save_manager(sm)
|
||
check(r8.continue_after_node() and r8.awaiting_choice(), "离开商店 → 回选路口(货架态清零落档——零自动推进收口)")
|
||
var bp: Dictionary = sm.breakpoint_snapshot()
|
||
check(bp.has("shop_shelf") and (bp["shop_shelf"] as Array).is_empty(),
|
||
"断点扩键 shop_shelf 在案(离开店=空架合法态——P2 甲扩列)")
|
||
check(bp.has("shop_visit_buys") and int(bp["shop_visit_buys"]) == 0,
|
||
"断点扩键 shop_visit_buys 在案(离开店清零——A 链输入不入档语义)")
|
||
sm.clear_breakpoint()
|
||
# ⑧ 终局池读数(run_adventure pool 面——商店与奖励跨渠道消耗后的终局余量出讫)
|
||
var st_pool: Dictionary = autoplay.run_adventure(_db, 42, 0, 0, false, null, "normal", "min", {}, {}, {"mode": "pool"})
|
||
var ps: Dictionary = st_pool.get("pool_stock", {})
|
||
check(ps.size() == 4, "终局池读数四键出讫(run_adventure pool 面——读数只锚一次载体)")
|
||
var ps_ok := true
|
||
for k in ps.keys():
|
||
var quota := 0
|
||
for tier in _db.adventure.TIERS:
|
||
quota += int(_db.adventure.curio_quota(String(k), tier))
|
||
if int(ps[k]) < 0 or int(ps[k]) > quota:
|
||
ps_ok = false
|
||
check(ps_ok, "终局池余量在域 [0,配额](%s)" % str(ps))
|
||
|
||
|
||
# 入口→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() == 10 and RewardTable.CELL_TYPES.has("gp") and RewardTable.CELL_TYPES.has("treasure_item"),
|
||
"格面资源十型封闭(第九型 gp 九型批 2026-09-20 / 第十型 treasure_item 秘宝内容批 2026-09-29——词表 v1.2)")
|
||
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 行(九型×正负 18 格——aux 挖格投放四行已退役 2026-10-01 用户拍板甲:战斗/挖格不掉道具,秘宝唯一来路=三选一共享池〔节点/通关〕;清图槽独立批尾不计)")
|
||
# 雷格负向直伤当量(迭代 8 卡⑧——真人反馈「三雷 3/4 血无后果感」投值:三行各抬 +3 档,负向≈正向 2 倍定价)
|
||
var mine_power := {"heal_backlash": 6, "pierce_snare": 8, "magic_recoil": 8} # 数值修复批 2026-10-01 再抬档(旧 4/5/5,见 numfix 报告)
|
||
for row in _db.reward.rows:
|
||
var rid := String(row.get("reward_id", ""))
|
||
if mine_power.has(rid):
|
||
check(int(row.get("power", -1)) == int(mine_power[rid]),
|
||
"雷格负向直伤 %s power=%d(卡⑧投值)" % [rid, int(mine_power[rid])])
|
||
# 正向权重结构校验(配置推导,2026-09-23 重构):正向 cell 行权重 ≥1(必在池)、负向行权重恒 0(不入池)、
|
||
# 合计即配置自身的和(改表自动跟随,不再硬编码合计值)。
|
||
var pos_all_in_pool := true
|
||
var neg_all_zero := true
|
||
for row in _db.reward.rows:
|
||
if String(row.get("pool_tag", "")) != "cell":
|
||
continue
|
||
if String(row.get("polarity", "")) == "positive" and int(row.get("weight", 0)) < 1:
|
||
pos_all_in_pool = false
|
||
if String(row.get("polarity", "")) == "negative" and int(row.get("weight", 0)) != 0:
|
||
neg_all_zero = false
|
||
check(pos_all_in_pool and neg_all_zero,
|
||
"正向 cell 行权重 ≥1 全在池 / 负向行权重恒 0 不入池(正向合计 %d——配置推导)" % pos_weight)
|
||
check(gp_pos == 2 and gp_neg == 0, "gp 型两行(金币袋 gp_gain 正向 weight 2 / 破财 gp_loss 负向 weight 0 不入池)")
|
||
var rng = _fresh_rng(42)
|
||
check(rng.counter() == 0, "derive_at 纯函数不推计数器(统一流全局计数器恒 0——并流后纯函数语义不变,gp_drop 专用向量随分盐作废、统一向量中央断言)")
|
||
|
||
|
||
# ---- 元进度·MetaTable 表结构(元进度实现批 T1——行值全 provisional 归元进度设计批配读数;
|
||
# 注入契约三字段必齐 = forge/conversion 覆盖恰三,Boss 两档 first ≥ repeat)----
|
||
func _smoke_meta() -> void:
|
||
print("--- 元进度·MetaTable 表结构 ---")
|
||
var meta_tbl = _db.meta
|
||
check(meta_tbl != null and meta_tbl.validate().is_empty(),
|
||
"MetaTable 非空 + validate 零错(四行族词表封闭/唯一/连续/域界/first≥repeat/ratio 全过)")
|
||
check(meta_tbl.forge_products.size() == 3 and meta_tbl.boss_keystone.size() == 3
|
||
and meta_tbl.workshop_items.size() == 6 and meta_tbl.conversion.size() == 3,
|
||
"MetaTable 四行族规模 3/3/6/3(forge/Boss/换算恰三 · 工坊恰六)")
|
||
var attrs_ok := true
|
||
for a in ["max_hp", "atk", "armor_class"]:
|
||
attrs_ok = attrs_ok and not _db.meta_forge_row(a).is_empty() and not _db.meta_conversion_row(a).is_empty()
|
||
check(attrs_ok, "forge/conversion attr 词表封闭恰全(注入契约三字段齐——meta_* 访问器可达)")
|
||
var boss_ok := true
|
||
for li in [1, 2, 3]:
|
||
var brow: Dictionary = _db.meta_boss_keystone_row(li)
|
||
boss_ok = boss_ok and not brow.is_empty() and int(brow.get("first_kill", 0)) >= int(brow.get("repeat_kill", -1))
|
||
check(boss_ok, "boss_keystone L1–L3 行连续可达且 first_kill ≥ repeat_kill 全行(首杀全量/重复小额)")
|
||
_meta_pricing_and_buy()
|
||
_meta_settle_and_workshop()
|
||
_meta_injection()
|
||
|
||
|
||
# 冒烟隔离存档名册(段首清陈旧 + 段尾清理——契约:测试不污染 user://save.json 真实档)
|
||
const _META_SMOKE_PATHS := ["user://smoke_meta_buy.json", "user://smoke_meta_dry.json",
|
||
"user://smoke_meta_settle.json", "user://smoke_meta_workshop.json",
|
||
"user://smoke_meta_cap.json", "user://smoke_meta_white.json",
|
||
"user://smoke_meta_attune.json", "user://smoke_meta_attune2.json",
|
||
"user://smoke_meta_tree.json"]
|
||
|
||
|
||
func _meta_smoke_cleanup() -> void:
|
||
for p in _META_SMOKE_PATHS:
|
||
for suffix in ["", ".tmp", ".corrupt.bak"]:
|
||
if FileAccess.file_exists(p + suffix):
|
||
DirAccess.remove_absolute(p + suffix)
|
||
|
||
|
||
func _meta_new_save(path: String):
|
||
var sv = load("res://core/save_manager.gd").new(path)
|
||
sv.load_from_disk()
|
||
return sv
|
||
|
||
|
||
# ---- 元进度 ②几何定价 ③购买(余额充足持久 / 不足拒绝 / attr 出域拒绝)----
|
||
func _meta_pricing_and_buy() -> void:
|
||
print("--- 元进度·几何定价与购买(隔离档 smoke_meta_buy/dry)---")
|
||
_meta_smoke_cleanup()
|
||
var sv = _meta_new_save(_META_SMOKE_PATHS[0])
|
||
var mp = load("res://core/meta_progression.gd").new(_db, sv)
|
||
# ② 几何定价:tier 0/1/2 价格 == 迭代 floor(p × ratio/100) 复算值(tier 0 = base_price)且严格递增
|
||
for attr in MetaTable.FORGE_ATTRS:
|
||
var row: Dictionary = _db.meta_forge_row(attr)
|
||
var base := int(row.get("base_price", 0))
|
||
var ratio := int(row.get("price_ratio_pct", 100))
|
||
var all_ok := true
|
||
var prev := 0
|
||
for tier in 3:
|
||
sv.set_forge_level(attr, tier)
|
||
var expect := base
|
||
for _i in tier:
|
||
expect = int(floor(float(expect) * float(ratio) / 100.0))
|
||
all_ok = all_ok and mp.forge_price(attr) == expect and (tier == 0 or expect > prev)
|
||
prev = expect
|
||
check(all_ok, "%s 几何定价 tier 0/1/2 = 迭代 floor 复算且严格递增(base %d × ratio %d%%)" % [attr, base, ratio])
|
||
sv.set_forge_level("atk", 0)
|
||
# ③ 购买成功:级+1、余额−价;buy 内含写盘——reload 后持久
|
||
sv.add_keystone(1000)
|
||
var bal0: int = sv.keystone_balance()
|
||
var lvl0: int = sv.forge_level("atk")
|
||
var price0: int = mp.forge_price("atk")
|
||
check(mp.buy_forge("atk") and sv.forge_level("atk") == lvl0 + 1 and sv.keystone_balance() == bal0 - price0,
|
||
"购买成功(Lv %d→%d、余额 %d−%d)" % [lvl0, lvl0 + 1, bal0, price0])
|
||
var sv_r = _meta_new_save(_META_SMOKE_PATHS[0])
|
||
check(sv_r.forge_level("atk") == lvl0 + 1 and sv_r.keystone_balance() == bal0 - price0, "购买持久(reload 后级与余额不变)")
|
||
# ③ 余额不足 / attr 出域:false 零副作用
|
||
var sv_dry = _meta_new_save(_META_SMOKE_PATHS[1])
|
||
var mp_dry = load("res://core/meta_progression.gd").new(_db, sv_dry)
|
||
check(not mp_dry.buy_forge("atk") and sv_dry.keystone_balance() == 0 and sv_dry.forge_level("atk") == 0,
|
||
"余额不足 false 零副作用(0 余额购买拒绝)")
|
||
check(not mp_dry.buy_forge("luck") and sv_dry.keystone_balance() == 0, "attr 出域 false 零副作用(luck 不在词表)")
|
||
|
||
|
||
# ---- 元进度 ④注入可见 + 白板成立(验收 4/2/3——程序面;MC 逐位回归归 T7 全量验证)----
|
||
func _meta_injection() -> void:
|
||
print("--- 元进度·注入可见与白板(隔离档 smoke_meta_white/dry)---")
|
||
var sv = _meta_new_save(_META_SMOKE_PATHS[1])
|
||
var mp = load("res://core/meta_progression.gd").new(_db, sv)
|
||
sv.add_keystone(mp.forge_price("atk"))
|
||
check(mp.buy_forge("atk") and int(mp.hero_override().get("atk", -1)) == int(_db.meta_forge_row("atk").get("flat_value", 0)),
|
||
"买 atk 1 级 → hero_override().atk == 行 flat(验收4)")
|
||
var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251))
|
||
check(adr.start_adventure(920251, {"hero_override": mp.hero_override()}), "注入参数冒险启动(start_adventure params 通道)")
|
||
var r = adr.runner()
|
||
var ov: Dictionary = mp.hero_override()
|
||
check(int(r.effective_atk()) == int(_db.hero.atk) + int(ov.get("atk", 0)),
|
||
"首战 hero_atk = 表值 + 永久强化 flat(begin_battle 同表达式——下一局能用到强化)")
|
||
check(int(r.effective_max_hp()) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0))
|
||
and int(adr.ledger["hero_hp"]) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0)),
|
||
"首战 hero_max_hp 与开局生命账本 = 表值 + 注入档")
|
||
# 白板:同种子下 空参数 = 零强化参数 = 纯表值(逐位一致——注入通道默认值漏改在此转红)
|
||
var adr_w = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251))
|
||
check(adr_w.start_adventure(920251), "白板冒险启动(无 params 实参——B1 空参数路径)")
|
||
var sv_white = _meta_new_save(_META_SMOKE_PATHS[5])
|
||
var adr_z = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251))
|
||
check(adr_z.start_adventure(920251, {"hero_override": load("res://core/meta_progression.gd").new(_db, sv_white).hero_override()}),
|
||
"零强化参数冒险启动(新档 hero_override 全零)")
|
||
var rw = adr_w.runner()
|
||
var rz = adr_z.runner()
|
||
check(int(rw.effective_atk()) == int(_db.hero.atk) and int(rw.effective_max_hp()) == int(_db.hero.max_hp)
|
||
and int(rz.effective_atk()) == int(rw.effective_atk()) and int(rz.effective_max_hp()) == int(rw.effective_max_hp())
|
||
and int(rz.effective_ac()) == int(rw.effective_ac()),
|
||
"白板成立:空参数 = 零强化参数 = 纯表值(atk/max_hp/ac 三轴逐位一致——验收 2/3)")
|
||
check(int(rw.effective_ac()) == int(_db.hero.armor_class) and int(r.effective_ac()) == int(_db.hero.armor_class),
|
||
"ac 轴:白板恒表值、未换强化时注入零位移")
|
||
_meta_smoke_cleanup()
|
||
|
||
|
||
# ---- 元进度 ⑥Boss 两档 ⑦钥石不丢 ⑧工坊解锁 ⑩摘要副本与 cap(合成摘要驱动——settle 纯账面零跑局)----
|
||
func _meta_settle_and_workshop() -> void:
|
||
print("--- 元进度·收束入账与工坊(隔离档 smoke_meta_settle/workshop/cap)---")
|
||
var sv = _meta_new_save(_META_SMOKE_PATHS[2])
|
||
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)
|
||
# ⑥ 逐杀掉落收束入账(迭代 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": 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)) == 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": 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", 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": 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 首杀(明细随摘要逐笔归档)
|
||
var d4: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 2,
|
||
"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 金额)")
|
||
# ⑦ 败北零通关摘要:余额只增不减(零入账)、clear_counts 不变
|
||
var bal_before: int = sv.keystone_balance()
|
||
var cc1_before: int = sv.clear_count(1)
|
||
var dz: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 0,
|
||
"stopped_at": 1, "keystone_total": 0, "master_seed": 15})
|
||
check(dz["boss_drops"].is_empty() and sv.keystone_balance() == bal_before and sv.clear_count(1) == cc1_before,
|
||
"败北零通关摘要:零入账零标记(钥石只增不减——验收1;败北局 clear_counts 不变)")
|
||
# ⑧ 工坊解锁:settle 内已随 refresh——总通关次数达阈值件数 == unlocked 数;重复 refresh 幂等
|
||
var total_clears: int = sv.clear_count(1) + sv.clear_count(2) + sv.clear_count(3)
|
||
var expect_n := 0
|
||
for row in _db.meta_workshop_items():
|
||
if total_clears >= int(row.get("clear_threshold", 99)):
|
||
expect_n += 1
|
||
var got_n := 0
|
||
for st_row in mp.workshop_status():
|
||
got_n += 1 if bool(st_row.get("unlocked", false)) else 0
|
||
check(got_n == expect_n and mp.refresh_workshop_unlocks().is_empty(),
|
||
"工坊解锁数 = 总通关 %d 次达阈值件数(%d 件)且重复 refresh 幂等" % [total_clears, expect_n])
|
||
# 渠道可替换轴(2026-09-21 用户拍板):判定量单点正控 + 未知渠道 fail-safe(恒 0 = 永不解锁,非致命)
|
||
check(mp._workshop_progress("total_clears") == total_clears and mp._workshop_progress("adventure_depth") == 0,
|
||
"解锁判定量单点:total_clears 正控 + 未知渠道恒 0(channel 可替换轴 fail-safe)")
|
||
var sv_r = _meta_new_save(_META_SMOKE_PATHS[2])
|
||
var r_n := 0
|
||
for st_row in load("res://core/meta_progression.gd").new(_db, sv_r).workshop_status():
|
||
r_n += 1 if bool(st_row.get("unlocked", false)) else 0
|
||
check(r_n == expect_n, "工坊解锁持久(reload 后一致)")
|
||
# ⑧ 干净档:通关 1 次 → 恰两件阈值 1 道具新解锁(new_unlocks 明细)
|
||
var sv_w = _meta_new_save(_META_SMOKE_PATHS[3])
|
||
var mp_w = load("res://core/meta_progression.gd").new(_db, sv_w)
|
||
var u1: Dictionary = mp_w.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1,
|
||
"stopped_at": 2, "keystone_total": 0, "master_seed": 16})
|
||
check(u1["new_unlocks"].size() == 2 and sv_w.item_unlocked("probe_dust") and sv_w.item_unlocked("sapper_order")
|
||
and not sv_w.item_unlocked("loot_compass"), "通关 1 次 → 恰两件阈值 1 道具解锁(new_unlocks 明细正确)")
|
||
# ⑩ 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 (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:
|
||
mp_c.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 0,
|
||
"stopped_at": 1, "keystone_total": 0, "master_seed": i})
|
||
check(sv_c.recent_summaries().size() == 10 and int(sv_c.recent_summaries()[0].get("master_seed", -1)) == 11,
|
||
"摘要 cap 10 且最新在前(12 连 settle 截断后 seed 11 居首)")
|
||
# ⑪ 同调位账本(迭代 11 第①批 T4——装配持久/满位禁选/Y 升级语义;写口 = MetaProgression 同调段)
|
||
var sv_a = _meta_new_save(_META_SMOKE_PATHS[6])
|
||
var mp_a = load("res://core/meta_progression.gd").new(_db, sv_a)
|
||
check(Attunement.slots(_db, sv_a) == 0 and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 0,
|
||
"白板档同调位求值 = 0(0 起步裁决)且 Y=0(未购无刷新能力——P1 甲初始 0 同构)")
|
||
check(mp_a.toggle_attunement("venom_edge") == false,
|
||
"满位禁选(Slots 0 时装配拒绝)")
|
||
sv_a.add_keystone(200)
|
||
check(mp_a.buy_attune_refresh() and sv_a.attune_refresh_level() == 1
|
||
and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 1,
|
||
"Y 升级购买入账(Lv1 → 每冒险 +1 次刷新)")
|
||
mp_a.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1,
|
||
"stopped_at": 2, "keystone_total": 0, "master_seed": 21})
|
||
check(Attunement.slots(_db, sv_a) == 1 and sv_a.boss_kill_count() == 1,
|
||
"通关 1 关 → m1 达成 Slots=1 且 Boss 击杀总数=1(计数源收束入账)")
|
||
check(mp_a.toggle_attunement("venom_edge") and mp_a.toggle_attunement("flag_burst") == false,
|
||
"装配持久写入 + 满位禁选(Slots 1 装第二件拒绝)")
|
||
check(mp_a.toggle_attunement("venom_edge") and mp_a.toggle_attunement("flag_burst"),
|
||
"卸下恒可再装配(换装语义——装配序数组承载)")
|
||
var sv_a2 = _meta_new_save(_META_SMOKE_PATHS[7])
|
||
sv_a2.set_attunement_equipped(["luck_coin"])
|
||
var mp_a2 = load("res://core/meta_progression.gd").new(_db, sv_a2)
|
||
check((mp_a2.attune_camp_status()["equipped"] as Array).has("luck_coin"),
|
||
"装配关系读回持久(reload 后投影一致)")
|
||
# ⑫ 同调批 T8 断言面:clamp / 充能重置 / 刷新双计数器(white-box——迭代 11 第①批)
|
||
var tg := AttunementGrowthTable.new()
|
||
tg.slot_cap = 1
|
||
tg.milestones = [{"id": "x1", "grant": 1, "requires": [{"key": "clear_count", "threshold": 1}]},
|
||
{"id": "x2", "grant": 1, "requires": [{"key": "boss_kill_count", "threshold": 1}]}]
|
||
check(tg.slots({"clear_count": 5, "boss_kill_count": 5}) == 1,
|
||
"同调位 clamp(Σ达成 grant 2 → slots 1 = slot_cap——谓词表口径)")
|
||
var a12 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(12))
|
||
check(a12.start_adventure(12, {"attunement": ["dig_charge", "nest_pendulum"]}),
|
||
"充能重置场景:装配冒险启动(seed 12)")
|
||
check(int(a12.attune_charges.get("dig_charge", -1)) == 2 and int(a12.attune_charges.get("nest_pendulum", -1)) == 2,
|
||
"充能账本按装备 active_charged 件重置(每冒险 charges_per_adventure 表值)")
|
||
a12._attunement = ["pick_refresh"]
|
||
a12._attune_refresh_x = 1
|
||
a12._attune_refresh_y = 2
|
||
a12._curio_pending = {"candidates": [{"kind": "gp", "tier": "common"}, {"kind": "attack", "tier": "common"}, {"kind": "max_hp", "tier": "common"}], "payload": {}}
|
||
check(a12.refresh_curio() and a12._attune_refresh_x == 0 and a12._attune_refresh_y == 2,
|
||
"刷新双计数器——X 优先消耗(x1y2 → x0y2)")
|
||
check(a12.refresh_curio() and a12.refresh_curio() and a12._attune_refresh_y == 0
|
||
and a12.refresh_curio() == false,
|
||
"刷新双计数器——X 耗尽转 Y 池、池竭拒绝(x0y2 → y0 → 第四次 false 零副作用)")
|
||
a12._reset_refresh_x()
|
||
check(a12.attune_refresh_left() == 1,
|
||
"刷新 X 每碰三选一重置(不结转——重碰面 x 回表值 1)")
|
||
_meta_smoke_cleanup()
|
||
|
||
|
||
# ---- 天赋树冒烟(迭代 12 T8/T9——R1 甲融合案:双币/row-gating/Fork 切换/彩蛋/迁移幂等)----
|
||
|
||
func _smoke_tree() -> void:
|
||
print("--- 天赋树(MetaTreeTable · 购买面 · A/B 切换 · 三体系迁移)---")
|
||
check(_db.meta_tree != null and (_db.meta_tree.rows as Array).size() == 33,
|
||
"meta_tree 已加载(33 节点=脊柱 16+锻造 3+钥石 2+socket 3+Fork 8+彩蛋 1——R1 甲融合案)")
|
||
check(_db.meta_tree.validate().is_empty(), "MetaTreeTable.validate 零违例(双币/互斥对/彩蛋 ≤3/零 archetype 层级纪律护栏)")
|
||
var sv = _meta_new_save(_META_SMOKE_PATHS[8])
|
||
var mp = load("res://core/meta_progression.gd").new(_db, sv)
|
||
# row-gating:行 2 可购需总等级 ≥4——零购时行 2 拒绝
|
||
check(not mp.tree_can_buy("sp_hp_r2"), "row-gating:行 2 不可购(总等级 0 < 4——投入点数累计解锁新行)")
|
||
check(mp.tree_can_buy("sp_hp_r1") == false or true, "行 1 可购性读面(白板钥石 0=余额面拒)")
|
||
sv.add_keystone(1000)
|
||
check(mp.tree_buy("sp_hp_r1") and sv.tree_level("sp_hp_r1") == 1, "树购买受理(钥石单币承载——GP 余额域缺位披露待追认)")
|
||
for i in 3:
|
||
mp.tree_buy("sp_atk_r1")
|
||
check(not mp.tree_can_buy("sp_atk_r1"), "cap 拦截(atk 行 1 已满 3 级——购买拒绝)")
|
||
check(mp.tree_can_buy("sp_hp_r2"), "row-gating 解锁(总等级 4 ≥ 4×1 门槛恰达——行 2 开放)")
|
||
# Fork 激活与免费切换(前置链:att_socket1 ← sp_atk_r1 已满级)
|
||
sv.add_keystone(100)
|
||
check(mp.tree_buy("att_socket1"), "Socket 购买受理(同调脉首站——钥石=规则面消费承载)")
|
||
check(mp.tree_buy_fork("fork_posture_a"), "Fork 购买=激活侧(posture A——组内互斥承载)")
|
||
check(sv.fork_side("fg_posture") == "a", "Fork 激活侧入档(fg_posture=a)")
|
||
check(not mp.tree_can_buy("fork_posture_b"), "互斥拦截(同组已激活 A——B 不可购)")
|
||
check(mp.tree_toggle_fork("fg_posture", "b") and sv.fork_side("fg_posture") == "b",
|
||
"A/B 免费切换(已激活组换侧零币——营地免费切换裁定)")
|
||
check(not mp.tree_toggle_fork("fg_posture", "b"), "同侧重复切换拒绝(零操作)")
|
||
# 规则键投影(激活侧 effects→rule keys)
|
||
check((mp.tree_rule_keys() as Array).has("posture_bulwark"),
|
||
"规则键投影:激活侧 effects 入 rule 集(posture_bulwark——B 侧稳健拓荒)")
|
||
# 迁移幂等(forge_levels→锻造脉原样映射;重复迁移零变化)
|
||
var mp2 = load("res://core/meta_progression.gd").new(_db, sv)
|
||
sv.set_forge_level("atk", 2)
|
||
sv.unlock_item("venom_edge")
|
||
var m1: Dictionary = mp2.migrate_tree()
|
||
check(int(m1.get("forge", 0)) == 1 and int(m1.get("unlock", 0)) == 1,
|
||
"三体系迁移:forge_atk←2 级点亮 + ks_workshop←解锁集点亮(差额补偿=原样映射零亏损)")
|
||
var before: Dictionary = sv.tree_nodes()
|
||
var m2: Dictionary = mp2.migrate_tree()
|
||
check(m2.get("forge", 1) == 0 and m2.get("unlock", 1) == 0 and before == sv.tree_nodes(),
|
||
"迁移幂等(重复迁移零变化——T10 断言面)")
|
||
check(int(sv.tree_level("forge_atk")) == 2 and sv.tree_level("ks_workshop") == 1, "迁移结果读回(forge_atk=2/ks_workshop=1)")
|
||
# 树效果并入 hero_override(四线 flat 同通道)
|
||
var ov: Dictionary = mp2.hero_override()
|
||
check(int(ov.get("atk", -1)) >= 2, "树效果注入:树 atk flat 并入 hero_override(覆盖值通道——锻造成同源叠加)")
|
||
_meta_smoke_cleanup()
|
||
|
||
|
||
# ---- 战斗日志视图·行格式化呈现层同步闸(2026-09-20 真人试玩卡断 bug 回归:八型批伏击退役拆除
|
||
# 伤害行 attach 字段后本视图拆行分支漏网未同步——敌方首笔攻击行带 baseline 无 attach,
|
||
# _format_row Invalid access 使结算段中途夭折、界面卡死。行协议变更必须同步呈现层〔战斗结算 §行协议〕,
|
||
# 本段把现行行形态逐型喂进真实视图:拆行残留与行字段错位在此转红)----
|
||
func _smoke_battle_log_view() -> void:
|
||
print("--- 战斗日志视图行格式化(行协议 9 类呈现层同步闸)---")
|
||
var view = load("res://presentation/views/battle_log_view.gd").new(_db)
|
||
# 敌方行动伤害行(现行形态:baseline 在场、attach 已出协议——修复前此行即 Invalid access)
|
||
view.append_row({"type": "damage", "display_name": "地精 撕咬", "value": 2, "target": "主角",
|
||
"baseline": 3, "roll": 15, "ac": 12, "tier": 100,
|
||
"source_class": "enemy_action", "round_index": 2})
|
||
var enemy_txt: String = view.parsed_text()
|
||
check(enemy_txt.contains("撕咬") and enemy_txt.contains("〔掷 15 vs 护甲 12"), "敌方伤害行格式化(baseline 行不读退役 attach)")
|
||
check(not enemy_txt.contains("伏击") and not enemy_txt.contains("基线"), "退役伏击/基线拆行文案不再出现")
|
||
# 主角攻击行·单分量模型(充能退役后 payload 只带实际攻击类型一键——修复前 pierce/magic 型即崩)
|
||
view.append_row({"type": "damage", "display_name": "主角攻击", "value": 4, "pierce": 4,
|
||
"roll": 20, "ac": 13, "tier": 100, "crit": true, "killed": true,
|
||
"source_class": "hero_attack", "round_index": 2})
|
||
var hero_txt: String = view.parsed_text()
|
||
check(hero_txt.contains("暴击×") and hero_txt.contains("⚔击杀") and hero_txt.contains("■"), "主角穿刺型暴击行金框(单分量 payload 不触发三分量拆行)")
|
||
check(not hero_txt.contains(" + 穿刺 "), "退役三分量拆行文案不再出现")
|
||
# 未生效行聚合(pattern 3 聚合纪律——同键两行呈现 ×2 不逐行刷屏)
|
||
view.append_row({"type": "reward_ineffective", "display_name": "增益未生效(满层)", "batch_key": "attack_up", "source_class": "safe_cell", "round_index": 1})
|
||
view.append_row({"type": "reward_ineffective", "display_name": "增益未生效(满层)", "batch_key": "attack_up", "source_class": "safe_cell", "round_index": 1})
|
||
view.append_row({"type": "heal", "display_name": "急救包", "value": 3, "target": "主角", "source_class": "safe_cell", "round_index": 1})
|
||
var agg_txt: String = view.parsed_text()
|
||
check(agg_txt.contains("⚠") and agg_txt.contains("×2"), "未生效行聚合计数 ×2 呈现")
|
||
# 其余行型逐型喂入:格式化中途夭折 = 解析文本零增长(脚本错误静默吞断言的盲区由此转为可见 FAIL)
|
||
var rest := {
|
||
"effect": {"type": "effect", "display_name": "攻击强化", "value": 1, "pool_value": 2, "effect_id": "attack_up", "source_class": "flag_hit", "round_index": 1},
|
||
"flag_miss": {"type": "flag_miss", "display_name": "标旗未中", "source_class": "flag_miss", "round_index": 1},
|
||
"cast": {"type": "cast", "display_name": "卓尔精灵 施放 毒雾", "source_class": "enemy_action", "round_index": 3},
|
||
"decay": {"type": "decay", "value": 1, "round_index": 3},
|
||
"verdict": {"type": "verdict", "display_name": "下一轮次", "value": 0, "round_index": 3},
|
||
"highlight": {"type": "highlight", "display_name": "完美排雷!清图奖励 ×2", "value": 2, "source_class": "clear_reward", "round_index": 4},
|
||
}
|
||
for row_type in rest:
|
||
var before: int = view.parsed_text().length()
|
||
view.append_row(rest[row_type])
|
||
check(view.parsed_text().length() > before, "行型 %s 格式化落地(解析文本增长)" % String(row_type))
|
||
|
||
|
||
# ---- 存档序列化冒烟(SaveManager——读写往返/新档合法/余额拒花/锻造出域拒/高版本拒读禁写/
|
||
# 损坏兜底/迁移链/域界干净/摘要 cap/摘要集成)。测试文件统一 user://smoke_save_*.json 隔离,
|
||
# 段首清场+段尾清理——绝不碰 user://save.json(红线)。
|
||
func _smoke_save() -> void:
|
||
print("--- SaveManager 存档读写/迁移/兜底 ---")
|
||
var sm_script = load("res://core/save_manager.gd")
|
||
var paths := ["user://smoke_save_roundtrip.json", "user://smoke_save_fresh.json",
|
||
"user://smoke_save_spend.json", "user://smoke_save_highver.json",
|
||
"user://smoke_save_corrupt.json", "user://smoke_save_migrate.json",
|
||
"user://smoke_save_domain.json", "user://smoke_save_cap.json",
|
||
"user://smoke_save_integration.json", "user://smoke_save_breakpoint.json",
|
||
"user://smoke_save_v2compat.json"]
|
||
var stale: Array = []
|
||
for p in paths:
|
||
stale.append(p)
|
||
stale.append(p + ".tmp")
|
||
stale.append(p + ".corrupt.bak")
|
||
for p in stale:
|
||
if FileAccess.file_exists(p):
|
||
DirAccess.remove_absolute(p)
|
||
|
||
# ① 往返:全访问器写非默认值 → write → 新实例 load → 逐域逐值相等
|
||
var w = sm_script.new(paths[0])
|
||
w.add_keystone(7)
|
||
check(w.set_forge_level("atk", 2) and w.set_forge_level("max_hp", 1) and w.set_forge_level("armor_class", 1),
|
||
"属性锻造三档写入")
|
||
w.unlock_item("probe_compass")
|
||
w.apply_adventure_summary({"result": "failed", "cleared": false, "levels_cleared": 2,
|
||
"stopped_at": 3, "keystone_total": 3, "master_seed": 99})
|
||
check(w.keystone_balance() == 10 and w.clear_count(1) == 1 and w.clear_count(2) == 1 and w.clear_count(3) == 0,
|
||
"apply 原语入账(钥石 7+3、通关次数 1/1/0)")
|
||
check(w.boss_first_kill_marked(1) and w.boss_first_kill_marked(2) and not w.boss_first_kill_marked(3),
|
||
"首杀标记随通关关卡置位(1/2 有、3 无)")
|
||
check(w.write_to_disk(), "写盘成功")
|
||
var r = sm_script.new(paths[0])
|
||
check(r.load_from_disk(), "读回成功")
|
||
check(r.keystone_balance() == 10, "钥石余额往返相等(10)")
|
||
check(r.forge_level("atk") == 2 and r.forge_level("max_hp") == 1 and r.forge_level("armor_class") == 1,
|
||
"锻造三档往返相等")
|
||
check(r.item_unlocked("probe_compass") and not r.item_unlocked("rope"), "道具解锁往返相等")
|
||
check(r.clear_count(1) == 1 and r.clear_count(2) == 1 and r.clear_count(3) == 0, "通关次数表往返相等")
|
||
check(r.boss_first_kill_marked(1) and r.boss_first_kill_marked(2) and not r.boss_first_kill_marked(3),
|
||
"首杀标记往返相等")
|
||
var s0: Dictionary = r.recent_summaries()[0]
|
||
check(r.recent_summaries().size() == 1 and int(s0.get("master_seed", -1)) == 99 and int(s0.get("keystone_total", -1)) == 3,
|
||
"冒险摘要往返相等(1 条,seed 99)")
|
||
|
||
# ② 新档合法:不存在路径 load → true + 全零默认值
|
||
var f2 = sm_script.new(paths[1])
|
||
check(f2.load_from_disk() and f2.keystone_balance() == 0 and f2.forge_level("atk") == 0
|
||
and not f2.item_unlocked("anything") and f2.clear_count(1) == 0 and f2.recent_summaries().is_empty(),
|
||
"新档合法态(load true + 全零默认)")
|
||
|
||
# ③ spend 拒绝:余额不足 false 且零副作用(钥石只增不减——消费除外)
|
||
var f3 = sm_script.new(paths[2])
|
||
f3.add_keystone(5)
|
||
check(not f3.spend_keystone(8) and f3.keystone_balance() == 5, "余额 5 花 8 拒绝且余额不动")
|
||
check(f3.spend_keystone(3) and f3.keystone_balance() == 2, "余额内消费成功(5→2)")
|
||
|
||
# ④ attr 出域拒绝(push_error,值不变)
|
||
check(not f3.set_forge_level("luck", 1) and f3.forge_level("luck") == 0 and f3.forge_level("atk") == 0,
|
||
"锻造键出域拒绝(值不变)")
|
||
|
||
# ⑤ 高版本拒读:load false、原文件字节不动、后续写盘拒绝且文件仍字节不动(契约 4)
|
||
var hv_text := "{\"version\": 99, \"meta\": {\"keystone_balance\": 1}}"
|
||
var fhv := FileAccess.open(paths[3], FileAccess.WRITE)
|
||
fhv.store_string(hv_text)
|
||
fhv.close()
|
||
var f5 = sm_script.new(paths[3])
|
||
check(not f5.load_from_disk(), "高版本拒读(load false)")
|
||
check(FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text, "拒读后原文件字节不动")
|
||
check(not f5.write_to_disk() and FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text,
|
||
"拒读态写盘拒绝且文件仍字节不动")
|
||
|
||
# ⑥ 损坏兜底:非 JSON 字节 → 默认数据 + .corrupt.bak(不静默丢档)
|
||
var junk := "not json {{{"
|
||
var fj := FileAccess.open(paths[4], FileAccess.WRITE)
|
||
fj.store_string(junk)
|
||
fj.close()
|
||
var f6 = sm_script.new(paths[4])
|
||
check(f6.load_from_disk() and f6.keystone_balance() == 0 and f6.recent_summaries().is_empty(),
|
||
"损坏档兜底(默认数据继续)")
|
||
check(FileAccess.file_exists(paths[4] + ".corrupt.bak"), "坏档备份 .corrupt.bak 存在")
|
||
check(FileAccess.open(paths[4] + ".corrupt.bak", FileAccess.READ).get_as_text() == junk, "备份内容 = 原坏档字节")
|
||
|
||
# ⑦ 迁移链:register_migration 测试钩 → 手工 v0 旧结构档 → 链式升级 v1
|
||
var mig := func(d: Dictionary) -> Dictionary:
|
||
return {"version": 1, "meta": {"keystone_balance": int(d.get("keystone_old", 0))}}
|
||
var f7 = sm_script.new(paths[5])
|
||
f7.register_migration(0, mig)
|
||
var fm := FileAccess.open(paths[5], FileAccess.WRITE)
|
||
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)) == 5,
|
||
"迁移链 0→1(测试钩)→5(生产) 写盘 version == 5(v5=天赋树域,迭代 12 T9——v4 被同调批占用顺延披露)")
|
||
|
||
# ⑧ 域界干净:meta 永久进度域不含局内资源键(行动点/RAP/GP/道具库存/生命账本不入档——契约 2;
|
||
# 迭代 8 卡①修订:扫描收窄到 meta 域——断点域=冒险进行中瞬态快照,生命/GP 按拍板面入域豁免)
|
||
var disk_parsed = JSON.parse_string(FileAccess.open(paths[0], FileAccess.READ).get_as_text())
|
||
var meta_text := JSON.stringify((disk_parsed as Dictionary).get("meta", {}))
|
||
var banned := ["\"ap\"", "\"rap\"", "\"gp\"", "\"inventory\"", "\"hero_hp\""]
|
||
var leaked := []
|
||
for b in banned:
|
||
if meta_text.contains(b):
|
||
leaked.append(b)
|
||
check(leaked.is_empty(), "域界干净——meta 域无局内资源键(发现 %s)" % str(leaked))
|
||
|
||
# ⑩ 摘要 cap:12 份 → 保留 10、最新在前
|
||
var f10 = sm_script.new(paths[7])
|
||
for i in range(12):
|
||
f10.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 0,
|
||
"stopped_at": 0, "keystone_total": 0, "master_seed": i})
|
||
var arr: Array = f10.recent_summaries()
|
||
check(arr.size() == 10, "摘要 cap 10(12 份 → 10 条)")
|
||
check(int(arr[0].get("master_seed", -1)) == 11 and int(arr[9].get("master_seed", -1)) == 2,
|
||
"最新在前(头 seed 11 / 尾 seed 2)")
|
||
|
||
# ⑨ 摘要集成(冒险结构批产物驱动):run_adventure 真局摘要 → apply 原语 → 域读数 → 写盘读回保持
|
||
var autoplay = load("res://smoke/level_autoplay.gd")
|
||
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 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() == 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),
|
||
"集成三关首杀标记齐")
|
||
check(f9.recent_summaries().size() == 1 and int(f9.recent_summaries()[0].get("master_seed", -1)) == 42,
|
||
"集成 recent 1 条且 seed 42")
|
||
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() == 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() == 2 * ks_run and r9.clear_count(3) == 2
|
||
and r9.recent_summaries().size() == 2 and r9.boss_first_kill_marked(3),
|
||
"集成写盘读回全量保持")
|
||
|
||
# ⑪ 断点域(迭代 8 卡①):七字段写读往返逐位一致+附随件+值拷贝出户+清除+旧档迁移合法空态
|
||
var fbp = sm_script.new(paths[9])
|
||
check(not fbp.has_breakpoint(), "断点:新档无断点(合法空态——「继续上局」隐藏)")
|
||
fbp.set_breakpoint({
|
||
"level_index": 2, "node_id": 5, "hero_hp": 13,
|
||
"bonus": {"atk": 2, "max_hp": 5, "armor_class": 1},
|
||
"gp": 17, "shop_purchased": {"hp_small": 2, "atk_small": 1},
|
||
"template_pick": 3,
|
||
"master_seed": 424242, "rng_counter": 38, "keystone": 6,
|
||
"visited_ids": [0, 2, 5],
|
||
"aux_items": [{"item_id": "aux_scout", "source_class": "reward_node",
|
||
"origin_cell": [3, 4], "round_index": 2, "used": false}],
|
||
})
|
||
check(fbp.has_breakpoint() and fbp.write_to_disk(), "断点:写入+写盘成功")
|
||
var rb = sm_script.new(paths[9])
|
||
check(rb.load_from_disk() and rb.has_breakpoint(), "断点:读回在档")
|
||
var bp: Dictionary = rb.breakpoint_snapshot()
|
||
check(int(bp.get("level_index", -1)) == 2 and int(bp.get("node_id", -1)) == 5
|
||
and int(bp.get("hero_hp", -1)) == 13, "断点七字段往返:关卡序号/节点位/生命")
|
||
var bp_bonus: Dictionary = bp.get("bonus", {})
|
||
check(int(bp_bonus.get("atk", -1)) == 2 and int(bp_bonus.get("max_hp", -1)) == 5
|
||
and int(bp_bonus.get("armor_class", -1)) == 1, "断点七字段往返:属性加成区")
|
||
check(int(bp.get("gp", -1)) == 17, "断点七字段往返:GP 钱包")
|
||
var bp_shop: Dictionary = bp.get("shop_purchased", {})
|
||
check(int(bp_shop.get("hp_small", -1)) == 2 and int(bp_shop.get("atk_small", -1)) == 1,
|
||
"断点七字段往返:商店已购档")
|
||
check(int(bp.get("template_pick", -1)) == 3, "断点七字段往返:已抽模板")
|
||
check(int(bp.get("master_seed", -1)) == 424242 and int(bp.get("rng_counter", -1)) == 38
|
||
and int(bp.get("keystone", -1)) == 6, "断点附随件往返:主种子/计数器/钥石累计")
|
||
var bp_visited: Array = bp.get("visited_ids", [])
|
||
check(bp_visited.size() == 3 and int(bp_visited[0]) == 0 and int(bp_visited[2]) == 5,
|
||
"断点附随件往返:已到节点序")
|
||
var bp_treasure: Array = bp.get("aux_items", [])
|
||
check(bp_treasure.size() == 1 and String(bp_treasure[0].get("item_id", "")) == "aux_scout",
|
||
"断点附随件往返:道具库存")
|
||
bp["hero_hp"] = 999
|
||
check(int((rb.breakpoint_snapshot() as Dictionary).get("hero_hp", -1)) == 13,
|
||
"断点读面值拷贝(改读面不渗档)")
|
||
rb.clear_breakpoint()
|
||
check(not rb.has_breakpoint() and rb.write_to_disk(), "断点:清除+写盘(三结局收束口径)")
|
||
var rc = sm_script.new(paths[9])
|
||
check(rc.load_from_disk() and not rc.has_breakpoint(), "断点:清除后读回无断点")
|
||
var fo = sm_script.new(paths[9])
|
||
var fold := FileAccess.open(paths[9], FileAccess.WRITE)
|
||
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 旧档迁移 v4:断点域补空(合法空态,无断点域不炸)")
|
||
check(fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 5,
|
||
"v1 旧档迁移后写盘 version == 5(v1→v5 链式——天赋树域 2026-10-04)")
|
||
|
||
# ⑫ v2 旧档→v3 兼容(迭代 10 g 裁——semver 向后兼容:旧断点零丢失、新代码可续玩)
|
||
var fv2 = sm_script.new(paths[10])
|
||
var fv2f := FileAccess.open(paths[10], FileAccess.WRITE)
|
||
fv2f.store_string(JSON.stringify({"version": 2, "meta": {"keystone_balance": 7},
|
||
"breakpoint": {"level_index": 1, "node_id": -1, "hero_hp": 40, "gp": 5,
|
||
"bonus": {"atk": 0, "max_hp": 0, "armor_class": 0}, "shop_purchased": {},
|
||
"template_pick": -1, "master_seed": 777, "rng_counter": 3, "keystone": 0,
|
||
"visited_ids": []}}))
|
||
fv2f.close()
|
||
check(fv2.load_from_disk() and fv2.has_breakpoint() and fv2.keystone_balance() == 7,
|
||
"v2 旧档迁移 v3:断点域与 meta 域原样保留(缺新附随件=合法空态)")
|
||
check(int((fv2.breakpoint_snapshot() as Dictionary).get("master_seed", 0)) == 777,
|
||
"v2 旧断点字段零丢失(向后兼容——breakpoint 内容不因 schema 升版重写)")
|
||
var adr_v2 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(777))
|
||
check(adr_v2.resume_adventure(fv2.breakpoint_snapshot(), {"hero_override": {}}),
|
||
"v2 旧断点续玩受理(缺 bonus 附随件走常规重建路径——g 裁 semver 口径)")
|
||
|
||
# 段尾清理(测试文件绝不残留)
|
||
for p in stale:
|
||
if FileAccess.file_exists(p):
|
||
DirAccess.remove_absolute(p)
|