Files
gplan-2/game/smoke/level_autoplay.gd
T

356 lines
19 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——签名与
# 返回结构零变化,行为逐位不变。
# 运行前提:db 已 load_tables;run_level/run_adventure 均须全新 RngService(同种子 ⇒ 全程逐步可回放)。
# 实现注:信号处理体全部为静态方法 + bind 传状态——GDScript 多行 lambda 内嵌 match/控制流的解析不可靠。
extends RefCounted
const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口:自动玩家策略单一实现
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.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": [],
}
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)
# 停点前移批 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))
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 跨关延续验收取数面)
}
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}):
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 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)))
r.continue_after_node() # 商店房零购离开(自动路径直通 Boss——购买行为归冒烟专项/真人试玩)
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"
# 冒险终局(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)
# 路径轴策略(确定性——候选编号最小/最大;min 侧 = 岔路恒取小编号支)。
# 编号取 runner.next_candidate_ids()(编号空间解析单一出口——单链下标 / 图 node_depth)
static func _route_pick(runner, route: String) -> 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]
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() # 商店房零购离开(GP+商店房批——自动路径直通 Boss;购买行为归冒烟商店专项断言/真人试玩)
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
# 普通策略档 v2(2026-09-19 用户拍板):基础定式画像——单步决策循环(move/dig/flag 经 settle_batch 提交,
# 推理分级见 auto_player.gd 普通策略档节:新手级单数字满足规则 + 盲挖边界兜底、无 refresh);
# 旧"无推理挖最近格"口径随批退役。连续拒绝 ≥3 = 编排违约防御(同完美档 fail-fast 语义)。
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.novice_action(settlement.minesweeper)
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)
if ok:
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})
# 完美策略档战斗驱动(2026-09-14):单步决策循环——move/dig/flag 经 排雷玩法 settle_batch 提交,
# refresh 经 settlement.player_request_refresh(战斗结算 核心编排:行动经济 扣 RAP + 退役 + 当场换新板),
# stop 出内环走统一停手提交。连续拒绝 ≥3 = 编排违约防御(fail-fast 语义,停手防死循环)。
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)