Files
gplan-2/game/smoke/level_autoplay.gd
T
DKChan 5f9d8e7b77 chore(代码卫生): 文件头零工作日志红线落文+全仓头注变更叙事清偿(2026-10-15 用户令)
- CLAUDE.md 硬性红线新增「文件头零工作日志」;code-conventions.md 新增 §1.4.1(含反面案例)
- 全仓 .gd 文件头注清偿:删除全部带日期条目、批次叙事段与迁移退役叙述,保留并改写为
  现在时的职责、接口契约、GDD 锚内容(main.gd 头注 42→31 行、save_manager.gd 49→45 行)
- 纯注释改动零行为变更,按用户令免测试免冒烟
2026-10-15 04:08:38 +08:00

621 lines
34 KiB
GDScript
Raw 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
# Date: 2026-09-11
# 日志合规:任何模拟运行与真人试玩同日志——操作记录 / 轮次状态 / 战斗结束 / 通关结束 全量 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://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
if own_session:
logger = load("res://presentation/playtest_logger.gd").new()
logger.start_session(seed_val)
logger.ap_per_round = int(db.action_economy.ap_per_round)
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": [],
# 旗经济五字段(迭代14②判式换代批 T1——runsum 读数面;聚合自结算流水 source_class,
# 零新随机源零核心改动;η=flag_hit÷flag_placed 与标空率派生归分析侧)
"flag_placed": 0, "flag_hit": 0, "flag_miss": 0, "flag_hit_gain": 0, "flag_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, {}, template_pick):
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)
# 停点前移批 2026-09-23:入口停点(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)
# 三关连探冒险驱动(冒险结构实现批 2026-09-21)——AdventureRunner 编排:主种子恰一次
# (start_adventure 唯一 new_adventure 调用点)、大奖励立即确认、冒险终局置 result/summary。
# 返回结构 = run_level 同族 + summary / wallet_events / encounter_max_hp_seq / reward_events /
# visited_ids(全冒险节点行进序列——跨关累计,非末关 runner 的 visited 随实例更替,此处为唯一全量面)。
# 2026-09-22 数值验证工具批:增 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.start_session(seed_val)
logger.ap_per_round = int(db.action_economy.ap_per_round)
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")), # 特性分层开关(迭代 12 T7——off=316-f 通用竞争/on=成型画像优先捕捞 relic)
"shop_gp_seq": [], # 商店房到达余额序(逐关 1 次——GP 跨关延续验收取数面)
# 旗经济五字段(迭代14②判式换代批 T1——与 run_level 同口径,见彼处注释)
"flag_placed": 0, "flag_hit": 0, "flag_miss": 0, "flag_hit_gain": 0, "flag_miss_penalty": 0,
"prng": _policy_rng(seed_val), "mine_slips": 0, "skip_digs": 0, "shop_buys": 0,
"adv": true, # v3 档位行为开关(选路评分/失误注入/商店购买只挂冒险测量面——单关夹具保持旧语义零扰动)
"level_templates": [], # 逐关图模板名(关卡节点图生成 v0.4 分层出数)
}
adr.step_row.connect(_on_step_row.bind(adr, st, collect_rows))
adr.node_entered.connect(_on_adventure_node_entered.bind(adr, st, logger))
adr.wallet_changed.connect(_on_adventure_wallet.bind(st, logger))
# 三选一停点驱动(迭代 10 奖励重构批——big_reward_paid 退役:确定性策略候选取首件、
# 兑付后推进下一关——零随机流自开,同种子逐位可复放;满位替换策略随迭代 22 T5 拆除)
adr.curio_choice_presented.connect(_on_adventure_bonus.bind(st, adr))
adr.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)
# 同调分层注入(迭代 11 第①批 T8——缺省 {} 与既有调用方逐位一致零行为变更)
var adv_params := {"hero_override": hero_override, "first_script": false}
if not attune.is_empty():
adv_params["attunement"] = attune.get("equipped", [])
adv_params["attune_refresh_y"] = int(attune.get("refresh_y", 0))
# 特性注入(迭代 12 T7——formed 画像=开局注入七流派各 2 件档一成型集;测量面专用)
if relic.has("seed_ids"):
adv_params["relics_seed"] = relic.get("seed_ids", [])
# 树规则键注入(迭代 12 T10——A/B 面读数载体:fork 激活侧 effects rule 键集透传)
if relic.has("tree_rules"):
adv_params["tree_rules"] = relic.get("tree_rules", [])
# 商店分层注入(迭代 11 第②批 T5——缺省 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:
# 终局池读数(迭代 11 第②批 T5——摘要 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() # 特性终局持有集(迭代 12 T1——摘要 additive 键清账前快照,同 pool_stock 模式)
st.log_path = logger.log_path()
if own_session:
logger.close_session()
return st
# 冒险模式节点采集(_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_templates"):
st["level_templates"] = []
st["level_templates"].append(r.active_template_name) # MC 分层出数(关卡×图模板——v0.4 测量对账条款)
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("node_depth", -1))
st.hp_at_boss = int(adr.ledger["hero_hp"])
elif t == "reward":
# 奖励节点三选一(迭代 10 追加批——共享池确定性策略;TP0 起 316-f 判读:1 级最稀有、
# 同档并列取最左;替换放弃新件;兑付后 continue_after_node 回图选路)
var ndraw: Dictionary = adr.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.present_node_curio(ncands)
var npick: Dictionary = _pick_token(ncands, String(st.get("feat_mode", "off")))
if adr.apply_node_curio(String(npick.get("kind", "")), String(npick.get("tier", "")), String(npick.get("feat_id", ""))):
# T5 门票拿到即开——候选已被替换为专长三选一,就地再选一轮(确定性策略同款 316-f);
# 不作此分支则节点停点悬空→选路卡死(MC timeout 形态,2026-10-05 批内实测)
if String(npick.get("kind", "")) == "feat_ticket":
var fcands: Array = (adr.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.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) # 共享池货架贪心(迭代 11 第②批 T5——买一件=奖励面少一件的机会成本画像维持贪心语义)
else:
_autoplay_shop(r, st) # 贪心买齐(2026-10-01——旧零购离开退役: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)
# 三选一停点驱动(迭代 10——确定性策略;TP0 起 316-f 判读:1 级最稀有、同档并列取最左;
# 奖励事件随 reward_events 留档)
static func _on_adventure_bonus(payload: Dictionary, st: Dictionary, adr) -> void:
st.reward_events.append({"curio_pick": payload.duplicate(true)})
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.pick_curio(String(pick.get("kind", "")), String(pick.get("tier", "")), String(pick.get("feat_id", ""))):
st.result = "advance_failed"
# 三选一确定性选件(TP0 316-f——1 级优先度=最稀有〔迭代 20 四级:legendary>rare>excellent>common〕、2 级=最左;
# 候选并列同档取最左=稳定序保证。空候选/未知档位 = 回退最左防御口径。
# 成型画像(迭代 12 T7——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 := 0
match String(t.get("tier", "")):
"legendary":
rank = 4
"rare":
rank = 3
"excellent":
rank = 2
"common":
rank = 1
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"
# 商店贪心买齐(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
# 商店共享池贪心(迭代 11 第②批 T5):货架三选一同构面贪心买齐——affordable 集挑最便宜反复买
# (每次买后全重抽、货架随买随换,枯竭/不可负担即止,guard 防死循环)。购买走 runner.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_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.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(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
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_shelf(): # pool 面:货架现价(迭代 11 第②批 T5)
cheapest = mini(cheapest, int(c.get("price", 1 << 30)))
else:
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:
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;盲挖惩罚链埋点)
# 旗经济聚合(迭代14②判式换代批 T1——自结算流水聚合,零新随机源):
# ⚠ 行侧判据考证实录:结算段对存续增益实例每轮复发行 effect 行且复用实例 source_class
#(battle_orchestrator 状态列举行)——source_class=flag_hit 的行 ≠ 标中事件。事件行唯一判据
# = 授予行专带 highlight="flag_hit"(实测 87 行 == 受理插旗 87 动作,1:1 精确);
# 雷区图谱追加分grant 无高光,以 display_name 后缀补捕(计入事件与合计——同旗第二份兑付)。
# 满层拒收行(reward_ineffective)零兑付不入。标空事件=归因行(type=flag_miss 每格恰一次);
# 标空惩罚合计只入一次性兑付行(flat 直伤 damage/heal + gp_loss escrow 行)——sapped 授予行
# 与其状态复发行行侧不可区分不入合计(登记注记,判读侧按事件数补算)。
if row.source_class == "flag_hit" and row.type != "reward_ineffective":
if String(row.get("highlight", "")) == "flag_hit" \
or String(row.get("display_name", "")).contains("雷区图谱"):
st.flag_hit += 1
st.flag_hit_gain += int(row.value)
if row.type == "flag_miss":
st.flag_miss += 1
elif row.source_class == "flag_miss" and (row.type == "damage" or row.type == "heal" \
or String(row.get("effect_id", "")) == "gp_loss"):
st.flag_miss_penalty += int(row.value)
if collect_rows:
st.rows.append(row)
# 敌方显示名采集(数值验证工具批 2026-09-22):敌方行动/施放行文本首词 = 单位显示名
#(攻击行「地精 撕咬(暴击)」、施放行「地精 施放 毒雾」——显示名不含空格,去重连续重复)
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("node_depth", -1))
st.hp_at_boss = runner.hero_hp
elif t == "reward":
runner.continue_after_node() # 奖励点 = 内容待拍板直通节点(格面资源批 2026-09-15)
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 单点分派(T6 样板收口批 2026-10-02——原 _autoplay_battle/_autoplay_battle_perfect
# 双循环逐行相同仅差 act 来源行,合并单循环;三档决策语义逐位不变,见 _decide_act)。
# 普通策略档 v3(2026-10-01 用户重定义):完美策略 + 受控失误注入——v2「新手级单步推理+盲挖
# 兜底」画像被真人实证推翻(零加成真人首通三关踩 23 雷 vs v2 普通档 L2 条件通关率 10.6%)。
# v3 语义 = 会玩的普通玩家:推理与完美档同源(subset 解算/标旗/卡死刷新),失误只注入两手——
# 标雷误挖(NORMAL_MINE_SLIP_P)+ 安全挖跳过(NORMAL_SKIP_DIG_P)。连续拒绝 ≥3 = 编排违约防御。
# 完美策略档(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)——
# 「完美」须是元游戏意义上的上界(会购物会补给),不再只是扫雷子游戏上界。
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
var action_guard := 400
var rejects := 0
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: Dictionary = _decide_act(st, settlement)
var kind := String(act.get("kind", "stop"))
if kind == "stop":
break
var ok := false
if kind == "move":
ok = _submit_intent(settlement, "move", act.get("cell"), logger, start_round)
elif kind == "dig":
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
elif kind == "flag":
st.flag_placed = int(st.get("flag_placed", 0)) + 1 # 插旗数(η 分母=全部插旗数——迭代14② Q5 裁决;失误注入转化旗不计数=转化后实为挖)
rejects = 0
else:
rejects += 1
if runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" and settlement.round_index == start_round:
var out_s: Array = settlement.minesweeper.settle_batch([{"action": "stop"}])
if logger != null:
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}
# 战斗决策单行分派(T6 双循环合并抽取——原两循环唯一差异行):
# 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 新手画像保持
static func _submit_intent(settlement, action: String, cell, logger, round_index: int) -> bool:
var out: Array = settlement.minesweeper.settle_batch([{"action": action, "cell": cell}])
if logger != null:
logger.log_action(action, cell, _accepted(out), {"round": round_index})
return _accepted(out)
static func _accepted(outcomes: Array) -> bool:
return not outcomes.is_empty() and bool(outcomes[0].accepted)