Files
gplan-2/game/sim/level_autoplay.gd

555 lines
34 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VERTICAL SLICE - NOT FOR PRODUCTION
# 日志合规:任何模拟运行与真人试玩同日志——操作记录 / 轮次状态 / 战斗结束 / 关卡完成 全量 JSONL。
# logger=null 时自建会话(每局一文件),批量调用方传共享 logger(log_run 局次标记切分)。
# 关卡自动完成策略(理性玩家近似)——呈现层数值调整板的批量模拟与 headless 扫描
# 共用同一实现,保证"面板模拟读数"与"扫描表读数"同口径。策略与 smoke_all 战斗自动玩家同族:
# 挖最近可挖格 / 无可挖停手 / 奖励恢复点立即确认。
# run_level = 机制夹具/单战锚点专用(smoke_all 机制断言、rhythm/defense/damage 单战扫描)——
# 关卡完成率/难度类测量禁用本入口(满血零上下文进关,与真实冒险条件关卡完成率脱节),一律走 run_adventure。
# 策略档 policy:"normal"(默认)/ "perfect"(零盲挖/全封印/卡死刷新——上界测量仪,求解器与
# 动作语义见 auto_player.gd 完美策略档节)。
# 选路驱动(零自动推进):兑付确认/战斗胜利后 runner 进选路态,驱动循环按 route 策略显式
# pick_next_node;route = "min"/"max"(候选节点句柄最小/最大——确定性路径轴,零随机流自开;默认 "min")。
# run_adventure 三关连探驱动(AdventureRunner——主种子恰一次、关卡完成兑付停点确认、adventure_finished
# 置 result/summary;外层每关重取 runner 实例经 _drive_level 复用单关驱动)。
# 大奖励停点 = 三选一驱动(候选取首件/替换放弃新件)。
# 普通档 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://sim/auto_player.gd") # 自动玩家策略单一实现
const BattleDriver := preload("res://sim/battle_driver.gd") # 庚案收敛:驱动循环单点(策略仍归 _decide_act 单点分派)
# ── 普通档 v3 失误注入参数(踩雷期望=完美+2/板、每板少挖几个正确格)──
# 标雷→误挖转化率:主踩雷源=卡死赌挖(不确定性概率命中),误挖只作偶发真手滑——
# 真人几乎不挖自己封印的格(p 过高会让首轮已证封印变保底命中,非失误形态)
const NORMAL_MINE_SLIP_P := 0.10
# 安全挖跳过率:每板安全格 ~70-90 × p ≈ 3-5 次漏挖(信息与推进双损失)
const NORMAL_SKIP_DIG_P := 0.05
# 空闲提前赌挖率:已证安全格在手时小概率先赌一格——完美系把不确定性攒到残局
# (残局 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") -> Dictionary:
var own_session := logger == null
if own_session:
logger = load("res://presentation/playtest_logger.gd").new()
logger.ap_per_round = int(db.run.action_economy.ap_per_round) # 注入序前置(守卫拒后开——R5 必填)
logger.start_session(seed_val)
logger.log_run({"seed": seed_val, "level_index": level_index, "atk_pct": atk_pct, "hp_pct": hp_pct, "policy": policy, "route": route})
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var runner = load("res://core/level_runner.gd").new(db, rng)
var st := {
"result": "", "hp_left": 0, "battles": 0, "rounds": 0, "hero_dmg": 0,
"rest_healed": 0, "hp_before_rest": -1,
"defeat_node_index": -1, "defeat_node_id": -1, "defeat_node_type": "",
"boss_depth": -1, "hp_at_boss": -1, "rows": [], "timeout": false,
"log_path": "", "policy": policy, "refreshes": 0, "mine_hits": 0, "mine_dmg": 0,
"visited_ids": [], "choices": 0, "last_battle_enemies": [],
# 封印经济五字段(runsum 读数面;聚合自结算流水 source_class,
# 零新随机源零核心改动;η=seal_hit÷seal_placed 与标空率派生归分析侧)
"seal_placed": 0, "seal_hit": 0, "seal_miss": 0, "seal_hit_gain": 0, "seal_miss_penalty": 0,
"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))
runner.level_finished.connect(_on_level_finished.bind(st, logger))
runner.set_hero_override(atk_pct, hp_pct)
if not runner.start_level(level_index, seed_val, {}):
st.result = "start_failed"
runner.restore_hero_base()
if own_session:
logger.close_session()
return st
_drive_level(runner, st, logger, policy, route)
if st.result == "":
st.result = "timeout"
st.timeout = true
for v in runner.visited_ids():
st.visited_ids.append(int(v))
runner.restore_hero_base()
st.log_path = logger.log_path()
if own_session:
logger.close_session()
return st
# 单关驱动段(run_level 抽取——零自动推进停点 + 战斗自动玩家;policy 经 st 入 _autoplay_battle)
# 入口停点(node_index 尚 -1 = 进节点前无 current_node)也经本分支驱动——
# 读 next_candidate_ids 候选(入口单候选)→ pick_next_node,同一 _enter_node 等价(零 RNG 漂移);
# 入口 pick 不计 st.choices(单候选强制选路,计数口径与测量面同构)。
static func _drive_level(runner, st: Dictionary, logger, policy: String, route: String) -> void:
st["policy"] = policy
var guard_battles := 20
var guard_actions := 200
while st.result == "" and guard_battles > 0 and guard_actions > 0:
guard_actions -= 1
if runner.awaiting_choice():
# 选路态(入口停点 / 兑付确认 / 战斗胜利后的零自动推进停点)——route 策略显式选下一跳
if runner.node_index >= 0:
st.choices = int(st.choices) + 1
runner.pick_next_node(_route_pick(runner, route, st))
continue
guard_battles -= 1
var this_settlement = runner.current_battle()
if this_settlement == null:
break # level_finished 已发(settlement 置 null)而信号尚未置 result 的防御
_autoplay_battle(runner, this_settlement, st, logger)
# 三关连探冒险驱动——AdventureRunner 编排:主种子恰一次
# (start_adventure 唯一 new_adventure 调用点)、大奖励立即确认、冒险终局置 result/summary。
# 返回结构 = run_level 同族 + summary / wallet_events / encounter_max_hp_seq / reward_events /
# visited_ids(全冒险节点行进序列——跨关累计,非末关 runner 的 visited 随实例更替,此处为唯一全量面)。
# hero_override 尾参 = 永久强化 flat 注入透传(缺省 {} 与既有调用方
# 逐位一致零行为变更)+ run 行携带该参数(分析侧按元进度档切分)+ 败退敌人名采集
# (last_battle_enemies——末场战斗敌方显示名序列,失败局即死亡遭遇)。
static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_rows: bool = false,
logger = null, policy: String = "normal", route: String = "min",
hero_override: Dictionary = {}, attune: Dictionary = {}, shop: Dictionary = {},
relic: Dictionary = {}) -> Dictionary:
var own_session := logger == null
if own_session:
logger = load("res://presentation/playtest_logger.gd").new()
logger.ap_per_round = int(db.run.action_economy.ap_per_round) # 注入序前置(守卫拒后开——R5 必填)
logger.start_session(seed_val)
logger.log_run({"seed": seed_val, "adventure": true, "atk_pct": atk_pct, "hp_pct": hp_pct,
"policy": policy, "route": route, "hero_override": hero_override})
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var adr = load("res://core/adventure_runner.gd").new(db, rng)
var st := {
"result": "", "hp_left": 0, "battles": 0, "rounds": 0, "hero_dmg": 0,
"rest_healed": 0, "hp_before_rest": -1,
"defeat_node_index": -1, "defeat_node_id": -1, "defeat_node_type": "",
"boss_depth": -1, "hp_at_boss": -1, "rows": [], "timeout": false,
"log_path": "", "policy": policy, "refreshes": 0, "mine_hits": 0, "mine_dmg": 0,
"visited_ids": [], "choices": 0, "last_battle_enemies": [],
"summary": {}, "wallet_events": [], "encounter_max_hp_seq": [], "reward_events": [],
"feat_mode": String(relic.get("mode", "off")), # 特性分层开关(off=316-f 通用竞争/on=成型画像优先捕捞 relic)
"shop_gp_seq": [], # 商店房到达余额序(逐关 1 次——GP 跨关延续验收取数面)
# 封印经济五字段(与 run_level 同口径,见彼处注释)
"seal_placed": 0, "seal_hit": 0, "seal_miss": 0, "seal_hit_gain": 0, "seal_miss_penalty": 0,
"prng": _policy_rng(seed_val), "mine_slips": 0, "skip_digs": 0, "shop_buys": 0,
"adv": true, # v3 档位行为开关(选路评分/失误注入/商店购买只挂冒险测量面——单关夹具语义不变)
"level_maps": [], # 逐关节点图标识(生成图 = 关卡序号+本局主种子——MC 分层出数)
}
# 信号直连(拆件批 T3):runner 信号在 level_started 重取点重连(转发族退役——重取点前移到本回调)
adr.level_started.connect(_on_autoplay_level_started.bind(adr, st, collect_rows, logger))
# 首关信号在 start_adventure 内同步发射(驱动循环起步前)——入口候选订阅须在本行前就位:
# _on_autoplay_level_started 于本行同步执行(start_adventure 返回前),runner 三信号先于驱动循环就绪
# 三选一停点驱动(确定性策略候选取首件、
# 兑付后推进下一关——零随机流自开,同种子逐位可复放;无满位替换策略;信号面归停点件)
adr.stop().curio_choice_presented.connect(_on_adventure_bonus.bind(st, adr))
adr.stop().curio_resolved.connect(_on_adventure_curio_resolved.bind(st, adr))
adr.adventure_finished.connect(_on_adventure_finished.bind(st, logger))
adr.set_hero_override(atk_pct, hp_pct)
# 同调分层注入(缺省 {} 与既有调用方逐位一致零行为变更)
var adv_params := {"hero_override": hero_override}
if not attune.is_empty():
adv_params["attunement"] = attune.get("equipped", [])
adv_params["attune_refresh_y"] = int(attune.get("refresh_y", 0))
# 特性注入(formed 画像=开局注入七流派各 2 件档一成型集;测量面专用)
if relic.has("seed_ids"):
adv_params["relics_seed"] = relic.get("seed_ids", [])
# 树规则参数字典注入(A/B 面读数载体:fork 激活侧 effects_* 参数字典透传)
if relic.has("tree_rules"):
adv_params["tree_rules"] = relic.get("tree_rules", {}) if relic.get("tree_rules", {}) is Dictionary else {}
# 商店分层注入(缺省 legacy 与既有调用方逐位一致零行为变更;pool=共享池货架面)
var pool_shop: bool = String(shop.get("mode", "")) == "pool"
if pool_shop:
adv_params["shop_mode"] = "pool"
st["shop_mode"] = "pool"
if not adr.start_adventure(seed_val, adv_params): # 测量夹具恒抽签(v0.4——首次脚本分支不进任何读数)
st.result = "start_failed"
if own_session:
logger.close_session()
return st
var guard_drives := 10 # 每次外层迭代 = 驱动一关(关卡更替由三选一兑付确认与 runner 重取承载)
while st.result == "" and guard_drives > 0:
guard_drives -= 1
_drive_level(adr.runner(), st, logger, policy, route)
if st.result == "":
st.result = "timeout"
st.timeout = true
if adr.runner() != null:
adr.runner().restore_hero_base()
if pool_shop:
# 终局池读数(摘要 additive 键 pool_stock:_settle 清账前快照,
# 商店买走 vs 奖励领走 vs 枯竭的终局面;事后读 adr.pool_stock 会被收束清账重置=满库假象)
st["pool_stock"] = (st.get("summary", {}).get("pool_stock", {}) as Dictionary).duplicate(true)
st["relics"] = (st.get("summary", {}).get("relics", []) as Array).duplicate() # 特性终局持有集(摘要 additive 键清账前快照,同 pool_stock 模式)
st.log_path = logger.log_path()
if own_session:
logger.close_session()
return st
# 每关开始(autoplay 直连面):重取 runner 并重连三信号(恰好一次——同关守卫:
# level_started 每关恰一次发射,bind Callable 每次新建故以关卡号守卫防重入)
static func _on_autoplay_level_started(level_index: int, _display_name: String, adr, st: Dictionary, collect_rows: bool, logger) -> void:
if int(st.get("wired_level", -1)) == int(level_index):
return # 同关重入防御(恰好一次——bind 句柄每回调新建,不以 Callable 相等判重)
st["wired_level"] = int(level_index)
var r = adr.runner()
if r == null:
return
r.step_row.connect(_on_step_row.bind(adr, st, collect_rows))
r.node_entered.connect(_on_adventure_node_entered.bind(adr, st, logger))
r.wallet_changed.connect(_on_adventure_wallet.bind(st, logger))
# 冒险模式节点采集(_on_node_entered 的 AdventureRunner 门面版——runner 逐关更替,经 adr.runner() 重取;
# encounter_max_hp_seq 抓 effective_max_hp:node_entered 先于 begin_battle,encounter 尚为空字典,
# 该值 = begin_battle 将收到 hero_max_hp 同一表达式〔规划书抓取点与代码事实冲突,按代码为准已登记〕)
static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr, st: Dictionary, logger) -> void:
var r = adr.runner()
var t := String(node.get("type", ""))
st.visited_ids.append(int(r.node_id_of(node)))
if not st.has("level_maps"):
st["level_maps"] = []
st["level_maps"].append("%d#%d" % [int(r.level_row.get("level_index", 0)), int(adr.master_seed)]) # MC 分层出数(关卡×生成图标识)
if t == "battle" or t == "boss":
st.battles += 1
st.last_battle_enemies = [] # 末场战斗敌方名序列按战斗重置(失败局终值 = 死亡遭遇)
st.encounter_max_hp_seq.append(int(r.effective_max_hp()))
if logger != null and r.current_battle() != null:
logger.attach_battle(r.current_battle())
if t == "boss":
st.boss_depth = int(node.get("depth", -1))
st.hp_at_boss = adr.stats().hero_hp
elif t == "reward":
# 奖励节点三选一(共享池确定性策略;TP0 起 316-f 判读:1 级最稀有、
# 同档并列取最左;替换放弃新件;兑付后 continue_after_node 回图选路)
var ndraw: Dictionary = adr.stop().node_curio_candidates()
var ncands: Array = ndraw.get("candidates", []) as Array
st.reward_events.append({"node_curio_pick": ncands.duplicate(true)})
if ncands.is_empty():
r.continue_after_node() # 牌库空合法态直通(池耗即枯竭——防御保留)
else:
adr.stop().present_node_curio(ncands)
var npick: Dictionary = _pick_token(ncands, String(st.get("feat_mode", "off")))
if adr.stop().apply_node_curio(String(npick.get("kind", "")), String(npick.get("tier", "")), String(npick.get("feat_id", ""))):
# 专长凭证拿到即开——候选已被替换为专长三选一,就地再选一轮(确定性策略同款 316-f);
# 不作此分支则节点停点悬空→选路卡死(MC timeout 形态)
if String(npick.get("kind", "")) == "feat_ticket":
var fcands: Array = (adr.stop().node_curio_pending_state().get("candidates", []) as Array)
if not fcands.is_empty():
var fpick: Dictionary = _pick_token(fcands, String(st.get("feat_mode", "off")))
adr.stop().apply_node_curio(String(fpick.get("kind", "")), String(fpick.get("tier", "")), String(fpick.get("feat_id", "")))
else:
st.result = "advance_failed"
elif t == "rest":
st.rest_healed = int(info.get("healed", 0))
st.hp_before_rest = int(info.get("hp", 0)) - st.rest_healed
r.continue_after_node()
elif t == "shop":
st.shop_gp_seq.append(int(info.get("gp", -1)))
if String(st.get("shop_mode", "legacy")) == "pool":
_autoplay_shop_pool(r, st) # 共享池货架贪心(买一件=奖励面少一件的机会成本,画像维持贪心语义)
else:
_autoplay_shop(r, st) # 贪心买齐(GP 局内死钱——真人实证两店全购)
r.continue_after_node()
if logger != null:
logger.log_node(node, info, r.node_index)
# 冒险钱包事件采集(wallet 行族——GP 跨关延续与冒险收束 clear 的验收取数面;同日志合规转发)
static func _on_adventure_wallet(event: String, gp_val: int, extra: Dictionary, st: Dictionary, logger) -> void:
st.wallet_events.append({"event": String(event), "gp": int(gp_val),
"amount": int(extra.get("amount", 0)), "product": String(extra.get("product", "")),
"source": String(extra.get("source", ""))})
if logger != null:
logger.log_wallet(event, gp_val, extra)
# 三选一停点驱动(确定性策略;TP0 起 316-f 判读:1 级最稀有、同档并列取最左;
# 奖励事件随 reward_events 留档)
static func _on_adventure_bonus(payload: Dictionary, st: Dictionary, adr) -> void:
var pl := payload.duplicate(true)
pl["bonus_max_hp"] = adr.journal().bonus_total("max_hp") # 观测面:停点时刻加算区快照(回血精确断言用)
st.reward_events.append({"curio_pick": pl})
var candidates: Array = payload.get("candidates", []) as Array
if candidates.is_empty():
st.result = "advance_failed"
return
var pick: Dictionary = _pick_token(candidates, String(st.get("feat_mode", "off")))
if not adr.stop().pick_curio(String(pick.get("kind", "")), String(pick.get("tier", "")), String(pick.get("feat_id", ""))):
st.result = "advance_failed"
# 三选一确定性选件(TP0 316-f——1 级优先度=最稀有〔四级:legendary>rare>excellent>common〕、2 级=最左;
# 候选并列同档取最左=稳定序保证。空候选/未知档位 = 回退最左防御口径。
# 成型画像(relic 模式 on:候选含 relic 令牌时优先在 relic 集内按 316-f 择一
# 〔贪心捕捞→特性入局→成型画像全流派覆盖——R3 乙单锚承载〕;off=relic 与属性加成型特性同规则竞争)
static func _pick_token(candidates: Array, feat_mode: String = "off") -> Dictionary:
var pool: Array = candidates
if feat_mode == "on":
var feats_only: Array = []
for tok in candidates:
if String((tok as Dictionary).get("kind", "")) == "relic":
feats_only.append(tok)
if not feats_only.is_empty():
pool = feats_only
var best: Dictionary = {}
var best_rank := -1
for tok in pool:
var t: Dictionary = tok as Dictionary
var rank: int = int(TreasureItemTable.TIER_RANK.get(String(t.get("tier", "")), 0)) # 档位排序读表(未知档 0=原 match 落空同语义)
if best.is_empty() or rank > best_rank:
best = t
best_rank = rank
return best
# 三选一兑付完成 → 上下文分派(关卡完成停点=推进下一关 / 奖励节点=回图选路)
static func _on_adventure_curio_resolved(result: Dictionary, st: Dictionary, adr) -> void:
st.reward_events.append({"curio_resolved": result.duplicate(true)})
if String(result.get("context", "")) == "reward_node":
var r = adr.runner()
if r == null or not r.continue_after_node():
st.result = "advance_failed"
return
if not adr.continue_to_next_level():
st.result = "advance_failed"
# 商店贪心买齐:GP 局内死钱——买得起就买( affordable 集里挑最便宜的
# 反复买到达不上任何商品为止,guard 防死循环)。确定性(零随机流);购买出账/加算区入账全走
# runner.shop.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.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.shop.buy_product(String(pick.get("product_id", ""))):
return
st.shop_buys = int(st.get("shop_buys", 0)) + 1
# 商店共享池贪心:货架三选一同构面贪心买齐——affordable 集挑最便宜反复买
# (每次买后全重抽、货架随买随换,枯竭/不可负担即止,guard 防死循环)。购买走 runner.shop.buy_pool_item
# 核心编排(与呈现层购买同一出账+池消费路径,wallet_changed 信号照常采集)。确定性(零随机流)。
static func _autoplay_shop_pool(r, st: Dictionary) -> void:
var guard := 30
while guard > 0:
guard -= 1
var affordable: Array = []
for c in r.shop.shop_shelf():
if r.gp >= int(c.get("price", 1 << 30)):
affordable.append(c)
if affordable.is_empty():
return
var pick: Dictionary = affordable[0]
for c in affordable:
if int(c.get("price", 1 << 30)) < int(pick.get("price", 1 << 30)):
pick = c
if not r.shop.buy_pool_item(String(pick.get("kind_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)
st.result = String(summary.get("result", ""))
if logger != null:
logger.log_level_end(summary)
# 策略流 RNG:种子派生自局种子 × 黄金比率常数 + 偏移——与核心统一流
# (rng_service 单盐派生)零共享零扰动;逐局确定性保持(同种子 ⇒ 同决策序列 ⇒ 同回放)
static func _policy_rng(seed_val: int) -> RandomNumberGenerator:
var prng := RandomNumberGenerator.new()
prng.seed = seed_val * 2654435761 + 912931
return prng
# 路径轴选路(目标价值评分制):候选按节点类型评分取最高,
# 同分 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
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
if String(st.get("shop_mode", "legacy")) == "pool":
for c in runner.shop.shop_shelf(): # pool 面:货架现价
cheapest = mini(cheapest, int(c.get("price", 1 << 30)))
else:
for p in runner.shop.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:
if row.type == "damage" and row.source_class == "enemy_action":
st.hero_dmg += int(row.value)
_note_enemy(String(row.get("text", "")), st)
elif row.type == "damage" and row.source_class == "mine_hit" and "全体敌人" not in String(row.get("text", "")):
st.mine_dmg += int(row.value) # 踩雷直伤承伤(k 系数分母补算字段——口径与真人分析端「敌方行动+踩雷直伤」对齐)
elif row.type == "cast" and row.source_class == "enemy_action":
_note_enemy(String(row.get("text", "")), st) # 施放行(毒爆挂毒等——伤害行可能缺席的死亡遭遇仍可归因)
if row.source_class == "mine_hit":
st.mine_hits += 1 # 踩雷证据行(负向兑付——完美档断言恒 0;盲挖惩罚链埋点)
# 封印经济聚合(自结算流水聚合,零新随机源):
# ⚠ 行侧判据考证实录:结算段对存续增益实例每轮复发行 effect 行且复用实例 source_class
#(battle_orchestrator 状态列举行)——source_class=seal_hit 的行 ≠ 标中事件。事件行唯一判据
# = 授予行专带 highlight="seal_hit"(实测 87 行 == 受理封印 87 动作,1:1 精确)。
# 满层拒收行(reward_ineffective)零兑付不入。标空事件=归因行(type=seal_miss 每格恰一次);
# 标空惩罚合计只入一次性兑付行(flat 直伤 damage/heal + gp_loss escrow 行)——sapped 授予行
# 与其状态复发行行侧不可区分不入合计(登记注记,判读侧按事件数补算)。
if row.source_class == "seal_hit" and row.type != "reward_ineffective":
if String(row.get("highlight", "")) == "seal_hit":
st.seal_hit += 1
st.seal_hit_gain += int(row.value)
if row.type == "seal_miss":
st.seal_miss += 1
elif row.source_class == "seal_miss" and (row.type == "damage" or row.type == "heal" \
or String(row.get("effect_id", "")) == "gp_loss"):
st.seal_miss_penalty += int(row.value)
if collect_rows:
st.rows.append(row)
# 敌方显示名采集:敌方行动/施放行文本首词 = 单位显示名
#(攻击行「地精 撕咬(暴击)」、施放行「地精 施放 毒雾」——显示名不含空格,去重连续重复)
static func _note_enemy(text: String, st: Dictionary) -> void:
if text.is_empty():
return
var ename := text.split(" ")[0]
if ename.is_empty():
return
var arr: Array = st.last_battle_enemies
if arr.is_empty() or String(arr[arr.size() - 1]) != ename:
arr.append(ename)
static func _on_node_entered(node: Dictionary, info: Dictionary, runner, st: Dictionary, logger) -> void:
var t := String(node.get("type", ""))
if t == "battle" or t == "boss":
st.battles += 1
st.last_battle_enemies = [] # 末场战斗敌方名序列按战斗重置(失败局终值 = 死亡遭遇)
if logger != null and runner.current_battle() != null:
logger.attach_battle(runner.current_battle())
if t == "boss":
st.boss_depth = int(node.get("depth", -1))
st.hp_at_boss = runner.hero_hp
elif t == "reward":
runner.continue_after_node() # autoplay 直通不解三选一——sim 策略选择(测量期不进兑付面,非机制缺位)
elif t == "rest":
st.rest_healed = int(info.get("healed", 0))
st.hp_before_rest = int(info.get("hp", 0)) - st.rest_healed
runner.continue_after_node()
elif t == "shop":
runner.continue_after_node() # 单关机制夹具(run_level)零购离开保持——smoke 商店/战斗断言面
# 不掺购买行为;冒险口径(run_adventure)已在 _on_adventure_node_entered 改贪心买齐
if logger != null:
logger.log_node(node, info, runner.node_index)
static func _on_level_finished(p: Dictionary, st: Dictionary, logger) -> void:
st.result = String(p.get("result", ""))
st.hp_left = int(p.get("hp", 0))
st.defeat_node_index = int(p.get("node_index", -1))
st.defeat_node_id = int(p.get("node_id", -1)) # 编号空间解析与行进同源(图版 = node_depth——直入口死亡节点可读面)
st.defeat_node_type = String(p.get("node_type", ""))
if logger != null:
logger.log_level_end(p)
static func _autoplay_battle(runner, settlement, st: Dictionary, logger) -> void:
# 策略档经 _decide_act 单点分派(单循环承载三档——三档决策语义逐位不变,见 _decide_act)。
# 普通策略档 v3:完美策略 + 受控失误注入(标定依据=真人实证:
# 零加成真人首通三关踩 23 雷)。
# v3 语义 = 会玩的普通玩家:推理与完美档同源(subset 解算/封印/卡死刷新),失误只注入两手——
# 标雷误挖(NORMAL_MINE_SLIP_P)+ 安全挖跳过(NORMAL_SKIP_DIG_P)。连续拒绝 ≥3 = 编排违约防御。
# 完美策略档:单步决策循环——move/dig/seal 经 排雷玩法 settle_batch 提交,
# refresh 经 settlement.player_request_refresh(战斗结算 核心编排:行动经济 扣 RAP + 退役 + 当场换新板),
# stop 出内环走统一停手提交。连续拒绝 ≥3 = 编排违约防御(fail-fast 语义,停手防死循环)。
# 上界口径:选路=目标价值评分(_route_pick)+ 商店贪心买齐(_autoplay_shop)——
# 「完美」是元游戏意义上的上界(会购物会补给),不只扫雷子游戏上界。
var round_guard := 60
while st.result == "" and runner.current_battle() == settlement and settlement.battle_result == "" and round_guard > 0:
round_guard -= 1
st.rounds += 1
var start_round: int = settlement.round_index
# 内层单轮循环委托 BattleDriver.drive_round(驱动循环单点——庚案收敛:本类禁再持第二轮驱动循环;
# 策略分派/失误注入全保留在 _decide_act 原地,日志与计数经 _action_logger 回调承载)
BattleDriver.drive_round(settlement,
func() -> bool: return runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" and settlement.round_index == start_round,
_act_provider(st), _action_logger(st, logger), 3)
# 策略回调适配器(drive_round act_provider 契约——三档分派 perfect/novice/slip 全在 _decide_act 原地)
static func _act_provider(st: Dictionary) -> Callable:
return func(s) -> Dictionary: return _decide_act(st, s)
# 日志回调适配器(drive_round on_action 契约——log_action 带轮号 + refresh/seal 计数语义逐位同原内联)
static func _action_logger(st: Dictionary, logger) -> Callable:
return func(kind: String, cell: Vector2i, ok: bool, ri: int) -> void:
if logger != null:
logger.log_action(kind, cell, ok, {"round": ri})
if ok and kind == "refresh":
st.refreshes = int(st.refreshes) + 1
elif ok and kind == "seal_place":
st.seal_placed = int(st.get("seal_placed", 0)) + 1 # 封印数(η 分母=全部封印数〔Q5〕;失误注入转化封印不计数=转化后实为挖)
# 普通档 v3 失误注入:三类——
# ①标雷误挖:把「封印已证雷」动作直接转成「挖该格」(真人手滑原型:知道是雷、位置点错,
# 踩雷吃伤且该雷奖励报废);期望踩雷增量 ≈ 每板已证雷数 × 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 == "seal" 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}
# 战斗决策单行分派:
# perfect 档 = 完美策略单步决策(上界口径);
# normal 档 v3 = 完美+受控失误注入(冒险测量面 adv=true);单关机制夹具保持 v2 新手画像(adv 缺省)。
# 决策函数纯读板面/编排面(零 RNG 消费——失误流独走 st.prng,见 _apply_normal_slips)。
static func _decide_act(st: Dictionary, settlement) -> Dictionary:
if String(st.get("policy", "normal")) == "perfect":
return AutoPlayer.perfect_action(settlement.minesweeper, settlement)
if bool(st.get("adv", false)):
return _apply_normal_slips(AutoPlayer.perfect_action(settlement.minesweeper, settlement), settlement, st) # v3:完美+失误
return AutoPlayer.novice_action(settlement.minesweeper) # 单关机制夹具:v2 新手画像保持