Files
gplan-2/game/smoke/level_autoplay.gd
T
DKChan a2f72f9d9e iter9-numfix-b: 模拟档位重构——普通档 v3=完美+失误注入·完美档修上界(用户拍板「普通档≠新手画像」)
- 依据:真人实证推翻 v2 普通档——零加成真人首通三关踩 23 雷 vs 新手画像 L2 条件通关率 10.6%
  (尺子比真人弱 20 倍);完美档选路机械取最小编号+商店零购=不是元游戏上界(真人两店全购)
- 普通档 v3:完美策略(subset 解算/标旗/卡死刷新)+受控失误注入——标雷误挖 NORMAL_MINE_SLIP_P
  0.22(目标踩雷期望=完美+2/板,用户规格)+安全挖跳过 NORMAL_SKIP_DIG_P 0.05(每板少挖几个);
  失误流=独立策略 RNG(局种子派生,逐局可回放;核心七流零消费零扰动)
- 完美档上界修正:选路机械最小编号→目标价值评分(rest 伤重/shop 买得起/reward 占位/boss 缓冲,
  同分 tie-break 沿 route 方向——verifier 双窗轴保持);商店零购→贪心买齐(GP 局内死钱)
- 夹具保护:v3 行为只挂 run_adventure 测量面(st.adv 旗标)——run_level 单关机制夹具保持旧语义
  (机械选路+novice 画像+商店零购),smoke 断言零扰动
- novice_action 留库不删(历史读数对账面);轻闸=冒烟 596/0 全绿(夹具零漂移实证)
2026-10-01 01:06:49 +08:00

480 lines
26 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(2026-09-12 增完整遥测接线——用户要求:任何模拟运行与真人试玩同日志合规:
# 操作记录 / 轮次状态 / 战斗结束 / 通关结束 全量 JSONL。logger=null 时自建会话(每局一文件),
# 批量调用方传共享 logger(log_run 局次标记切分)。
# 关卡自动通关策略(理性玩家近似)——呈现层数值调整板的批量模拟与 headless 扫描
# 共用同一实现,保证"面板模拟读数"与"扫描表读数"同口径。策略与 smoke_all 战斗自动玩家同族:
# 挖最近可挖格 / 无可挖停手 / 奖励恢复点立即确认。
# 2026-09-23 单关直入口口径退役(用户拍板):run_level 降格为机制夹具/单战锚点专用
# (smoke_all 机制断言、rhythm/defense/damage 单战扫描)——通关率/难度类测量禁用本入口
# (满血零上下文进关,与真实冒险条件通关率脱节),一律走 run_adventure。
# 2026-09-15 格面资源批:道具窗口选择与可选类获取计数随可选类道具删除下线。
# 2026-09-14 增完美策略档(policy="perfect",默认 "normal" 零行为变更——既有调用方签名兼容):
# 零盲挖/全标旗/卡死刷新——上界测量仪,求解器与动作语义见 auto_player.gd 完美策略档节。
# 2026-09-17 分支节点图批:选路适配(零自动推进)——兑付确认/战斗胜利后 runner 进选路态,
# 驱动循环按 route 策略显式 pick_next_node;route = "min"/"max"(候选节点编号最小/最大——
# 确定性路径轴,零随机流自开;默认 "min" 既有调用方签名兼容)。
# 2026-09-21 冒险结构实现批:run_adventure 三关连探驱动(AdventureRunner——主种子恰一次、
# 大奖励立即确认 continue_to_next_level、adventure_finished 置 result/summary;外层每关重取
# runner 实例经 _drive_level 复用单关驱动)。run_level 驱动段抽成 _drive_level——签名与
# 返回结构零变化,行为逐位不变。
# 2026-10-01 模拟档位重构(用户拍板「普通档≠新手画像」——普通档 v2 被真人实证推翻:零加成真人
# 首通三关踩 23 雷 vs 新手画像 L2 条件通关率 10.6%,尺子比真人弱 20 倍):
# ①普通档 v3 = 完美策略 + 受控失误注入(标雷误挖 NORMAL_MINE_SLIP_P / 安全挖跳过
# NORMAL_SKIP_DIG_P;失误流走独立策略 RNG——种子派生自局种子,逐局确定性/可回放保持,
# 核心七流零消费零扰动);v2「新手级单步推理+盲挖兜底」口径退役(novice_action 留库不删——
# 历史读数对账面)。
# ②完美档修上界:选路从机械最小编号改目标价值评分(rest 伤重优先/shop 有钱优先/boss 缓冲),
# 商店从零购离开改贪心买齐(GP 局内死钱——不购物=玩的不是同一个游戏,真人实证两店全购)。
# ③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/板、每板少挖几个正确格)──
# 标雷→误挖转化率:每板已证雷 ~9-14 颗 × p ≈ 2-3 次踩雷(目标完美+2/板±0.3——首测读数后校准此值)
const NORMAL_MINE_SLIP_P := 0.22
# 安全挖跳过率:每板安全格 ~70-90 × p ≈ 3-5 次漏挖(信息与推进双损失)
const NORMAL_SKIP_DIG_P := 0.05
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,
"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))
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 = {}) -> 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,
"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 分层出数)
}
adr.step_row.connect(_on_step_row.bind(adr, st, collect_rows))
adr.node_entered.connect(_on_adventure_node_entered.bind(adr, st, logger))
adr.wallet_changed.connect(_on_adventure_wallet.bind(st, logger))
adr.big_reward_paid.connect(_on_adventure_reward.bind(st, adr))
adr.adventure_finished.connect(_on_adventure_finished.bind(st, logger))
adr.set_hero_override(atk_pct, hp_pct)
if not adr.start_adventure(seed_val, {"hero_override": hero_override, "first_script": false}): # 测量夹具恒抽签(v0.4——首次脚本分支不进任何读数)
st.result = "start_failed"
if own_session:
logger.close_session()
return st
var guard_drives := 10 # 每次外层迭代 = 驱动一关(关卡更替由 big_reward 确认与 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()
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":
r.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
r.continue_after_node()
elif t == "shop":
st.shop_gp_seq.append(int(info.get("gp", -1)))
_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", ""))})
if logger != null:
logger.log_wallet(event, gp_val, extra)
# 大奖励立即确认(非末关通关 → continue_to_next_level 进下一关——自动路径无大奖励屏停点)
static func _on_adventure_reward(reward: Dictionary, st: Dictionary, adr) -> void:
st.reward_events.append(reward.duplicate(true))
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
# 冒险终局(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
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 == "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;盲挖惩罚链埋点)
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:
if String(st.get("policy", "normal")) == "perfect":
_autoplay_battle_perfect(runner, settlement, st, logger)
return
# 普通策略档 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
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
if bool(st.get("adv", false)):
act = _apply_normal_slips(AutoPlayer.perfect_action(settlement.minesweeper, settlement), st) # v3:完美+失误
else:
act = AutoPlayer.novice_action(settlement.minesweeper) # 单关机制夹具:v2 新手画像保持
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
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/板)
# ②安全挖跳过:把「挖已证安全格」动作转成本轮停手(少挖几个正确的雷——信息与推进双损失)
# 失误流 = st.prng(策略流 RNG,种子派生自局种子——同种子逐位可回放;核心七流零消费零扰动)
static func _apply_normal_slips(act: Dictionary, 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" and prng.randf() < NORMAL_SKIP_DIG_P:
st.skip_digs = int(st.get("skip_digs", 0)) + 1
return {"kind": "stop"}
return act
# 完美策略档战斗驱动(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:
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 := AutoPlayer.perfect_action(settlement.minesweeper, 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
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})
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)