636 lines
34 KiB
GDScript
636 lines
34 KiB
GDScript
# 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") # 自动玩家策略单一实现
|
||
|
||
# ── 普通档 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.start_session(seed_val)
|
||
logger.ap_per_round = int(db.run.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": [],
|
||
# 封印经济五字段(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.start_session(seed_val)
|
||
logger.ap_per_round = int(db.run.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")), # 特性分层开关(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 rule 键集透传)
|
||
if relic.has("tree_rules"):
|
||
adv_params["tree_rules"] = relic.get("tree_rules", [])
|
||
# 商店分层注入(缺省 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 := 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"
|
||
|
||
|
||
# 商店贪心买齐: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
|
||
|
||
|
||
# 商店共享池贪心:货架三选一同构面贪心买齐——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:种子派生自局种子 × 黄金比率常数 + 偏移——与核心统一流
|
||
# (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_shelf(): # pool 面:货架现价
|
||
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;盲挖惩罚链埋点)
|
||
# 封印经济聚合(自结算流水聚合,零新随机源):
|
||
# ⚠ 行侧判据考证实录:结算段对存续增益实例每轮复发行 effect 行且复用实例 source_class
|
||
#(battle_orchestrator 状态列举行)——source_class=seal_hit 的行 ≠ 标中事件。事件行唯一判据
|
||
# = 授予行专带 highlight="seal_hit"(实测 87 行 == 受理封印 87 动作,1:1 精确);
|
||
# 雷区图谱追加分grant 无高光,以 display_name 后缀补捕(计入事件与合计——同封印第二份兑付)。
|
||
# 满层拒收行(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" \
|
||
or String(row.get("display_name", "")).contains("雷区图谱"):
|
||
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
|
||
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 == "seal":
|
||
ok = _submit_intent(settlement, "seal_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 == "seal":
|
||
st.seal_placed = int(st.get("seal_placed", 0)) + 1 # 封印数(η 分母=全部封印数〔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 失误注入:三类——
|
||
# ①标雷误挖:把「封印已证雷」动作直接转成「挖该格」(真人手滑原型:知道是雷、位置点错,
|
||
# 踩雷吃伤且该雷奖励报废);期望踩雷增量 ≈ 每板已证雷数 × 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 新手画像保持
|
||
|
||
|
||
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)
|
||
|