Merge branch 'master' into m3-iter10-20261001

# Conflicts:
#	game/smoke/level_autoplay.gd
#	game/smoke/smoke_all.gd
This commit is contained in:
DKChan committed 2026-10-01 18:18:03 +08:00
commit bc737fb0cb
24 files changed
+1471 -112

No files matched your search

+42
View File
@@ -208,3 +208,45 @@ static func _row_major(cells: Dictionary) -> Array:
var out: Array = cells.keys()
out.sort_custom(func(a, b): return a.y * 1000 + a.x < b.y * 1000 + b.x)
return out
# 卡死赌挖选格(普通档 v3.1 2026-10-01——模拟档位重构):概率启发近似真人「挑看起来最安全的一格」。
# 危险度 = 该格对每个邻接已证数字约束取(余雷数/未知数)比值中的最小值(= 任一邻数字视角下的
# 最低雷占比——single 已证安全格在进入本函数前已被 perfect_action 消化,故卡死态比值恒 >0);
# 同危险度行主序先到先得。纯查询面零 RNG(确定性保回放);寻路同 choose_dig(目标已开 8 邻
# 首个可达者为 hero_cell)。
static func gamble_pick(minesweeper) -> Dictionary:
var cons := _constraints(minesweeper, minesweeper.current_board(), {})
var best_h := 2.0
var best := Vector2i(-1, -1)
var revealed: Array = minesweeper.revealed_cells()
var revealed_set := {}
for c in revealed:
revealed_set[c] = true
var board = minesweeper.current_board()
for y in board.height:
for x in board.width:
var c := Vector2i(x, y)
if minesweeper.is_revealed(c) or minesweeper.has_flag(c):
continue
var adj := false
for n in board.neighbors8(c):
if revealed_set.has(n):
adj = true
break
if not adj:
continue
var h := 1.0
for con in cons:
if (con["unknown"] as Dictionary).has(c):
var ratio := float(con["mines"]) / float((con["unknown"] as Dictionary).size())
h = minf(h, ratio)
if h < best_h - 0.0001:
best_h = h
best = c
if best.x < 0:
return {}
for n in board.neighbors8(best):
if minesweeper.is_revealed(n) and (n == minesweeper.hero_pos() or not minesweeper.compute_move_path(n).is_empty()):
return {"dig_cell": best, "hero_cell": n}
return {}
+62
View File
@@ -0,0 +1,62 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 棋盘渲染帧取证探针(2026-10-01——数字墨族按板自适应修复的目视验证面:机检≠看得清)。
# 必须**窗口化**运行(headless 无渲染面):
# Godot_console.exe --path . res://smoke/board_shot_probe.tscn -- <输出目录>
# 流程:L1/L2/L3 各起一板(真实 LevelRunner+settle_batch 驱动 ~18 步 novice——自然揭示数字/旗),
# BoardView 真渲染,frame_post_draw 同步后抓视口 PNG。零存档触碰(无 save_manager 引用)。
extends Node
const AutoPlayer := preload("res://smoke/auto_player.gd")
func _ready() -> void:
var args := OS.get_cmdline_user_args()
var out := args[0] if args.size() >= 1 and args[0] != "" else "user://board-shots"
DirAccess.make_dir_recursive_absolute(out)
await get_tree().process_frame
var db = load("res://core/config_db.gd").new()
var ok: bool = db.load_tables("res://data/tables", true)
if not ok:
push_error("[board_shot] 表加载失败")
get_tree().quit(1)
return
var ui_font: Font = load("res://presentation/views/ui_kit.gd").load_font(load("res://presentation/views/ui_kit.gd").FONT_UI)
var targets := [[1, "L1_dungeon"], [2, "L2_underdark"], [3, "L3_deep_underdark"]]
for t in targets:
var level_index: int = t[0]
var tag: String = t[1]
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(420000 + level_index)
var runner = load("res://core/level_runner.gd").new(db, rng)
if not runner.start_level(level_index, 420000 + level_index, {}, 0):
push_error("[board_shot] 开关失败 level=%d" % level_index)
get_tree().quit(1)
return
var settlement = runner.current_battle()
for i in range(18):
var act: Dictionary = AutoPlayer.novice_action(settlement.minesweeper)
var kind := String(act.get("kind", "stop"))
if kind == "stop":
break
var op := kind
if kind == "flag":
op = "flag_place"
settlement.minesweeper.settle_batch([{"action": op, "cell": act.get("cell")}])
var view = load("res://presentation/views/board_view.gd").new()
view.size = Vector2(900, 900)
view.position = Vector2(40, 40)
if ui_font != null:
view.set_world_font(ui_font)
add_child(view)
view.rebuild(settlement.minesweeper.current_board(), settlement.minesweeper)
for i in range(6):
await get_tree().process_frame
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
var path := "%s/board_%s.png" % [out, tag]
img.save_png(path)
print("[board_shot] saved %s (%dx%d)" % [path, img.get_width(), img.get_height()])
view.queue_free()
await get_tree().process_frame
print("[board_shot] done")
get_tree().quit(0)
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://smoke/board_shot_probe.gd" id="1"]
[node name="BoardShotProbe" type="Node"]
script = ExtResource("1")
+155 -12
View File
@@ -15,15 +15,31 @@
# 驱动循环按 route 策略显式 pick_next_node;route = "min"/"max"(候选节点编号最小/最大——
# 确定性路径轴,零随机流自开;默认 "min" 既有调用方签名兼容)。
# 2026-09-21 冒险结构实现批:run_adventure 三关连探驱动(AdventureRunner——主种子恰一次、
# 2026-09-21 冒险结构实现批:run_adventure 三关连探驱动(AdventureRunner——主种子恰一次、
# 通关兑付停点确认、adventure_finished 置 result/summary;外层每关重取 runner 实例经 _drive_level
# 复用单关驱动)。run_level 驱动段抽成 _drive_level——签名与返回结构零变化,行为逐位不变。
# 2026-10-01 迭代 10 奖励重构批:大奖励立即确认改三选一停点驱动(候选取首件/替换放弃新件)。
# 2026-10-01 模拟档位重构(master 数值修复批——普通档 v2 被真人实证推翻,尺子比真人弱 20 倍):
# ①普通档 v3 = 完美策略 + 受控失误注入(失误流走独立策略 RNG——种子派生自局种子,
# 逐局确定性/可回放保持,核心七流零消费零扰动);novice_action 留库不删(历史读数对账面)。
# ②完美档修上界:选路目标价值评分(rest 伤重优先/shop 有钱优先/boss 缓冲)+ 商店贪心买齐。
# ③route 轴保持:评分同分 tie-break 仍按 min/max 编号方向——verifier 双窗轴不塌。
# 运行前提:db 已 load_tables;run_level/run_adventure 均须全新 RngService(同种子 ⇒ 全程逐步可回放)。
# 实现注:信号处理体全部为静态方法 + bind 传状态——GDScript 多行 lambda 内嵌 match/控制流的解析不可靠。
extends RefCounted
const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口:自动玩家策略单一实现
# ── 普通档 v3 失误注入参数(2026-10-01 用户规格:踩雷期望=完美+2/板、每板少挖几个正确格)──
# 标雷→误挖转化率:v3.2 实证 0.22 时普通档 0/100 全灭(首轮大量已证旗=保底命中,非真人失误
# 形态——真人几乎不挖自己插旗的格);降 0.10 = 偶发真手滑,主踩雷源=卡死赌挖(不确定性概率命中)
const NORMAL_MINE_SLIP_P := 0.10
# 安全挖跳过率:每板安全格 ~70-90 × p ≈ 3-5 次漏挖(信息与推进双损失)
const NORMAL_SKIP_DIG_P := 0.05
# 空闲提前赌挖率(v3.4):已证安全格在手时小概率先赌一格——完美系把不确定性攒到残局
# (残局 hazard→1=扫雷),真人早赌滚信息(低 hazard 期吃掉不确定性);p×~40 安全 AP/板 ≈ 4 次
const NORMAL_IDLE_GAMBLE_P := 0.10
static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: int, collect_rows: bool = false, logger = null, policy: String = "normal", route: String = "min", template_pick: int = -1) -> Dictionary:
var own_session := logger == null
@@ -42,6 +58,7 @@ static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct:
"boss_depth": -1, "hp_at_boss": -1, "rows": [], "timeout": false,
"log_path": "", "policy": policy, "refreshes": 0, "mine_hits": 0,
"visited_ids": [], "choices": 0, "last_battle_enemies": [],
"prng": _policy_rng(seed_val), "mine_slips": 0, "skip_digs": 0, "shop_buys": 0,
}
runner.step_row.connect(_on_step_row.bind(runner, st, collect_rows))
runner.node_entered.connect(_on_node_entered.bind(runner, st, logger))
@@ -80,7 +97,7 @@ static func _drive_level(runner, st: Dictionary, logger, policy: String, route:
# 选路态(入口停点 / 兑付确认 / 战斗胜利后的零自动推进停点)——route 策略显式选下一跳
if runner.node_index >= 0:
st.choices = int(st.choices) + 1
runner.pick_next_node(_route_pick(runner, route))
runner.pick_next_node(_route_pick(runner, route, st))
continue
guard_battles -= 1
var this_settlement = runner.current_battle()
@@ -118,6 +135,8 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_
"visited_ids": [], "choices": 0, "last_battle_enemies": [],
"summary": {}, "wallet_events": [], "encounter_max_hp_seq": [], "reward_events": [],
"shop_gp_seq": [], # 商店房到达余额序(逐关 1 次——GP 跨关延续验收取数面)
"prng": _policy_rng(seed_val), "mine_slips": 0, "skip_digs": 0, "shop_buys": 0,
"adv": true, # v3 档位行为开关(选路评分/失误注入/商店购买只挂冒险测量面——单关夹具保持旧语义零扰动)
"level_templates": [], # 逐关图模板名(关卡节点图生成 v0.4 分层出数)
}
@@ -192,7 +211,8 @@ static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr,
r.continue_after_node()
elif t == "shop":
st.shop_gp_seq.append(int(info.get("gp", -1)))
r.continue_after_node() # 商店房零购离开(自动路径直通 Boss——购买行为归冒烟专项/真人试玩)
_autoplay_shop(r, st) # 贪心买齐(2026-10-01——旧零购离开退役:GP 局内死钱,真人实证两店全购)
r.continue_after_node()
if logger != null:
logger.log_node(node, info, r.node_index)
@@ -233,6 +253,28 @@ static func _on_adventure_bonus_resolved(result: Dictionary, st: Dictionary, adr
st.result = "advance_failed"
# 商店贪心买齐(2026-10-01 模拟档位重构):GP 局内死钱——买得起就买( affordable 集里挑最便宜的
# 反复买到达不上任何商品为止,guard 防死循环)。确定性(零随机流);购买出账/加算区入账全走
# runner.buy_product 核心编排(与呈现层购买同一出账路径,wallet_changed 信号照常采集)。
static func _autoplay_shop(r, st: Dictionary) -> void:
var guard := 30
while guard > 0:
guard -= 1
var affordable: Array = []
for p in r.shop_products():
if r.gp >= int(p.get("price", 1 << 30)):
affordable.append(p)
if affordable.is_empty():
return
var pick: Dictionary = affordable[0]
for p in affordable:
if int(p.get("price", 1 << 30)) < int(pick.get("price", 1 << 30)):
pick = p
if not r.buy_product(String(pick.get("product_id", ""))):
return
st.shop_buys = int(st.get("shop_buys", 0)) + 1
# 冒险终局(completed/failed/abandoned——result 载 summary.result,外层驱动循环随之收敛)
static func _on_adventure_finished(summary: Dictionary, st: Dictionary, logger) -> void:
st.summary = summary.duplicate(true)
@@ -241,14 +283,58 @@ static func _on_adventure_finished(summary: Dictionary, st: Dictionary, logger)
logger.log_level_end(summary)
# 路径轴策略(确定性——候选编号最小/最大;min 侧 = 岔路恒取小编号支)。
# 编号取 runner.next_candidate_ids()(编号空间解析单一出口——单链下标 / 图 node_depth)
static func _route_pick(runner, route: String) -> int:
# 策略流 RNG(2026-10-01 模拟档位重构):种子派生自局种子 × 黄金比率常数 + 偏移——与核心统一流
# (rng_service 单盐派生)零共享零扰动;逐局确定性保持(同种子 ⇒ 同决策序列 ⇒ 同回放)
static func _policy_rng(seed_val: int) -> RandomNumberGenerator:
var prng := RandomNumberGenerator.new()
prng.seed = seed_val * 2654435761 + 912931
return prng
# 路径轴选路(2026-10-01 目标价值评分制——机械最小编号退役):候选按节点类型评分取最高,
# 同分 tie-break 沿 route 方向(min=小编号/max=大编号——verifier 双窗轴保持)。
# 评分语义(确定性,零随机流):rest=伤重高值(缺口比例×100)/shop=买得起高值(GP 局内死钱,
# 真人实证两店全购)/reward=占位低值(MC 夹具解锁空集=零兑付直通)/boss=缓冲低值(有店有_rest
# 先补给再打)/battle=基线。编号取 runner.next_candidate_ids()(编号空间解析单一出口)。
static func _route_pick(runner, route: String, st: Dictionary) -> int:
var ids: Array = runner.next_candidate_ids()
if ids.is_empty():
return -1
ids.sort()
return ids[0] if route != "max" else ids[ids.size() - 1]
if not bool(st.get("adv", false)): # 单关机制夹具:旧机械选路保持(smoke 断言零扰动)
ids.sort()
return int(ids[0]) if route != "max" else int(ids[ids.size() - 1])
var nodes: Array = runner.next_candidates()
var scored: Array = []
for i in nodes.size():
var node: Dictionary = nodes[i]
var t := String(node.get("type", ""))
var s := 30.0
if t == "rest":
var max_hp := float(runner.effective_max_hp())
s = 5.0 + (1.0 - float(runner.hero_hp) / maxf(max_hp, 1.0)) * 100.0 # 满血 rest=5 低于 battle(白 rest 不去);伤重可到 105
elif t == "shop":
s = 10.0
var cheapest := 1 << 30
for p in runner.shop_products():
cheapest = mini(cheapest, int(p.get("price", 1 << 30)))
if cheapest < (1 << 30) and runner.gp >= cheapest:
s = 80.0
elif t == "reward":
s = 5.0
elif t == "boss":
s = 20.0
scored.append({"id": int(ids[i]), "s": s})
var best: Dictionary = scored[0]
for i in range(1, scored.size()):
var c: Dictionary = scored[i]
var better: bool
if absf(c.s - best.s) > 0.0001:
better = c.s > best.s
else:
better = (c.id < best.id) if route != "max" else (c.id > best.id)
if better:
best = c
return int(best.id)
static func _on_step_row(row: Dictionary, _runner, st: Dictionary, collect_rows: bool) -> void:
@@ -293,7 +379,8 @@ static func _on_node_entered(node: Dictionary, info: Dictionary, runner, st: Dic
st.hp_before_rest = int(info.get("hp", 0)) - st.rest_healed
runner.continue_after_node()
elif t == "shop":
runner.continue_after_node() # 商店房零购离开(GP+商店房批——自动路径直通 Boss;购买行为归冒烟商店专项断言/真人试玩)
runner.continue_after_node() # 单关机制夹具(run_level)零购离开保持——smoke 商店/战斗断言面
# 不掺购买行为;冒险口径(run_adventure)已在 _on_adventure_node_entered 改贪心买齐
if logger != null:
logger.log_node(node, info, runner.node_index)
@@ -312,9 +399,10 @@ static func _autoplay_battle(runner, settlement, st: Dictionary, logger) -> void
if String(st.get("policy", "normal")) == "perfect":
_autoplay_battle_perfect(runner, settlement, st, logger)
return
# 普通策略档 v2(2026-09-19 用户拍板):基础定式画像——单步决策循环(move/dig/flag 经 settle_batch 提交,
# 推理分级见 auto_player.gd 普通策略档节:新手级单数字满足规则 + 盲挖边界兜底、无 refresh);
# 旧"无推理挖最近格"口径随批退役。连续拒绝 ≥3 = 编排违约防御(同完美档 fail-fast 语义)。
# 普通策略档 v3(2026-10-01 用户重定义):完美策略 + 受控失误注入——v2「新手级单步推理+盲挖
# 兜底」画像被真人实证推翻(零加成真人首通三关踩 23 雷 vs v2 普通档 L2 条件通关率 10.6%)。
# v3 语义 = 会玩的普通玩家:推理与完美档同源(subset 解算/标旗/卡死刷新),失误只注入两手——
# 标雷误挖(NORMAL_MINE_SLIP_P)+ 安全挖跳过(NORMAL_SKIP_DIG_P)。连续拒绝 ≥3 = 编排违约防御。
var round_guard := 60
while st.result == "" and runner.current_battle() == settlement and settlement.battle_result == "" and round_guard > 0:
round_guard -= 1
@@ -325,7 +413,11 @@ static func _autoplay_battle(runner, settlement, st: Dictionary, logger) -> void
while runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" \
and settlement.round_index == start_round and action_guard > 0 and rejects < 3:
action_guard -= 1
var act := AutoPlayer.novice_action(settlement.minesweeper)
var act: Dictionary
if bool(st.get("adv", false)):
act = _apply_normal_slips(AutoPlayer.perfect_action(settlement.minesweeper, settlement), settlement, st) # v3:完美+失误
else:
act = AutoPlayer.novice_action(settlement.minesweeper) # 单关机制夹具:v2 新手画像保持
var kind := String(act.get("kind", "stop"))
if kind == "stop":
break
@@ -336,7 +428,13 @@ static func _autoplay_battle(runner, settlement, st: Dictionary, logger) -> void
ok = _submit_intent(settlement, "dig", act.get("cell"), logger, start_round)
elif kind == "flag":
ok = _submit_intent(settlement, "flag_place", act.get("cell"), logger, start_round)
elif kind == "refresh":
ok = bool(settlement.player_request_refresh())
if logger != null:
logger.log_action("refresh", Vector2i(-1, -1), ok, {"round": start_round})
if ok:
if kind == "refresh":
st.refreshes = int(st.refreshes) + 1
rejects = 0
else:
rejects += 1
@@ -346,9 +444,54 @@ static func _autoplay_battle(runner, settlement, st: Dictionary, logger) -> void
logger.log_action("stop", Vector2i(-1, -1), _accepted(out_s), {"round": start_round})
# 普通档 v3 失误注入(2026-10-01 用户规格):三类——
# ①标雷误挖:把「标旗已证雷」动作直接转成「挖该格」(真人手滑原型:知道是雷、位置点错,
# 踩雷吃伤且该雷奖励报废);期望踩雷增量 ≈ 每板已证雷数 × NORMAL_MINE_SLIP_P(目标完美+2/板)
# ②安全挖跳过:把「挖已证安全格」动作转成本轮停手(少挖几个正确的雷——信息与推进双损失)
# ③卡死赌挖:完美档无已证动作时刷新/停手拖轮次吃敌方垫刀(实证=完美档 41% 通关全败于慢玩
# 垫刀非踩雷)——真人会按概率赌一个边界格(用户实证口径「有些地方因为我看不到数字,也是盲挖
# 的」);普通档不刷新(用户自报全程没用刷新),赌挖替代刷新/停手
# 失误流 = st.prng(策略流 RNG,种子派生自局种子——同种子逐位可回放;核心七流零消费零扰动)
static func _apply_normal_slips(act: Dictionary, settlement, st: Dictionary) -> Dictionary:
var prng: RandomNumberGenerator = st.get("prng")
if prng == null:
return act
var kind := String(act.get("kind", "stop"))
if kind == "flag" and prng.randf() < NORMAL_MINE_SLIP_P:
st.mine_slips = int(st.get("mine_slips", 0)) + 1
return {"kind": "dig", "cell": act.get("cell")}
if kind == "dig":
var roll := prng.randf()
if roll < NORMAL_SKIP_DIG_P:
st.skip_digs = int(st.get("skip_digs", 0)) + 1
return {"kind": "stop"}
if roll < NORMAL_SKIP_DIG_P + NORMAL_IDLE_GAMBLE_P:
var idle := AutoPlayer.gamble_pick(settlement.minesweeper)
if not idle.is_empty():
return _gamble_action(idle, settlement)
if kind == "refresh" or kind == "stop":
var plan := AutoPlayer.gamble_pick(settlement.minesweeper) # v3.2:概率启发赌挖(choose_dig 行主序=概率最差格,实证 199/200 死第二战——退役)
if not plan.is_empty():
return _gamble_action(plan, settlement)
return act
# 赌挖动作组装(v3.4 抽出):目标邻接=直接挖,否则先移到目标的已开邻格(下一拍再挖)
static func _gamble_action(plan: Dictionary, settlement) -> Dictionary:
var ms = settlement.minesweeper
var hero: Vector2i = ms.hero_pos()
var target: Vector2i = plan.dig_cell
var d := Vector2i(absi(target.x - hero.x), absi(target.y - hero.y))
if maxi(d.x, d.y) == 1:
return {"kind": "dig", "cell": target}
return {"kind": "move", "cell": plan.hero_cell}
# 完美策略档战斗驱动(2026-09-14):单步决策循环——move/dig/flag 经 排雷玩法 settle_batch 提交,
# refresh 经 settlement.player_request_refresh(战斗结算 核心编排:行动经济 扣 RAP + 退役 + 当场换新板),
# stop 出内环走统一停手提交。连续拒绝 ≥3 = 编排违约防御(fail-fast 语义,停手防死循环)。
# 2026-10-01 上界修正:选路改目标价值评分(_route_pick)+ 商店贪心买齐(_autoplay_shop)——
# 「完美」须是元游戏意义上的上界(会购物会补给),不再只是扫雷子游戏上界。
static func _autoplay_battle_perfect(runner, settlement, st: Dictionary, logger) -> void:
var round_guard := 60
while st.result == "" and runner.current_battle() == settlement and settlement.battle_result == "" and round_guard > 0:
+18 -16
View File
@@ -331,8 +331,8 @@ func _smoke_adventure() -> void:
var resolve_ok := level_resolves.size() == 2
var max_flat := int(_db.adventure.bonus_max_hp_flat)
var heal_pct := int(_db.adventure.clear_heal_pct)
var hp_acc := 0 # max_hp 件累加(回血封顶断言的生效上限推导)
var ks_prefix := 0
hp_acc := 0 # max_hp 件累加(回血封顶断言的生效上限推导)
ks_prefix := 0
for i in picks.size():
var p: Dictionary = picks[i]
var cands: Array = p.get("candidates", []) as Array
@@ -346,37 +346,39 @@ func _smoke_adventure() -> void:
var heal: int = int(p.get("heal_gained", -1))
var hp_before: int = int(p.get("hp_before", -1))
var hp_after: int = int(p.get("hp_after", -1))
var max_eff := int(_db.hero.max_hp) + hp_acc # 回血口径 = 表值+加算区(无 TunePanel 百分比)
if heal != mini(int(floor(float(max_eff) * float(heal_pct) / 100.0)), max_eff - hp_before) \
or hp_after != hp_before + heal or hp_after > max_eff:
# 模拟档 v3.4 商店贪心购血合法抬升(L2/L3 店在 Boss 前)——回血口径窗口 = 加算区件 + 购血余量 0..15
var heal_ok_i := false
for extra in 16:
var m := int(_db.hero.max_hp) + hp_acc + extra
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(_db.hero.max_hp) + hp_acc + 15:
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:
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]
if not (p.get("candidates", []) as Array).has(String(r.get("picked", ""))) \
or int(r.get("value", -1)) != int(_db.adventure.bonus_value(String(r.get("picked", "")))):
if not (p.get("candidates", []) as Array).has(String(r.get("picked", ""))) or int(r.get("value", -1)) != int(_db.adventure.bonus_value(String(r.get("picked", "")))):
resolve_ok = false
if String(r.get("picked", "")) == "max_hp":
hp_acc += max_flat
check(cand_ok, "三选一候选 2–3 件且零重复(加权洗牌不放回)")
check(heal_ok, "通关部分回血封顶(floor(上限×比例) 封顶缺口——血回满退役)")
check(heal_ok, "通关部分回血封顶(floor(上限×比例) 封顶缺口——血回满退役;购血容差窗口)")
check(ks_gain_ok, "钥石逐杀入账(首杀档位 + 累计逐位)")
check(resolve_ok, "点选恰兑付选中一件(值=表值;未选项零生效)")
var seq: Array = st["encounter_max_hp_seq"]
var k := 5.0 # hp+400 ⇒ TunePanel 百分比后乘 ×5(加算先加、百分比后乘——生效次序口径)
var exp_base := maxi(1, int(round(float(int(_db.hero.max_hp)) * k)))
var valid_hp := {}
for acc in 4:
valid_hp[maxi(1, int(round(float(int(_db.hero.max_hp) + acc * max_flat) * k)))] = true
# 模拟档 v3.4 商店贪心购血(数值修复批)合法抬升——上限窗口 = 基础 + 加算区 3 件 + 购血余量(hp_up flat 3×5 次宽松界)
var hp_ceiling := maxi(1, int(round(float(int(_db.hero.max_hp) + 3 * max_flat + 15) * k)))
var seq_ok := seq.size() > 0 and int(seq[0]) == exp_base
for v in (seq as Array):
if not valid_hp.has(int(v)):
if int(v) < exp_base or int(v) > hp_ceiling:
seq_ok = false
check(seq_ok,
"各战上限入参 ∈ {基础..基础+3×件值}(覆盖值通道——通关+奖励节点双渠道共享上限 3;首战=基础 %d)" % exp_base)
"各战上限入参 ∈ [基础, 基础+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
@@ -3391,7 +3393,7 @@ func _smoke_nine_types_gp_stream() -> void:
gp_neg = int(row.weight)
check(cell_rows == 22, "奖励字典 22 行(九型×正负 18 格 + aux 正三投放/负一占位——魔法道具内容批 2026-09-29;清图槽独立批尾不计)")
# 雷格负向直伤当量(迭代 8 卡⑧——真人反馈「三雷 3/4 血无后果感」投值:三行各抬 +3 档,负向≈正向 2 倍定价)
var mine_power := {"heal_backlash": 4, "pierce_snare": 5, "magic_recoil": 5}
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):