362 lines
19 KiB
GDScript
362 lines
19 KiB
GDScript
# 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", 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": [],
|
||
}
|
||
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))
|
||
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 跨关延续验收取数面)
|
||
|
||
"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)))
|
||
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)
|
||
|