merge(master)+禁令残留清扫:并回 master 新进提交(gdstyle 闸件批/S9 卡/iter36 收尾),冲突 test-conventions §8.4 保重编号版+吸收 master 新条款「判据绑可观测提交面」(措辞去变异化);随批清扫——待办清单 gdstyle/S9 两条目「变异验证义务」改撤销证明口径;S9 活卡全量改写(契约 3/T2/D2/D4/来源行/对照表列头/审计面/两轮 grilling 记录——旧「闸件豁免」定界标注经用户全项目禁令推翻,验证义务统一撤销证明,禁令条款原文保留)
This commit is contained in:
commit
e89f486aa9
94 files changed
+4608
-3451
No files matched your search
@@ -42,7 +42,7 @@ var _pending_next_level := false # 大奖励已兑付、等 continue_to
|
||||
var _last_level_payload := {} # 最近一次关卡结束载荷(失败结算的败退节点明细来源——数值验证取数面)
|
||||
var _override := [0, 0] # 调试浮窗百分比通道 [atk_pct, hp_pct](每关新 runner 都要应用)
|
||||
var _base_override := {"atk": 0, "max_hp": 0, "armor_class": 0}
|
||||
var _first_script := false # 首次脚本模式(关卡节点图生成 v0.4——true = 全关零抽签取池内模板 0;测量夹具恒 false) # 永久强化 flat 注入(冒险开始参数面 params.hero_override 存入,共享给 LevelRunner;全零 = 白板)
|
||||
# 永久强化 flat 注入(冒险开始参数面 params.hero_override 存入,共享给 LevelRunner;全零 = 白板)
|
||||
var _last_treasure_cleared := 0 # additive(最近一次关卡末清空计数,summary 取数面)
|
||||
var _treasure_unlocked := {} # aux 解锁门控集(params.treasure_unlocked 接收面)
|
||||
var _attunement: Array[Variant] = [] # 同调装配集(params.attunement 接收面,start_level 外下传 LevelRunner)
|
||||
@@ -99,7 +99,6 @@ func start_adventure(seed_val: int, params: Dictionary = {}) -> bool:
|
||||
_base_override = _normalize_override(params.get("hero_override", {}))
|
||||
hero_attack_type = String(params.get("attack_type", "")) # 两根类型轴快照(契约 8 锁死:开局后不随存档选中变化)
|
||||
hero_armor_type = String(params.get("armor_type", ""))
|
||||
_first_script = bool(params.get("first_script", false)) # 缺省 = 非首次(抽签路径;真实侧由呈现层读存档布尔位传入)
|
||||
_shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 缺省 legacy(off 对照零扰动)
|
||||
_treasure_unlocked = params.get("treasure_unlocked", {}) if params.get("treasure_unlocked", {}) is Dictionary else {}
|
||||
_attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive
|
||||
@@ -144,6 +143,11 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool:
|
||||
if bp.is_empty():
|
||||
push_error("[冒险结构] resume_adventure 拒绝——空断点快照")
|
||||
return false
|
||||
if int(bp.get("graph_gen_v2", 0)) != 1:
|
||||
# 旧代差断点(模板池时代的 node_id 与生成图句柄空间可碰撞错位)——显式拒绝,
|
||||
# 禁按新语义静默回放;旧模板图已随模板池退役,无迁移动作可做(凭空捏图=兜底图红线)。
|
||||
push_error("[冒险结构] resume_adventure 拒绝——断点缺生成图代差标记(graph_gen_v2),属换代前旧断点")
|
||||
return false
|
||||
master_seed = int(bp.get("master_seed", 0))
|
||||
rng.restore_adventure(master_seed, int(bp.get("rng_counter", 0)))
|
||||
ledger = {"hero_hp": int(bp.get("hero_hp", 0)), "gp": int(bp.get("gp", 0))}
|
||||
@@ -191,7 +195,6 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool:
|
||||
# 钳制用 flat 已含的保守口径即可,加算区恢复后其余战斗照常)。
|
||||
ledger["hero_hp"] = mini(int(ledger.get("hero_hp", 0)), maxi(1,
|
||||
int(round(float(int(db.hero.max_hp) + int(_base_override.get("max_hp", 0))) * (100.0 + float(_override[1])) / 100.0))))
|
||||
_first_script = false # 续玩模板已随快照强制(已抽模板零重抽)——脚本模式不重放
|
||||
_shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 续玩参数面同 start_adventure(缺省 legacy)
|
||||
_treasure_unlocked = params.get("treasure_unlocked", {}) if params.get("treasure_unlocked", {}) is Dictionary else {}
|
||||
_attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive
|
||||
@@ -844,14 +847,12 @@ func _start_current_level() -> bool:
|
||||
_runner.wallet_changed.connect(_forward_wallet_changed)
|
||||
_runner.level_finished.connect(_on_level_finished)
|
||||
_runner.set_hero_override(int(_override[0]), int(_override[1]))
|
||||
# 断点续玩分支:模板随快照强制(已抽模板零重抽零 RNG)、resume_state 带
|
||||
# 停点节点与已到序——重建到停点后经 present_resumed_choice 呈现(信号顺序契约同入口)。
|
||||
# 断点续玩分支:图由主种子+参数档重生成同图(产图与计数器号位无关,停点零重抽语义随之消解),
|
||||
# resume_state 带停点句柄与已到序——重建到停点后经 present_resumed_choice 呈现(信号顺序契约同入口)。
|
||||
var resume_state: Dictionary[Variant, Variant] = {}
|
||||
var template_pick: int = 0 if _first_script else -1 # 首次脚本 = 每关固定模板 0 零抽签;否则逐关抽签
|
||||
if not _resume_bp.is_empty():
|
||||
resume_state = {"node_id": int(_resume_bp.get("node_id", -1)),
|
||||
"visited_ids": _resume_bp.get("visited_ids", []) as Array}
|
||||
template_pick = int(_resume_bp.get("template_pick", -1))
|
||||
# 接口①开始关卡:生命经共享账本交接(账本当前值即开局生命——冒险开始置满 / 大奖励回满已写好);
|
||||
# _base_override 共享引用随载荷传入(永久强化 flat——LevelRunner.effective_* 叠加序第二项)。
|
||||
# defer_entry=true 起每关(含首关)——先 emit level_started(呈现层在
|
||||
@@ -860,7 +861,7 @@ func _start_current_level() -> bool:
|
||||
_runner.set_run_ledger(run_ledger) # 共享四账单类下传(run_ledger=四账类,ledger=关卡账)
|
||||
_runner.sync_attune_inventory() # 能力件骑库存面注入(随账下传同拍)
|
||||
var ok: bool = _runner.start_level_external(level_index, ledger, purchased,
|
||||
apply_rest, _base_override, true, template_pick, resume_state)
|
||||
apply_rest, _base_override, true, resume_state)
|
||||
if ok:
|
||||
_runner.set_attunement(_attunement) # 同调装配下传(enc 携带进战斗结算)
|
||||
_runner.set_feats(_feats) # 特性持有集下传(begin_battle enc.feats 求值面)
|
||||
@@ -1010,7 +1011,7 @@ func _snapshot_breakpoint() -> void:
|
||||
"shop_shelf": _runner.shop_bp_shelf() if _runner != null else [], # 货架入断点(离开店/非店停点 = 空架合法态)
|
||||
"shop_visit_buys": _runner.shop_bp_visit_buys() if _runner != null else 0, # 本次进店已购次数(A 链定价输入)
|
||||
"attunement_charges": run_ledger.attune_charges.duplicate(true), # 同调主动件充能余量随断点(save v4 域)
|
||||
"template_pick": _runner.active_template_index if _runner != null else -1,
|
||||
"graph_gen_v2": 1, # 生成图换代代差标记(缺键 = 换代前旧断点 → resume 显式拒绝)
|
||||
"master_seed": master_seed,
|
||||
"rng_counter": rng.counter(),
|
||||
"keystone": keystone,
|
||||
|
||||
@@ -794,7 +794,7 @@ func _grant_routed(snap: Dictionary, polarity: String, source_class: String, cel
|
||||
if halve_pct > 0 and polarity == "negative" and source_class == "mine_hit":
|
||||
row = row.duplicate()
|
||||
var half: int = int(ceil(float(int(row.power)) * float(100 - halve_pct) / 100.0))
|
||||
_emit_row("effect", source_class, cell, ri, "惊巢反制——挖雷惩罚减半(%d→%d)" % [int(row.power), half], 0, {"effect_id": "nest_counter"}, highlight)
|
||||
_emit_row("effect", source_class, cell, ri, "惊巢反制——危险格挖掘惩罚减半(%d→%d)" % [int(row.power), half], 0, {"effect_id": "nest_counter"}, highlight)
|
||||
row.power = half
|
||||
return _dispatch_row(row, source_class, cell, ri, highlight, label_suffix)
|
||||
|
||||
|
||||
@@ -51,60 +51,8 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void:
|
||||
for s in required_sources:
|
||||
if not st_tab.source_label_map.has(s):
|
||||
push_diag.call("error", "segorder_violation", "source_label_map 缺必需键: %s" % s)
|
||||
# ④ LevelTable 节点悬空引用(battle/boss 节点:board_gen 侧须有精确行;enemy 侧按
|
||||
# (关卡序号, 行类) 带寻址——落带即设计语义,节点深度高于顶带键=顶 clamp 消解属调参事故,加载期直接拦。
|
||||
# 同关同一张图多终点 Boss 节点解析带键须互异(共带=两终点命中同一 Boss 行集,「走哪条路打谁」落空))
|
||||
var lv: LevelTable = loaded.get("level")
|
||||
var bg: BoardGenTable = loaded.get("board_gen")
|
||||
var en: EnemyTable = loaded.get("enemy")
|
||||
if lv != null and bg != null and en != null:
|
||||
var bg_keys := {}
|
||||
for row in bg.rows:
|
||||
bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
|
||||
var en_bands := {} # "%d_%s" % [L, row_kind] → 升序带键数组
|
||||
for row in en.rows:
|
||||
var ek := "%d_%s" % [int(row.level_index), String(row.get("row_kind", ""))]
|
||||
if not en_bands.has(ek):
|
||||
en_bands[ek] = []
|
||||
var eb := int(row.get("depth_band", -1))
|
||||
if not (en_bands[ek] as Array).has(eb):
|
||||
(en_bands[ek] as Array).append(eb)
|
||||
for ek in en_bands.keys():
|
||||
(en_bands[ek] as Array).sort()
|
||||
for lrow in lv.rows:
|
||||
var li: int = int(lrow.level_index)
|
||||
# 模板池感知(关卡节点图生成 v0.4——池内全模板跨表校验,不只 row.nodes)
|
||||
var node_lists: Array[Array] = []
|
||||
var tpls: Array[Variant] = lrow.get("templates", [])
|
||||
if tpls.is_empty():
|
||||
node_lists.append(lrow.get("nodes", []))
|
||||
else:
|
||||
for tpl in tpls:
|
||||
node_lists.append(tpl.get("nodes", []))
|
||||
for tpl_i in range(node_lists.size()):
|
||||
var boss_bands := {} # 本图已占 Boss 带键
|
||||
for node in node_lists[tpl_i]:
|
||||
var t: String = String(node.get("type", ""))
|
||||
if t != "battle" and t != "boss":
|
||||
continue
|
||||
var nd: int = int(node.get("node_depth", -1))
|
||||
var key := "%d_%d" % [li, nd]
|
||||
if not bg_keys.has(key):
|
||||
push_diag.call("error", "level_node_dangling", "level %d %s 节点 (%s) 在 board_gen 无精确行" % [li, t, key])
|
||||
var nk := "%d_%s" % [li, t]
|
||||
if not en_bands.has(nk):
|
||||
push_diag.call("error", "level_node_dangling", "level %d %s 节点 (%d) 在 enemy 无该行类行集" % [li, t, nd])
|
||||
continue
|
||||
var hit_band: int = EnemyTable.band_of(en_bands[nk], nd)
|
||||
if hit_band < 0:
|
||||
push_diag.call("error", "level_node_dangling", "level %d %s 节点深度 %d 高于 (level %d, %s) 顶带键 %d——顶 clamp 消解" % [
|
||||
li, t, nd, li, t, int((en_bands[nk] as Array)[-1])])
|
||||
continue
|
||||
if t == "boss":
|
||||
if boss_bands.has(hit_band):
|
||||
push_diag.call("error", "rowset_rule", "level %d 模板 %d 多终点 Boss 节点同带(band %d)——共带使「走哪条路打谁」落空" % [li, tpl_i, hit_band])
|
||||
boss_bands[hit_band] = true
|
||||
# ⑤ 技能池引用完整性(敌人技能池批——敌人表池 × 技能表;独立入口 validate_skill_pool_links 供冒烟直验)
|
||||
var en: EnemyTable = loaded.get("enemy")
|
||||
var skt = loaded.get("skill")
|
||||
if skt != null and en != null:
|
||||
for e in validate_skill_pool_links(skt, en, loaded.get("status_effects")):
|
||||
|
||||
+71
-84
@@ -64,7 +64,7 @@ var _phase: int = Phase.IDLE
|
||||
var level_result := "" # "" / "victory" / "defeat"(外部契约面)
|
||||
var _candidates: Array[Dictionary] = [] # 当前选路候选(节点引用数组)
|
||||
var visited: Array[Dictionary] = [] # 已兑付/到达节点序列(呈现层图条数据源;含当前节点)
|
||||
var _graph_mode := false # 图形态缓存(行集任一节点带 next;单链 = false)
|
||||
var _generator # 参数化长图生成器(惰性建——NodeMapGenerator 持注入的 RngService)
|
||||
|
||||
|
||||
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
|
||||
@@ -127,7 +127,7 @@ func restore_hero_base() -> void:
|
||||
|
||||
# ---- 关卡生命周期 ----
|
||||
|
||||
func start_level(level_index: int, seed_val: int, base_override_in: Dictionary = {}, template_pick: int = -1) -> bool:
|
||||
func start_level(level_index: int, seed_val: int, base_override_in: Dictionary = {}) -> bool:
|
||||
# 单关测试模式:私有账本、内置恢复数学、自调 new_adventure(漂移①
|
||||
# 单关=冒险的历史简化在测试模式保留;正式种子域归 AdventureRunner.start_adventure)
|
||||
# base_override_in = 永久强化 flat 注入尾参(缺省 {} 全零白板,
|
||||
@@ -146,34 +146,31 @@ func start_level(level_index: int, seed_val: int, base_override_in: Dictionary =
|
||||
_phase = Phase.IDLE
|
||||
_candidates = []
|
||||
visited = []
|
||||
_graph_mode = false
|
||||
gp = 0 # 冒险钱包清零开局(局内货币生命周期起点)
|
||||
run_ledger.bonus = {"atk": 0, "max_hp": 0, "armor_class": 0} # 加算区冒险开始全 0(单关入口零元进度——注入面见上)
|
||||
base_override = {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat:缺省全零白板(单关入口零元进度——注入面见上)
|
||||
_shop_purchased = {} # 已购档数清零(跨冒险不延续——递增定价按冒险内累计)
|
||||
for k in base_override.keys():
|
||||
base_override[k] = maxi(0, int(base_override_in.get(k, 0))) # 注入规范化(契约同冒险开始参数面)
|
||||
for node in nodes():
|
||||
if node.has("next"):
|
||||
_graph_mode = true
|
||||
break
|
||||
hero_hp = effective_max_hp()
|
||||
rng.new_adventure(seed_val) # 关卡起点 = 冒险起点(切片口径;跨关接续归 冒险结构)
|
||||
level_row.assign(_resolve_template_row(row, template_pick)) # 池解析在播种后(node_map 抽签须已播种——EC7)
|
||||
_phase = Phase.RUNNING
|
||||
var entry := _entry_node()
|
||||
if entry.is_empty():
|
||||
push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)")
|
||||
if not _assign_generated_graph(row, seed_val):
|
||||
_phase = Phase.ENDED
|
||||
return false
|
||||
return _enter_node(entry)
|
||||
_phase = Phase.RUNNING
|
||||
var entries := _entry_nodes()
|
||||
if entries.is_empty():
|
||||
push_error("[关卡节点地图] 入口节点缺失(无入边节点集为空——validate_graph 应已拦)")
|
||||
_phase = Phase.ENDED
|
||||
return false
|
||||
return _enter_node(entries[0])
|
||||
|
||||
|
||||
# 冒险结构接口①:开始关卡(载荷 = 关卡序号 + 当前生命 + 生效上限——生命经共享账本交接;
|
||||
# 种子域归冒险结构:本路径不重播种;账本归冒险结构:本路径不重置 gp/bonus/_shop_purchased,
|
||||
# 也不重置 hero_hp——账本当前值即开局生命,AdventureRunner 已在 ledger 写好)。
|
||||
# base_override = 永久强化 flat 共享引用(缺省 {} 全零白板,与 B1 空参数路径同值)。
|
||||
# 流程与 start_level 同款(_find_level_row → 状态初始化 → _graph_mode 探测 → _enter_node(entry)),
|
||||
# 流程与 start_level 同款(_find_level_row → 生成图装配 → 状态初始化 → _enter_node(入口)),
|
||||
# 差异仅在账本/加算区/已购档/恢复执行体的注入与零重置。
|
||||
# defer_entry 尾参(缺省 false = 直接进节点)——true 时不自动
|
||||
# _enter_node(entry),改置选路态(awaiting_choice + 单候选 = 入口节点),由 AdventureRunner 在
|
||||
@@ -183,7 +180,7 @@ func start_level(level_index: int, seed_val: int, base_override_in: Dictionary =
|
||||
# _enter_node(entry)——测量面零漂移红线。
|
||||
func start_level_external(level_index: int, ledger: Dictionary,
|
||||
purchased_ref: Dictionary, rest_handler: Callable, base_override_ref: Dictionary = {},
|
||||
defer_entry: bool = false, template_pick: int = -1, resume_state: Dictionary = {}) -> bool:
|
||||
defer_entry: bool = false, resume_state: Dictionary = {}) -> bool:
|
||||
var row := _find_level_row(level_index)
|
||||
if row.is_empty():
|
||||
push_error("[关卡节点地图] level_index %d 在 LevelTable 无行" % level_index)
|
||||
@@ -193,34 +190,30 @@ func start_level_external(level_index: int, ledger: Dictionary,
|
||||
base_override = base_override_ref
|
||||
_shop_purchased = purchased_ref
|
||||
_rest_handler = rest_handler
|
||||
level_row.assign(_resolve_template_row(row, template_pick))
|
||||
if not _assign_generated_graph(row, rng.master_seed):
|
||||
return false
|
||||
node_index = -1
|
||||
level_result = ""
|
||||
_phase = Phase.IDLE
|
||||
_candidates = []
|
||||
visited = []
|
||||
_graph_mode = false
|
||||
for node in nodes():
|
||||
if node.has("next"):
|
||||
_graph_mode = true
|
||||
break
|
||||
_phase = Phase.RUNNING
|
||||
# 断点续玩路径(defer_entry 之外的第三种停点入口):直接重建到停点所在节点,
|
||||
# 零 RNG 消耗(模板已随 template_pick 强制、节点状态纯重建不 _enter_node——当场战斗从头打)。
|
||||
# 零 RNG 消耗(图由主种子+参数档重生成同图、节点状态纯重建不 _enter_node——当场战斗从头打)。
|
||||
# resume_state = {"node_id": int(-1 = 入口停点), "visited_ids": Array[int]};空字典 = 既有路径逐位不变。
|
||||
if not resume_state.is_empty():
|
||||
return _restore_to_stop(int(resume_state.get("node_id", -1)),
|
||||
resume_state.get("visited_ids", []) as Array)
|
||||
var entry := _entry_node()
|
||||
if entry.is_empty():
|
||||
push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)")
|
||||
var entries := _entry_nodes()
|
||||
if entries.is_empty():
|
||||
push_error("[关卡节点地图] 入口节点缺失(无入边节点集为空——validate_graph 应已拦)")
|
||||
_phase = Phase.ENDED
|
||||
return false
|
||||
if defer_entry:
|
||||
_phase = Phase.AWAITING_CHOICE # 入口停点(node_index 保持 -1——进节点前无 current_node)
|
||||
_candidates = [entry]
|
||||
_phase = Phase.AWAITING_CHOICE # 入口停点(node_index 保持 -1)——候选=全部入口(多入口首跳即选路)
|
||||
_candidates = entries
|
||||
return true
|
||||
return _enter_node(entry)
|
||||
return _enter_node(entries[0])
|
||||
|
||||
|
||||
# 入口停点呈现(defer_entry=true 的第二步):发出入口候选供呈现层弹
|
||||
@@ -240,14 +233,14 @@ func present_entry_choice() -> bool:
|
||||
# 候选 = 停点节点出边(与 choices_presented 停点语义逐位一致)。
|
||||
func _restore_to_stop(node_id: int, visited_ids: Array) -> bool:
|
||||
if node_id < 0:
|
||||
# 入口停点:与 defer_entry 同款形态(node_index 保持 -1,单候选 = 入口节点)
|
||||
var entry := _entry_node()
|
||||
if entry.is_empty():
|
||||
# 入口停点:与 defer_entry 同款形态(node_index 保持 -1,候选 = 全部入口)
|
||||
var entries := _entry_nodes()
|
||||
if entries.is_empty():
|
||||
push_error("[关卡节点地图] 断点恢复失败——入口节点缺失")
|
||||
_phase = Phase.ENDED
|
||||
return false
|
||||
_phase = Phase.AWAITING_CHOICE
|
||||
_candidates = [entry]
|
||||
_candidates = entries
|
||||
return true
|
||||
var stop: Dictionary[Variant, Variant] = {}
|
||||
for node in nodes():
|
||||
@@ -296,32 +289,31 @@ func _find_level_row(level_index: int) -> Dictionary:
|
||||
return db.level_row(level_index) # 表访问收口(行集直扫归 LevelTable/ConfigDB 访问器)
|
||||
|
||||
|
||||
# ---- 模板池解析(关卡节点图生成 v0.4——M-3)----
|
||||
# row.templates 非空 = 池形态:template_pick >= 0 = 强制取该模板(首次脚本模式/冒烟固定图,零 RNG 消费);
|
||||
# -1 = 抽签(node_map 用途标签恰一 scope,均匀取模)。空池 = 旧单图形态原样返回(向后兼容零迁移)。
|
||||
# 解析产物 = row 副本换 nodes(下游 nodes()/校验/呈现全透明);active_template_name 供 MC 分层出数;
|
||||
# active_template_index 供断点快照备案(已抽模板续玩零重抽零 RNG 消耗)。
|
||||
var active_template_name := ""
|
||||
var active_template_index := -1 # -1 = 非池形态(旧单图)/未解析
|
||||
|
||||
func _resolve_template_row(row: Dictionary, template_pick: int) -> Dictionary:
|
||||
var templates: Array[Variant] = row.get("templates", [])
|
||||
active_template_name = ""
|
||||
active_template_index = -1
|
||||
if templates.is_empty():
|
||||
return row
|
||||
var idx := 0
|
||||
if template_pick >= 0:
|
||||
idx = clampi(template_pick, 0, templates.size() - 1)
|
||||
else:
|
||||
var stream: RandomNumberGenerator = rng.open_stream("node_map")
|
||||
idx = stream.randi_range(0, templates.size() - 1)
|
||||
var tpl: Dictionary[Variant, Variant] = templates[idx]
|
||||
active_template_name = String(tpl.get("display_name", ""))
|
||||
active_template_index = idx
|
||||
var out := row.duplicate(true)
|
||||
out["nodes"] = tpl.get("nodes", [])
|
||||
return out
|
||||
# ---- 图源装配(关卡节点图生成 v1.1——图 = f(参数档, 种子) 纯函数;无模板池、无兜底图)----
|
||||
# 参数档按关卡序号解析(消费侧职责——生成器不认关卡号),种子取本局口径:单关模式用传入种子、
|
||||
# 冒险模式用主种子(产图与全局计数器号位无关,断点在停点重生成同图)。产图先过消费侧独立校验
|
||||
# LevelTable.validate_graph(与产出侧自证同源规格、分工不重叠);违规=起关拒绝,不降级不回退。
|
||||
func _assign_generated_graph(row: Dictionary, seed_val: int) -> bool:
|
||||
var level_index: int = int(row.get("level_index", 0))
|
||||
var params: Dictionary[Variant, Variant] = db.level_gen_params_row(level_index)
|
||||
if params.is_empty():
|
||||
push_error("[关卡节点地图] level %d 无参数档行(level_gen_params 缺行——配置错误)" % level_index)
|
||||
return false
|
||||
if _generator == null:
|
||||
_generator = NodeMapGenerator.new(rng)
|
||||
var graph: Dictionary[Variant, Variant] = _generator.generate(params, seed_val)
|
||||
if graph.is_empty() or _generator.is_fatal():
|
||||
push_error("[关卡节点地图] level %d 生成图失败(参数无解或产出不合格——无兜底图)" % level_index)
|
||||
return false
|
||||
var bad := LevelTable.validate_graph(graph["nodes"] as Array)
|
||||
if not bad.is_empty():
|
||||
for e in bad:
|
||||
push_error("[关卡节点地图] level %d 生成图违规: %s" % [level_index, String(e["message"])])
|
||||
return false
|
||||
var merged := row.duplicate(true)
|
||||
merged["nodes"] = graph["nodes"]
|
||||
level_row.assign(merged)
|
||||
return true
|
||||
|
||||
|
||||
func nodes() -> Array:
|
||||
@@ -334,37 +326,30 @@ func current_node() -> Dictionary:
|
||||
return nodes()[node_index]
|
||||
|
||||
|
||||
# ---- 图版行进面(v1.0:出边表选路 · 零自动推进 · 单链 = 隐式出边退化形态)----
|
||||
# 编号空间(与 LevelTable 校验器同一解析):图形态 = node_depth(全节点编号化);单链 = 数组下标。
|
||||
# 混用两空间会在单链上碰撞(如 reward 下标 2 vs 战斗 node_depth 2)——节点寻址一律经 _node_id。
|
||||
# ---- 图版行进面(生成图恒带显式出边;单链退化形态 = 出度 ≤1 的图,无数组序隐式边)----
|
||||
# 节点身份=生成图句柄 id(图内唯一)。定位信息另算(enc 携带关卡序号 + 节点深度),
|
||||
# 禁再以深度当身份(句柄/位置两轴语义单一)。
|
||||
|
||||
func _node_id(node: Dictionary) -> int:
|
||||
if _graph_mode:
|
||||
return int(node.get("node_depth", -1))
|
||||
return nodes().find(node)
|
||||
return int(node.get("id", -1))
|
||||
|
||||
|
||||
# 入口节点 = 无入边唯一节点(图合法性校验保证恰一)
|
||||
func _entry_node() -> Dictionary:
|
||||
# 入口节点集 = 全部无入边节点(个数=参数档 entry_count;多入口时首跳即选路)
|
||||
func _entry_nodes() -> Array[Dictionary]:
|
||||
var inbound := {}
|
||||
for node in nodes():
|
||||
for tid in _out_ids(node):
|
||||
inbound[tid] = true
|
||||
inbound[int(tid)] = true
|
||||
var out: Array[Dictionary] = []
|
||||
for node in nodes():
|
||||
if not inbound.has(_node_id(node)):
|
||||
return node
|
||||
return {}
|
||||
out.append(node)
|
||||
return out
|
||||
|
||||
|
||||
# 出边编号表:显式 next 优先;单链(无 next 键)= 数组顺序下一项(退化形态)
|
||||
# 出边句柄表(生成图恒显式 next)
|
||||
func _out_ids(node: Dictionary) -> Array:
|
||||
if node.has("next"):
|
||||
return node.get("next", [])
|
||||
var nodes: Array[Variant] = nodes()
|
||||
var i := nodes.find(node)
|
||||
if i >= 0 and i < nodes.size() - 1:
|
||||
return [i + 1]
|
||||
return []
|
||||
return node.get("next", [])
|
||||
|
||||
|
||||
# 出边候选(节点引用数组——选路口/呈现层共用读面)
|
||||
@@ -442,11 +427,13 @@ func type_visible_ids() -> Array[int]:
|
||||
# 不可达节点不落表(加载期孤儿校验应已拦截)。
|
||||
func _bfs_layers() -> Dictionary:
|
||||
var layers := {}
|
||||
var entry := _entry_node()
|
||||
if entry.is_empty():
|
||||
var entries := _entry_nodes()
|
||||
if entries.is_empty():
|
||||
return layers
|
||||
var queue: Array[Dictionary] = [entry]
|
||||
layers[_node_id(entry)] = 0
|
||||
var queue: Array[Dictionary] = []
|
||||
for entry in entries:
|
||||
layers[_node_id(entry)] = 0
|
||||
queue.append(entry)
|
||||
var i := 0
|
||||
while i < queue.size():
|
||||
var cur: Dictionary[Variant, Variant] = queue[i]
|
||||
@@ -510,7 +497,7 @@ func _start_battle(node: Dictionary) -> bool:
|
||||
# node_entered 先于 begin_battle:呈现层须先拿到 settlement 引用,才能响应 begin 期间发出的
|
||||
# board_changed(首板安装)并重建棋盘——后发会导致首战棋盘不渲染
|
||||
node_entered.emit(node, {})
|
||||
if not _settlement.begin_battle({"level_index": int(level_row.level_index), "node_depth": int(node.get("node_depth", 0)),
|
||||
if not _settlement.begin_battle({"level_index": int(level_row.level_index), "node_depth": int(node.get("depth", 0)),
|
||||
"hero_hp": hero_hp, "hero_atk": effective_atk(), "hero_max_hp": effective_max_hp(),
|
||||
"hero_armor_class": effective_ac(), "hero_ap_bonus": effective_ap_bonus(),
|
||||
"attack_type": _hero_attack_type, "armor_type": _hero_armor_type, # 两根类型轴(spawn 英雄快照;空串=锻造配置默认值回退)
|
||||
@@ -523,7 +510,7 @@ func _start_battle(node: Dictionary) -> bool:
|
||||
"voucher_rap": _voucher_rap,
|
||||
"voucher_time_sand": _voucher_time_sand,
|
||||
"node_type": String(node.get("type", ""))}):
|
||||
push_error("[关卡节点地图] 战斗启动失败(depth %s)" % str(node.get("node_depth", -1)))
|
||||
push_error("[关卡节点地图] 战斗启动失败(depth %s)" % str(node.get("depth", -1)))
|
||||
return false
|
||||
_voucher_rap = 0 # 节点级一次性:注入即消费(战斗未启动则保留至下次注入前不清)
|
||||
_voucher_time_sand = 0
|
||||
@@ -535,7 +522,7 @@ func _forward_step_row(row: Dictionary) -> void:
|
||||
|
||||
|
||||
# battle_index=第几场战斗(关卡域 1 基序——visited 已含当前节点〔_enter_node :437 先追加后启动〕;
|
||||
# 按已到节点 type=battle 计数,与图形态 node_depth/单链数组下标两编号空间解耦〔混编碰撞免疫〕;
|
||||
# 按已到节点 type=battle 计数,与句柄 id / 节点深度两轴解耦〔身份与位置互不兼任〕;
|
||||
# 开局姿态组「每关首轮窗」条件面)
|
||||
func _battle_index() -> int:
|
||||
var n := 0
|
||||
|
||||
@@ -299,9 +299,10 @@ func _default_data() -> Dictionary[String, Variant]:
|
||||
}
|
||||
|
||||
|
||||
# ---- 首次冒险布尔位(关卡节点图生成 v0.4)----
|
||||
# false = 首次脚本模式(全关零抽签取池内模板 0=现行固定图);置位只在冒险收束挂点(MetaProgression.settle_adventure)。
|
||||
# 老档迁移:缺字段 = 该档已有游玩史 → true(不重放教学脚本)。
|
||||
# ---- 首次冒险布尔位(关卡节点图生成 v1.1 确定性承载)----
|
||||
# false = 本档尚无游玩史 → 首局走固定种子 42 + 参数档生成图(教学与存档复现的固定图由此承载,无兜底模板);
|
||||
# 置位只在冒险收束挂点(MetaProgression.settle_adventure)。
|
||||
# 老档迁移:缺字段 = 该档已有游玩史 → true(不重放首局固定图)。
|
||||
|
||||
func is_first_adventure_done() -> bool:
|
||||
return bool(_data["meta"].get("first_adventure_done", true))
|
||||
|
||||
+117
-165
@@ -1,13 +1,14 @@
|
||||
## VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
## 关卡节点表 schema:逐关节点链(单链/节点图两形态)的类型化读取(垂直切片验证:线性第一关承载元进度循环)。
|
||||
## 两形态同跑一套图合法性算法,违例加载期致命(validate 报错由 ConfigDB 收敛为 fail-fast)。
|
||||
## 关卡节点表 schema:逐关行集(关数/显示名/休整回复/迷雾层数)的类型化读取(垂直切片验证:改表即改关卡)。
|
||||
## 图合法性只在 `validate_graph` 一处陈述(消费侧起关校验入口——关卡节点图生成 v1.1 产出规格为准);
|
||||
## 表加载期不校验图(关卡表不再携带节点数据,图源=参数化生成器)。
|
||||
class_name LevelTable
|
||||
extends Resource
|
||||
|
||||
@export var rows: Array[Dictionary] = [] # Array[Dictionary]
|
||||
@export var info_visible_layers: int = 2 # 信息迷雾可见层数 N(int ≥1;类型仅在邻近 N 层内可见——关卡节点地图 v2.1)
|
||||
|
||||
const NODE_TYPES := ["battle", "reward", "rest", "boss", "shop"] # 五类(关卡节点地图 v1.1)
|
||||
const NODE_TYPES := ["battle", "reward", "rest", "boss", "shop"] # 五类(关卡节点地图 v2.1)
|
||||
|
||||
|
||||
# 行单点读取(表访问收口——LevelRunner._find_level_row 禁直扫;键 = level_index)
|
||||
@@ -38,182 +39,133 @@ func validate() -> Array:
|
||||
var heal: int = int(row.get("rest_heal_pct", -1))
|
||||
if heal < 0 or heal > 100:
|
||||
errs.append({"category": "domain", "message": "level %d rest_heal_pct 越域 [0,100]" % L})
|
||||
# 模板池(关卡节点图生成 v0.4):row.templates 非空 = 池形态,池内全模板加载期校验
|
||||
# (违规致命——不只抽中的那张);空 = 单图形态(row.nodes 直读,向后兼容)。
|
||||
var node_lists: Array[Array] = [] # [ [上下文标签, nodes 数组], ... ]
|
||||
var templates: Array[Variant] = row.get("templates", [])
|
||||
if templates.is_empty():
|
||||
node_lists.append(["", row.get("nodes", [])])
|
||||
else:
|
||||
for j in templates.size():
|
||||
var tpl = templates[j]
|
||||
if typeof(tpl) != TYPE_DICTIONARY or (tpl.get("nodes", []) as Array).is_empty():
|
||||
errs.append({"category": "domain", "message": "level %d 模板[%d] 缺 nodes 或为空" % [L, j]})
|
||||
continue
|
||||
node_lists.append(["模板[%d]=%s" % [j, String(tpl.get("display_name", ""))], tpl.get("nodes", [])])
|
||||
for nl in node_lists:
|
||||
for e in _validate_level_nodes(L, nl[0], nl[1]):
|
||||
errs.append(e)
|
||||
return errs
|
||||
|
||||
|
||||
# 单节点链校验体(validate 抽出——row.nodes 与池内每张模板同跑一套;ctx = 消息上下文标签)
|
||||
func _validate_level_nodes(L: int, ctx: String, nodes: Array) -> Array:
|
||||
var errs: Array[Dictionary] = []
|
||||
var tag: String = "" if ctx.is_empty() else "〔%s〕" % ctx
|
||||
# 图合法性校验单点(消费侧起关入口——生成图到手、进节点前调用;违规=致命,无软回退)。
|
||||
# 判据=四检+良构基底:① 入口(无入边)≥1 且终点(无出边)≥1;② 全部节点从某入口可达且可达某终点
|
||||
#(每条路径恰从一个入口出发、恰到好处一个终点,无孤儿无断头);③ 终点恒为 Boss(终点 type=boss 且 boss 无出边);
|
||||
# ④ 无环(出边目标深度严格大于源)。良构=句柄唯一·出边全命中·type 在五类枚举·battle/boss 带深度。
|
||||
# 入口/终点个数与参数档的一致性归产出侧(关卡节点图生成 v1.1),本处不核个数;商店位归冒烟断言把关,本处禁检。
|
||||
static func validate_graph(nodes: Array) -> Array:
|
||||
if nodes.is_empty():
|
||||
errs.append({"category": "domain", "message": "level %d%s 节点链为空" % [L, tag]})
|
||||
return errs
|
||||
var boss_count := 0
|
||||
var battle_count := 0
|
||||
var is_graph := false
|
||||
for node in nodes:
|
||||
if node.has("next"):
|
||||
is_graph = true
|
||||
var t: String = String(node.get("type", ""))
|
||||
if not NODE_TYPES.has(t):
|
||||
errs.append({"category": "enum", "message": "level %d%s 未知节点类型: %s" % [L, tag, t]})
|
||||
continue
|
||||
if t == "battle" or t == "boss":
|
||||
if int(node.get("node_depth", -1)) < 0:
|
||||
errs.append({"category": "domain", "message": "level %d%s %s 节点缺 node_depth" % [L, tag, t]})
|
||||
battle_count += 1
|
||||
if t == "boss":
|
||||
boss_count += 1
|
||||
if boss_count != 1:
|
||||
errs.append({"category": "structural", "message": "level %d%s Boss 节点数 = %d(须恰一)" % [L, tag, boss_count]})
|
||||
if battle_count < 2:
|
||||
errs.append({"category": "structural", "message": "level %d%s 战斗节点(含 Boss)= %d(≥2 才构成关卡)" % [L, tag, battle_count]})
|
||||
if not is_graph:
|
||||
# 单链形态纪律(第一关零迁移):末节点必须是 Boss(关底收尾——冒险结构 关卡定义)
|
||||
if String(nodes[nodes.size() - 1].get("type", "")) != "boss":
|
||||
errs.append({"category": "structural", "message": "level %d%s 末节点非 Boss(单链以 Boss 收尾)" % [L, tag]})
|
||||
# 图合法性(单链与图同一套算法——单链 = 出边恰一的退化形态,违规同样加载致命)
|
||||
for e in _validate_graph(L, nodes, is_graph, tag):
|
||||
errs.append(e)
|
||||
# U2 目检规则(两条,加载期同检):①单链前三位纯战斗(奖励/休整禁入前三)
|
||||
if not is_graph and nodes.size() >= 3:
|
||||
for i in 3:
|
||||
if String(nodes[i].get("type", "")) != "battle":
|
||||
errs.append({"category": "structural", "message": "level %d%s 第 %d 位非战斗(单链前三位纯战斗——U2 目检规则)" % [L, tag, i + 1]})
|
||||
return errs
|
||||
return [{"category": "structural", "message": "图节点集为空"}]
|
||||
var well := _check_nodes_wellformed(nodes)
|
||||
if not (well["errs"] as Array).is_empty():
|
||||
return well["errs"]
|
||||
var by_id: Dictionary = well["by_id"]
|
||||
var edges := _check_edges(by_id, well["indeg"])
|
||||
if not (edges["errs"] as Array).is_empty():
|
||||
return edges["errs"]
|
||||
var stops := _check_terminals(by_id, edges["outs"], edges["indeg"])
|
||||
if not (stops["errs"] as Array).is_empty():
|
||||
return stops["errs"]
|
||||
return _check_reachability(by_id, edges["outs"], stops["entries"], stops["terminals"])
|
||||
|
||||
|
||||
# 图合法性核心(返回错误列表):id 解析(图=node_depth / 单链=数组下标)→ 出边表 →
|
||||
# 唯一性/前进方向/入口唯一/Boss 终点/双向可达六检。参考 关卡节点地图 v1.0 分支节点图规则。
|
||||
func _validate_graph(L: int, nodes: Array, is_graph: bool, tag: String = "") -> Array:
|
||||
# 良构基底:字段齐·type 在枚举·深度非负·句柄唯一(返回 by_id 与入度骨架供后续检复用)
|
||||
static func _check_nodes_wellformed(nodes: Array) -> Dictionary:
|
||||
var errs: Array[Dictionary] = []
|
||||
var ids: Array[int] = [] # 每节点 id(下标对齐)
|
||||
var nexts: Array[Array] = [] # 每节点出边 id 数组
|
||||
var id_to_idx := {}
|
||||
var by_id := {}
|
||||
var indeg := {}
|
||||
for i in nodes.size():
|
||||
var node: Dictionary[Variant, Variant] = nodes[i]
|
||||
var nid: int = int(node.get("node_depth", i)) if is_graph else i
|
||||
if is_graph and not node.has("node_depth"):
|
||||
errs.append({"category": "structural", "message": "level %d%s 图形态节点[%d] 缺 node_depth(全节点须编号化)" % [L, tag, i]})
|
||||
nid = i # 降级继续(后续校验仍有意义)
|
||||
ids.append(nid)
|
||||
if id_to_idx.has(nid):
|
||||
errs.append({"category": "dup_key", "message": "level %d%s 节点编号重复: %d(关卡内唯一定位地址)" % [L, tag, nid]})
|
||||
id_to_idx[nid] = i
|
||||
var nx: Array[int] = []
|
||||
if node.has("next"):
|
||||
for v in node.get("next", []):
|
||||
nx.append(int(v))
|
||||
elif i < nodes.size() - 1:
|
||||
nx.append(i + 1) # 单链隐式出边 = 数组下一项(id 即下标)
|
||||
nexts.append(nx)
|
||||
# ② 出边引用存在 + 前进方向(目标 id 严格递增 ⇒ 无环——环必含非递增边)
|
||||
for i in nodes.size():
|
||||
for tid in nexts[i]:
|
||||
if not id_to_idx.has(tid):
|
||||
errs.append({"category": "dangling_next", "message": "level %d%s 节点 %d 出边指向不存在编号: %d" % [L, tag, ids[i], tid]})
|
||||
elif tid <= ids[i]:
|
||||
errs.append({"category": "structural", "message": "level %d%s 连线 %d→%d 违反前进方向(目标编号须严格更大)" % [L, tag, ids[i], tid]})
|
||||
# ③b 出度上限(「节点出度最大为 2」):单节点出边 >2 即加载致命。
|
||||
# 规则连带:出度 ≤2 + 入口唯一下 BFS 层宽上限 = 4(序列 1,2,4——层宽上限 5 与出度约束
|
||||
# 不相容,模板池按层宽 ≤4 构造)。
|
||||
for i in nodes.size():
|
||||
if nexts[i].size() > 2:
|
||||
errs.append({"category": "fanout_violation", "message": "level %d%s 节点 %d 出度 %d > 2(出度上限拍板 2026-10-01)" % [L, tag, ids[i], nexts[i].size()]})
|
||||
# ④ 入口唯一(无入边节点恰一——唯一入口)
|
||||
var has_inbound := {}
|
||||
for i in nodes.size():
|
||||
for tid in nexts[i]:
|
||||
has_inbound[tid] = true
|
||||
var entries: Array[int] = []
|
||||
for i in nodes.size():
|
||||
if not has_inbound.has(ids[i]):
|
||||
entries.append(i)
|
||||
if entries.size() != 1:
|
||||
errs.append({"category": "structural", "message": "level %d%s 入口节点数 = %d(须恰一——无入边节点唯一)" % [L, tag, entries.size()]})
|
||||
# ⑤ Boss 恒唯一终点:无出边 + 编号最大(图形态;单链由"末节点 Boss"既有校验覆盖终点性)
|
||||
for i in nodes.size():
|
||||
if String(nodes[i].get("type", "")) != "boss":
|
||||
for k in ["id", "type", "depth", "next"]:
|
||||
if not node.has(k):
|
||||
errs.append({"category": "structural", "message": "节点[%d] 缺字段 %s" % [i, k]})
|
||||
var t := String(node.get("type", ""))
|
||||
if not NODE_TYPES.has(t):
|
||||
errs.append({"category": "enum", "message": "未知节点类型: %s" % t})
|
||||
var d: int = int(node.get("depth", -1))
|
||||
if d < 0:
|
||||
errs.append({"category": "domain", "message": "节点 %s 深度越域(≥0): %d" % [str(node.get("id", i)), d]})
|
||||
if t == "battle" and not node.has("depth"):
|
||||
errs.append({"category": "structural", "message": "battle 节点 %s 缺深度" % str(node.get("id", i))})
|
||||
var nid: int = int(node.get("id", i))
|
||||
if by_id.has(nid):
|
||||
errs.append({"category": "dup_key", "message": "节点句柄重复: %d" % nid})
|
||||
by_id[nid] = node
|
||||
if not indeg.has(nid):
|
||||
indeg[nid] = 0
|
||||
return {"errs": errs, "by_id": by_id, "indeg": indeg}
|
||||
|
||||
|
||||
# 出边良构+无环判据(④):目标句柄存在、目标深度严格大于源;同时产出去边表与入度计数
|
||||
static func _check_edges(by_id: Dictionary, indeg: Dictionary) -> Dictionary:
|
||||
var errs: Array[Dictionary] = []
|
||||
var outs := {}
|
||||
for nid in by_id.keys():
|
||||
outs[nid] = []
|
||||
for nid in by_id.keys():
|
||||
for tid in (by_id[nid]["next"] as Array):
|
||||
if not by_id.has(int(tid)):
|
||||
errs.append({"category": "dangling_next", "message": "节点 %d 出边指向不存在句柄: %s" % [nid, tid]})
|
||||
continue
|
||||
if int(by_id[int(tid)]["depth"]) <= int(by_id[nid]["depth"]):
|
||||
errs.append({"category": "structural", "message": "连线 %d→%d 违反前进方向(目标深度须严格大于源)" % [nid, int(tid)]})
|
||||
outs[nid].append(int(tid))
|
||||
indeg[int(tid)] = int(indeg[int(tid)]) + 1
|
||||
return {"errs": errs, "outs": outs, "indeg": indeg}
|
||||
|
||||
|
||||
# 检①+检③:入口(无入边)≥1、终点(无出边)≥1、终点恒为 Boss 且 Boss 无出边
|
||||
static func _check_terminals(by_id: Dictionary, outs: Dictionary, indeg: Dictionary) -> Dictionary:
|
||||
var errs: Array[Dictionary] = []
|
||||
var entries := []
|
||||
var terminals := []
|
||||
for nid in by_id.keys():
|
||||
if int(indeg[nid]) == 0:
|
||||
entries.append(nid)
|
||||
if not (outs[nid] as Array).is_empty():
|
||||
if String(by_id[nid]["type"]) == "boss":
|
||||
errs.append({"category": "structural", "message": "Boss 节点 %d 带出边(终点不可外出)" % nid})
|
||||
continue
|
||||
if not nexts[i].is_empty():
|
||||
errs.append({"category": "structural", "message": "level %d Boss 节点 %d 带出边(终点不可外出)" % [L, ids[i]]})
|
||||
if is_graph and ids[i] != ids.max():
|
||||
errs.append({"category": "structural", "message": "level %d Boss 编号 %d 非最大 %d(终点编号最大)" % [L, ids[i], ids.max()]})
|
||||
if entries.size() == 1:
|
||||
# ⑥ 从入口可达全部(无孤儿)· ⑦ 全部节点可达 Boss(逆向)——邻接表统一下标空间
|
||||
var fwd := []
|
||||
var back := []
|
||||
for i in nodes.size():
|
||||
fwd.append([])
|
||||
back.append([])
|
||||
for i in nodes.size():
|
||||
for tid in nexts[i]:
|
||||
if id_to_idx.has(tid):
|
||||
fwd[i].append(id_to_idx[tid])
|
||||
back[id_to_idx[tid]].append(i)
|
||||
var reach := _reachable(entries[0], fwd, nodes.size())
|
||||
for i in nodes.size():
|
||||
if not reach.has(i):
|
||||
errs.append({"category": "structural", "message": "level %d 节点 %d 从入口不可达(孤儿节点)" % [L, ids[i]]})
|
||||
var boss_idx := -1
|
||||
for i in nodes.size():
|
||||
if String(nodes[i].get("type", "")) == "boss":
|
||||
boss_idx = i
|
||||
if boss_idx >= 0:
|
||||
var to_boss := _reachable(boss_idx, back, nodes.size())
|
||||
for i in nodes.size():
|
||||
if not to_boss.has(i):
|
||||
errs.append({"category": "structural", "message": "level %d 节点 %d 沿连线不可达 Boss" % [L, ids[i]]})
|
||||
# ⑧ U2 目检规则:BFS 同深度层节点数上限 5(分支宽度天花板)
|
||||
var layer_of := {}
|
||||
var bfs: Array[Variant] = [entries[0]]
|
||||
layer_of[entries[0]] = 0
|
||||
while not bfs.is_empty():
|
||||
var cur: int = bfs.pop_front()
|
||||
for tid in nexts[cur]:
|
||||
var tidx: int = int(id_to_idx.get(tid, -1))
|
||||
if tidx >= 0 and not layer_of.has(tidx):
|
||||
layer_of[tidx] = int(layer_of[cur]) + 1
|
||||
bfs.append(tidx)
|
||||
var width := {}
|
||||
for i in nodes.size():
|
||||
if layer_of.has(i):
|
||||
width[layer_of[i]] = int(width.get(layer_of[i], 0)) + 1
|
||||
for d in width.keys():
|
||||
if int(width[d]) > 5:
|
||||
errs.append({"category": "structural", "message": "level %d BFS 第 %d 层 %d 节点(同层上限 5——U2 目检规则)" % [L, d, int(width[d])]})
|
||||
terminals.append(nid)
|
||||
if String(by_id[nid]["type"]) != "boss":
|
||||
errs.append({"category": "structural", "message": "终点 %d 非 Boss(终点恒为 Boss)" % nid})
|
||||
if entries.is_empty():
|
||||
errs.append({"category": "structural", "message": "入口节点数 = 0(须 ≥1)"})
|
||||
if terminals.is_empty():
|
||||
errs.append({"category": "structural", "message": "终点节点数 = 0(须 ≥1)"})
|
||||
return {"errs": errs, "entries": entries, "terminals": terminals}
|
||||
|
||||
|
||||
# 检②:全部节点从某入口可达且可达某终点(无孤儿、无断头)
|
||||
static func _check_reachability(by_id: Dictionary, outs: Dictionary, entries: Array, terminals: Array) -> Array:
|
||||
var errs: Array[Dictionary] = []
|
||||
var from_entry := {}
|
||||
for eid in entries:
|
||||
_mark_reachable(int(eid), outs, from_entry)
|
||||
var to_terminal := {}
|
||||
for tid in terminals:
|
||||
_mark_backward(int(tid), outs, to_terminal)
|
||||
for nid in by_id.keys():
|
||||
if not from_entry.has(nid):
|
||||
errs.append({"category": "structural", "message": "节点 %d 从任何入口都不可达(孤儿)" % nid})
|
||||
if not to_terminal.has(nid):
|
||||
errs.append({"category": "structural", "message": "节点 %d 沿连线到不了任何终点(断头)" % nid})
|
||||
return errs
|
||||
|
||||
|
||||
# BFS 可达集(邻接表 adj: 节点下标 → 下标数组)
|
||||
func _reachable(start: int, adj: Array, n: int) -> Dictionary:
|
||||
var seen := {}
|
||||
if start < 0 or start >= n:
|
||||
return seen
|
||||
# 正向可达集(含起点)
|
||||
static func _mark_reachable(start: int, outs: Dictionary, seen: Dictionary) -> void:
|
||||
var queue: Array[Variant] = [start]
|
||||
seen[start] = true
|
||||
while not queue.is_empty():
|
||||
var cur: int = queue.pop_front()
|
||||
if cur >= adj.size():
|
||||
continue
|
||||
for nb in adj[cur]:
|
||||
if not seen.has(nb):
|
||||
seen[nb] = true
|
||||
queue.append(nb)
|
||||
return seen
|
||||
for tid in outs[cur]:
|
||||
if not seen.has(int(tid)):
|
||||
seen[int(tid)] = true
|
||||
queue.append(int(tid))
|
||||
|
||||
|
||||
# 逆向可达集(含起点)——反向邻接由 outs 现推(图规模小,禁第二份邻接表持有)
|
||||
static func _mark_backward(target: int, outs: Dictionary, seen: Dictionary) -> void:
|
||||
var queue: Array[Variant] = [target]
|
||||
seen[target] = true
|
||||
while not queue.is_empty():
|
||||
var cur: int = queue.pop_front()
|
||||
for nid in outs.keys():
|
||||
if (outs[nid] as Array).has(cur) and not seen.has(int(nid)):
|
||||
seen[int(nid)] = true
|
||||
queue.append(int(nid))
|
||||
@@ -70,6 +70,32 @@ rows = [{
|
||||
"shave_ring_radius": 1,
|
||||
"r0_max_cap": 43
|
||||
}, {
|
||||
"level_index": 1,
|
||||
"node_depth": 5,
|
||||
"board_width": 12,
|
||||
"board_height": 12,
|
||||
"mine_density": 0.25,
|
||||
"inferability_target": 0.88,
|
||||
"solver_rules": "single_subset",
|
||||
"min_safe_count": 60,
|
||||
"max_gen_attempts": 24,
|
||||
"board_type": "dungeon",
|
||||
"shave_ring_radius": 1,
|
||||
"r0_max_cap": 43
|
||||
}, {
|
||||
"level_index": 1,
|
||||
"node_depth": 6,
|
||||
"board_width": 12,
|
||||
"board_height": 12,
|
||||
"mine_density": 0.25,
|
||||
"inferability_target": 0.88,
|
||||
"solver_rules": "single_subset",
|
||||
"min_safe_count": 60,
|
||||
"max_gen_attempts": 24,
|
||||
"board_type": "dungeon",
|
||||
"shave_ring_radius": 1,
|
||||
"r0_max_cap": 43
|
||||
}, {
|
||||
"level_index": 2,
|
||||
"node_depth": 0,
|
||||
"board_width": 12,
|
||||
@@ -97,6 +123,32 @@ rows = [{
|
||||
"r0_max_cap": 43
|
||||
}, {
|
||||
"level_index": 2,
|
||||
"node_depth": 2,
|
||||
"board_width": 12,
|
||||
"board_height": 12,
|
||||
"mine_density": 0.25,
|
||||
"inferability_target": 0.88,
|
||||
"solver_rules": "single_subset",
|
||||
"min_safe_count": 60,
|
||||
"max_gen_attempts": 24,
|
||||
"board_type": "underdark",
|
||||
"shave_ring_radius": 1,
|
||||
"r0_max_cap": 43
|
||||
}, {
|
||||
"level_index": 2,
|
||||
"node_depth": 3,
|
||||
"board_width": 12,
|
||||
"board_height": 12,
|
||||
"mine_density": 0.25,
|
||||
"inferability_target": 0.88,
|
||||
"solver_rules": "single_subset",
|
||||
"min_safe_count": 60,
|
||||
"max_gen_attempts": 24,
|
||||
"board_type": "underdark",
|
||||
"shave_ring_radius": 1,
|
||||
"r0_max_cap": 43
|
||||
}, {
|
||||
"level_index": 2,
|
||||
"node_depth": 4,
|
||||
"board_width": 12,
|
||||
"board_height": 12,
|
||||
@@ -149,6 +201,19 @@ rows = [{
|
||||
"r0_max_cap": 43
|
||||
}, {
|
||||
"level_index": 2,
|
||||
"node_depth": 8,
|
||||
"board_width": 12,
|
||||
"board_height": 12,
|
||||
"mine_density": 0.25,
|
||||
"inferability_target": 0.88,
|
||||
"solver_rules": "single_subset",
|
||||
"min_safe_count": 60,
|
||||
"max_gen_attempts": 24,
|
||||
"board_type": "underdark",
|
||||
"shave_ring_radius": 1,
|
||||
"r0_max_cap": 43
|
||||
}, {
|
||||
"level_index": 2,
|
||||
"node_depth": 9,
|
||||
"board_width": 12,
|
||||
"board_height": 12,
|
||||
@@ -188,6 +253,32 @@ rows = [{
|
||||
"r0_max_cap": 51
|
||||
}, {
|
||||
"level_index": 3,
|
||||
"node_depth": 2,
|
||||
"board_width": 13,
|
||||
"board_height": 13,
|
||||
"mine_density": 0.25,
|
||||
"inferability_target": 0.88,
|
||||
"solver_rules": "single_subset",
|
||||
"min_safe_count": 70,
|
||||
"max_gen_attempts": 32,
|
||||
"board_type": "deep_underdark",
|
||||
"shave_ring_radius": 1,
|
||||
"r0_max_cap": 51
|
||||
}, {
|
||||
"level_index": 3,
|
||||
"node_depth": 3,
|
||||
"board_width": 13,
|
||||
"board_height": 13,
|
||||
"mine_density": 0.25,
|
||||
"inferability_target": 0.88,
|
||||
"solver_rules": "single_subset",
|
||||
"min_safe_count": 70,
|
||||
"max_gen_attempts": 32,
|
||||
"board_type": "deep_underdark",
|
||||
"shave_ring_radius": 1,
|
||||
"r0_max_cap": 51
|
||||
}, {
|
||||
"level_index": 3,
|
||||
"node_depth": 4,
|
||||
"board_width": 13,
|
||||
"board_height": 13,
|
||||
@@ -240,6 +331,19 @@ rows = [{
|
||||
"r0_max_cap": 51
|
||||
}, {
|
||||
"level_index": 3,
|
||||
"node_depth": 8,
|
||||
"board_width": 13,
|
||||
"board_height": 13,
|
||||
"mine_density": 0.25,
|
||||
"inferability_target": 0.88,
|
||||
"solver_rules": "single_subset",
|
||||
"min_safe_count": 70,
|
||||
"max_gen_attempts": 32,
|
||||
"board_type": "deep_underdark",
|
||||
"shave_ring_radius": 1,
|
||||
"r0_max_cap": 51
|
||||
}, {
|
||||
"level_index": 3,
|
||||
"node_depth": 9,
|
||||
"board_width": 14,
|
||||
"board_height": 14,
|
||||
@@ -251,6 +355,58 @@ rows = [{
|
||||
"board_type": "deep_underdark",
|
||||
"shave_ring_radius": 1,
|
||||
"r0_max_cap": 59
|
||||
}, {
|
||||
"level_index": 3,
|
||||
"node_depth": 10,
|
||||
"board_width": 14,
|
||||
"board_height": 14,
|
||||
"mine_density": 0.25,
|
||||
"inferability_target": 0.88,
|
||||
"solver_rules": "single_subset",
|
||||
"min_safe_count": 82,
|
||||
"max_gen_attempts": 32,
|
||||
"board_type": "deep_underdark",
|
||||
"shave_ring_radius": 1,
|
||||
"r0_max_cap": 59
|
||||
}, {
|
||||
"level_index": 3,
|
||||
"node_depth": 11,
|
||||
"board_width": 14,
|
||||
"board_height": 14,
|
||||
"mine_density": 0.25,
|
||||
"inferability_target": 0.88,
|
||||
"solver_rules": "single_subset",
|
||||
"min_safe_count": 82,
|
||||
"max_gen_attempts": 32,
|
||||
"board_type": "deep_underdark",
|
||||
"shave_ring_radius": 1,
|
||||
"r0_max_cap": 59
|
||||
}, {
|
||||
"level_index": 3,
|
||||
"node_depth": 12,
|
||||
"board_width": 14,
|
||||
"board_height": 14,
|
||||
"mine_density": 0.25,
|
||||
"inferability_target": 0.88,
|
||||
"solver_rules": "single_subset",
|
||||
"min_safe_count": 82,
|
||||
"max_gen_attempts": 32,
|
||||
"board_type": "deep_underdark",
|
||||
"shave_ring_radius": 1,
|
||||
"r0_max_cap": 59
|
||||
}, {
|
||||
"level_index": 3,
|
||||
"node_depth": 13,
|
||||
"board_width": 14,
|
||||
"board_height": 14,
|
||||
"mine_density": 0.25,
|
||||
"inferability_target": 0.88,
|
||||
"solver_rules": "single_subset",
|
||||
"min_safe_count": 82,
|
||||
"max_gen_attempts": 32,
|
||||
"board_type": "deep_underdark",
|
||||
"shave_ring_radius": 1,
|
||||
"r0_max_cap": 59
|
||||
}]
|
||||
|
||||
; 切片调参登记(SLICE-TUNED provisional,调参所有权仍归 C3):
|
||||
|
||||
@@ -99,6 +99,31 @@ rows = [{
|
||||
"armor_class": 2
|
||||
}, {
|
||||
"level_index": 1,
|
||||
"row_kind": "battle",
|
||||
"depth_band": 6,
|
||||
"enemy_id": "bugbear",
|
||||
"display_name": "熊地精",
|
||||
"unit_count": 1,
|
||||
"unit_count_max": 2,
|
||||
"hp": 30,
|
||||
"atk": 1,
|
||||
"atk_min": 0,
|
||||
"atk_max": 2,
|
||||
"pool": [{
|
||||
"skill_id": "attack",
|
||||
"weight": 6
|
||||
}, {
|
||||
"skill_id": "charge",
|
||||
"weight": 2
|
||||
}, {
|
||||
"skill_id": "heavy",
|
||||
"weight": 2
|
||||
}],
|
||||
"attack_type": "normal",
|
||||
"armor_type": "light",
|
||||
"armor_class": 4
|
||||
}, {
|
||||
"level_index": 1,
|
||||
"row_kind": "boss",
|
||||
"depth_band": 4,
|
||||
"enemy_id": "ogre_chief",
|
||||
@@ -126,6 +151,34 @@ rows = [{
|
||||
"armor_type": "light",
|
||||
"armor_class": 8
|
||||
}, {
|
||||
"level_index": 1,
|
||||
"row_kind": "boss",
|
||||
"depth_band": 6,
|
||||
"enemy_id": "ogre_chief",
|
||||
"display_name": "食人魔酋长",
|
||||
"unit_count": 1,
|
||||
"hp": 88,
|
||||
"atk": 1,
|
||||
"atk_min": 0,
|
||||
"atk_max": 2,
|
||||
"pool": [{
|
||||
"skill_id": "attack",
|
||||
"weight": 6
|
||||
}, {
|
||||
"skill_id": "heavy",
|
||||
"weight": 2
|
||||
}, {
|
||||
"skill_id": "harden",
|
||||
"weight": 2
|
||||
}, {
|
||||
"skill_id": "quake_smash",
|
||||
"weight": 2,
|
||||
"unlock_hp_pct": 50
|
||||
}],
|
||||
"attack_type": "normal",
|
||||
"armor_type": "light",
|
||||
"armor_class": 8
|
||||
}, {
|
||||
"level_index": 2,
|
||||
"row_kind": "battle",
|
||||
"depth_band": 0,
|
||||
@@ -308,6 +361,65 @@ rows = [{
|
||||
"armor_class": 3
|
||||
}, {
|
||||
"level_index": 2,
|
||||
"row_kind": "battle",
|
||||
"depth_band": 9,
|
||||
"enemy_id": "giant_spider",
|
||||
"display_name": "巨蜘蛛巢群",
|
||||
"unit_count": 2,
|
||||
"unit_count_max": 3,
|
||||
"hp": 40,
|
||||
"atk": 2,
|
||||
"atk_min": 1,
|
||||
"atk_max": 3,
|
||||
"pool": [{
|
||||
"skill_id": "attack",
|
||||
"weight": 6
|
||||
}, {
|
||||
"skill_id": "bite",
|
||||
"weight": 2
|
||||
}, {
|
||||
"skill_id": "charge",
|
||||
"weight": 1
|
||||
}, {
|
||||
"skill_id": "venom_spit",
|
||||
"weight": 2
|
||||
}],
|
||||
"attack_type": "normal",
|
||||
"armor_type": "light",
|
||||
"armor_class": 3
|
||||
}, {
|
||||
"level_index": 2,
|
||||
"row_kind": "boss",
|
||||
"depth_band": 8,
|
||||
"enemy_id": "drider",
|
||||
"display_name": "蛛化卓尔精灵",
|
||||
"unit_count": 1,
|
||||
"hp": 112,
|
||||
"atk": 3,
|
||||
"atk_min": 2,
|
||||
"atk_max": 4,
|
||||
"pool": [{
|
||||
"skill_id": "attack",
|
||||
"weight": 6
|
||||
}, {
|
||||
"skill_id": "heavy",
|
||||
"weight": 2
|
||||
}, {
|
||||
"skill_id": "venom_burst",
|
||||
"weight": 2
|
||||
}, {
|
||||
"skill_id": "web_snare",
|
||||
"weight": 2
|
||||
}, {
|
||||
"skill_id": "venom_curb",
|
||||
"weight": 2,
|
||||
"unlock_hp_pct": 50
|
||||
}],
|
||||
"attack_type": "normal",
|
||||
"armor_type": "ward",
|
||||
"armor_class": 10
|
||||
}, {
|
||||
"level_index": 2,
|
||||
"row_kind": "boss",
|
||||
"depth_band": 9,
|
||||
"enemy_id": "drider",
|
||||
@@ -495,6 +607,34 @@ rows = [{
|
||||
"armor_class": 4
|
||||
}, {
|
||||
"level_index": 3,
|
||||
"row_kind": "battle",
|
||||
"depth_band": 13,
|
||||
"enemy_id": "duergar",
|
||||
"display_name": "灰矮人小队",
|
||||
"unit_count": 2,
|
||||
"unit_count_max": 3,
|
||||
"hp": 42,
|
||||
"atk": 3,
|
||||
"atk_min": 2,
|
||||
"atk_max": 4,
|
||||
"pool": [{
|
||||
"skill_id": "attack",
|
||||
"weight": 6
|
||||
}, {
|
||||
"skill_id": "charge",
|
||||
"weight": 2
|
||||
}, {
|
||||
"skill_id": "heavy",
|
||||
"weight": 2
|
||||
}, {
|
||||
"skill_id": "shield_bash",
|
||||
"weight": 2
|
||||
}],
|
||||
"attack_type": "normal",
|
||||
"armor_type": "heavy",
|
||||
"armor_class": 4
|
||||
}, {
|
||||
"level_index": 3,
|
||||
"row_kind": "boss",
|
||||
"depth_band": 9,
|
||||
"enemy_id": "duergar_king",
|
||||
@@ -521,6 +661,90 @@ rows = [{
|
||||
"attack_type": "normal",
|
||||
"armor_type": "heavy",
|
||||
"armor_class": 12
|
||||
}, {
|
||||
"level_index": 3,
|
||||
"row_kind": "boss",
|
||||
"depth_band": 11,
|
||||
"enemy_id": "duergar_king",
|
||||
"display_name": "灰矮人石君王",
|
||||
"unit_count": 1,
|
||||
"hp": 150,
|
||||
"atk": 3,
|
||||
"atk_min": 2,
|
||||
"atk_max": 4,
|
||||
"pool": [{
|
||||
"skill_id": "attack",
|
||||
"weight": 6
|
||||
}, {
|
||||
"skill_id": "heavy",
|
||||
"weight": 2
|
||||
}, {
|
||||
"skill_id": "earth_pulse",
|
||||
"weight": 2
|
||||
}, {
|
||||
"skill_id": "rockslide",
|
||||
"weight": 2,
|
||||
"unlock_hp_pct": 50
|
||||
}],
|
||||
"attack_type": "normal",
|
||||
"armor_type": "heavy",
|
||||
"armor_class": 12
|
||||
}, {
|
||||
"level_index": 3,
|
||||
"row_kind": "boss",
|
||||
"depth_band": 12,
|
||||
"enemy_id": "duergar_king",
|
||||
"display_name": "灰矮人石君王",
|
||||
"unit_count": 1,
|
||||
"hp": 162,
|
||||
"atk": 3,
|
||||
"atk_min": 2,
|
||||
"atk_max": 4,
|
||||
"pool": [{
|
||||
"skill_id": "attack",
|
||||
"weight": 6
|
||||
}, {
|
||||
"skill_id": "heavy",
|
||||
"weight": 2
|
||||
}, {
|
||||
"skill_id": "earth_pulse",
|
||||
"weight": 2
|
||||
}, {
|
||||
"skill_id": "rockslide",
|
||||
"weight": 2,
|
||||
"unlock_hp_pct": 50
|
||||
}],
|
||||
"attack_type": "normal",
|
||||
"armor_type": "heavy",
|
||||
"armor_class": 12
|
||||
}, {
|
||||
"level_index": 3,
|
||||
"row_kind": "boss",
|
||||
"depth_band": 13,
|
||||
"enemy_id": "duergar_king",
|
||||
"display_name": "灰矮人石君王",
|
||||
"unit_count": 1,
|
||||
"hp": 176,
|
||||
"atk": 3,
|
||||
"atk_min": 2,
|
||||
"atk_max": 4,
|
||||
"pool": [{
|
||||
"skill_id": "attack",
|
||||
"weight": 6
|
||||
}, {
|
||||
"skill_id": "heavy",
|
||||
"weight": 2
|
||||
}, {
|
||||
"skill_id": "earth_pulse",
|
||||
"weight": 2
|
||||
}, {
|
||||
"skill_id": "rockslide",
|
||||
"weight": 2,
|
||||
"unlock_hp_pct": 50
|
||||
}],
|
||||
"attack_type": "normal",
|
||||
"armor_type": "heavy",
|
||||
"armor_class": 12
|
||||
}]
|
||||
|
||||
; 切片调参登记 v3(2026-09-12 试玩批次 1 触发——F7 流程;C3 GDD F5 对照):
|
||||
|
||||
@@ -6,20 +6,11 @@
|
||||
script = ExtResource("1")
|
||||
info_visible_layers = 2
|
||||
rows = [
|
||||
{"level_index": 1, "display_name": "第一层 · 地精洞窟", "rest_heal_pct": 30, "templates": [{"display_name": "地精洞窟·主线洞道(现行)", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"node_depth": 3, "type": "battle"}, {"type": "reward"}, {"type": "rest"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·先赏后整", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "reward"}, {"type": "rest"}, {"node_depth": 3, "type": "battle"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·先整后赏", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "rest"}, {"type": "reward"}, {"node_depth": 3, "type": "battle"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·赏战整", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "reward"}, {"node_depth": 3, "type": "battle"}, {"type": "rest"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·整战赏", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "rest"}, {"node_depth": 3, "type": "battle"}, {"type": "reward"}, {"node_depth": 4, "type": "boss"}]}]},
|
||||
{"level_index": 2, "display_name": "第二层 · 幽暗地域", "rest_heal_pct": 30, "templates": [{"display_name": "宽廊双干(现行)", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干中廊", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6]}, {"node_depth": 4, "type": "battle", "next": [6, 7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "宽廊斜岔", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干宽门", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6, 7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "窄廊短支", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5]}, {"node_depth": 3, "type": "rest", "next": [5, 6]}, {"node_depth": 4, "type": "battle", "next": [6]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}]},
|
||||
{"level_index": 3, "display_name": "第三层 · 灰矮人矿城", "rest_heal_pct": 30, "templates": [{"display_name": "宽廊双干(现行)", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干中廊", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6]}, {"node_depth": 4, "type": "battle", "next": [6, 7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "宽廊斜岔", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干宽门", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6, 7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "窄廊短支", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5]}, {"node_depth": 3, "type": "rest", "next": [5, 6]}, {"node_depth": 4, "type": "battle", "next": [6]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}]}
|
||||
{"level_index": 1, "display_name": "第一层 · 地精洞窟", "rest_heal_pct": 30},
|
||||
{"level_index": 2, "display_name": "第二层 · 幽暗地域", "rest_heal_pct": 30},
|
||||
{"level_index": 3, "display_name": "第三层 · 灰矮人矿城", "rest_heal_pct": 30}
|
||||
]
|
||||
|
||||
; 关卡节点图生成 v0.4 台阶一内容批(M-3 迭代 5 T5,2026-09-28——15 张骨架 = 3 关 × 5,拍板点④):
|
||||
; L2/L3 网状图约束(承袭):战斗节点编号恒 ∈ 遭遇表行集 {0,1,4,5,6,7}、Boss 9 最大编号、
|
||||
; 商店房 8→Boss 必经位承袭;遭遇表按编号共用——零新增敌人数值。L1 五模板 = 单链排列变体
|
||||
; (战斗深度序单调保难度坡,变化面 = 奖励/恢复位次)。
|
||||
; 池内全模板加载期校验(level_table.gd validate + config_db 跨表校验模板感知);
|
||||
; 行为读 LevelRunner._resolve_template_row(node_map 标签恰一 scope 抽签 / template_pick 强制)。
|
||||
; 生成器 = tools/t5_build_templates.py(生成前自校验,一次性内容批工具随批入库留档)。
|
||||
; U2 目检返工(M-3 迭代 5 返工批,2026-09-29——U2 目检 15/15 不过两规则缺口,全池重生成):
|
||||
; ① L1 奖励/休整禁入前三位(用户目检裁决)——五链全部前三位纯战斗,位 4-6 = {战斗3,奖励,休整} 排列;
|
||||
; ② L2/L3 BFS 同深度层上限 5 且应用足(每张最大层宽 4-5,旧池层宽最大 3 被否);模板 0 一并重排
|
||||
; (待批点甲按推荐=随新规则重生成——首次冒险/钉固断言期望随新图更新;模板 0 名保持「(现行)」后缀=新现行图锚)。
|
||||
; 加载校验两规则同落 level_table.gd validate(单链前三纯战斗/图形态层宽 ≤5)。
|
||||
; 节点图源已换代为参数化生成器(关卡节点图生成 v1.1)——本表逐关行只承载关卡序号/显示名/恢复比例,
|
||||
; 不再携带节点数据;图 = f(参数档, 种子),参数档见 level_gen_params.tres 三行(消费侧按 level_index 解析后交 generate)。
|
||||
; 旧 15 张模板池(3 关 × 5)随迭代 35 卡② 清出,历史查 git 提交史与 tools/t5_build_templates.py、t17_fanout2_rebuild.py 留档。
|
||||
@@ -11,7 +11,7 @@ segment_order = PackedStringArray("grant", "buff_debuff", "combat", "decay", "ve
|
||||
source_label_map = {
|
||||
"safe_cell": "安全格",
|
||||
"seal_hit": "封印命中",
|
||||
"mine_hit": "踩雷",
|
||||
"mine_hit": "危险格",
|
||||
"clear_reward": "清图奖励",
|
||||
"enemy_action": "敌方行动",
|
||||
"hero_attack": "主角攻击",
|
||||
|
||||
@@ -139,7 +139,7 @@ static func bonus_result_text(result: Dictionary) -> String:
|
||||
# 表无 archetype,fallback 兜底词「流派」会实显僵尸文案),卡片=件名+效果摘要两行)
|
||||
static func feat_card_text(row: Dictionary) -> String:
|
||||
if row.is_empty() or String(row.get("feat_id", "")) == "":
|
||||
return "特性\n???"
|
||||
return "专长\n???"
|
||||
var summary := String(row.get("desc", ""))
|
||||
if summary != "":
|
||||
return "%s\n%s" % [String(row.get("display_name", row.get("feat_id", ""))), summary]
|
||||
@@ -151,9 +151,9 @@ static func bonus_keystone_line(payload: Dictionary) -> String:
|
||||
return "钥石 +%d · 累计 %d" % [int(payload.get("keystone_gained", 0)), int(payload.get("keystone_total", 0))]
|
||||
|
||||
|
||||
# 奖励节点三选一头部行(与通关三选一共享同池;node-reward.md v2)
|
||||
# 奖励节点三选一头部行(与通关三选一共享同库;渠道白名单分流——node-reward.md v2)
|
||||
static func node_curio_header() -> String:
|
||||
return "关卡奖励点\n从提升池抽三选一(与通关三选一同池同权重同上限):\n选择一件带走:"
|
||||
return "关卡奖励点\n从冒险牌库抽三选一(属性值提升与补给;与通关专长三选一同库分流):\n选择一件带走:"
|
||||
|
||||
|
||||
# 冒险结算屏文案(摘要五件事:打穿没有 / 通关几关 / 止步第几关 / 钥石合计 / 主种子)
|
||||
|
||||
+31
-13
@@ -589,11 +589,9 @@ func _start_adventure() -> void:
|
||||
logger.start_session(seed_val)
|
||||
run_scope.adventure.set_hero_override(debug_overlay.atk_pct(), debug_overlay.hp_pct())
|
||||
# 永久强化注入:冒险开始参数面——meta 现算 override 显式传参(全零 = 白板)
|
||||
# 首次冒险脚本(关卡节点图生成 v0.4):存档布尔位 false = 全关固定模板 0(教学重放);置位后逐关抽签
|
||||
# 同调三键随开局下传(build_adventure_params 统一构造,与营地投影同源)
|
||||
var first_script: bool = not save_manager.is_first_adventure_done()
|
||||
# 开局参数面(同调三键同源构造——确定性承载=固定种子+参数档,首局教学图由种子域三分判定给 42)
|
||||
if not run_scope.adventure.start_adventure(seed_val, build_adventure_params(save_manager, meta,
|
||||
{"first_script": first_script, "treasure_unlocked": _treasure_ui.unlocked_set(), "shop_mode": "pool"})):
|
||||
{"treasure_unlocked": _treasure_ui.unlocked_set(), "shop_mode": "pool"})):
|
||||
_fatal_screen("冒险启动失败——见输出日志")
|
||||
return
|
||||
|
||||
@@ -604,12 +602,16 @@ func _on_adventure_level_started(level_index: int, display_name: String) -> void
|
||||
run_scope.runner = run_scope.adventure.runner()
|
||||
# 开场行传当前生效值(runner.effective_atk/effective_max_hp 与战斗面板同源:
|
||||
# 表值+永久强化 flat+加算区 flat 后乘调试浮窗 pct,本回调时点 runner 已带全部加成);「含面板加成」
|
||||
# 后缀判断认局外加成——永久强化 atk/max_hp flat 或调试浮窗 pct 任一存在才带,白板局不带
|
||||
# 后缀判断认任一加成源——永久强化 atk/max_hp flat 或调试浮窗 pct 或局内加算区 atk/max_hp
|
||||
#(run_ledger.bonus——属性值提升入账后显示值已含加算区 flat,注记随动;armor_class/ap 不入条件:
|
||||
# 开场行只显示 atk/max_hp 两值,注记指向不存在的显示轴反成失真),白板局不带
|
||||
var ov: Dictionary[Variant, Variant] = meta.hero_override()
|
||||
var boosted: bool = int(ov.get("atk", 0)) > 0 or int(ov.get("max_hp", 0)) > 0 \
|
||||
or debug_overlay.atk_pct() > 0 or debug_overlay.hp_pct() > 0 \
|
||||
or int(run_scope.runner.run_ledger.bonus.get("atk", 0)) > 0 \
|
||||
or int(run_scope.runner.run_ledger.bonus.get("max_hp", 0)) > 0
|
||||
log_view.append_bb(Narrator.level_open_line(display_name, int(run_scope.adventure.master_seed),
|
||||
run_scope.runner.effective_atk(), run_scope.runner.effective_max_hp(),
|
||||
int(ov.get("atk", 0)) > 0 or int(ov.get("max_hp", 0)) > 0
|
||||
or debug_overlay.atk_pct() > 0 or debug_overlay.hp_pct() > 0))
|
||||
run_scope.runner.effective_atk(), run_scope.runner.effective_max_hp(), boosted))
|
||||
|
||||
|
||||
func _on_runner_node(node: Dictionary, info: Dictionary) -> void:
|
||||
@@ -1064,18 +1066,34 @@ func _on_settings_closed() -> void:
|
||||
|
||||
# ---- 局内状态面板·只读(入口角标在两屏装配段;开合与数据喂入归组合根)----
|
||||
|
||||
# 状态面板快照投影(数据只读同源:每次开层实时读 MetaProgression 访问器现值拼装——
|
||||
# 状态面板快照投影(数据只读同源:每次开层实时读访问器现值拼装——
|
||||
# 快照仅服务单次开层生命周期(open_status 重建各行),跨开层零持有;零写调用。
|
||||
# 状态面板零锻造段)
|
||||
# 八键换代(迭代 36 卡③):跨关解锁概览退役(营地/生涯承载)——只留本局信息;
|
||||
# ledger 直取下标(键缺失=致命,禁 .get 缺省留门——fail-fast 红线);禁 null 守卫软回退
|
||||
# (两入口开层时 runner 恒在役——状态面板卡 F3 证据链)
|
||||
func _status_data() -> Dictionary:
|
||||
return {
|
||||
"balance": meta.keystone_balance(),
|
||||
"workshop": meta.treasure_shop_status(), # 秘宝匣 12 件解锁投影
|
||||
"tree": meta.tree_camp_rows(),
|
||||
"fork_groups": meta.tree_fork_groups(),
|
||||
"hero_hp": int(run_scope.runner.hero_hp), # LevelRunner 属性面(_ledger 直取下标——键缺失=致命)
|
||||
"max_hp": run_scope.runner.effective_max_hp(),
|
||||
"atk": run_scope.runner.effective_atk(),
|
||||
"ac": run_scope.runner.effective_ac(),
|
||||
"gp": int(run_scope.runner.gp),
|
||||
"curios": _status_curios(),
|
||||
"feats": HudPresenter.feat_holdings_lines(db, run_scope.runner.feats_held()),
|
||||
}
|
||||
|
||||
|
||||
# 秘宝行映射(inventory_projection 现役投影逐行取 name/used 两键——受理序沿库存投影;
|
||||
# 禁第二份投影:件名换算已在 inventory_projection 收口,此处只做键裁剪)
|
||||
func _status_curios() -> Array:
|
||||
var out: Array[Variant] = []
|
||||
for row in (_treasure_ui.inventory_projection() as Array):
|
||||
var r: Dictionary[Variant, Variant] = row
|
||||
out.append({"name": String(r.get("name", "")), "used": bool(r.get("used", false))})
|
||||
return out
|
||||
|
||||
|
||||
func _open_status_panel() -> void:
|
||||
_open_modal(status_panel) # 模态栈单口(开层前排空排播队列——既有层同款行为,目检确认项)
|
||||
status_panel.open_status(_status_data())
|
||||
|
||||
@@ -267,7 +267,8 @@ func _draw() -> void:
|
||||
elif fog_state == 2:
|
||||
# 记忆态换装(demo 口径「地板纹理 + 灰罩 α.66」:
|
||||
# 地板纹理原样直画 + FOG_MEMORY_PLATE α.66 灰罩压顶;素材零改动、格边长零改动。
|
||||
# 记忆墨 FOG_MEMORY_INK 对合成底实测 ≈6.7:1 ≥4.5:1(§7.2 硬指标;floor avg #453020 复算在册))
|
||||
# 记忆墨 FOG_MEMORY_INK 对合成底归一后(真实渲染面)三关 5.71–5.93:1 ≥4.5:1(§7.2 硬指标;
|
||||
# 测点行值与素材随动,见 ui_contrast_probe 迷雾段))
|
||||
if tex_base != null:
|
||||
draw_texture_rect(tex_base, Rect2(Vector2.ZERO, Vector2(edge, edge)), false)
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(edge, edge)), Color(UiKit.FOG_MEMORY_PLATE, 0.66), true)
|
||||
|
||||
@@ -727,7 +727,7 @@ func _paint_cell(c: Vector2i, hero: Vector2i) -> void:
|
||||
var rec: Dictionary[Variant, Variant] = _minesweeper.reveal_record(c) # 已开记录经查询面
|
||||
if rec.get("kind", "") == "mine":
|
||||
bg = COL_FLOOR
|
||||
glyph = "雷"
|
||||
glyph = "危"
|
||||
digit_col = UiKit.RED_MINE
|
||||
else:
|
||||
bg = COL_FLOOR
|
||||
@@ -773,13 +773,13 @@ func _paint_cell(c: Vector2i, hero: Vector2i) -> void:
|
||||
u.bg_sb = sb
|
||||
u.glyph = glyph
|
||||
u.glyph_color = digit_col
|
||||
# 色牌后备(数字直画退牌;雷/「?」维持牌载保读性)+ v3.4:双峰纹理板
|
||||
# 色牌后备(数字直画退牌;「危」/「?」维持牌载保读性)+ v3.4:双峰纹理板
|
||||
#(_digit_plate——underdark 系亮白筋+近黑斑并存的直画不可救纹理)数字落牌,牌上深墨 12:1
|
||||
var plate_digit := _digit_plate and glyph != "" and glyph != "雷" and glyph != "?" and glyph != "封" and glyph != "人"
|
||||
var plate_digit := _digit_plate and glyph != "" and glyph != "危" and glyph != "?" and glyph != "封" and glyph != "人"
|
||||
if plate_digit:
|
||||
digit_col = COL_DIGIT # 牌上深墨族(素牌 12:1——双峰纹理免疫)
|
||||
u.glyph_color = digit_col
|
||||
u.plate = revealed and (glyph == "雷" or glyph == "?" or plate_digit)
|
||||
u.plate = revealed and (glyph == "危" or glyph == "?" or plate_digit)
|
||||
u.outline_sides.assign([]) # typed Array[int](assign 保持引用语义)
|
||||
u.facade_south = false
|
||||
u.proj_band = 0.0
|
||||
|
||||
@@ -55,7 +55,7 @@ func _init() -> void:
|
||||
corner.add_theme_constant_override("separation", 8)
|
||||
status_badge = UiKit.button("状态", 0.0) # 角落小件豁免宽度基准
|
||||
status_badge.add_theme_font_size_override("font_size", UiKit.FS_HINT)
|
||||
status_badge.tooltip_text = "查看钥石与永久强化进度(只读)"
|
||||
status_badge.tooltip_text = "查看本局冒险进度(只读)"
|
||||
status_badge.visible = false # 显式初始化——非对局期不误现(可见性归组合根同步)
|
||||
corner.add_child(status_badge)
|
||||
corner_wrap.add_child(corner)
|
||||
|
||||
@@ -164,7 +164,7 @@ func _init() -> void:
|
||||
atlas_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
atlas_panel.size_flags_vertical = Control.SIZE_FILL # 四栏等高:任一栏长高时四栏同步拉伸
|
||||
mid.add_child(atlas_panel)
|
||||
var lock_l := UiKit.label(" —— 柜台结构位预留:内容排 Alpha(地图战斗资源差异化批)",UiKit.FS_HINT, false, UiKit.STONE)
|
||||
var lock_l := UiKit.label(" —— 柜台结构位预留:内容排 Alpha(地图战斗资源差异化批)",UiKit.FS_HINT, false, UiKit.STONE_DEEP)
|
||||
lock_l.size_flags_vertical = Control.SIZE_SHRINK_CENTER # 锁文在等高栏内垂直居中
|
||||
lock_l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
lock_l.custom_minimum_size = Vector2(280, 0)
|
||||
@@ -414,7 +414,7 @@ func refresh_camp(data: Dictionary) -> void:
|
||||
card.progress_l.text = prog_text
|
||||
# 状态色用亮板双色墨制(深墨族替代暖白族——亮板上暖白不可读)
|
||||
var st_text := "未解锁"
|
||||
var st_col: Color = UiKit.STONE
|
||||
var st_col: Color = UiKit.STONE_DEEP
|
||||
if unlocked:
|
||||
st_text = "已解锁"
|
||||
st_col = UiKit.GOLD_INK # GOLD_DEEP 对行板 3.00:1 越带,换深金墨 5.84:1
|
||||
@@ -473,7 +473,7 @@ func _rebuild_workshop(data: Dictionary) -> void:
|
||||
progress_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
line.add_child(progress_l)
|
||||
# 状态色用亮板双色墨制(深墨族替代暖白族)——色随刷新态条件赋值,非机械两行式
|
||||
var status_l := UiKit.label("",UiKit.FS_HINT, true, UiKit.STONE)
|
||||
var status_l := UiKit.label("",UiKit.FS_HINT, true, UiKit.STONE_DEEP)
|
||||
status_l.clip_text = true # 同上——「已解锁 · 能力封存中」不撑柜(余宽吸收者)
|
||||
status_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
status_l.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
@@ -506,11 +506,11 @@ func _rebuild_attune(data: Dictionary) -> void:
|
||||
name_l.clip_text = true
|
||||
name_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
line.add_child(name_l)
|
||||
var form_l := UiKit.label("被动" if String(row.get("form", "")) == "passive" else "主动·充能",UiKit.FS_CAPTION, false, UiKit.STONE)
|
||||
var form_l := UiKit.label("被动" if String(row.get("form", "")) == "passive" else "主动·充能",UiKit.FS_CAPTION, false, UiKit.STONE_DEEP)
|
||||
form_l.custom_minimum_size = Vector2(58, 0)
|
||||
line.add_child(form_l)
|
||||
# 档位标签(槽位档位显示:件档=普通/优秀/稀有,装入须件档≤格档契约 2 的呈现半边)
|
||||
var tier_l := UiKit.label(String(UiKit.TREASURE_TIER_LABELS.get(String(row.get("tier", "")), "—")),UiKit.FS_CAPTION, false, UiKit.STONE)
|
||||
var tier_l := UiKit.label(String(UiKit.TREASURE_TIER_LABELS.get(String(row.get("tier", "")), "—")),UiKit.FS_CAPTION, false, UiKit.STONE_DEEP)
|
||||
tier_l.custom_minimum_size = Vector2(36, 0)
|
||||
line.add_child(tier_l)
|
||||
var btn := UiKit.button("装配", 0.0) # 行板紧凑钮豁免
|
||||
@@ -522,7 +522,7 @@ func _rebuild_attune(data: Dictionary) -> void:
|
||||
_attune_btns.append({"item_id": iid, "btn": btn, "unlocked": bool(row.get("unlocked", false)), "equipped": bool(row.get("equipped", false))})
|
||||
var refresh_line := HBoxContainer.new()
|
||||
refresh_line.add_theme_constant_override("separation", 6)
|
||||
_refresh_l = UiKit.label("",UiKit.FS_CAPTION, false, UiKit.STONE)
|
||||
_refresh_l = UiKit.label("",UiKit.FS_CAPTION, false, UiKit.STONE_DEEP)
|
||||
refresh_line.add_child(_refresh_l)
|
||||
_refresh_btn = UiKit.button("升级", 0.0) # 行板紧凑钮豁免
|
||||
_refresh_btn.pressed.connect(_emit_buy_requested.bind("attune_refresh"))
|
||||
|
||||
@@ -70,7 +70,7 @@ func _init() -> void:
|
||||
_feat_section.add_theme_constant_override("separation", 4)
|
||||
_feat_section.visible = false
|
||||
col.add_child(_feat_section)
|
||||
_feat_section.add_child(UiKit.label("特性",UiKit.FS_BODY, true))
|
||||
_feat_section.add_child(UiKit.label("专长",UiKit.FS_BODY, true))
|
||||
_feat_list = VBoxContainer.new()
|
||||
_feat_list.add_theme_constant_override("separation", 2)
|
||||
_feat_section.add_child(_feat_list)
|
||||
@@ -90,7 +90,7 @@ func _init() -> void:
|
||||
_curio_section.add_theme_constant_override("separation", 4)
|
||||
_curio_section.visible = false
|
||||
col.add_child(_curio_section)
|
||||
_curio_section.add_child(UiKit.label("特性",UiKit.FS_BODY, true))
|
||||
_curio_section.add_child(UiKit.label("属性值提升",UiKit.FS_BODY, true))
|
||||
_curio_list = VBoxContainer.new()
|
||||
_curio_list.add_theme_constant_override("separation", 2)
|
||||
_curio_section.add_child(_curio_list)
|
||||
|
||||
@@ -8,7 +8,7 @@ signal closed
|
||||
const ENTRIES := [
|
||||
["#C9AF83", "完整覆尘地砖", "未开格——左键挖开(1AP,须相邻)· 右键封印(1AP)"],
|
||||
["#B39A78", "碎裂裸土", "已开 0 格——可通行,左键移动(免费)"],
|
||||
["#000000", "封印", "封印印记(呈现名·不能拔)——轮末自动判定标中;插错可左键直接挖开(安全格零奖励、危险格吃挖雷惩罚)"],
|
||||
["#000000", "封印", "封印印记(呈现名·不能拔)——轮末自动判定标中;标空可左键直接挖开(安全格零奖励、危险格受挖掘惩罚)"],
|
||||
["#2e86de", "小人", "探险者——8 向相邻未开格常驻微光 = 可挖域"],
|
||||
["#F0A828", "金徽章", "已开格奖励身份 icon(封印之下、数值之上,不进数字区)"],
|
||||
["#F0A828", "九徽章解码", "攻=攻击 · 守=护甲 · 爆=爆裂 · 医=回复 · 弱=削弱 · 穿=穿刺 · 魔=魔法 · 行=行动点 · 金=GP"],
|
||||
|
||||
@@ -104,7 +104,7 @@ func _init() -> void:
|
||||
# 信号连接归组合根(视图零连接))
|
||||
status_badge = UiKit.button("状态", 0.0)
|
||||
status_badge.add_theme_font_size_override("font_size", UiKit.FS_HINT)
|
||||
status_badge.tooltip_text = "查看钥石与永久强化进度(只读)"
|
||||
status_badge.tooltip_text = "查看本局冒险进度(只读)"
|
||||
status_badge.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
top.add_child(status_badge)
|
||||
_title = UiKit.label(TITLE_FORK,UiKit.FS_SUB, true, UiKit.WARM)
|
||||
@@ -168,7 +168,7 @@ func _init() -> void:
|
||||
_bonus_row.add_child(bonus_box)
|
||||
var bonus_inner := VBoxContainer.new()
|
||||
bonus_box.add_child(bonus_inner)
|
||||
var bonus_cap := UiKit.label("特性",UiKit.FS_CAPTION, false, UiKit.INK_HINT) # 深墨(亮板可读)
|
||||
var bonus_cap := UiKit.label("属性值提升",UiKit.FS_CAPTION, false, UiKit.INK_HINT) # 深墨(亮板可读)
|
||||
bonus_inner.add_child(bonus_cap)
|
||||
_bonus_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_TITLE) # 淡墨→深墨(同上)
|
||||
bonus_inner.add_child(_bonus_value)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
## VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
## 局内状态面板·只读:钥石余额与进度概览(入口=两屏角标,本类不含入口)。
|
||||
## 局内状态面板·只读:本局信息面板(钥石总量+本局四件:带的秘宝/已获专长/当前属性/现有 GP)。
|
||||
## 跨关解锁概览(秘宝匣解锁态/天赋树已购)由营地/生涯承载,本面板不镜像。
|
||||
## 账面快照由组合根 open_status 喂入;进出零动效直开直关;构成件全部经 UiKit 工厂产出——禁裸色值自建样式。
|
||||
class_name StatusPanel
|
||||
extends Control
|
||||
|
||||
signal closed
|
||||
|
||||
var _balance_badge: UiKit.KeystoneBadge # 钥石余额徽(badge 工厂件——Q10 甲)
|
||||
var _workshop_holder: VBoxContainer
|
||||
var _tree_label: Label
|
||||
var _balance_badge: UiKit.KeystoneBadge # 钥石总量徽(badge 工厂件——Q10 甲)
|
||||
var _attrs_label: Label
|
||||
var _curios_label: Label
|
||||
var _feats_label: Label
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
@@ -19,11 +21,11 @@ func _init() -> void:
|
||||
var title := UiKit.label("状态",UiKit.FS_TITLE, true, UiKit.INK_TITLE)
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
box.add_child(title)
|
||||
var hint := UiKit.label("查看本局钥石与永久强化进度(只读——购买请回营地)",UiKit.FS_HINT, false, UiKit.INK_HINT)
|
||||
var hint := UiKit.label("查看本局冒险进度(只读)",UiKit.FS_HINT, false, UiKit.INK_HINT)
|
||||
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
box.add_child(hint)
|
||||
|
||||
# 钥石余额行(badge 工厂件——与营地顶栏钥石徽同形制)
|
||||
# 钥石总量行(badge 工厂件——与营地顶栏钥石徽同形制)
|
||||
var bal_row := HBoxContainer.new()
|
||||
bal_row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
box.add_child(bal_row)
|
||||
@@ -31,13 +33,20 @@ func _init() -> void:
|
||||
bal_row.add_child(_balance_badge)
|
||||
|
||||
box.add_child(HSeparator.new())
|
||||
_section_head(box, "道具工坊 · 解锁态")
|
||||
_workshop_holder = _section_holder(box)
|
||||
_section_head(box, "本局状态")
|
||||
_attrs_label = UiKit.label("",UiKit.FS_HINT, false, UiKit.INK_LABEL)
|
||||
_attrs_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
box.add_child(_attrs_label)
|
||||
|
||||
_section_head(box, "天赋树 · 已购概览")
|
||||
_tree_label = UiKit.label("",UiKit.FS_HINT, false, UiKit.INK_LABEL)
|
||||
_tree_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
box.add_child(_tree_label)
|
||||
_section_head(box, "带的秘宝")
|
||||
_curios_label = UiKit.label("",UiKit.FS_HINT, false, UiKit.INK_LABEL)
|
||||
_curios_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
box.add_child(_curios_label)
|
||||
|
||||
_section_head(box, "已获专长")
|
||||
_feats_label = UiKit.label("",UiKit.FS_HINT, false, UiKit.INK_LABEL)
|
||||
_feats_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
box.add_child(_feats_label)
|
||||
|
||||
box.add_child(HSeparator.new())
|
||||
var close_row := HBoxContainer.new()
|
||||
@@ -56,80 +65,26 @@ func _section_head(box: VBoxContainer, text: String) -> void:
|
||||
box.add_child(head)
|
||||
|
||||
|
||||
func _section_holder(box: VBoxContainer) -> VBoxContainer:
|
||||
var holder := VBoxContainer.new()
|
||||
holder.add_theme_constant_override("separation", 4)
|
||||
box.add_child(holder)
|
||||
return holder
|
||||
|
||||
|
||||
# ---- 开层(组合根唯一调用口——快照随开随建,R4 单次开层生命周期口径)----
|
||||
|
||||
# 八键快照(组合根 _status_data 换代):balance/hero_hp/max_hp/atk/ac/gp/curios/feats——
|
||||
# 本局四件拼装在此,禁第二份投影(秘宝=inventory_projection 行映射、专长=feat_holdings_lines 现成行)
|
||||
func open_status(data: Dictionary) -> void:
|
||||
_balance_badge.set_text("钥石 %d" % int(data.get("balance", 0)))
|
||||
_rebuild_workshop(data)
|
||||
_rebuild_tree(data)
|
||||
visible = true
|
||||
|
||||
|
||||
# 道具工坊六行(解锁态文案与营地柜台同口径——退役/封存/已解锁/未解锁;进度=解锁判定量读数)
|
||||
# 秘宝匣 12 行只读概览(数据源=treasure_shop_status 投影,
|
||||
# 解锁态文案与营地柜台同口径;进度列=解锁混合制三形态:钥石价/条件进度拼接/已入手)
|
||||
func _rebuild_workshop(data: Dictionary) -> void:
|
||||
for c in _workshop_holder.get_children():
|
||||
c.queue_free()
|
||||
for row in (data.get("workshop", []) as Array):
|
||||
var line := HBoxContainer.new()
|
||||
line.add_theme_constant_override("separation", 8)
|
||||
var name_l := UiKit.label(String(row.get("display_name", "")),UiKit.FS_HINT, true, UiKit.INK)
|
||||
name_l.custom_minimum_size = Vector2(120, 0)
|
||||
name_l.clip_text = true
|
||||
name_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
line.add_child(name_l)
|
||||
var unlocked := bool(row.get("unlocked", false))
|
||||
var prog_text := "已入手"
|
||||
if not unlocked:
|
||||
if String(row.get("mode", "")) == "keystone":
|
||||
prog_text = "钥石 %d" % int(row.get("price", 0))
|
||||
else:
|
||||
var parts: Array[String] = []
|
||||
for req_v in (row.get("reqs", []) as Array):
|
||||
var req: Dictionary[Variant, Variant] = req_v as Dictionary
|
||||
var lbl := String(UiKit.TREASURE_REQ_LABELS.get(String(req.get("key", "")), "条件"))
|
||||
parts.append("%s %d/%d" % [lbl, int(req.get("progress", 0)), int(req.get("threshold", 0))])
|
||||
prog_text = "、".join(parts)
|
||||
var prog_l := UiKit.label(prog_text,UiKit.FS_HINT, false, UiKit.INK_HINT)
|
||||
prog_l.custom_minimum_size = Vector2(150, 0)
|
||||
prog_l.clip_text = true
|
||||
prog_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
line.add_child(prog_l)
|
||||
var st_l := UiKit.label("已解锁" if unlocked else "未解锁",UiKit.FS_HINT, true, UiKit.INK_LABEL)
|
||||
st_l.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
line.add_child(st_l)
|
||||
_workshop_holder.add_child(line)
|
||||
|
||||
|
||||
# 天赋树已购概览(单条聚合文案——已购节点等级 + Fork 组激活侧;零已购=空态句)
|
||||
func _rebuild_tree(data: Dictionary) -> void:
|
||||
var owned: Array[String] = []
|
||||
for row in (data.get("tree", []) as Array):
|
||||
var lvl := int(row.get("level", 0))
|
||||
if lvl > 0:
|
||||
owned.append("%s Lv%d/%d" % [String(row.get("display_name", "")), lvl, int(row.get("cap", 1))])
|
||||
for fg in (data.get("fork_groups", []) as Array):
|
||||
var active := String(fg.get("active_side", ""))
|
||||
if active == "":
|
||||
continue
|
||||
var side_name := active
|
||||
for sd in (fg.get("sides", []) as Array):
|
||||
if String(sd.get("side", "")) == active:
|
||||
side_name = String(sd.get("display_name", active))
|
||||
break
|
||||
owned.append("%s·激活 %s" % [String(fg.get("display_name", "")), side_name])
|
||||
if owned.is_empty():
|
||||
_tree_label.text = "尚未购买任何节点(天赋树在营地主柜台)"
|
||||
_attrs_label.text = "生命 %d/%d · 攻击 %d · 护甲 AC %d · GP %d" % [
|
||||
int(data.get("hero_hp", 0)), int(data.get("max_hp", 0)), int(data.get("atk", 0)),
|
||||
int(data.get("ac", 0)), int(data.get("gp", 0))]
|
||||
var curios: Array[Variant] = data.get("curios", [])
|
||||
if curios.is_empty():
|
||||
_curios_label.text = "未携带秘宝"
|
||||
else:
|
||||
_tree_label.text = "已购 %d 项:%s" % [owned.size(), " · ".join(owned)]
|
||||
var parts: Array[String] = []
|
||||
for row in curios:
|
||||
var r: Dictionary[Variant, Variant] = row
|
||||
parts.append(String(r.get("name", "")) + ("(已用)" if bool(r.get("used", false)) else ""))
|
||||
_curios_label.text = " · ".join(parts)
|
||||
var feats: Array[Variant] = data.get("feats", [])
|
||||
_feats_label.text = "未获专长" if feats.is_empty() else " · ".join(feats)
|
||||
visible = true
|
||||
|
||||
|
||||
func _emit_closed() -> void:
|
||||
|
||||
@@ -37,7 +37,7 @@ const EFFECT_SEMANTIC := {
|
||||
"empower": "敌方输出 ×2(enemy_output 池加算——蓄力技施放;持续 1 个有效结算)",
|
||||
"fortify": "敌方防御 −50(enemy_defense 池——硬化技施放,主角攻击 ×0.5;持续 1 个有效结算)",
|
||||
}
|
||||
const SOURCE_NAMES := {"safe_cell": "安全格", "seal_hit": "封印命中", "mine_hit": "踩雷", "clear_reward": "清图奖励"}
|
||||
const SOURCE_NAMES := {"safe_cell": "安全格", "seal_hit": "封印命中", "mine_hit": "危险格", "clear_reward": "清图奖励"}
|
||||
# 未知图标键的占位(HUD框架:占位也要可见,禁静默空图标——debug 断言先行,release 回退槽内 ? 文本)
|
||||
const ICON_PLACEHOLDER := "?"
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ func _ready() -> void:
|
||||
var rng = load("res://core/rng_service.gd").new()
|
||||
rng.set_master_seed(420000 + level_index)
|
||||
var runner = load("res://core/level_runner.gd").new(db, rng)
|
||||
if not runner.start_level(level_index, 420000 + level_index, {}, 0):
|
||||
if not runner.start_level(level_index, 420000 + level_index):
|
||||
push_error("[board_shot] 开关失败 level=%d" % level_index)
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# 每屏双帧 + frame_post_draw 同步后抓 get_viewport().get_texture(),PNG 落输出目录;
|
||||
# 最小自检 = 七件存在且非零字节(像素方差断言归 tools/shot_checker.py——B 档骨架分工)。
|
||||
# 商店房不纳屏(真实路径口径):真实路径必经 L1 打穿→L2 商店节点
|
||||
# 选路(商店层在 L2 Boss 前必经位)——探针现行驱动只进首战节点,低代价确定性
|
||||
# 选路(商店按摆放约束恒在终点前一位,每局必经恰一店)——探针现行驱动只进首战节点,低代价确定性
|
||||
# 到达不成立;合成投喂(ui_struct_probe 同款)破坏本探针真实路径口径 → 不纳屏。
|
||||
extends Node
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ func _ready() -> void:
|
||||
var rng = load("res://core/rng_service.gd").new()
|
||||
rng.set_master_seed(421001)
|
||||
var runner = load("res://core/level_runner.gd").new(db, rng)
|
||||
if not runner.start_level(1, 421001, {}, 0):
|
||||
if not runner.start_level(1, 421001):
|
||||
push_error("[type_glow_shot] 开局失败")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
@@ -26,6 +26,9 @@ var _tested_review := false
|
||||
var _tested_end_review := false # 终局面板回看入口(z 序修复回归)
|
||||
var _tested_refresh_chain := false
|
||||
var _tested_tooltip := false
|
||||
var _tested_copy := false # 迭代36 卡A——C 区两段头真实入口断言(每 run 一次)
|
||||
var _tested_curio_head := false # 迭代36 卡A——三选一头部旧话清零断言(奖励点停点捕获)
|
||||
var _tested_map_cap := false # 迭代36 卡A——地图屏加成帽换代断言(每 run 一次)
|
||||
var _tested_status := false # 状态面板角标开合/守卫断言(每 run 一次)
|
||||
var _tested_map_status := false # 地图屏角标一次性 case(选路态零扰动)
|
||||
var _inter_confirms := 0 # 大奖励屏确认计数(冒险推进停点——首见确认推进 L2,再见 = L2 通关收口)
|
||||
@@ -121,6 +124,54 @@ func _ready() -> void:
|
||||
iso_fk.load_from_disk()
|
||||
if String(iso_fk.fork_side("fg_posture")) != "b":
|
||||
_problems.append("Fork 切态未持久(reload 后激活态 != b)")
|
||||
# ---- 营地六处灰字取色断言(迭代36 wc4——真实入口开营地后按文本锚取 label,
|
||||
# font_color 逐处 == STONE_DEEP;六源位 :167 图鉴锁文/:417+:476 行状态字/:509 同调形态/:513 档位标签/:525 刷新行)----
|
||||
var camp_labels: Array = []
|
||||
_collect_labels_by_text(main.camp_overlay, camp_labels)
|
||||
var atlas_locks := _labels_with_text(camp_labels, "柜台结构位预留")
|
||||
if atlas_locks.is_empty():
|
||||
_problems.append("营地灰字:图鉴柜台锁文 label 未找到(wc4 定位锚失守)")
|
||||
for l in atlas_locks:
|
||||
if l.get_theme_color("font_color") != UiKit.STONE_DEEP:
|
||||
_problems.append("营地灰字:图鉴锁文未用 STONE_DEEP(wc4 :167)")
|
||||
# 「未解锁」锚收窄到秘宝匣行容器子树(树行/锻造行也有「未解锁」态字样——非 wc4 目标位)
|
||||
var locked_status: Array = []
|
||||
if main.camp_overlay.get("_workshop_holder") != null:
|
||||
var ws_labels: Array = []
|
||||
_collect_labels_by_text(main.camp_overlay._workshop_holder, ws_labels)
|
||||
locked_status = _labels_with_text(ws_labels, "未解锁")
|
||||
if locked_status.is_empty():
|
||||
_problems.append("营地灰字:秘宝匣行「未解锁」态 label 未找到(wc4 夹具前提失守——新档应有锁定秘宝行)")
|
||||
for l in locked_status:
|
||||
if l.get_theme_color("font_color") != UiKit.STONE_DEEP:
|
||||
_problems.append("营地灰字:秘宝行「未解锁」态未用 STONE_DEEP(wc4 :417/:476)")
|
||||
var form_found := false
|
||||
for l in camp_labels:
|
||||
if l.text == "被动" or l.text == "主动·充能":
|
||||
form_found = true
|
||||
if l.get_theme_color("font_color") != UiKit.STONE_DEEP:
|
||||
_problems.append("营地灰字:同调形态标签未用 STONE_DEEP(wc4 :509)")
|
||||
if not form_found:
|
||||
_problems.append("营地灰字:同调行形态标签未找到(wc4 定位锚失守——同调段应有行)")
|
||||
var tier_vals := {}
|
||||
for v in UiKit.TREASURE_TIER_LABELS.values():
|
||||
tier_vals[String(v)] = true
|
||||
var tier_found := false
|
||||
for l in camp_labels:
|
||||
if tier_vals.has(l.text):
|
||||
tier_found = true
|
||||
if l.get_theme_color("font_color") != UiKit.STONE_DEEP:
|
||||
_problems.append("营地灰字:同调档位标签未用 STONE_DEEP(wc4 :513)")
|
||||
if not tier_found:
|
||||
_problems.append("营地灰字:同调档位标签未找到(wc4 定位锚失守)")
|
||||
var refresh_inked := false
|
||||
for l in camp_labels:
|
||||
if String(l.text).contains("三选一刷新"):
|
||||
refresh_inked = true
|
||||
if l.get_theme_color("font_color") != UiKit.STONE_DEEP:
|
||||
_problems.append("营地灰字:刷新行未用 STONE_DEEP(wc4 :525)")
|
||||
if not refresh_inked:
|
||||
_problems.append("营地灰字:刷新行 label 未找到(wc4 定位锚失守)")
|
||||
main._on_camp_closed()
|
||||
await get_tree().process_frame
|
||||
main._on_save_select_closed() # 关闭被栈还原的选档层(动线序中间态——卡④ 换代点)
|
||||
@@ -322,6 +373,12 @@ func _ready() -> void:
|
||||
var t1 := Time.get_ticks_msec()
|
||||
main._start_adventure_from_menu()
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
# 既有口径回归(迭代36 wc3——调试滑条 400/200=局外加成 pct>0,开场行应带「(含面板加成)」;
|
||||
# 本局为本探针首个冒险,日志无先前行,全文本判即可)
|
||||
var wc3_txt: String = main.log_view._log.get_parsed_text()
|
||||
if not wc3_txt.contains("种子 42") or not wc3_txt.contains("(含面板加成)"):
|
||||
_problems.append("开场行回归失守:局外加成(pct>0)局的开场行应带「(含面板加成)」(wc3)")
|
||||
# 关卡开始 = 入口选路停点——press_candidate 图上命中路径选入口候选进首战
|
||||
#(首格可点 AC 口径随之含入口选路一步:菜单点击 → 停点呈现 → pick → 棋盘安装)
|
||||
if main.level_screen.visible:
|
||||
@@ -358,6 +415,12 @@ func _ready() -> void:
|
||||
# 未适配则走到即卡死)。两态分派:result 态(兑付反馈——点继续推进)/
|
||||
# 待选态(点首件+确认)。真实信号路径全线。〔无替换子态——装配不可变契约。〕
|
||||
# 三选一停点输入锁定断言(停点态断言面:待选/兑付两态共用此层)。
|
||||
if not _tested_curio_head and main.run_scope.adventure != null and not main.run_scope.adventure._curio_pending.is_empty() and String(main.curio_overlay._header).contains("关卡奖励点"):
|
||||
_tested_curio_head = true
|
||||
var ch := String(main.curio_overlay._header)
|
||||
if not ch.contains("冒险牌库") or not ch.contains("属性值提升与补给") or ch.contains("同权重") or ch.contains("同上限") or ch.contains("提升池"):
|
||||
_problems.append("三选一头部旧话残留或新话缺失(wa4——%s)" % ch.replace("
|
||||
", "|"))
|
||||
if not main._input_locked():
|
||||
_problems.append("三选一停点打开时输入未锁定")
|
||||
if main.curio_overlay._continue_btn.visible:
|
||||
@@ -370,7 +433,7 @@ func _ready() -> void:
|
||||
_seals_placed.clear()
|
||||
continue
|
||||
if main.shop_overlay.visible:
|
||||
# 商店房覆盖层(L2 商店为 Boss 前必经位):零购离开(真实 leave 信号路径)放行选路。
|
||||
# 商店房覆盖层(摆放约束:每终点前一位恰一店,真实路径恒必经一店):零购离开(真实 leave 信号路径)放行选路。
|
||||
# 分派链序:商店层=选路页上的模态覆盖(停点呈现为
|
||||
# level_screen+shop 双 visible)——必须先于 level_screen 分派,否则下层候选点击
|
||||
# 空转烧尽预算(实证:lv2 商店停点双 visible 循环 12000 帧 done=false)。
|
||||
@@ -395,6 +458,18 @@ func _ready() -> void:
|
||||
rbtn2.pressed.emit()
|
||||
if main.status_panel.visible or not main.level_screen.visible:
|
||||
_problems.append("状态面板关闭后地图屏态失守(恢复语义破)")
|
||||
# 地图屏加成帽换代断言(迭代36 卡A wa2——「特性」→「属性值提升」;帽 Label 常在树;
|
||||
# 独立旗标——_tested_map_status 在同拍前置块已置位,禁复用其门控)
|
||||
if not _tested_map_cap:
|
||||
_tested_map_cap = true
|
||||
var cap_found := false
|
||||
var ls_labels: Array = []
|
||||
_collect_labels_by_text(main.level_screen, ls_labels)
|
||||
for l in ls_labels:
|
||||
if l.text == "属性值提升":
|
||||
cap_found = true
|
||||
if not cap_found:
|
||||
_problems.append("地图屏加成帽未换代为「属性值提升」(wa2)")
|
||||
main.level_screen.press_candidate(_route_clicks % 2)
|
||||
_route_clicks += 1
|
||||
_seals_placed.clear()
|
||||
@@ -537,6 +612,12 @@ func _ready() -> void:
|
||||
# ① 战败结局清断点(T3 口径——三结局清断点之一):停点快照随局写入,战败结算须清
|
||||
iso_chk.load_from_disk()
|
||||
if iso_chk.has_breakpoint():
|
||||
var bpf: Dictionary = iso_chk.breakpoint_snapshot()
|
||||
print("[click_probe][diag] 断点未清指纹 keys=%s lv=%s node=%s bonus=%s deck=%s pending=%s" % [
|
||||
str((bpf as Dictionary).keys()), str((bpf as Dictionary).get("level_index")),
|
||||
str((bpf as Dictionary).get("node_id")), str((bpf as Dictionary).get("bonus")),
|
||||
str(((bpf as Dictionary).get("bonus_deck", []) as Array).size()),
|
||||
str((bpf as Dictionary).get("bonus_pending"))])
|
||||
_problems.append("断点段:战败结算后断点未清(T3 三结局清断点失守)")
|
||||
if not main._end_overlay.is_open():
|
||||
_problems.append("断点段:战败后未出冒险终局面")
|
||||
@@ -553,6 +634,67 @@ func _ready() -> void:
|
||||
# ②③ 中断→续玩全链+互斥确认两分支:随三入口化撤出(卡② §7.2 申报——断点确认探针段移交
|
||||
# 卡④大门 T5 接收,两头登记禁静默删除;续玩钮已退役,主菜单确认链已摘 mn6)。
|
||||
# ① 战斗收口断点写盘断言保留(上方——冒险流直调中间态 mn10)。
|
||||
|
||||
# ---- 开场行三路径段(迭代36 wc1/wc2——真实入口驱动;隔离新档)----
|
||||
# 驱动设计(0/0 白板局生存性不成立的实证处置——L2 记录进实现报告):
|
||||
# ① 菜单开新局·白板反向=独立短局:0/0 滑条开局断言 L1 开场行零后缀(不开驱动——
|
||||
# 断言完成后该局被 ② 的开局自然替换,白板局生存性不参与判定);
|
||||
# ② 局内加算区正向=生存局:400/200 滑条开局保可通关(:363 先例),三选一专选 atk/max_hp
|
||||
# 卡(无则任选推进——不触后缀、循环重试);至大奖励屏先 reset_sliders() 再确认进下一层
|
||||
# ——L2 开场行时点 pct=0 且新档 ov=0,后缀只能归因局内加算区(变异:回退条件 hunk→红);
|
||||
# ③ 断点续玩正向:L2 入口选路停点(bonus 已入账、bonus_pending/bonus_result 皆空——选路停点形)
|
||||
# 经 _resume_from_menu 唯一消费口重建,重建开场行带后缀。
|
||||
# 判定面=开局清日志后的新局日志(_start_adventure 内 log_view.clear());「(含面板加成)」
|
||||
# 唯一来源=开场行,计数即判定;「· 种子 」=开场行独有形态锚。
|
||||
for p_ol in ["user://smoke_click_probe_openline.json", "user://smoke_click_probe_openline.json.tmp"]:
|
||||
if FileAccess.file_exists(p_ol):
|
||||
DirAccess.remove_absolute(p_ol)
|
||||
ProbeUiUtil.isolate_save(main, "smoke_click_probe_openline", true, false, true)
|
||||
main.debug_overlay.reset_sliders() # ① 白板反向前提(_start_adventure 只关窗不清值——残值跨段存活防坑)
|
||||
main.debug_overlay.seed_edit.text = "42"
|
||||
main._start_adventure_from_menu()
|
||||
var ol_l1_wait := 0
|
||||
while ol_l1_wait < 24 and String(main.log_view._log.get_parsed_text()).count("· 种子 ") < 1:
|
||||
ol_l1_wait += 1
|
||||
await get_tree().process_frame
|
||||
if ol_l1_wait >= 24:
|
||||
_problems.append("开场行路径①:L1 开场行未达日志(开局清日志后 24 帧内未现种子行)")
|
||||
elif String(main.log_view._log.get_parsed_text()).count("(含面板加成)") > 0:
|
||||
_problems.append("开场行路径①失守:白板局 L1 开场行带「(含面板加成)」(wc1 反向)")
|
||||
print("[click_probe] 开场行①判定 wait=%d 帧断言完成" % ol_l1_wait)
|
||||
# ②③ 断点续玩正向(既有快照喂入形态——代拍板 2 预授权;生产「继续上局」钮已随三入口化
|
||||
# 撤出,_resume_from_menu=唯一消费口;断点形态=选路停点形钉值:bonus 已入账、
|
||||
# bonus_pending/bonus_result 皆空——set_breakpoint 注入口同 system_test V3i 先例,
|
||||
# 卡内警示的 bonus_pending 形夹具是本形态反例,禁复用)。续玩重建开场行时点 pct=0
|
||||
# +新档 ov=0+bonus.atk=5 已回填(resume_adventure :151-154)——后缀只能归因局内加算区。
|
||||
# 【卡片前提缺陷记录(随实现报告/停机呈报)】路径②原设计「奖励节点三选一拿属性卡→下一关
|
||||
# 开场行」在现行 build 不可驱动:L1 全模板零商店节点、reward 节点=零兑付直通占位
|
||||
# (level_runner _enter_node 注记)——新开局在任一开场行时点均无局内 bonus 来源。
|
||||
# 条件加算区两项的机制面由本续玩路径全链承载(回填→level_started→条件求值,与 fresh 同式),
|
||||
# 变异(回退条件 hunk)见红;fresh 正向拦截能力未证,如实申报。
|
||||
var bp_ol: Dictionary = {
|
||||
"master_seed": 42, "rng_counter": 0, "hero_hp": 30, "gp": 10, "keystone": 0,
|
||||
"level_index": 1, "bonus": {"atk": 5, "max_hp": 0, "armor_class": 0, "ap": 0},
|
||||
"shop_purchased": {}, "relics": [], "keystone_drops": [], "bonus_taken": {},
|
||||
"node_id": -1, "visited_ids": [], "graph_gen_v2": 1,
|
||||
}
|
||||
main.save_manager.set_breakpoint(bp_ol)
|
||||
# 判定面=续玩开局清日志后的新局日志(_resume_from_menu 内 log_view.clear()——选路停点形
|
||||
# resume 同步重建开场行,返回时已在日志)
|
||||
main._resume_from_menu()
|
||||
var ol_r_wait := 0
|
||||
while ol_r_wait < 24 and String(main.log_view._log.get_parsed_text()).count("· 种子 ") < 1:
|
||||
ol_r_wait += 1
|
||||
await get_tree().process_frame
|
||||
if main.run_scope.runner == null or int(main.run_scope.runner.run_ledger.bonus.get("atk", 0)) != 5:
|
||||
_problems.append("开场行路径②③前提失守:续玩重建后加算区 atk!=5(断点回填链断)")
|
||||
if ol_r_wait >= 24:
|
||||
_problems.append("开场行路径②③失守:续玩重建未发开场行(选路停点形应重发——形态/驱动复核)")
|
||||
elif String(main.log_view._log.get_parsed_text()).count("(含面板加成)") < 1:
|
||||
_problems.append("开场行路径②③失守:bonus 已入账(pct=0/ov=0)的续玩重建开场行未带「(含面板加成)」(wc1 正向/wc2)")
|
||||
else:
|
||||
print("[click_probe] 开场行②③判定:续玩重建开场行带后缀(bonus.atk=5、pct=0)— wait=%d 帧" % ol_r_wait)
|
||||
|
||||
print("[click_probe] 循环退出(done=%s budget=%s)问题=%d" % [
|
||||
str(_done), str(action_budget), _problems.size()])
|
||||
for p in _problems:
|
||||
@@ -568,6 +710,23 @@ func _ready() -> void:
|
||||
# ---- 模态/tooltip/刷新链一次性覆盖(真实信号路径驱动) ----
|
||||
|
||||
func _run_modal_probes_once() -> void:
|
||||
# ⓪ 玩家面文案真实入口断言(迭代36 卡A wa1——C 区两段头随战斗常驻)
|
||||
if not _tested_copy and main.info_panel != null and main.info_panel.visible:
|
||||
_tested_copy = true
|
||||
var feat_head: Label = null
|
||||
var curio_head: Label = null
|
||||
for child in main.info_panel._feat_section.get_children():
|
||||
if child is Label:
|
||||
feat_head = child
|
||||
break
|
||||
for child in main.info_panel._curio_section.get_children():
|
||||
if child is Label:
|
||||
curio_head = child
|
||||
break
|
||||
if feat_head == null or feat_head.text != "专长":
|
||||
_problems.append("C 区专长段头未换代(wa1——真实入口)")
|
||||
if curio_head == null or curio_head.text != "属性值提升":
|
||||
_problems.append("C 区属性值提升段头未换代(wa1——真实入口)")
|
||||
# ① 图例模态(pattern 6):打开 → 遮挡物隐藏 + 输入锁定;关闭 → 恢复
|
||||
if not _tested_legend:
|
||||
_tested_legend = true
|
||||
@@ -582,6 +741,10 @@ func _run_modal_probes_once() -> void:
|
||||
for e in main.legend_overlay.ENTRIES:
|
||||
if String(e[1]) == "封印":
|
||||
legend_has_seal = true
|
||||
# 图例封印行换代断言(迭代36 卡A wa5——真实 AP 条图例钮开层路径)
|
||||
if not String(e[2]).contains("标空") or not String(e[2]).contains("受挖掘惩罚") or String(e[2]).contains("插错") or String(e[2]).contains("挖雷"):
|
||||
_problems.append("图例封印行旧词残留(wa5——%s)" % String(e[2]).replace("
|
||||
", "|"))
|
||||
if not legend_has_seal:
|
||||
_problems.append("图例缺『封印』格态条目(格态图例基线)")
|
||||
main.legend_overlay.close()
|
||||
@@ -605,6 +768,46 @@ func _run_modal_probes_once() -> void:
|
||||
var bal_txt: String = main.status_panel._balance_badge.value_label.text
|
||||
if not bal_txt.begins_with("钥石 "):
|
||||
_problems.append("状态面板余额徽投影缺失(%s)" % bal_txt)
|
||||
# ---- 本局信息面板换代断言(迭代36 卡③——真实入口三断言)----
|
||||
# ① 面板子树零旧段文案(跨关概览退役零残留)
|
||||
var sv_labels: Array = []
|
||||
_collect_labels_by_text(main.status_panel, sv_labels)
|
||||
for l in sv_labels:
|
||||
if String(l.text).contains("道具工坊") or String(l.text).contains("天赋树"):
|
||||
_problems.append("状态面板子树残留旧段文案「%s」(sv6①——零旧段失守)" % String(l.text))
|
||||
# ② 属性行全值 = 契约4 格式 % runner 五现值(main 侧投影值层真链路)
|
||||
var rnr = main.run_scope.runner
|
||||
if rnr == null:
|
||||
_problems.append("状态面板开层时 runner 空指针(契约5——状态机缺陷,禁软回退)")
|
||||
else:
|
||||
var attr_expect := "生命 %d/%d · 攻击 %d · 护甲 AC %d · GP %d" % [
|
||||
int(rnr.hero_hp), rnr.effective_max_hp(), rnr.effective_atk(),
|
||||
rnr.effective_ac(), int(rnr.gp)]
|
||||
if String(main.status_panel._attrs_label.text) != attr_expect:
|
||||
_problems.append("状态面板属性行投影失真(sv6②——期望 %s 实测 %s)" % [attr_expect, main.status_panel._attrs_label.text])
|
||||
# ③ _status_data() 直调对拍(sv7——键集恰八键+curios/feats 独立 oracle)
|
||||
var sd: Dictionary = main._status_data()
|
||||
var keys := ["balance", "hero_hp", "max_hp", "atk", "ac", "gp", "curios", "feats"]
|
||||
var keys_ok: bool = (sd as Dictionary).size() == 8
|
||||
for k in keys:
|
||||
keys_ok = keys_ok and (sd as Dictionary).has(k)
|
||||
if not keys_ok:
|
||||
_problems.append("_status_data() 键集≠八键(sv7——实测 %s)" % str((sd as Dictionary).keys()))
|
||||
var oracle: Array = []
|
||||
for rec in rnr.treasure_inventory.query_inventory(Callable()):
|
||||
var rr: Dictionary = rec
|
||||
oracle.append({"name": String(main.db.treasure_item_row(String(rr.get("item_id", ""))).get("display_name", "")),
|
||||
"used": bool(rr.get("used", false))})
|
||||
var sd_curios: Array = (sd as Dictionary).get("curios", [])
|
||||
var curios_ok: bool = (sd_curios as Array).size() == (oracle as Array).size()
|
||||
if curios_ok:
|
||||
for i in (oracle as Array).size():
|
||||
curios_ok = curios_ok and String((sd_curios[i] as Dictionary).get("name", "")) == String((oracle[i] as Dictionary).get("name", "")) and bool((sd_curios[i] as Dictionary).get("used", false)) == bool((oracle[i] as Dictionary).get("used", false))
|
||||
if not curios_ok:
|
||||
_problems.append("_status_data().curios 对拍独立 oracle 失守(sv7——实测 %s vs %s)" % [str(sd_curios), str(oracle)])
|
||||
var feats_expect: Array = load("res://presentation/hud_presenter.gd").feat_holdings_lines(main.db, rnr.feats_held())
|
||||
if str((sd as Dictionary).get("feats", [])) != str(feats_expect):
|
||||
_problems.append("_status_data().feats 对拍 feat_holdings_lines 失守(sv7)")
|
||||
var resume_btn: Button = ProbeUiUtil.find_button(main.status_panel, "继续", "prefix")
|
||||
if resume_btn == null:
|
||||
_problems.append("状态面板关闭钮缺失(「继续」)")
|
||||
@@ -660,6 +863,23 @@ func _run_modal_probes_once() -> void:
|
||||
_problems.append("HUD 刷新余量与快照推导失符(HUD='%s' 应含 %d)" % [main.ap_bar.refresh_remaining_text(), remain_expect])
|
||||
|
||||
|
||||
# ---- 营地灰字取色断言辅助(迭代36 wc4——文本锚定位,全 Label 递归收集)----
|
||||
|
||||
func _collect_labels_by_text(root: Node, out: Array) -> void:
|
||||
if root is Label:
|
||||
out.append(root)
|
||||
for child in root.get_children():
|
||||
_collect_labels_by_text(child, out)
|
||||
|
||||
|
||||
func _labels_with_text(labels: Array, needle: String) -> Array:
|
||||
var found: Array = []
|
||||
for l in labels:
|
||||
if String(l.text).contains(needle):
|
||||
found.append(l)
|
||||
return found
|
||||
|
||||
|
||||
func _play_one_action() -> void:
|
||||
var settlement = main.run_scope.settlement
|
||||
if settlement == null:
|
||||
|
||||
@@ -58,10 +58,22 @@ func _pairs() -> Array:
|
||||
["设置·选项标签/设置板", UiKit.INK_LABEL, UiKit.PANEL_DATA, 1.0], # 设置行标签/状态字(标签深墨)
|
||||
["三选一·停点头部墨/暗页底", UiKit.WARM_SOFT, UiKit.BG_ROOT, 1.0], # 三选一停点头部/回血反馈行(page_shell 根底档)
|
||||
["三选一·卡名墨/货架板", UiKit.INK_TITLE, UiKit.PANEL_DATA, 1.0], # bcard 选项卡名(与商店货架同构卡)
|
||||
["迷雾·记忆墨/灰罩合成底", UiKit.FOG_MEMORY_INK, # fog_state2 记忆板 α.66 压 floor avg #453020
|
||||
Color(UiKit.FOG_MEMORY_PLATE.r * 0.66 + 69.0 / 255.0 * 0.34, # 合成底(素材侧值无常量形态——取 art-bible §389 在册复算值,素材更新随动;
|
||||
UiKit.FOG_MEMORY_PLATE.g * 0.66 + 48.0 / 255.0 * 0.34, # 三关逐关 avg 缺在册且素材不入 git 不可实查——最坏情况判据扩对归移交)
|
||||
UiKit.FOG_MEMORY_PLATE.b * 0.66 + 32.0 / 255.0 * 0.34), 1.0],
|
||||
# ---- 迷雾测点三关扩对(迭代36 wc5——甲·归一后基准:行值=三关归一后(真实渲染面)
|
||||
# 地板均值合成底(写卡会话 PIL 复算+两轮 grilling 独立复算吻合;T0 探针派生首跑对拍
|
||||
# L2/L3 修值(PIL↔Godot 导入解码系统差约 5/255,卡内 L2 条款预授权以派生为准);
|
||||
# 素材更新随动——一致性由 _ready 派生校验断言钉住,行值回原图基准或素材换代未随动均红)----
|
||||
["迷雾·记忆墨/合成底L1地牢", UiKit.FOG_MEMORY_INK,
|
||||
Color(UiKit.FOG_MEMORY_PLATE.r * 0.66 + 111.0 / 255.0 * 0.34, # L1 归一后地板均值 #6F4D33(合成底 #4C4038 5.71:1·最差关)
|
||||
UiKit.FOG_MEMORY_PLATE.g * 0.66 + 77.0 / 255.0 * 0.34,
|
||||
UiKit.FOG_MEMORY_PLATE.b * 0.66 + 51.0 / 255.0 * 0.34), 1.0],
|
||||
["迷雾·记忆墨/合成底L2幽暗", UiKit.FOG_MEMORY_INK,
|
||||
Color(UiKit.FOG_MEMORY_PLATE.r * 0.66 + 74.0 / 255.0 * 0.34, # L2 归一后地板均值 #4A4F60(合成底 #3F4147 5.83:1)
|
||||
UiKit.FOG_MEMORY_PLATE.g * 0.66 + 79.0 / 255.0 * 0.34,
|
||||
UiKit.FOG_MEMORY_PLATE.b * 0.66 + 96.0 / 255.0 * 0.34), 1.0],
|
||||
["迷雾·记忆墨/合成底L3深幽", UiKit.FOG_MEMORY_INK,
|
||||
Color(UiKit.FOG_MEMORY_PLATE.r * 0.66 + 71.0 / 255.0 * 0.34, # L3 归一后地板均值 #474B63(合成底 #3E4048 5.93:1)
|
||||
UiKit.FOG_MEMORY_PLATE.g * 0.66 + 75.0 / 255.0 * 0.34,
|
||||
UiKit.FOG_MEMORY_PLATE.b * 0.66 + 99.0 / 255.0 * 0.34), 1.0],
|
||||
]
|
||||
|
||||
|
||||
@@ -94,8 +106,9 @@ func _ready() -> void:
|
||||
if r < MIN_RATIO:
|
||||
problems.append("[contrast] %s = %.2f:1 < %.1f(测点对须垫牌或换墨)" % [p[0], r, MIN_RATIO])
|
||||
problems.append_array(_scan_static())
|
||||
problems.append_array(_fog_derived_check())
|
||||
if problems.is_empty():
|
||||
print("[ui_contrast_probe] PASS — 对比度 %d 对测点全绿(≥%.1f:1 无区域例外)+ 裸色白名单/preload 红线静态段绿" % [pairs.size(), MIN_RATIO])
|
||||
print("[ui_contrast_probe] PASS — 对比度 %d 对测点全绿(≥%.1f:1 无区域例外)+ 裸色白名单/preload 红线静态段绿 + 迷雾三关派生校验绿" % [pairs.size(), MIN_RATIO])
|
||||
get_tree().quit(0)
|
||||
else:
|
||||
for msg in problems:
|
||||
@@ -104,6 +117,52 @@ func _ready() -> void:
|
||||
get_tree().quit(1)
|
||||
|
||||
|
||||
# ---- 迷雾测点行值一致性派生校验(迭代36 wc5——行值 vs 运行时按生产归一函数实算的
|
||||
# 三关归一后地板均值,容差 ±2/255=8-bit 量化级;行值回原图基准或素材换代未随动均红)----
|
||||
func _fog_derived_check() -> Array:
|
||||
var problems: Array = []
|
||||
var rows := [
|
||||
["L1地牢", "dungeon", Color8(111, 77, 51)],
|
||||
["L2幽暗", "underdark", Color8(74, 79, 96)],
|
||||
["L3深幽", "deep_underdark", Color8(71, 75, 99)],
|
||||
]
|
||||
for row in rows:
|
||||
var derived := _norm_floor_avg(row[1])
|
||||
var expect: Color = row[2]
|
||||
if absi(derived.r8 - expect.r8) > 2 or absi(derived.g8 - expect.g8) > 2 or absi(derived.b8 - expect.b8) > 2:
|
||||
problems.append("[contrast] 迷雾测点行值失真:%s 行值 (%d,%d,%d) vs 运行时归一派生 (%d,%d,%d)(±2/255——素材更新须随动改行值)"
|
||||
% [row[0], expect.r8, expect.g8, expect.b8, derived.r8, derived.g8, derived.b8])
|
||||
return problems
|
||||
|
||||
|
||||
# 迷雾测点派生助手(迭代36 wc5 契约 3——全仓唯一「按板型算归一后均值」判据面):纹理路径经
|
||||
# BoardView.TILE_FLOOR_FMT 常量(禁复制字面量)、归一经 BoardView._normalize_floor_luma
|
||||
# 生产静态函数直呼(真渲染同源——禁探针内复制 k 公式);逐像素归一后均值再五变体平均。
|
||||
static func _norm_floor_avg(board_type: String) -> Color:
|
||||
var sum_r := 0.0
|
||||
var sum_g := 0.0
|
||||
var sum_b := 0.0
|
||||
var px := 0
|
||||
for i in range(1, BoardView.TILE_FLOOR_VARIANTS + 1):
|
||||
var tex := UiKit.load_tex(BoardView.TILE_FLOOR_FMT % [board_type, i])
|
||||
if tex == null:
|
||||
continue
|
||||
var img: Image = BoardView._normalize_floor_luma(tex, 0.10, null).get_image()
|
||||
if img.is_compressed():
|
||||
img.decompress()
|
||||
img.convert(Image.FORMAT_RGBA8)
|
||||
for y in range(img.get_height()):
|
||||
for x in range(img.get_width()):
|
||||
var c := img.get_pixel(x, y)
|
||||
sum_r += c.r
|
||||
sum_g += c.g
|
||||
sum_b += c.b
|
||||
px += 1
|
||||
if px == 0:
|
||||
return Color(-1, -1, -1) # 素材缺位=派生校验必红(禁跳过——契约 3 齐盘条款)
|
||||
return Color(sum_r / px, sum_g / px, sum_b / px)
|
||||
|
||||
|
||||
# ---- 机检段②③(需求卡片 §7):呈现层裸 Color8( 计数 vs 白名单 + 素材 preload 计数 = 0 ----
|
||||
func _scan_static() -> Array:
|
||||
var problems: Array = []
|
||||
|
||||
@@ -43,6 +43,11 @@ func _ready() -> void:
|
||||
else:
|
||||
_settle()
|
||||
await _audit("camp", [main.camp_overlay])
|
||||
# 营地灰字源 token 扫描(迭代36 wc4 静态半——源内零裸 UiKit.STONE;判别式=
|
||||
# STONE 出现次数 == STONE_DEEP 出现次数,即每处 STONE 都带 _DEEP 后缀)
|
||||
var camp_src := FileAccess.get_file_as_string("res://presentation/views/camp_overlay_view.gd")
|
||||
if camp_src.count("UiKit.STONE") != camp_src.count("UiKit.STONE_DEEP"):
|
||||
problems.append("[camp] camp_overlay_view.gd 存在裸 UiKit.STONE(非 _DEEP 后缀——wc4 源扫描)")
|
||||
main._on_camp_closed()
|
||||
_settle()
|
||||
main._on_save_select_closed() # 关闭被栈还原的选档层(动线序中间态——卡④ 换代点)
|
||||
@@ -252,11 +257,12 @@ func _ready() -> void:
|
||||
get_tree().quit(1)
|
||||
|
||||
|
||||
# ⑦resume 夹具:最小合法断点快照——bonus_pending 非空=三选一停点分支(合计 gp=24 供守卫兜底断言)
|
||||
# ⑦resume 夹具:最小合法断点快照(含生成图代差标记键)——bonus_pending 非空=三选一停点分支(合计 gp=24 供守卫兜底断言)
|
||||
func _resume_bp_fixture() -> Dictionary:
|
||||
return {
|
||||
"master_seed": 42, "rng_counter": 0, "hero_hp": 20, "gp": 24, "keystone": 0,
|
||||
"level_index": 1, "bonus": {}, "shop_purchased": {}, "relics": [],
|
||||
"graph_gen_v2": 1, # 生成图换代代差标记键(缺键=换代前旧断点,resume_adventure 显式拒绝)
|
||||
"keystone_drops": [], "bonus_taken": {},
|
||||
"bonus_pending": {"candidates": [{"kind": "heal", "tier": "common"}], "payload": {"gp_dropped": 0}},
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ func _initialize() -> void:
|
||||
"encounter_max_hp_seq": st["encounter_max_hp_seq"],
|
||||
"reward_events": st["reward_events"],
|
||||
"shop_gp_seq": st["shop_gp_seq"],
|
||||
"level_templates": st.get("level_templates", []),
|
||||
"level_maps": st.get("level_maps", []),
|
||||
"wallet_count": (st["wallet_events"] as Array).size(),
|
||||
}
|
||||
if shop_mode == "on":
|
||||
|
||||
+11
-11
@@ -9,7 +9,7 @@
|
||||
# 策略档 policy:"normal"(默认)/ "perfect"(零盲挖/全封印/卡死刷新——上界测量仪,求解器与
|
||||
# 动作语义见 auto_player.gd 完美策略档节)。
|
||||
# 选路驱动(零自动推进):兑付确认/战斗胜利后 runner 进选路态,驱动循环按 route 策略显式
|
||||
# pick_next_node;route = "min"/"max"(候选节点编号最小/最大——确定性路径轴,零随机流自开;默认 "min")。
|
||||
# pick_next_node;route = "min"/"max"(候选节点句柄最小/最大——确定性路径轴,零随机流自开;默认 "min")。
|
||||
# run_adventure 三关连探驱动(AdventureRunner——主种子恰一次、通关兑付停点确认、adventure_finished
|
||||
# 置 result/summary;外层每关重取 runner 实例经 _drive_level 复用单关驱动)。
|
||||
# 大奖励停点 = 三选一驱动(候选取首件/替换放弃新件)。
|
||||
@@ -34,7 +34,7 @@ const NORMAL_SKIP_DIG_P := 0.05
|
||||
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:
|
||||
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()
|
||||
@@ -60,7 +60,7 @@ static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct:
|
||||
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):
|
||||
if not runner.start_level(level_index, seed_val, {}):
|
||||
st.result = "start_failed"
|
||||
runner.restore_hero_base()
|
||||
if own_session:
|
||||
@@ -138,7 +138,7 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_
|
||||
"prng": _policy_rng(seed_val), "mine_slips": 0, "skip_digs": 0, "shop_buys": 0,
|
||||
"adv": true, # v3 档位行为开关(选路评分/失误注入/商店购买只挂冒险测量面——单关夹具语义不变)
|
||||
|
||||
"level_templates": [], # 逐关图模板名(关卡节点图生成 v0.4 分层出数)
|
||||
"level_maps": [], # 逐关节点图标识(生成图 = 关卡序号+本局主种子——MC 分层出数)
|
||||
}
|
||||
adr.step_row.connect(_on_step_row.bind(adr, st, collect_rows))
|
||||
adr.node_entered.connect(_on_adventure_node_entered.bind(adr, st, logger))
|
||||
@@ -150,7 +150,7 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_
|
||||
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, "first_script": false}
|
||||
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))
|
||||
@@ -197,9 +197,9 @@ static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr,
|
||||
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 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
|
||||
@@ -208,7 +208,7 @@ static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr,
|
||||
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.boss_depth = int(node.get("depth", -1))
|
||||
st.hp_at_boss = int(adr.ledger["hero_hp"])
|
||||
elif t == "reward":
|
||||
# 奖励节点三选一(共享池确定性策略;TP0 起 316-f 判读:1 级最稀有、
|
||||
@@ -376,7 +376,7 @@ static func _policy_rng(seed_val: int) -> RandomNumberGenerator:
|
||||
|
||||
|
||||
# 路径轴选路(目标价值评分制):候选按节点类型评分取最高,
|
||||
# 同分 tie-break 沿 route 方向(min=小编号/max=大编号——verifier 双窗轴保持)。
|
||||
# 同分 tie-break 沿 route 方向(min=小句柄/max=大句柄——verifier 双窗轴保持)。
|
||||
# 评分语义(确定性,零随机流):rest=伤重高值(缺口比例×100)/shop=买得起高值(GP 局内死钱,
|
||||
# 真人实证两店全购)/reward=占位低值(MC 夹具解锁空集=零兑付直通)/boss=缓冲低值(有店有_rest
|
||||
# 先补给再打)/battle=基线。编号取 runner.next_candidate_ids()(编号空间解析单一出口)。
|
||||
@@ -478,7 +478,7 @@ static func _on_node_entered(node: Dictionary, info: Dictionary, runner, st: Dic
|
||||
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.boss_depth = int(node.get("depth", -1))
|
||||
st.hp_at_boss = runner.hero_hp
|
||||
elif t == "reward":
|
||||
runner.continue_after_node() # 奖励点 = 内容未定直通节点
|
||||
|
||||
+22
-1
@@ -1,4 +1,4 @@
|
||||
# Test Infrastructure — Dungeon Sweeper
|
||||
# Test Manual — Dungeon Sweeper(测试工具手册)
|
||||
|
||||
**Engine**: Godot 4.7.2-stable(GDScript 单语言)
|
||||
**Test Framework**: 标准四级测试层(冒烟/单元/集成/系统)+ headless 场景探针与渲染 golden;gdUnit4 **deferred**(D2 OQ1 关闭形态——本仓库不引第三方测试框架,需求见下「门禁」节)
|
||||
@@ -6,6 +6,11 @@
|
||||
**Setup date**: 2026-09-12(2026-09-27 `7f24b640` 三层收敛:根 tests/ 并入 `game/tests/`、docs 并入 `design/engineering/`、production 解散)
|
||||
**参考实现**: `game/probe/`(场景探针族)· `game/sim/`(测量/诊断/模拟驱动件)(场景探针族——正式工程的活样板;系统测试断言面过渡宿主 `smoke_all.gd` 亦在此,迁移归断言迁移批)
|
||||
|
||||
本手册是测试面每件工具的介绍处:**它是什么、判什么、怎么单独跑、结果由谁判、什么时候会用它**。
|
||||
分工三线——介绍归本文;**规则与时机**(什么时候必须跑哪些测试、测试怎么写、怎么算合格)归
|
||||
`design/engineering/test-conventions.md`;**收口验收跑哪几道闸**(闸清单与改动域划分)只在
|
||||
`tools/run_all_gates.sh` 脚本内定义一处,本手册不另列闸清单。
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
@@ -47,6 +52,21 @@ tests/
|
||||
|
||||
两口径数值不同且各自浮动,禁互相换算或写死。
|
||||
|
||||
## 工具速查(逐件:判什么 / 结果谁判 / 什么时候用)
|
||||
|
||||
| 工具 | 判什么 | 结果谁判 | 什么时候会用它 |
|
||||
|---|---|---|---|
|
||||
| `tools/run_smoke.py`(冒烟测试) | 这版能否启动、能否加载表、能否进主屏——零逻辑判定 | 退出码+脚本错误扫描 | 开机路径/表加载改动后的快速自检;收口由闸编排 |
|
||||
| `tools/run_system_test.py`(系统测试) | 真实玩法路径的行为断言面(推理→战斗→结算→进度推进) | 退出码+脚本错误扫描 | 玩法链路改动的调试期确认 |
|
||||
| `tests/run_all.gd`(单元与集成) | 公式/状态机/契约/跨系统闭环(40 套件 manifest) | 退出码+三硬闸(见下) | 新套件单跑调试;逻辑改动的最小单元复跑 |
|
||||
| `tools/probe_gate.sh`(场景探针,`--click`/`--full` 两模式) | UI 可达性、可见性、状态——真实实例化场景+真实输入路径 | 探针自检+退出码 | 新增 UI 区域的配套探针直跑与断言复核 |
|
||||
| `tools/render_gate.sh`(渲染 golden) | 画面基线——截图与基线图逐像素容差比对 | golden 比对脚本(依赖 PIL) | 主题/素材/排版类改动;窗口化运行,需开显示器 |
|
||||
| `game/tests/static_scan.sh`(S 域) | 项目纪律+第三方质量规则:RNG 单点/core 纯度/类型标注/文件头/注释禁词/旧符号残留;S13=gdstyle 质量规则(重复 load、循环内 await、循环内分配、不可达代码等真缺陷类,存量基线外新违规才红) | 退出码(逐规则 FAIL 行) | 改产品码前后的快速自检;新增纪律先补扫描规则再修码 |
|
||||
| `tools/parity_anchor.py`(回放对拍) | 同种子下三变体逐位确定性 | 工具脚本逐位比对 | 本批触及数值/存档/RNG/结算面时;须用户批准发起 |
|
||||
| 测量件(`game/sim/`,逐件见下「游离脚本台账」) | 期望/分布读数——只给读数,不判对错 | 人工读 stdout 或配套工具脚本 | 调参与归因;MC 类一律用户发起(档位表见 `sim-data/README.md`「MC 采集档位表」) |
|
||||
|
||||
> 单件直跑是调试期最窄确认手段,**不构成验收**——验收只有 `tools/run_all_gates.sh` 一个入口(判定三硬闸见下「本地闸」节)。
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
@@ -121,6 +141,7 @@ timeout 120s godot --headless --script tests/run_all.gd
|
||||
|
||||
> **纪律**:本表是这些件的唯一在册处。新增手动测量脚本必须同批登记(用途 + 触发方式 + 结果由谁判);
|
||||
> 退役走 `design/engineering/doc-conventions.md` 第 10 节退役规则,禁止「没看见=死件」式清理。
|
||||
> 测量类批量的发起规则(档位与批准)见 `sim-data/README.md`「MC 采集档位表」——无数值改动禁重跑。
|
||||
|
||||
## Static Scan(提交前本地跑)
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ func _feats_breakpoint_roundtrip() -> void:
|
||||
var live_rows: FeatTable = _db.feats
|
||||
_db.feats = rt
|
||||
var a_r = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(72))
|
||||
a_r.start_adventure(72, {"first_script": true}) # 源 :723(受理断言留单元宿主)
|
||||
a_r.start_adventure(72) # 源 :723(受理断言留单元宿主)
|
||||
a_r._curio_pending = {"candidates": [{"kind": "relic", "tier": "rare", "feat_id": "x3"}, {"kind": "gp", "tier": "common"}], "payload": {}}
|
||||
a_r.pick_curio("relic", "rare", "x3") # 源 :781(兑付断言留单元宿主)
|
||||
# 断点往返(持有集+余库 feat_id 随行)
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
# tests/integration/node_map_templates_test.gd — L2 集成用例:关卡节点图生成·模板池
|
||||
# 断言面 = 模板池套(批 B 断言迁移自 smoke_all.gd `_smoke_node_map_templates`,G12 施工组;
|
||||
# tests/integration/node_map_templates_test.gd — L2 集成用例:关卡节点图生成·确定性承载
|
||||
# 断言面 = 生成图确定性套(批 B 断言迁移自 smoke_all.gd,G12 施工组;
|
||||
# 主会话裁决六整套集成不拆——跨四系统且走落盘)。
|
||||
# 跨系统面 = RngService(node_map 用途标签/抽签确定性/零消耗计账)×
|
||||
# LevelRunner(start_level_external 受理/模板 0 回滚档)×
|
||||
# AdventureRunner(first_script 全关零抽签/逐关恰一抽签)×
|
||||
# 跨系统面 = RngService(纯派生流零计数器占用)×
|
||||
# LevelRunner(start_level/start_level_external 受理·生成图装配·单一图源)×
|
||||
# AdventureRunner(开局种子承载·断点写读后同图)×
|
||||
# SaveManager+MetaProgression(首次冒险布尔位收束置位落盘——真实档写盘)。
|
||||
# 与单元侧同入口同协议:extends SceneTree+Kit.check 主通道;--script 模式无 autoload,
|
||||
# ConfigDB 显式实例化构造注入;表加载失败不中止(沿被迁断言面的源语义——诊断展开,红由断言落)。
|
||||
# ConfigDB 显式实例化构造注入;表加载失败不中止(沿被迁断言面的源语义——诊断展开,红由具体断言落)。
|
||||
# 运行:godot --headless --path game --script res://tests/integration/node_map_templates_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const Kit := preload("res://tests/unit/test_kit.gd")
|
||||
const SaveManagerScript := preload("res://core/save_manager.gd")
|
||||
const AdventureRunnerScript := preload("res://core/adventure_runner.gd")
|
||||
|
||||
var _db
|
||||
var _bp_path := "user://smoke_node_map_save.json"
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
@@ -24,84 +27,121 @@ func _init() -> void:
|
||||
for d in _db.diagnostics():
|
||||
if d.severity == "error" or d.severity == "fatal":
|
||||
push_error(" [%s] %s: %s" % [d.severity, d.category, d.message])
|
||||
_smoke_node_map_templates()
|
||||
_smoke_generated_map_determinism()
|
||||
quit(Kit.finish("node_map_templates_test"))
|
||||
|
||||
|
||||
# ---- 关卡节点图生成·模板池冒烟(v0.4 §P0.6 断言面)----
|
||||
func _smoke_node_map_templates() -> void:
|
||||
# ① 扩签:node_map 入用途标签封闭词表(派生零改动——标签只进事件日志)
|
||||
var svc = load("res://core/rng_service.gd").new()
|
||||
Kit.check("node_map 标签入 rng_service 封闭词表(扩签通道=改代码+冒烟守卫)",
|
||||
(svc.PURPOSE_TAGS as Array).has("node_map"))
|
||||
# ② 池参数:三关各 5 模板、模板 0 存在(回滚档成员)
|
||||
for li in range(1, 4):
|
||||
var row := {}
|
||||
for r in _db.level.rows:
|
||||
if int(r.level_index) == li:
|
||||
row = r
|
||||
var tpls: Array = row.get("templates", [])
|
||||
Kit.check("L%d 模板池 = 5 张(拍板点④——首版池规模)" % li, tpls.size() == 5)
|
||||
Kit.check("L%d 模板 0 = 现行固定图(回滚档成员)" % li,
|
||||
not tpls.is_empty() and String(tpls[0].get("display_name", "")).ends_with("(现行)"))
|
||||
# ③ 强制取 0 = 模板 0 节点表逐位相等 + 零 scope 消费
|
||||
var rng0 = load("res://core/rng_service.gd").new()
|
||||
rng0.new_adventure(910001)
|
||||
var runner0 = load("res://core/level_runner.gd").new(_db, rng0)
|
||||
var c_before: int = rng0.counter()
|
||||
Kit.check("强制 template_pick=0 起关受理", runner0.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, # start_level_external 签名收口(bonus 参数由 RunLedger 单类下传)
|
||||
Callable(self, "_smoke_rest_stub"), {}, true, 0))
|
||||
Kit.check("强制取模板零 RNG 消耗(零抽签——首次脚本语义)", rng0.counter() == c_before)
|
||||
var tpl0: Dictionary = {}
|
||||
for r in _db.level.rows:
|
||||
if int(r.level_index) == 2:
|
||||
tpl0 = r.get("templates", [])[0]
|
||||
Kit.check("强制 0 的生效节点表 = 模板 0 逐位相等(回滚档成立)",
|
||||
runner0.level_row.get("nodes", []) == tpl0.get("nodes", []))
|
||||
# ④ 抽签确定性:同种子两实例同模板(同种子同操作序列 → 逐位同图)
|
||||
var names := []
|
||||
for i in range(2):
|
||||
var rg = load("res://core/rng_service.gd").new()
|
||||
rg.new_adventure(910002)
|
||||
var rn = load("res://core/level_runner.gd").new(_db, rg)
|
||||
rn.start_level_external(2, {"hero_hp": 20, "gp": 0}, {},
|
||||
Callable(self, "_smoke_rest_stub"), {}, true, -1)
|
||||
names.append(rn.active_template_name)
|
||||
Kit.check("抽签确定性:同种子逐位同图(实测 %s)" % String(names[0]),
|
||||
names[0] == names[1] and String(names[0]) != "")
|
||||
# ⑤ N=1 池与现行固定图等价:单模板行过图校验(抽签取模 1 恒 0 = 回滚档)
|
||||
var lt = load("res://data/schemas/level_table.gd").new()
|
||||
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30,
|
||||
"templates": [tpl0]}])
|
||||
Kit.check("N=1 池表通过图校验(回滚档形态合法)", lt.validate().is_empty())
|
||||
# ⑥ 首次脚本分支:first_script=true 全关模板 0;false = 抽签(scope 恰一差异)
|
||||
var rngF = load("res://core/rng_service.gd").new()
|
||||
rngF.new_adventure(910003)
|
||||
var adrF = load("res://core/adventure_runner.gd").new(_db, rngF)
|
||||
adrF.start_adventure(910003, {"first_script": true})
|
||||
Kit.check("首次脚本模式:L1 取模板 0(全关零抽签——存档布尔位 false 语义)",
|
||||
String(adrF.runner().active_template_name).ends_with("(现行)"))
|
||||
var cF: int = rngF.counter()
|
||||
var rngT = load("res://core/rng_service.gd").new()
|
||||
rngT.new_adventure(910003)
|
||||
var adrT = load("res://core/adventure_runner.gd").new(_db, rngT)
|
||||
adrT.start_adventure(910003, {"first_script": false})
|
||||
Kit.check("抽签模式恰多消费 1 scope(node_map 逐关恰一——L1 起关抽签)", rngT.counter() == cF + 1)
|
||||
Kit.check("抽签模式 active_template_name 有值(MC 分层出数取数面在位)",
|
||||
String(adrT.runner().active_template_name) != "")
|
||||
# ---- 关卡节点图生成·确定性承载断言面 ----
|
||||
func _smoke_generated_map_determinism() -> void:
|
||||
# ① 纯函数复现:图 = f(参数档, 种子)——同档同种子两次产图逐位同图,换种子必变图
|
||||
var gen = NodeMapGenerator.new(_fresh_rng(910001))
|
||||
var params2: Dictionary = _db.level_gen_params_row(2)
|
||||
var g_a: Dictionary = gen.generate(params2, 910002)
|
||||
var g_b: Dictionary = gen.generate(params2, 910002)
|
||||
var g_c: Dictionary = gen.generate(params2, 910003)
|
||||
var nodes_a: Array = g_a.get("nodes", [])
|
||||
Kit.check("同参数档同种子两次产图逐位同图(D2 执行边界)〔v7〕",
|
||||
nodes_a == (g_b.get("nodes", []) as Array))
|
||||
Kit.check("同参数档换种子即变图(图确为种子函数,非恒定输出)",
|
||||
nodes_a != (g_c.get("nodes", []) as Array))
|
||||
Kit.check("产图节点携图内唯一句柄 id 与自入口 depth(消费侧寻址面)",
|
||||
_not_empty_graph(nodes_a) and _handles_unique(nodes_a))
|
||||
# ② 消费侧单一图源:起关装配的图与生成器直取逐位一致(runner 内不存第二份图数据)
|
||||
var runner1 = load("res://core/level_runner.gd").new(_db, _fresh_rng(910004))
|
||||
Kit.check("单关起关受理(种子 910004——生成图直供,无兜底图)",
|
||||
runner1.start_level(2, 910004))
|
||||
Kit.check("起关生效图 = 生成器同档同种子直取逐位一致(消费侧无第二图源)〔v7〕",
|
||||
runner1.level_row.get("nodes", []) == (gen.generate(params2, 910004) as Dictionary).get("nodes", []))
|
||||
# ③ 产图零统一流消费(纯派生流不占计数器号位——断点在任意号位重生成同图的机制承载)
|
||||
var rng_c = _fresh_rng(910005)
|
||||
var counter_before: int = rng_c.counter()
|
||||
var runner2 = load("res://core/level_runner.gd").new(_db, rng_c)
|
||||
Kit.check("冒险模式起关受理(入口停点)", runner2.start_level_external(3,
|
||||
{"hero_hp": 20, "gp": 0}, {}, Callable(self, "_smoke_rest_stub"), {}, true))
|
||||
Kit.check("起关产图零计数器消费(同图不依赖取数号位)", rng_c.counter() == counter_before)
|
||||
var params3: Dictionary = _db.level_gen_params_row(3)
|
||||
var entry_count: int = int(params3.get("entry_count", -1))
|
||||
Kit.check("入口停点候选数=参数档入口个数(L3 档 %d——多入口首跳即选路,禁写死单入口)〔v2〕" % entry_count,
|
||||
entry_count >= 1 and (runner2.next_candidates() as Array).size() == entry_count)
|
||||
if entry_count >= 2:
|
||||
var eids: Array = runner2.next_candidate_ids()
|
||||
Kit.check("第二入口经句柄显式进入(句柄 %d)〔v2〕" % int(eids[1]),
|
||||
runner2.pick_next_node(int(eids[1])) and int(runner2.current_node_id()) == int(eids[1]))
|
||||
# ④ 开局种子承载:新档(首局)种子 42 两次开局逐位同图,且与生成器直取一致
|
||||
DirAccess.remove_absolute(_bp_path) # 测试隔离——上轮落盘残留先删,断点面从零档起
|
||||
var sv_w = SaveManagerScript.new(_bp_path)
|
||||
var adr_x = AdventureRunnerScript.new(_db, _fresh_rng(42))
|
||||
adr_x.set_save_manager(sv_w) # 先挂档再开局——停点快照由真实写手落盘
|
||||
Kit.check("首局口径种子 42 开局受理(defer_entry 路径)", adr_x.start_adventure(42,
|
||||
{"hero_override": {}, "treasure_unlocked": {}}))
|
||||
var adr_y = AdventureRunnerScript.new(_db, _fresh_rng(42))
|
||||
Kit.check("同种子第二次开局受理", adr_y.start_adventure(42,
|
||||
{"hero_override": {}, "treasure_unlocked": {}}))
|
||||
Kit.check("种子 42 两次开局图逐位同图(教学局恒同图——首局固定种子的确定性承载)〔v7〕",
|
||||
adr_x.runner().nodes() == adr_y.runner().nodes())
|
||||
Kit.check("开局图 = L1 参数档在种子 42 下的生成图(开局无隐式取数分叉)",
|
||||
adr_x.runner().nodes() == (gen.generate(_db.level_gen_params_row(1), 42) as Dictionary).get("nodes", []))
|
||||
# ⑤ 断点写→恢复→再产图同图(续玩按主种子+参数档重生成,断点不存图快照)
|
||||
Kit.check("停点断点落盘在档", sv_w.has_breakpoint())
|
||||
var bp: Dictionary = sv_w.breakpoint_snapshot()
|
||||
var adr_z = AdventureRunnerScript.new(_db, load("res://core/rng_service.gd").new())
|
||||
Kit.check("断点续玩受理(代差标记键在档)〔v8〕", adr_z.resume_adventure(bp,
|
||||
{"hero_override": {}, "treasure_unlocked": {}}))
|
||||
Kit.check("续玩重生成图与中断前逐位同图〔v7〕", adr_z.runner().nodes() == adr_x.runner().nodes())
|
||||
var adr_old = AdventureRunnerScript.new(_db, load("res://core/rng_service.gd").new())
|
||||
var bp_old: Dictionary = bp.duplicate(true)
|
||||
bp_old.erase("graph_gen_v2") # 换代前旧断点形态(模板池时代无代差标记键)
|
||||
Kit.check("缺生成图代差标记键的旧断点被显式拒绝(禁按新语义静默回放)〔v8〕",
|
||||
adr_old.resume_adventure(bp_old, {}) == false)
|
||||
# ⑥ 关卡表不再携带图数据(图源换代后 level 行只剩行级字段)
|
||||
var tpl_free := true
|
||||
for li in range(1, _db.level_count() + 1):
|
||||
if (_db.level_row(li) as Dictionary).has("templates"):
|
||||
tpl_free = false
|
||||
Kit.check("level 表三行均无 templates 键(模板数据已清出)〔v9〕",
|
||||
tpl_free and _db.level_count() == 3)
|
||||
# ⑦ 收束置位:新档 false → settle 置 true 恰一次(幂等)
|
||||
DirAccess.remove_absolute("user://smoke_node_map_save.json") # 测试隔离——上轮 settle 写盘的残留档先删
|
||||
var sv = load("res://core/save_manager.gd").new("user://smoke_node_map_save.json")
|
||||
var sv = SaveManagerScript.new(_bp_path + ".flag.json")
|
||||
sv.load_from_disk()
|
||||
Kit.check("新档首次冒险布尔位 = false(默认域)", sv.is_first_adventure_done() == false)
|
||||
Kit.check("新档首次冒险布尔位 = false(默认域——首局恒走固定种子 42)",
|
||||
sv.is_first_adventure_done() == false)
|
||||
var mp = load("res://core/meta_progression.gd").new(_db, sv)
|
||||
mp.settle_adventure({"levels_cleared": 1, "keystone_total": 10, "cleared": false, "result": "failed"})
|
||||
Kit.check("冒险收束点置位(三结局同写——写入只在收束挂点)", sv.is_first_adventure_done() == true)
|
||||
mp.settle_adventure({"levels_cleared": 0, "keystone_total": 0, "cleared": false, "result": "failed"})
|
||||
Kit.check("置位幂等(重复收束不翻转)", sv.is_first_adventure_done() == true)
|
||||
DirAccess.remove_absolute(_bp_path)
|
||||
DirAccess.remove_absolute(_bp_path + ".flag.json")
|
||||
|
||||
|
||||
# ---- 冒烟恢复执行体(LevelRunner 注入用——与 AdventureRunner.apply_rest 同式占位;
|
||||
# 图数据良构前置(防空集假绿——节点数 > 1、句柄唯一、depth 与 next 键在位)
|
||||
func _not_empty_graph(nodes: Array) -> bool:
|
||||
if nodes.size() < 2:
|
||||
return false
|
||||
for node in nodes:
|
||||
var n := node as Dictionary
|
||||
if not n.has("id") or not n.has("depth") or not n.has("next"):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _handles_unique(nodes: Array) -> bool:
|
||||
var seen := {}
|
||||
for node in nodes:
|
||||
var id: int = int((node as Dictionary).get("id", -1))
|
||||
if id < 0 or seen.has(id):
|
||||
return false
|
||||
seen[id] = true
|
||||
return true
|
||||
|
||||
|
||||
func _fresh_rng(seed_val: int):
|
||||
var r = load("res://core/rng_service.gd").new()
|
||||
r.new_adventure(seed_val)
|
||||
return r
|
||||
|
||||
|
||||
# ---- 恢复执行体(LevelRunner 注入用——与 AdventureRunner.apply_rest 同式占位;
|
||||
# smoke_all.gd helper 的宿主自持私有副本——源文件收尾整体删除,宿主自给自足)----
|
||||
func _smoke_rest_stub(pct: int, hp: int, max_hp: int) -> int:
|
||||
return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp)
|
||||
@@ -54,6 +54,7 @@ func _suites() -> Array:
|
||||
{"path": "res://tests/unit/core/treasure_items_test.gd", "expect": 0}, # 秘宝结构冻结断言面
|
||||
{"path": "res://tests/unit/core/node_map_generator_test.gd", "expect": 0}, # 节点图生成器产出合格/可复现/岔路取舍/恰一店/归属锁定/战斗保底/出口结构/RNG 纯流面(卡① V1·V3·V5–V7·V9·V10)
|
||||
{"path": "res://tests/unit/core/node_map_growth_test.gd", "expect": 0}, # 生成图生长递增+参数无解 fail-fast 子进程通道(卡① V8/V4)
|
||||
{"path": "res://tests/unit/core/level_table_validate_test.gd", "expect": 0}, # 消费侧加载校验四检+良构基底正负向(卡② T1/V1〔v1〕)
|
||||
{"path": "res://tests/unit/core/breakpoint_resume_test.gd", "expect": 0}, # 断点往返/续玩确定性/幂等/退出清断点
|
||||
{"path": "res://tests/unit/core/curio_refresh_paid_test.gd", "expect": 0}, # 三选一刷新免费双源+GP 付费档(对齐商店 MC-1:价曲线/优先序/停点清零/钱包单口)
|
||||
{"path": "res://tests/unit/core/level_map_type_vis_test.gd", "expect": 0}, # 信息迷雾——类型可见性(层距 N+Boss/候选豁免+单圈+N 读表+续玩同值)
|
||||
@@ -86,7 +87,7 @@ func _suites() -> Array:
|
||||
{"path": "res://tests/unit/presentation/modal_flow_test.gd", "expect": 0}, # 模态编排纯逻辑(V6:直接 new/互斥域谓词/三例外锁/演播后排窗)
|
||||
# ---- L2 集成层(跨系统用例,与 unit/ 的隔离断言面分工)----
|
||||
{"path": "res://tests/integration/save_rng_roundtrip_test.gd", "expect": 0}, # 存档⇄RNG 快照往返(SaveManager 迁移链 × 断点消费 × RNG 续跑 × 停点态确定性)
|
||||
{"path": "res://tests/integration/node_map_templates_test.gd", "expect": 0}, # 关卡节点图生成·模板池(模板池跨系统×首冒险置位落盘——批 B 自 smoke_all 迁入,裁决六整套集成不拆)
|
||||
{"path": "res://tests/integration/node_map_templates_test.gd", "expect": 0}, # 关卡节点图生成·确定性承载(生成图复现×首冒险置位落盘——批 B 自 smoke_all 迁入,裁决六整套集成不拆)
|
||||
{"path": "res://tests/integration/cross_system_flow_test.gd", "expect": 0}, # 跨系统真实流×落盘(RNG 事件日志落盘/专长断点往返/存档摘要集成/试玩记录仪真实结算流——批 B 转办块宿主,裁决一/二/三/七)
|
||||
# ---- core 四件与呈现层两件的直接单测 ----
|
||||
{"path": "res://tests/unit/core/board_generator_test.gd", "expect": 0}, # 棋盘生成(尺寸合表/数字=八邻雷数/同种子逐位一致/可推理自洽)
|
||||
@@ -102,6 +103,7 @@ func _suites() -> Array:
|
||||
{"path": "res://tests/unit/presentation/battle_narrator_test.gd", "expect": 0}, # 战斗叙述(数值带入/胜负互异/收束态恒定)
|
||||
{"path": "res://tests/unit/presentation/type_glow_test.gd", "expect": 0}, # 未开格类型微光数据面(九型归组封闭/清单外显式失败/冻结色相只准动明度/α170·带宽cell÷8锚定/合成空间阶梯单调+模型CR≥1.20/probe API 形状)
|
||||
{"path": "res://tests/unit/presentation/theme_projection_test.gd", "expect": 0}, # 呈现投影与主题宿主(微光数据面/战斗日志行协议/全局主题与按钮五态/纹理过滤白名单/对比度墨色锚/元素细节源码锚/诊断钮退役源码契约/状态面板与生涯页只读投影——批 B 自 smoke_all 迁入;试玩记录仪整套按裁决七转办集成宿主)
|
||||
{"path": "res://tests/unit/presentation/player_copy_sweep_test.gd", "expect": 0}, # 玩家面文案清扫宿主(迭代36 卡A——钉值表换代断言①–⑧⑩+源 token 扫描两处;直建断言族,禁字「雷」族与旧术语清零钉值)
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -236,4 +236,19 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── S13 — 第三方质量规则闸(gdstyle v0.4.0,版本钉死)──
|
||||
# 规则说明自持本头注(随 S11/S12 惯例):gdstyle(github.com/atelico/gdstyle,Rust 单二进制)只开
|
||||
# quality/* 抓真缺陷(重复 load/循环内 await/循环内分配/不可达代码/自比较/字典重复键/残留
|
||||
# print 等),format/*/naming/*/order/* 与 S9/S10 职能重叠全关;扫描面=产品码三目录(与 S9 同口径,
|
||||
# probe/sim/tests 测量面直构惯例不在面)。存量基线 tools/gdstyle_baseline.txt(文件|规则|消息 三元组
|
||||
# ——消息含违规对象标识,行漂移稳定),只有基线外新违规才红;基线重生成=人工专用
|
||||
# (python tools/gdstyle_gate.py --refresh),禁清基线换绿。二进制本机固定路径
|
||||
# D:/code/gdstyle/gdstyle.exe(不入库),GDSTYLE_BIN 环境变量覆盖;版本与钉定版不符即红(升级须重测)。
|
||||
if python tools/gdstyle_gate.py; then
|
||||
echo "[static_scan] S13 ok"
|
||||
else
|
||||
echo "[static_scan] S13 FAIL: 基线外新违规或二进制/版本/扫描面问题(清单见上)"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
exit $fail
|
||||
@@ -290,7 +290,7 @@ func _smoke_encounter_roll() -> void:
|
||||
# 战斗结算 roll_lo 下限 + 敌人系统 spawn count<1 fail-fast 双闸;单深度单种子是盲区,全域扫描才钉死不变式)
|
||||
var depths: Array = []
|
||||
for node in _level_battle_nodes(1):
|
||||
depths.append(int(node.get("node_depth", -1)))
|
||||
depths.append(int(node.get("depth", -1)))
|
||||
var scan_bad := 0
|
||||
var scan_total := 0
|
||||
for i in range(12):
|
||||
@@ -317,22 +317,11 @@ func _smoke_encounter_roll() -> void:
|
||||
# 节点类型 → 敌人表 row_kind(非 Boss 战斗位恒 battle——深度带批寻址第三维)
|
||||
func _row_kind_of(level_index: int, depth: int) -> String:
|
||||
for n in _level_active_nodes(level_index):
|
||||
if int(n.get("node_depth", -1)) == depth and String(n.get("type", "")) == "boss":
|
||||
if int(n.get("depth", -1)) == depth and String(n.get("type", "")) == "boss":
|
||||
return "boss"
|
||||
return "battle"
|
||||
|
||||
|
||||
# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标)
|
||||
|
||||
|
||||
# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标)
|
||||
func _boss_depth(level_index: int) -> int:
|
||||
for n in _level_active_nodes(level_index):
|
||||
if String(n.get("type", "")) == "boss":
|
||||
return int(n.get("node_depth", -1))
|
||||
return -1
|
||||
|
||||
|
||||
# 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字
|
||||
|
||||
|
||||
@@ -350,18 +339,12 @@ func _first_encounter_band(level_index: int, enemy_id: String) -> int:
|
||||
return best
|
||||
|
||||
|
||||
# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义)
|
||||
|
||||
|
||||
# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义)
|
||||
# 生成图取数面(图 = f(关卡行参数档, 种子 42)——与 runner 起关同图;节点键 id/type/depth/next)
|
||||
func _level_active_nodes(level_index: int) -> Array:
|
||||
for row in _db.level.rows:
|
||||
if int(row.level_index) == level_index:
|
||||
var tpls: Array = row.get("templates", [])
|
||||
if not tpls.is_empty():
|
||||
return tpls[0].get("nodes", [])
|
||||
return row.get("nodes", [])
|
||||
return []
|
||||
var rng = load("res://core/rng_service.gd").new()
|
||||
rng.set_master_seed(42)
|
||||
var gen = NodeMapGenerator.new(rng)
|
||||
return (gen.generate(_db.level_gen_params_row(level_index), 42) as Dictionary).get("nodes", [])
|
||||
|
||||
|
||||
func _level_battle_nodes(level_index: int) -> Array:
|
||||
@@ -596,6 +579,16 @@ func _smoke_skill_pool_data() -> void:
|
||||
"3_battle_6_hook_horror": {"attack": 6, "bite": 2, "charge": 1},
|
||||
"3_battle_7_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2},
|
||||
"3_boss_9_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
|
||||
# 迭代 35 生成图深度扩面覆盖批八行=同关同 row_kind 顶档行的池克隆(零新池、零新技能引用):
|
||||
# 战斗带顶 L1→6/L2→9/L3→13;Boss 按终点深度分带 L1→6、L2→8、L3→11/12/13
|
||||
"1_battle_6_bugbear": {"attack": 6, "charge": 2, "heavy": 2},
|
||||
"1_boss_6_ogre_chief": {"attack": 6, "heavy": 2, "harden": 2, "quake_smash": 2},
|
||||
"2_battle_9_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2},
|
||||
"2_boss_8_drider": {"attack": 6, "heavy": 2, "venom_burst": 2, "web_snare": 2, "venom_curb": 2},
|
||||
"3_battle_13_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2},
|
||||
"3_boss_11_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
|
||||
"3_boss_12_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
|
||||
"3_boss_13_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
|
||||
}
|
||||
var pools_ok := true
|
||||
for row in _db.enemy.rows:
|
||||
@@ -608,7 +601,7 @@ func _smoke_skill_pool_data() -> void:
|
||||
got[String(entry.skill_id)] = int(entry.weight)
|
||||
if got != expect_pools[rk]:
|
||||
pools_ok = false
|
||||
Kit.check("二十行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕,其余带键仍恰一行)", pools_ok)
|
||||
Kit.check("二十八行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕;迭代 35 覆盖批八行为顶档池克隆,逐行同检)", pools_ok)
|
||||
var basic_count_ok := true
|
||||
for row in _db.enemy.rows:
|
||||
var n := 0
|
||||
|
||||
@@ -58,7 +58,7 @@ func _smoke_adventure_api() -> void:
|
||||
adr.level_started.connect(_api_mark_started.bind(sig_seq))
|
||||
adr.choices_presented.connect(_api_mark_choices.bind(sig_seq, obs))
|
||||
adr.node_entered.connect(_api_mark_node.bind(sig_seq))
|
||||
Kit.check("冒险启动受理(主种子注入恰一次——state=running;first_script=固定模板 0——精确路径断言需要现行图,零抽签)", adr.start_adventure(42, {"first_script": true}))
|
||||
Kit.check("冒险启动受理(主种子注入恰一次——state=running;种子 42=首局固定种子生成图——精确路径断言需要确定图(卡② 代差))", adr.start_adventure(42))
|
||||
Kit.check("进行中重复 start_adventure 拒绝(重开须待结算完毕)", not adr.start_adventure(43))
|
||||
Kit.check("停点前移次序契约:level_started 先于入口候选呈现(反序选路口会被呈现层 runner 空防御丢弃)", sig_seq.size() == 2 and String(sig_seq[0]) == "level_started" and String(sig_seq[1]) == "choices_presented")
|
||||
Kit.check("入口停点:选路态 + 入口单候选 + 首战未开(呈现零 RNG 消耗——pick 前零战斗域)",
|
||||
@@ -108,26 +108,27 @@ func _api_mark_node(_node: Dictionary, _info: Dictionary, seq: Array) -> void:
|
||||
func _smoke_adventure() -> void:
|
||||
print("--- 冒险结构验收(三关序列 · 大奖励 · 摘要五件事 · 账本封闭)---")
|
||||
var autoplay = load("res://sim/level_autoplay.gd")
|
||||
# ① 表与覆盖义务(序列关数 = 行集关数;行集内每个战斗/Boss 节点跨表精确行集——覆盖义务断言;
|
||||
# enemy 侧键域 = (level_index, row_kind, depth_band)——战斗节点查 battle 键域、Boss 节点查 boss 键域,
|
||||
# 同带多行合法,带键聚合天然兼容〔fv24〕)
|
||||
# ① 表与覆盖义务(序列关数 = 行集关数;节点源 = 参数档在固定种子下的生成图,
|
||||
# 每个战斗/Boss 节点经 ConfigDB 真实解析链查敌行集与棋盘行——顶 clamp 消解禁〔fv24〕)
|
||||
Kit.check("序列关数 = 行集关数(三关正式序列生效)", int(_db.level.rows.size()) == 3)
|
||||
var bg_keys := {}
|
||||
for row in _db.board_gen.rows:
|
||||
bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
|
||||
var en_keys := {}
|
||||
for row in _db.enemy.rows:
|
||||
en_keys["%d_%s_%d" % [int(row.level_index), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]] = true
|
||||
var miss := 0
|
||||
for row in _db.level.rows:
|
||||
for n in _level_active_nodes(int(row.level_index)):
|
||||
var cov_miss := 0
|
||||
var cov_scan := 0
|
||||
for li in range(1, int(_db.level.rows.size()) + 1):
|
||||
for n in _level_active_nodes(li):
|
||||
var nt := String(n.get("type", ""))
|
||||
if nt != "battle" and nt != "boss":
|
||||
continue
|
||||
var key := "%d_%s_%d" % [int(row.level_index), nt, int(n.get("node_depth", -1))]
|
||||
if not bg_keys.has("%d_%d" % [int(row.level_index), int(n.get("node_depth", -1))]) or not en_keys.has(key):
|
||||
miss += 1
|
||||
Kit.check("行集内每个战斗/Boss 节点在 board_gen + enemy 有精确行集(缺失 %d——带键 (L,row_kind,depth_band))" % miss, miss == 0)
|
||||
var d: int = int(n.get("depth", -1))
|
||||
cov_scan += 1
|
||||
var rk := "boss" if nt == "boss" else "battle"
|
||||
var rs: Array[Dictionary] = _db.resolve_enemy_rowset(li, d, rk)
|
||||
if rs.is_empty() or int(rs[0].get("depth_band", -1)) < d:
|
||||
cov_miss += 1
|
||||
if not (_db.resolve_board_gen(li, d) as Dictionary).is_empty():
|
||||
continue
|
||||
cov_miss += 1
|
||||
Kit.check("生成图每个战斗/Boss 节点经解析链命中敌行集与棋盘行(扫描 %d 节点,缺失 %d)〔fv24〕" % [cov_scan, cov_miss],
|
||||
cov_scan > 0 and cov_miss == 0)
|
||||
# ② 满配通关 + ④ 三层奖励兑付(三选一恰 2 次 · 回血封顶 · 钥石逐杀 · 候选零重复)
|
||||
var st: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
|
||||
var sum: Dictionary = st["summary"]
|
||||
@@ -151,7 +152,44 @@ func _smoke_adventure() -> void:
|
||||
elif (ev as Dictionary).has("curio_resolved"):
|
||||
resolves.append((ev as Dictionary)["curio_resolved"])
|
||||
Kit.check("通关三选一恰呈现 2 次、恰兑付 2 次(L1/L2 后——末关不发;resolves 含节点回执另计)", picks.size() == 2 and resolves.size() == 2 + node_picks)
|
||||
Kit.check("奖励节点三选一 ≥1 次(L1 链必经 1 个;L2/L3 图可选路经若干——实际经停 %d)" % node_picks, node_picks >= 1)
|
||||
# 奖励节点在生成图里是可选路位(旧单链模板的「L1 必经 1 个」形状前提随模板消亡)——
|
||||
# 接线证明拆两半:图侧每关含 ≥1 奖励节点,行进侧种子扫描内确有经停(扫描零经停=三选一接线不可达)
|
||||
var reward_levels := 0
|
||||
for li in range(1, int(_db.level.rows.size()) + 1):
|
||||
var rc := 0
|
||||
for n in _level_active_nodes(li):
|
||||
if String(n.get("type", "")) == "reward":
|
||||
rc += 1
|
||||
if rc >= 1:
|
||||
reward_levels += 1
|
||||
Kit.check("三关生成图各含 ≥1 奖励节点(节点三选一候选位在图)〔v4〕", reward_levels == 3)
|
||||
# 行进侧两断言走真实选路入口(满配赢下途中战→岔口按偏好显式点选):奖励支路经停即呈三选一、
|
||||
# 进战载荷带节点深度——确定性评分策略恒选推进支路,奖励位不在扫描路由上,故显式点选验证接线
|
||||
var rw: Dictionary = _drive_to_pref(autoplay, "reward")
|
||||
Kit.check("奖励分支点选→三选一候选可取且停点在途(候选 %s 件)" % str((rw.get("cands", []) as Array).size()),
|
||||
bool(rw.get("pending", false)) and (rw.get("cands", []) as Array).size() >= 1)
|
||||
var rw_pc: Dictionary = {}
|
||||
for c in (rw.get("cands", []) as Array):
|
||||
var cd := c as Dictionary
|
||||
if String(cd.get("kind", "")) != "feat_ticket":
|
||||
rw_pc = cd
|
||||
break
|
||||
if rw_pc.is_empty() and not (rw.get("cands", []) as Array).is_empty():
|
||||
rw_pc = (rw.get("cands", []) as Array)[0] as Dictionary
|
||||
var rw_pick_ok := false
|
||||
if not rw_pc.is_empty() and rw.get("adr") != null:
|
||||
rw_pick_ok = rw["adr"].apply_node_curio(String(rw_pc.get("kind", "")), String(rw_pc.get("tier", "")), String(rw_pc.get("feat_id", "")))
|
||||
Kit.check("奖励三选一点选兑付受理(件=%s)" % String(rw_pc.get("kind", "")), rw_pick_ok)
|
||||
var enc_run: Dictionary = _drive_to_pref(autoplay, "battle")
|
||||
var enc_probe: Dictionary = enc_run.get("enc", {}) as Dictionary
|
||||
var node_probe: Dictionary = enc_run.get("node", {}) as Dictionary
|
||||
# 定位信息两样(契约 2):战斗侧收到的 node_depth=节点深度,且命中节点句柄≠深度——
|
||||
# 变异「enc 取句柄」必红(V6)
|
||||
Kit.check("进战载荷两样=关卡序号+节点深度(实测 depth=%s 句柄=%s)〔v6〕" % [str(enc_probe.get("node_depth", -1)), str(node_probe.get("id", -1))],
|
||||
not enc_probe.is_empty()
|
||||
and int(enc_probe.get("node_depth", -1)) == int(node_probe.get("depth", -1))
|
||||
and int(enc_probe.get("level_index", -1)) == 1
|
||||
and int(node_probe.get("id", -1)) != int(node_probe.get("depth", -1)))
|
||||
var node_resolves := 0
|
||||
for r0 in resolves:
|
||||
if String((r0 as Dictionary).get("context", "")) == "reward_node":
|
||||
@@ -260,7 +298,7 @@ func _smoke_adventure() -> void:
|
||||
# ⑥ 重复宣布拒绝(settle 后再调 _on_level_finished——摘要零变化)
|
||||
var adr6 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(7))
|
||||
adr6.set_hero_override(900, 400)
|
||||
Kit.check("重复拒绝场景:冒险启动(seed 7——固定模板 0 同上)", adr6.start_adventure(7, {"first_script": true}))
|
||||
Kit.check("重复拒绝场景:冒险启动(seed 7——生成图确定同上)", adr6.start_adventure(7))
|
||||
adr6.runner().pick_next_node(int(adr6.runner().next_candidate_ids()[0])) # 入口停点 pick 进首战
|
||||
var b6 = adr6.runner().current_battle()
|
||||
var st6 := _adv_st()
|
||||
@@ -284,13 +322,13 @@ func _smoke_adventure() -> void:
|
||||
Kit.check("每战完成入账恰一次(gain %d = battles %d——GP 袋件入账单列)" % [gains, int(st["battles"])], gains == int(st["battles"]))
|
||||
Kit.check("钱包 clear 恰一次且为末事件(关卡收束不清——GP 冒险收束清零归冒险结构)", clears == 1 and wallet.size() > 0 and String(wallet[wallet.size() - 1]["event"]) == "clear")
|
||||
var shop_seq: Array = st["shop_gp_seq"]
|
||||
Kit.check("L2 商店房到达余额 > 0(%d——L1 积累跨关延续;两关商店房必经各 1)" % (shop_seq[0] if shop_seq.size() > 0 else -1), shop_seq.size() == 2 and int(shop_seq[0]) > 0)
|
||||
Kit.check("三关商店房各到达一次(每关入口→终点路径恰穿一店的驱动投影)且首达余额 > 0(%d——GP 跨关延续)" % (shop_seq[0] if shop_seq.size() > 0 else -1), shop_seq.size() == 3 and int(shop_seq[0]) > 0)
|
||||
# ⑧ 加算区同账(手工场景:L2 商店购买 → 三选一直调——同一本账累加 + 部分回血封顶)
|
||||
var same := {}
|
||||
for s in range(42, 92):
|
||||
var a8 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(s))
|
||||
a8.set_hero_override(900, 400)
|
||||
if not a8.start_adventure(s, {"first_script": true}): # 固定模板 0——种子扫描语义按现行图
|
||||
if not a8.start_adventure(s): # 种子扫描语义按生成图(同种子同图)
|
||||
continue
|
||||
if _adv_drive_to_shop(a8, _adv_st(), autoplay):
|
||||
same = {"a": a8, "s": s}
|
||||
@@ -335,7 +373,7 @@ func _smoke_adventure() -> void:
|
||||
# ⑩ 放弃(战斗中拒绝 · 非战斗期受理同失败型结算)
|
||||
var a10 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(15))
|
||||
a10.set_hero_override(900, 400)
|
||||
Kit.check("放弃场景:冒险启动(seed 15——固定模板 0 同上)", a10.start_adventure(15, {"first_script": true}))
|
||||
Kit.check("放弃场景:冒险启动(seed 15——生成图确定同上)", a10.start_adventure(15))
|
||||
Kit.check("放弃场景:入口停点 pick 进首战(停点前移批 2026-09-23 驱动口径)", a10.runner().pick_next_node(int(a10.runner().next_candidate_ids()[0])))
|
||||
Kit.check("战斗中 abandon 拒绝(战斗域单事务在飞)", not a10.abandon())
|
||||
var b10 = a10.runner().current_battle()
|
||||
@@ -348,7 +386,7 @@ func _smoke_adventure() -> void:
|
||||
# ⑪ 池耗即枯竭 + 池收缩 + 满血回 0(TP0——white-box:耗尽 attack/ap 令牌后候选零出现;
|
||||
# 无种类次数上限,「达上限剔除」语义由牌库耗尽承载)
|
||||
var a11 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(11))
|
||||
Kit.check("剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 wide_scout_band)", a11.start_adventure(11, {"first_script": true, "treasure_unlocked": {"wide_scout_band": true}}))
|
||||
Kit.check("剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 wide_scout_band)", a11.start_adventure(11, {"treasure_unlocked": {"wide_scout_band": true}}))
|
||||
for di in range(a11._curio_deck.size() - 1, -1, -1):
|
||||
var dk := String((a11._curio_deck[di] as Dictionary).get("kind", ""))
|
||||
if dk == "attack" or dk == "ap":
|
||||
@@ -370,7 +408,7 @@ func _smoke_adventure() -> void:
|
||||
Kit.check("池收缩——40 次重抽零 aux 候选(停点渠道 aux 卡退役,迭代 11 第①批 T7)", not treasure_seen)
|
||||
# 满血回 0 合法(通关回血封顶口径)
|
||||
var a12 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(12))
|
||||
a12.start_adventure(12, {"first_script": true})
|
||||
a12.start_adventure(12)
|
||||
var cap := {"payload": {}}
|
||||
a12.curio_choice_presented.connect(func(p): cap["payload"] = p)
|
||||
var max_full := int(_db.hero.max_hp) + int(a12._base_override.get("max_hp", 0))
|
||||
@@ -379,7 +417,7 @@ func _smoke_adventure() -> void:
|
||||
Kit.check("满血通关回血 0 合法(封顶口径——heal_gained=0 非负溢出)", int(cap["payload"].get("heal_gained", -1)) == 0)
|
||||
# ⑬ 奖励节点共享池(同库同池同摘牌;TP0:attack 令牌耗尽后节点域同样零出现)
|
||||
var a13 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(13))
|
||||
Kit.check("节点共享池场景:冒险启动(seed 13)", a13.start_adventure(13, {"first_script": true, "treasure_unlocked": {"wide_scout_band": true}}))
|
||||
Kit.check("节点共享池场景:冒险启动(seed 13)", a13.start_adventure(13, {"treasure_unlocked": {"wide_scout_band": true}}))
|
||||
for di13 in range(a13._curio_deck.size() - 1, -1, -1):
|
||||
if String((a13._curio_deck[di13] as Dictionary).get("kind", "")) == "attack":
|
||||
a13._curio_deck.remove_at(di13)
|
||||
@@ -411,7 +449,7 @@ func _smoke_feats() -> void:
|
||||
Kit.check("feat_table 已加载(30 件=基础 27+迁入 2+卡② 完美保险 1——迭代 31 卡② 换代)", _db.feats != null and (_db.feats.rows as Array).size() == 30)
|
||||
Kit.check("feat_table.validate 零违例(四级 rarity/cap 域/trigger 词表/行键白名单全过)", _db.feats.validate().is_empty())
|
||||
var a_r0 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(71))
|
||||
Kit.check("专长带场景:冒险启动(seed 71)", a_r0.start_adventure(71, {"first_script": true}))
|
||||
Kit.check("专长带场景:冒险启动(seed 71)", a_r0.start_adventure(71))
|
||||
var feat_tokens := 0
|
||||
var tier_counts := {}
|
||||
for t in a_r0._curio_deck:
|
||||
@@ -465,7 +503,7 @@ func _smoke_feats() -> void:
|
||||
var live_rows: FeatTable = _db.feats
|
||||
_db.feats = rt
|
||||
var a_r = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(72))
|
||||
Kit.check("专长带场景:冒险启动(seed 72——注入 5 行临时表)", a_r.start_adventure(72, {"first_script": true}))
|
||||
Kit.check("专长带场景:冒险启动(seed 72——注入 5 行临时表)", a_r.start_adventure(72))
|
||||
var deck_relic := 0
|
||||
for t in a_r._curio_deck:
|
||||
if String((t as Dictionary).get("kind", "")) == "relic":
|
||||
@@ -485,7 +523,7 @@ func _smoke_feats() -> void:
|
||||
Kit.check("节点渠道零专长(60 次重抽零 relic——专长在非 Boss 渠道只以专长凭证形态出现)", not node_relic_seen)
|
||||
# T5 专长凭证行为断言(A5 拿到即开 / A6 保底计数——契约④四口径;独立 runner 防污染 a_r 牌库)
|
||||
var a_t = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(91))
|
||||
a_t.start_adventure(91, {"first_script": true})
|
||||
a_t.start_adventure(91)
|
||||
var stock_before := {}
|
||||
for kk in ["attack", "gp", "heal"]:
|
||||
stock_before[kk] = a_t.pool_stock(kk)
|
||||
@@ -769,6 +807,50 @@ func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool:
|
||||
|
||||
|
||||
# 手工场景状态袋(_drive_level/_autoplay_battle 消费的最小字段族——run_adventure st 的同构子集)
|
||||
# 真实选路驱动到偏好支路:赢下途中战斗,遇候选含目标类型即显式点选(reward=首个奖励节点、
|
||||
# battle=首个句柄≠深度的战斗节点);返回 {node, enc, cands, pending, adr}
|
||||
func _drive_to_pref(autoplay, prefer: String) -> Dictionary:
|
||||
var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(42))
|
||||
adr.set_hero_override(900, 400)
|
||||
var out := {"node": {}, "enc": {}, "cands": [] as Array, "pending": false, "adr": adr}
|
||||
if not adr.start_adventure(42):
|
||||
return out
|
||||
var r = adr.runner()
|
||||
var st := _adv_st()
|
||||
var guard := 30
|
||||
while r != null and guard > 0:
|
||||
guard -= 1
|
||||
if r.awaiting_choice():
|
||||
var hit: Dictionary = {}
|
||||
for c in r.next_candidates():
|
||||
var cd := c as Dictionary
|
||||
if prefer == "reward" and String(cd.get("type", "")) == "reward":
|
||||
hit = cd
|
||||
break
|
||||
if prefer == "battle" and String(cd.get("type", "")) == "battle" and int(cd.get("id", -1)) != int(cd.get("depth", -1)):
|
||||
hit = cd
|
||||
break
|
||||
if not hit.is_empty():
|
||||
r.pick_next_node(int(hit.get("id", -1)))
|
||||
out["node"] = hit
|
||||
if prefer == "battle":
|
||||
out["enc"] = r.current_battle().encounter
|
||||
else:
|
||||
var cands: Array = (adr.node_curio_candidates() as Dictionary).get("candidates", []) as Array
|
||||
adr.present_node_curio(cands)
|
||||
out["cands"] = cands
|
||||
out["pending"] = not (adr._node_curio_pending as Dictionary).is_empty()
|
||||
return out
|
||||
var ids: Array = r.next_candidate_ids()
|
||||
if ids.is_empty():
|
||||
return out
|
||||
r.pick_next_node(int(ids[0]))
|
||||
continue
|
||||
var b = r.current_battle()
|
||||
if b == null:
|
||||
return out
|
||||
autoplay._autoplay_battle(r, b, st, null)
|
||||
return out
|
||||
func _adv_st() -> Dictionary:
|
||||
return {"result": "", "hp_left": 0, "battles": 0, "rounds": 0, "hero_dmg": 0,
|
||||
"rest_healed": 0, "hp_before_rest": -1, "defeat_node_index": -1, "defeat_node_type": "",
|
||||
@@ -809,12 +891,7 @@ func _fresh_rng(seed_val: int):
|
||||
# ---- ① GP 冒险钱包·到账 ----
|
||||
|
||||
|
||||
# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义)
|
||||
# 生成图取数面(图 = f(关卡行参数档, 种子 42)——与 runner 起关同图;节点键 id/type/depth/next)
|
||||
func _level_active_nodes(level_index: int) -> Array:
|
||||
for row in _db.level.rows:
|
||||
if int(row.level_index) == level_index:
|
||||
var tpls: Array = row.get("templates", [])
|
||||
if not tpls.is_empty():
|
||||
return tpls[0].get("nodes", [])
|
||||
return row.get("nodes", [])
|
||||
return []
|
||||
var gen = NodeMapGenerator.new(_fresh_rng(42))
|
||||
return (gen.generate(_db.level_gen_params_row(level_index), 42) as Dictionary).get("nodes", [])
|
||||
@@ -134,9 +134,24 @@ func _smoke_board_generator() -> void:
|
||||
var row = _db.resolve_board_gen(L, 0)
|
||||
Kit.check("BoardGen 行解析 (%d,0)" % L, not row.is_empty())
|
||||
if L == 2:
|
||||
# R1 ceil 口径:无精确行向上取整保证偏难——L2 深度 3 为恢复点无行,解析到深度 4
|
||||
# 迭代 35 生成图深度扩面覆盖批随动(声明):旧口径「(2,3) 无精确行→ceil 上取到 4」的判据对象
|
||||
# 是模板时代键域缺口;覆盖批补齐 0–13 后 (2,3) 有精确行,生产表上不再存在可走 ceil 的空位。
|
||||
# 判据不丢——精确命中改判在册,ceil 能力用「临时摘除该行」的夹具面保留,随后原样还原。
|
||||
var exact_row: Dictionary = _db.resolve_board_gen(2, 3)
|
||||
Kit.check("BoardGen 精确命中 (2,3)(覆盖批键域补齐)", int(exact_row.get("node_depth", -1)) == 3)
|
||||
var gap_idx := -1
|
||||
for i in _db.board_gen.rows.size():
|
||||
if int(_db.board_gen.rows[i].get("level_index", -1)) == 2 and int(_db.board_gen.rows[i].get("node_depth", -1)) == 3:
|
||||
gap_idx = i
|
||||
break
|
||||
var saved: Dictionary = _db.board_gen.rows[gap_idx]
|
||||
var rows_before: int = _db.board_gen.rows.size()
|
||||
_db.board_gen.rows.remove_at(gap_idx)
|
||||
var ceil_row: Dictionary = _db.resolve_board_gen(2, 3)
|
||||
Kit.check("R1 ceil 解析 (2,3)→(2,4)(向上取整偏难)", int(ceil_row.get("node_depth", -1)) == 4)
|
||||
Kit.check("R1 ceil 能力不失(夹具摘除 (2,3)→向上取整到 4·偏难口径)", int(ceil_row.get("node_depth", -1)) == 4)
|
||||
_db.board_gen.rows.insert(gap_idx, saved)
|
||||
Kit.check("夹具表原样还原(行数与解析回到覆盖批现值)",
|
||||
_db.board_gen.rows.size() == rows_before and int(_db.resolve_board_gen(2, 3).get("node_depth", -1)) == 3)
|
||||
var b1 = board_generator.generate(L, 0, _rng.open_stream("board_gen"), _db)
|
||||
Kit.check("L%d 生成产物非空" % L, b1 != null)
|
||||
if b1 == null:
|
||||
|
||||
@@ -147,8 +147,52 @@ func _init() -> void:
|
||||
Kit.check_quiet(adr_i.runner().pick_next_node(int(adr_i.runner().next_candidate_ids()[0])), "P6 续玩选路进首战")
|
||||
Kit.check_quiet(adr_i.runner().current_battle().attune_has("morning_order"), "P6 回局首战装配集仍含 morning_order(停点往返载体态一致)")
|
||||
|
||||
# ---- Part 7:生成图换代断言面(快照键换代、停点句柄往返、续玩按种子+参数档重生成同图)----
|
||||
var rng_j = load("res://core/rng_service.gd").new()
|
||||
var sm_j = SaveManagerScript.new(_bp_path)
|
||||
var adr_j = AdventureRunnerScript.new(db, rng_j)
|
||||
adr_j.set_save_manager(sm_j)
|
||||
Kit.check_quiet(adr_j.start_adventure(910042, params), "P7 冒险启动(seed 910042——生成图开局)")
|
||||
var runner_j = adr_j.runner()
|
||||
var graph_j: Array = runner_j.nodes()
|
||||
var bp_j: Dictionary = sm_j.breakpoint_snapshot()
|
||||
Kit.check_quiet(not bp_j.has("template_pick"),
|
||||
"P7 断点快照无已抽模板键(模板链路退役——件核面在档)〔v9〕")
|
||||
Kit.check_quiet(int(bp_j.get("graph_gen_v2", 0)) == 1,
|
||||
"P7 断点含生成图代差标记键(换代承载)〔v8〕")
|
||||
var entry_id: int = int(runner_j.next_candidate_ids()[0])
|
||||
var mid_id: int = _first_out_id(graph_j, entry_id)
|
||||
var bp_mid: Dictionary = bp_j.duplicate(true)
|
||||
bp_mid["node_id"] = mid_id
|
||||
bp_mid["visited_ids"] = [entry_id]
|
||||
var adr_m = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new())
|
||||
Kit.check_quiet(adr_m.resume_adventure(bp_mid, params), "P7 中途停点断点续玩受理(句柄落档形态)")
|
||||
var runner_m = adr_m.runner()
|
||||
Kit.check_quiet(int(runner_m.current_node_id()) == mid_id,
|
||||
"P7 停点句柄回读一致(图内 id 单空间寻址)〔v8〕")
|
||||
Kit.check_quiet(runner_m.visited_ids() == [entry_id], "P7 已到节点序按句柄往返一致")
|
||||
Kit.check_quiet(runner_m.nodes() == graph_j, "P7 续玩重生成图与中断前逐位同图(断点不存图快照)〔v7〕")
|
||||
var adr_l = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new())
|
||||
var bp_legacy: Dictionary = bp_mid.duplicate(true)
|
||||
bp_legacy.erase("graph_gen_v2")
|
||||
Kit.check_quiet(adr_l.resume_adventure(bp_legacy, params) == false,
|
||||
"P7 缺生成图代差标记键的旧断点显式拒绝(禁按新句柄空间静默回放)〔v8〕")
|
||||
var adr_n = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new())
|
||||
Kit.check_quiet(adr_n.start_adventure(910042, params) and adr_n.runner().nodes() == graph_j,
|
||||
"P7 同种子重开局同图(开局入口复现承载)〔v7〕")
|
||||
|
||||
_clear_disk()
|
||||
quit(Kit.finish("breakpoint_resume_test", "PASS — 断点往返/续玩确定性/幂等/退出清断点/装配存档回归 全绿"))
|
||||
quit(Kit.finish("breakpoint_resume_test", "PASS — 断点往返/续玩确定性/幂等/退出清断点/装配存档回归/生成图换代 全绿"))
|
||||
|
||||
|
||||
# 句柄寻址夹具辅助:取生成图中指定句柄节点的首个出边目标(构造中途停点落档形态)
|
||||
func _first_out_id(nodes: Array, node_id: int) -> int:
|
||||
for node in nodes:
|
||||
var n := node as Dictionary
|
||||
if int(n.get("id", -1)) == node_id:
|
||||
var outs: Array = n.get("next", [])
|
||||
return int(outs[0]) if not outs.is_empty() else -1
|
||||
return -1
|
||||
|
||||
|
||||
func _board_fingerprint(b) -> String:
|
||||
|
||||
@@ -16,7 +16,6 @@ func _init() -> void:
|
||||
_test_all_links_valid()
|
||||
_test_dangling_skill_id()
|
||||
_test_cast_side_mismatch()
|
||||
_test_level_node_band_rules()
|
||||
quit(Kit.finish("config_db_cross_validator_test"))
|
||||
|
||||
|
||||
@@ -83,61 +82,3 @@ func _test_cast_side_mismatch() -> void:
|
||||
var status := _status([{"effect_id": "st_hero_side", "target_side": "hero"}])
|
||||
var errs: Array = ValidatorScript.new().validate_skill_pool_links(skills, enemies, status)
|
||||
Kit.check("buff 挂主角侧状态被报(施放类 side 守卫)", _cats(errs).has("cast_side"))
|
||||
|
||||
|
||||
# ---- ④ 节点 × 敌人表带寻址(单终点零误报/同带双 Boss 报错/顶 clamp 消解报错)----
|
||||
const LevelTableScript := preload("res://data/schemas/level_table.gd")
|
||||
const BoardGenTableScript := preload("res://data/schemas/board_gen_table.gd")
|
||||
const RewardTableScript := preload("res://data/schemas/reward_table.gd")
|
||||
const SettlementTableScript := preload("res://data/schemas/settlement_table.gd")
|
||||
|
||||
|
||||
func _loaded(level_nodes: Array, bg_depths: Array, boss_bands: Array, battle_bands: Array) -> Dictionary:
|
||||
var lv := LevelTableScript.new()
|
||||
lv.rows.assign([{"level_index": 1, "display_name": "夹具关", "templates": [{"nodes": level_nodes}]}])
|
||||
var bg := BoardGenTableScript.new()
|
||||
var bg_rows: Array = []
|
||||
for d in bg_depths:
|
||||
bg_rows.append({"level_index": 1, "node_depth": int(d), "board_width": 10})
|
||||
bg.rows.assign(bg_rows)
|
||||
var en := EnemyTableScript.new()
|
||||
var en_rows: Array = []
|
||||
for b in battle_bands:
|
||||
en_rows.append({"level_index": 1, "row_kind": "battle", "depth_band": int(b), "enemy_id": "goblin",
|
||||
"unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]})
|
||||
for b in boss_bands:
|
||||
en_rows.append({"level_index": 1, "row_kind": "boss", "depth_band": int(b), "enemy_id": "ogre_chief",
|
||||
"unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]})
|
||||
en.rows.assign(en_rows)
|
||||
var st := SettlementTableScript.new()
|
||||
st.source_label_map.assign({"safe_cell": "a", "seal_hit": "b", "mine_hit": "c", "clear_reward": "d",
|
||||
"hero_attack": "e", "enemy_action": "f", "seal_miss": "g"})
|
||||
return {"status_effects": StatusTableScript.new(), "reward": RewardTableScript.new(),
|
||||
"settlement": st, "level": lv, "board_gen": bg, "enemy": en}
|
||||
|
||||
|
||||
func _cross_cats(loaded: Dictionary, needle: String) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
ValidatorScript.new().cross_validate(loaded,
|
||||
func(_sev, cat, msg) -> void:
|
||||
if String(msg).contains(needle):
|
||||
out.append(String(cat)))
|
||||
return out
|
||||
|
||||
|
||||
func _test_level_node_band_rules() -> void:
|
||||
# 正向:单终点三关同构(battle 节点落 band 0、boss 节点精确落顶带)→ ④ 零误报
|
||||
var ok_nodes := [{"type": "battle", "node_depth": 0}, {"type": "boss", "node_depth": 9}]
|
||||
var ok_cats := _cross_cats(_loaded(ok_nodes, [0, 9], [9], [0]), "level 1")
|
||||
Kit.check("单终点图 ④ 敌侧带寻址零误报〔fv17〕", ok_cats.is_empty())
|
||||
# 负向:一路两终点落同一 Boss 带(深度 3/4 都落带 4)→ rowset_rule
|
||||
var same_band_nodes := [{"type": "battle", "node_depth": 0},
|
||||
{"type": "boss", "node_depth": 3}, {"type": "boss", "node_depth": 4}]
|
||||
var same_band_cats := _cross_cats(_loaded(same_band_nodes, [0, 3, 4], [4, 9], [0]), "同带")
|
||||
Kit.check("同关双终点共带报 rowset_rule〔fv15〕",
|
||||
same_band_cats.has("rowset_rule"))
|
||||
# 负向:Boss 节点深度高于顶带键(顶 clamp 消解)→ level_node_dangling
|
||||
var over_nodes := [{"type": "battle", "node_depth": 0}, {"type": "boss", "node_depth": 10}]
|
||||
var over_cats := _cross_cats(_loaded(over_nodes, [0, 10], [9], [0]), "顶 clamp 消解")
|
||||
Kit.check("Boss 节点越顶带报 level_node_dangling〔fv16〕",
|
||||
over_cats.has("level_node_dangling"))
|
||||
@@ -53,12 +53,12 @@ func _init() -> void:
|
||||
Kit.check_quiet(lr.spend_gp(20) == true and int(lr.gp) == 10, "足额支出扣账(30→10)")
|
||||
|
||||
# ---- ⑥ Y 断点随存(缺陷锚:续玩自参数面整额重注入=用完 Y 退菜单再续玩满血复活)----
|
||||
var bp_y := {"level_index": 1, "hero_hp": 10, "attune_refresh_y": 2}
|
||||
var bp_y := {"level_index": 1, "hero_hp": 10, "attune_refresh_y": 2, "graph_gen_v2": 1}
|
||||
var adr_r = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new())
|
||||
Kit.check_quiet(adr_r.resume_adventure(bp_y, {"hero_override": {}, "attune_refresh_y": 3}), "续玩受理(最小断点+参数面)")
|
||||
Kit.check_quiet(adr_r._attune_refresh_y == 2, "断点有键=Y 余量随断点还原(花 1 剩 2——不再整额重注入=满血复活堵死)")
|
||||
var adr_o = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new())
|
||||
Kit.check_quiet(adr_o.resume_adventure({"level_index": 1, "hero_hp": 10}, {"hero_override": {}, "attune_refresh_y": 3}), "旧断点续玩受理(快照缺键)")
|
||||
Kit.check_quiet(adr_o.resume_adventure({"level_index": 1, "hero_hp": 10, "graph_gen_v2": 1}, {"hero_override": {}, "attune_refresh_y": 3}), "旧断点续玩受理(快照缺键)")
|
||||
Kit.check_quiet(adr_o._attune_refresh_y == 3, "旧断点缺键=回退参数面整额注入(旧档行为零变化)")
|
||||
|
||||
quit(Kit.finish("curio_refresh_paid"))
|
||||
|
||||
@@ -22,7 +22,7 @@ func _init() -> void:
|
||||
quit(1)
|
||||
return
|
||||
var a = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new())
|
||||
if not a.start_adventure(301, {"first_script": true}):
|
||||
if not a.start_adventure(301):
|
||||
Kit.check_quiet(false, "V9 前置:冒险启动")
|
||||
quit(Kit.finish("feat_vouchers"))
|
||||
return
|
||||
|
||||
@@ -29,61 +29,71 @@ func _init() -> void:
|
||||
|
||||
# ---- 夹具 ----
|
||||
|
||||
# 图形态夹具(id = node_depth):单干 0→1→2→3→4→5→6→7 加一条 6→3 跨层跳边。
|
||||
# BFS 层:0=0/1=1/2=1/3=2/4=3/5=4/6=5/7=6——锚 6 时候选 3 层距 3 = N+1。
|
||||
# 节点构造器(生成图口径:id=图内唯一句柄、depth=自入口最长路径步数、next=出边句柄数组)
|
||||
static func _gn(id: int, depth: int, type_: String, next_ids: Array) -> Dictionary:
|
||||
return {"id": id, "type": type_, "depth": depth, "next": next_ids}
|
||||
|
||||
|
||||
# 图形态夹具(id = 图内句柄,depth = 自入口沿边严格前进的深度):干 0→1→{2,8},2→3,
|
||||
# 8→4→5→6,长跳边 6→3(与 2→3 同落节点 3,目标 depth 严格大于源——生成图连线铁律),
|
||||
# 3 经尾链 9→10→11 汇入终点 7(7 的 depth = 自入口最长路径步数 10)。
|
||||
# BFS 层:0=0/1=1/2=1/3=2/4=3/5=4/6=5/7=6(8=2/9=3/10=4/11=5)——锚 6 时候选 3 层距 3 = N+1。
|
||||
func _graph_nodes() -> Array:
|
||||
return [
|
||||
{"node_depth": 0, "type": "battle", "next": [1, 2]},
|
||||
{"node_depth": 1, "type": "battle", "next": [2]},
|
||||
{"node_depth": 2, "type": "reward", "next": [3]},
|
||||
{"node_depth": 3, "type": "rest", "next": [4]},
|
||||
{"node_depth": 4, "type": "battle", "next": [5]},
|
||||
{"node_depth": 5, "type": "battle", "next": [6]},
|
||||
{"node_depth": 6, "type": "battle", "next": [7, 3]},
|
||||
{"node_depth": 7, "type": "boss", "next": []},
|
||||
_gn(0, 0, "battle", [1, 2]),
|
||||
_gn(1, 1, "battle", [2, 8]),
|
||||
_gn(2, 2, "reward", [3]),
|
||||
_gn(3, 6, "rest", [9]),
|
||||
_gn(4, 3, "battle", [5]),
|
||||
_gn(5, 4, "battle", [6]),
|
||||
_gn(6, 5, "battle", [7, 3]),
|
||||
_gn(7, 10, "boss", []),
|
||||
_gn(8, 2, "battle", [4]),
|
||||
_gn(9, 7, "battle", [10]),
|
||||
_gn(10, 8, "battle", [11]),
|
||||
_gn(11, 9, "battle", [7]),
|
||||
]
|
||||
|
||||
|
||||
# 单链夹具(无 next 键,仿 L1 现役模板形态):编号空间 = 数组下标,node_depth 只出现在战斗/Boss 上
|
||||
# 单链退化形态 = 出度 ≤1 的图(显式 next 出边、编号 = id 句柄——无数组下标隐式边)
|
||||
func _chain_nodes() -> Array:
|
||||
return [
|
||||
{"node_depth": 0, "type": "battle"},
|
||||
{"node_depth": 1, "type": "battle"},
|
||||
{"node_depth": 2, "type": "battle"},
|
||||
{"node_depth": 3, "type": "battle"},
|
||||
{"type": "reward"},
|
||||
{"type": "rest"},
|
||||
{"node_depth": 4, "type": "boss"},
|
||||
_gn(0, 0, "battle", [1]),
|
||||
_gn(1, 1, "battle", [2]),
|
||||
_gn(2, 2, "battle", [3]),
|
||||
_gn(3, 3, "battle", [4]),
|
||||
_gn(4, 4, "reward", [5]),
|
||||
_gn(5, 5, "rest", [6]),
|
||||
_gn(6, 6, "boss", []),
|
||||
]
|
||||
|
||||
|
||||
# validate 正样本(无跨层跳边的合法图:入口唯一、Boss 编号最大收尾、层宽 ≤5)
|
||||
# 表行夹具的正样本(无跨层跳边的合法图——表加载期只核行级字段,图合法性判据见 validate_graph·卡② T1 换代)
|
||||
func _valid_graph_nodes() -> Array:
|
||||
return [
|
||||
{"node_depth": 0, "type": "battle", "next": [1, 2]},
|
||||
{"node_depth": 1, "type": "battle", "next": [3]},
|
||||
{"node_depth": 2, "type": "reward", "next": [3]},
|
||||
{"node_depth": 3, "type": "battle", "next": [4]},
|
||||
{"node_depth": 4, "type": "boss", "next": []},
|
||||
_gn(0, 0, "battle", [1, 2]),
|
||||
_gn(1, 1, "battle", [3]),
|
||||
_gn(2, 1, "reward", [3]),
|
||||
_gn(3, 2, "battle", [4]),
|
||||
_gn(4, 3, "boss", []),
|
||||
]
|
||||
|
||||
|
||||
# 白盒 runner:夹具行集注入 db.level + level_row,停点态直设(anchor_id < 0 = 入口停点)
|
||||
func _runner(db, nodes: Array, anchor_id: int, candidates: Array,
|
||||
graph_mode: bool = true, n: int = 2):
|
||||
# 白盒 runner:夹具行集注入 db.level + level_row,停点态直设(anchor_id < 0 = 入口停点;
|
||||
# 锚点与候选的编号空间 = 节点 id 句柄——runner._node_id 单点)
|
||||
func _runner(db, nodes: Array, anchor_id: int, candidates: Array, n: int = 2):
|
||||
var t := LevelTableScript.new()
|
||||
t.info_visible_layers = n
|
||||
t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}]
|
||||
db.level = t
|
||||
var r = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new())
|
||||
r.level_row.assign({"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes})
|
||||
r._graph_mode = graph_mode
|
||||
r.visited.clear()
|
||||
r._candidates.assign(candidates)
|
||||
r.node_index = -1
|
||||
if anchor_id >= 0:
|
||||
for i in nodes.size():
|
||||
if int((nodes[i] as Dictionary).get("node_depth", -1)) == anchor_id:
|
||||
if int((nodes[i] as Dictionary).get("id", -1)) == anchor_id:
|
||||
r.node_index = i
|
||||
break
|
||||
return r
|
||||
@@ -157,15 +167,15 @@ func _test_single_circle(db) -> void:
|
||||
Kit.check("单圈只按锚层取窗:已过近端节点 4(层距 2)仍可见〔fv7〕", vis.has(4))
|
||||
|
||||
|
||||
# ---- ⑤ 单链退化形态(编号空间 = 数组下标)〔fv1〕----
|
||||
# ---- ⑤ 单链退化形态(出度 ≤1 图,编号 = id 句柄)〔fv1〕----
|
||||
|
||||
func _test_single_chain_entry(db) -> void:
|
||||
var nodes := _chain_nodes()
|
||||
var r = _runner(db, nodes, -1, [nodes[0]], false)
|
||||
var r = _runner(db, nodes, -1, [nodes[0]])
|
||||
var vis: Array = r.type_visible_ids()
|
||||
Kit.check("单链退化形态:入口锚层 0,下标 2 可见〔fv1〕", vis.has(2))
|
||||
Kit.check("单链退化形态:层距 N+1(下标 3)不可见〔fv1〕", not vis.has(3))
|
||||
Kit.check("单链编号空间 = 数组下标:Boss 以末位下标 6 入集(非 node_depth 4)〔fv1〕",
|
||||
Kit.check("单链退化形态:入口锚层 0,句柄 2 可见〔fv1〕", vis.has(2))
|
||||
Kit.check("单链退化形态:层距 N+1(句柄 3)不可见〔fv1〕", not vis.has(3))
|
||||
Kit.check("单链编号空间 = 图句柄 id:Boss 以句柄 6 入集(链上层距 4 的战斗节点 4 入雾)〔fv1〕",
|
||||
vis.has(6) and not vis.has(4))
|
||||
|
||||
|
||||
@@ -189,20 +199,37 @@ func _test_n_from_table(db) -> void:
|
||||
# ---- ⑦ 续玩重建同值〔fv7〕----
|
||||
|
||||
func _test_resume_rebuild(db) -> void:
|
||||
var nodes := _graph_nodes()
|
||||
var t := LevelTableScript.new()
|
||||
t.info_visible_layers = 2
|
||||
t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}]
|
||||
t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30}]
|
||||
db.level = t
|
||||
var ledger := {"hero_hp": 10, "gp": 0}
|
||||
var resumed = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new())
|
||||
var ok: bool = resumed.start_level_external(1, ledger, {}, Callable(), {}, false, 0,
|
||||
var ok: bool = resumed.start_level_external(1, ledger, {}, Callable(), {}, false,
|
||||
{"node_id": 2, "visited_ids": [0, 1, 2]})
|
||||
Kit.check_quiet(ok, "续玩用例:resume_state 重建停点成功")
|
||||
if not ok:
|
||||
return
|
||||
Kit.check_quiet(resumed.awaiting_choice(), "续玩用例:重建后处于选路停点")
|
||||
var direct = _runner(db, nodes, 2, [nodes[3]])
|
||||
direct.visited.assign([nodes[0], nodes[1], nodes[2]])
|
||||
# 图源 = 参数化生成图(图 = f(参数档, 种子))——直入对照侧与 resume 侧同吃一张生成图,
|
||||
# 停点态按句柄对齐:锚 = id 2、visited = id 0/1/2、候选 = id 2 出边指向的节点
|
||||
var gnodes: Array = resumed.nodes()
|
||||
var stop: Dictionary = {}
|
||||
var by_id := {}
|
||||
for node in gnodes:
|
||||
var nid: int = int((node as Dictionary).get("id", -1))
|
||||
by_id[nid] = node
|
||||
if nid == 2:
|
||||
stop = node
|
||||
var cand_nodes: Array = []
|
||||
for tid in (stop.get("next", []) as Array):
|
||||
if by_id.has(int(tid)):
|
||||
cand_nodes.append(by_id[int(tid)])
|
||||
var visited_nodes: Array = []
|
||||
for vid in [0, 1, 2]:
|
||||
if by_id.has(vid):
|
||||
visited_nodes.append(by_id[vid])
|
||||
var direct = _runner(db, gnodes, 2, cand_nodes)
|
||||
direct.visited.assign(visited_nodes)
|
||||
Kit.check("续玩重建同值:resume 路径与同停点直入 type_visible_ids() 逐位一致〔fv7〕",
|
||||
resumed.type_visible_ids() == direct.type_visible_ids())
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,102 @@
|
||||
# tests/unit/core/level_table_validate_test.gd — 消费侧加载校验四检单测(迭代 35 卡② T1/V1)
|
||||
# 覆盖:validate_graph 正例(多入口多终点/含岔口单终点/单链退化显式出边)+负例八类
|
||||
#(非 Boss 终点/Boss 带出边/环=回边与自环/双独立链全员可达正证/悬空出边/句柄重复/battle 缺深度/空图)
|
||||
#+「勿扩勿缩」反证:商店位与入口终点个数不在本检范围(关卡节点地图 v2.1「位置约束由冒烟断言把关」)。
|
||||
# 运行:godot --headless --path game --script res://tests/unit/core/level_table_validate_test.gd(expect 0)
|
||||
# 协议(tests/README):extends SceneTree + quit(code);夹具资源直构不碰生产表。
|
||||
extends SceneTree
|
||||
|
||||
const Kit := preload("res://tests/unit/test_kit.gd")
|
||||
const LevelTableScript := preload("res://data/schemas/level_table.gd")
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_test_positive()
|
||||
_test_negative()
|
||||
_test_scope_not_extended()
|
||||
quit(Kit.finish("level_table_validate_test"))
|
||||
|
||||
|
||||
func _n(id: int, type_: String, depth: int, outs: Array) -> Dictionary:
|
||||
return {"id": id, "type": type_, "depth": depth, "next": outs}
|
||||
|
||||
|
||||
func _errs(nodes: Array) -> Array:
|
||||
return LevelTableScript.validate_graph(nodes)
|
||||
|
||||
|
||||
func _has(errs: Array, cat: String, needle: String = "") -> bool:
|
||||
for e in errs:
|
||||
if String(e["category"]) != cat:
|
||||
continue
|
||||
if needle == "" or String(e["message"]).contains(needle):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# ---- 正例(四检全过)----
|
||||
|
||||
func _test_positive() -> void:
|
||||
# 多入口多终点:两入口共汇入一岔口,两终点各在互异深度
|
||||
var multi := [
|
||||
_n(0, "battle", 0, [2]), _n(1, "battle", 0, [2]),
|
||||
_n(2, "battle", 1, [3, 5]),
|
||||
_n(3, "rest", 2, [4]), _n(4, "shop", 3, [6]), _n(6, "boss", 4, []),
|
||||
_n(5, "reward", 2, [7]), _n(7, "shop", 3, [8]), _n(8, "boss", 5, [])]
|
||||
Kit.check("四检正例:多入口多终点图零错误〔v1〕", _errs(multi).is_empty())
|
||||
# 含岔口单终点:岔口两侧链与主去路同深、汇入同一后继
|
||||
var forked := [
|
||||
_n(0, "battle", 0, [1]),
|
||||
_n(1, "battle", 1, [2, 3]),
|
||||
_n(2, "reward", 2, [4]), _n(3, "rest", 2, [4]),
|
||||
_n(4, "shop", 3, [5]), _n(5, "boss", 4, [])]
|
||||
Kit.check("四检正例:含岔口单终点图零错误〔v1〕", _errs(forked).is_empty())
|
||||
# 单链退化形态合法(校验不设「必有岔口」)
|
||||
var chain := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 2, [3]), _n(3, "boss", 3, [])]
|
||||
Kit.check("四检正例:单链退化图零错误(不设必有岔口)〔v1〕", _errs(chain).is_empty())
|
||||
|
||||
|
||||
# ---- 负例九类(各报对应类别与判据消息)----
|
||||
|
||||
func _test_negative() -> void:
|
||||
var non_boss_end := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [])]
|
||||
Kit.check("负例:终点非 Boss 命中「终点恒为 Boss」〔v1〕",
|
||||
_has(_errs(non_boss_end), "structural", "终点 1 非 Boss"))
|
||||
var boss_out := [_n(0, "battle", 0, [1]), _n(1, "boss", 1, [2]), _n(2, "shop", 2, [])]
|
||||
Kit.check("负例:Boss 带出边被拒〔v1〕", _has(_errs(boss_out), "structural", "Boss 节点 1 带出边"))
|
||||
var back_edge := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 0, [3]), _n(3, "boss", 4, [])]
|
||||
Kit.check("负例:回边(目标深度不严格大于源)命中前进方向判据〔v1〕",
|
||||
_has(_errs(back_edge), "structural", "违反前进方向"))
|
||||
var cycle := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 2, [1]), _n(3, "boss", 3, [])]
|
||||
Kit.check("负例:环(2→1 回边)被前进方向拦下〔v1〕", _has(_errs(cycle), "structural", "违反前进方向"))
|
||||
# 判据②(无孤儿无断头)与判据④(前进方向)在无环图上互斥:DAG 的每个源点都是入口、每条极长路都止于终点,
|
||||
# 故 ② 是④失效时的纵深防御面,构造不出独立负例——此处以「两独立链双入口双终点」正证 ② 不误伤。
|
||||
var two_components := [
|
||||
_n(0, "battle", 0, [1]), _n(1, "shop", 1, [2]), _n(2, "boss", 2, []),
|
||||
_n(3, "battle", 0, [4]), _n(4, "rest", 1, [5]), _n(5, "boss", 2, [])]
|
||||
Kit.check("判据②正证:两独立链(双入口双终点)不误伤·全员可达〔v1〕", _errs(two_components).is_empty())
|
||||
var self_loop := [_n(0, "battle", 0, [1]), _n(1, "shop", 1, [2]), _n(2, "boss", 2, []), _n(3, "battle", 1, [3])]
|
||||
Kit.check("判据②兜底面:自环先被前进方向拦下(④ 与 ② 互斥的实证)〔v1〕",
|
||||
_has(_errs(self_loop), "structural", "违反前进方向"))
|
||||
var dangling := [_n(0, "battle", 0, [9]), _n(1, "boss", 1, [])]
|
||||
Kit.check("负例:悬空出边报 dangling_next〔v1〕", _has(_errs(dangling), "dangling_next"))
|
||||
var dup := [_n(0, "battle", 0, [1]), _n(1, "boss", 1, []), _n(1, "shop", 2, [])]
|
||||
Kit.check("负例:句柄重复报 dup_key〔v1〕", _has(_errs(dup), "dup_key"))
|
||||
var no_depth := [_n(0, "battle", 0, [1]), {"id": 1, "type": "battle", "next": [2]}, _n(2, "boss", 2, [])]
|
||||
Kit.check("负例:battle 节点缺深度被良构基底拒〔v1〕", _has(_errs(no_depth), "structural", "缺深度"))
|
||||
Kit.check("负例:空节点集被拒〔v1〕", _has(_errs([]), "structural", "图节点集为空"))
|
||||
var bad_type := [_n(0, "mine", 0, [1]), _n(1, "boss", 1, [])]
|
||||
Kit.check("负例:类型出域报 enum〔v1〕", _has(_errs(bad_type), "enum"))
|
||||
|
||||
|
||||
# ---- 「勿扩勿缩」反证:本检不含商店位与个数对参数档的核对 ----
|
||||
|
||||
func _test_scope_not_extended() -> void:
|
||||
# 合法形状但全程无商店/三个终点同入口图——四检不该因此报错(商店位归冒烟断言、个数归产出侧)
|
||||
var no_shop := [_n(0, "battle", 0, [1, 2]), _n(1, "battle", 1, [3]), _n(2, "rest", 1, [3]), _n(3, "boss", 2, [])]
|
||||
Kit.check("范围反证:无商店图不在加载校验内报错(商店位由冒烟断言把关)〔v1〕", _errs(no_shop).is_empty())
|
||||
var two_entries := [_n(0, "battle", 0, [2]), _n(1, "battle", 0, [2]), _n(2, "boss", 1, [])]
|
||||
Kit.check("范围反证:双入口合法(入口唯一判据已随模板池退役,个数归产出侧)〔v1〕", _errs(two_entries).is_empty())
|
||||
# 良构字段缺失(无 next 键)该报错——不属被退役判据
|
||||
var missing_key := [{"id": 0, "type": "battle", "depth": 0}, _n(1, "boss", 1, [])]
|
||||
Kit.check("范围反证:节点缺 next 字段仍被良构基底拒〔v1〕", _has(_errs(missing_key), "structural", "缺字段 next"))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhymincbe2j4m
|
||||
@@ -313,15 +313,15 @@ func _meta_settle_and_workshop() -> void:
|
||||
Kit.check("开局传参夹具前置:Fork 侧 a 激活 rule 键非空 + Y=Lv2 行表值 2(防断言空转)",
|
||||
rules_e.has("posture_first_strike") and mp_e.attune_refresh_charges() == 2)
|
||||
var p_new: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e,
|
||||
{"first_script": true, "treasure_unlocked": {}, "shop_mode": "pool"})
|
||||
{"treasure_unlocked": {"wide_scout_band": true}, "shop_mode": "pool"})
|
||||
Kit.check("主菜单开新冒险参数面:同调装配 = 存档装配集(营地装了什么开局就带什么)",
|
||||
(p_new.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array))
|
||||
Kit.check("主菜单开新冒险参数面:Y 刷新池 = 当前等级行表值(营地升几档开局就给几次)",
|
||||
int(p_new.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges())
|
||||
Kit.check("主菜单开新冒险参数面:树 rule 键集 = Fork 激活侧 effects(点哪侧带哪侧)",
|
||||
(p_new.get("tree_rules", []) as Array) == rules_e)
|
||||
Kit.check("主菜单开新冒险参数面:入口特有键(first_script/shop_mode)随 extra 原样并入",
|
||||
bool(p_new.get("first_script", false)) and String(p_new.get("shop_mode", "")) == "pool")
|
||||
Kit.check("主菜单开新冒险参数面:入口特有键(treasure_unlocked/shop_mode)随 extra 原样并入",
|
||||
bool((p_new.get("treasure_unlocked", {}) as Dictionary).get("wide_scout_band", false)) and String(p_new.get("shop_mode", "")) == "pool")
|
||||
var p_resume: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e,
|
||||
{"treasure_unlocked": {}})
|
||||
Kit.check("主菜单继续上局参数面:三键与开新冒险同源同值(续玩不吃白板开局)",
|
||||
@@ -333,7 +333,7 @@ func _meta_settle_and_workshop() -> void:
|
||||
var adr_res = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(19))
|
||||
var bp_min: Dictionary = {"level_index": 1, "node_id": -1, "hero_hp": 40, "gp": 5,
|
||||
"bonus": {"atk": 0, "max_hp": 0, "armor_class": 0}, "shop_purchased": {},
|
||||
"template_pick": -1, "master_seed": 777, "rng_counter": 3, "keystone": 0,
|
||||
"graph_gen_v2": 1, "master_seed": 777, "rng_counter": 3, "keystone": 0,
|
||||
"visited_ids": []}
|
||||
Kit.check("继续上局受理面:resume_adventure 受理三键参数面(尾裁微批b 受理闭环——受理不再吃白板)",
|
||||
adr_res.resume_adventure(bp_min, p_resume))
|
||||
@@ -662,6 +662,30 @@ func _smoke_tree_rules() -> void:
|
||||
ctx_fz2.level_bucket = lvl_shared # 挂同一 level 桶引用(新形态跨战持久:无 export/seed,同字典即持久)
|
||||
Kit.check("锋:限次跨战持久(level 桶共享引用——替代旧副本搬运链)〔无状态化 V4〕",
|
||||
(fz2.dispatch(ctx_fz2, "mine_hit", {}, Callable()) as Array).is_empty())
|
||||
# 锋·惊巢反制效果行换代断言(迭代36 u13/wa12——_grant_routed 减半分支效果行=战斗日志
|
||||
# step_row 真实生产面;display_name 玩家可见,禁字「挖雷」由此行清零钉住)
|
||||
var orch_nc = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777003))
|
||||
orch_nc.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40,
|
||||
"hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {},
|
||||
"feats": [], "tree_rules": [], "battle_index": 1,
|
||||
"bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {},
|
||||
"voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}) # 真实战斗上下文(状态容器开域——dispatch 链受理)
|
||||
var nc_rows: Array = [] # step_row 采集盒(lambda 捕引用容器——捕获变量赋值不出闭包)
|
||||
orch_nc.step_row.connect(func(r): nc_rows.append(r))
|
||||
# heal 负向行表值 power=6(极性字典行值——负向 power 走表值不走快照)→ halve 50% ceil=3
|
||||
orch_nc._grant_routed({"cell_type": "heal", "power": 10}, "negative",
|
||||
"mine_hit", Vector2i.ZERO, 0, "", "", 50)
|
||||
# 受理判据=惊巢反制效果行落 step_row(halve 分支先于 dispatch 发行——flat 行 _dispatch_row
|
||||
# 恒返 false,返回值不承载受理语义;行发行即分支受理)
|
||||
var nc_hit := ""
|
||||
for r in nc_rows:
|
||||
if String((r as Dictionary).get("display_name", "")).contains("惊巢反制"):
|
||||
nc_hit = String((r as Dictionary).get("display_name", ""))
|
||||
break
|
||||
Kit.check("锋:惊巢反制 mine_hit 减半分支受理(wa12 前提——halve_pct=50 效果行落 step_row)〔wa12〕",
|
||||
nc_hit != "")
|
||||
Kit.check("惊巢反制效果行==「惊巢反制——危险格挖掘惩罚减半(6→3)」零「挖雷」(u13 换代)〔wa12〕",
|
||||
nc_hit == "惊巢反制——危险格挖掘惩罚减半(6→3)" and not nc_hit.contains("挖雷"))
|
||||
# 守侧五场景(契约 12):编排 rig——begin_battle 携 sense_guard,授予段 _grant_seg._judge_seal 直注
|
||||
var orch = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777001))
|
||||
Kit.check("守侧 rig:战斗编排启动(tree_rules=sense_guard 携带)",
|
||||
|
||||
@@ -34,7 +34,7 @@ func _init() -> void:
|
||||
|
||||
# T10——空配置验收闸(三选一池空兜底/商店空货架)
|
||||
var e = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new())
|
||||
e.start_adventure(501, {"first_script": true})
|
||||
e.start_adventure(501)
|
||||
e._curio_pending = {"candidates": []}
|
||||
Kit.check_quiet(e.skip_empty_curio() and e._curio_pending.is_empty(),
|
||||
"空配置闸:三选一池空=停点整体跳过(skip_empty_curio 消化)〔专长批 T10〕")
|
||||
@@ -65,7 +65,7 @@ func _tiers_of(cands: Array) -> Array:
|
||||
|
||||
|
||||
func check_v7(a, db) -> void:
|
||||
if not a.start_adventure(201, {"first_script": true}):
|
||||
if not a.start_adventure(201):
|
||||
Kit.check_quiet(false, "V7 前置:冒险启动")
|
||||
return
|
||||
# 构成①:全池含优秀档以上——曝光槽应出 1 格高档(rank≥excellent)
|
||||
@@ -110,7 +110,7 @@ func check_v7(a, db) -> void:
|
||||
|
||||
|
||||
func check_v6(b, db) -> void:
|
||||
if not b.start_adventure(202, {"first_script": true}):
|
||||
if not b.start_adventure(202):
|
||||
Kit.check_quiet(false, "V6 前置:冒险启动")
|
||||
return
|
||||
# 拿了消耗:直置停点候选后 pick_curio——牌库摘牌单点
|
||||
@@ -139,7 +139,7 @@ func check_v6(b, db) -> void:
|
||||
|
||||
|
||||
func check_v12(m, db) -> void:
|
||||
if not m.start_adventure(401, {"first_script": true}):
|
||||
if not m.start_adventure(401):
|
||||
Kit.check_quiet(false, "V12 前置:冒险启动")
|
||||
return
|
||||
var tier_n := {"common": 0, "excellent": 0, "rare": 0, "legendary": 0}
|
||||
@@ -157,7 +157,7 @@ func check_v12(m, db) -> void:
|
||||
|
||||
|
||||
func check_v8(c, db) -> void:
|
||||
if not c.start_adventure(203, {"first_script": true}):
|
||||
if not c.start_adventure(203):
|
||||
Kit.check_quiet(false, "V8 前置:冒险启动")
|
||||
return
|
||||
# 概率位与 cap 两属性独立:改 cap 不动牌库(概率位)、改牌库不动 cap 字段
|
||||
|
||||
@@ -184,7 +184,7 @@ func _smoke_save() -> void:
|
||||
"level_index": 2, "node_id": 5, "hero_hp": 13,
|
||||
"bonus": {"atk": 2, "max_hp": 5, "armor_class": 1},
|
||||
"gp": 17, "shop_purchased": {"hp_small": 2, "atk_small": 1},
|
||||
"template_pick": 3,
|
||||
"graph_gen_v2": 1,
|
||||
"master_seed": 424242, "rng_counter": 38, "keystone": 6,
|
||||
"visited_ids": [0, 2, 5],
|
||||
"aux_items": [{"item_id": "aux_scout", "source_class": "reward_node",
|
||||
@@ -205,7 +205,7 @@ func _smoke_save() -> void:
|
||||
var bp_shop: Dictionary = bp.get("shop_purchased", {})
|
||||
Kit.check("断点七字段往返:商店已购档",
|
||||
int(bp_shop.get("hp_small", -1)) == 2 and int(bp_shop.get("atk_small", -1)) == 1)
|
||||
Kit.check("断点七字段往返:已抽模板", int(bp.get("template_pick", -1)) == 3)
|
||||
Kit.check("断点七字段往返:生成图代差标记键", int(bp.get("graph_gen_v2", 0)) == 1)
|
||||
Kit.check("断点附随件往返:主种子/计数器/钥石累计",
|
||||
int(bp.get("master_seed", -1)) == 424242 and int(bp.get("rng_counter", -1)) == 38
|
||||
and int(bp.get("keystone", -1)) == 6)
|
||||
@@ -237,7 +237,7 @@ func _smoke_save() -> void:
|
||||
fv2f.store_string(JSON.stringify({"version": 2, "meta": {"keystone_balance": 7},
|
||||
"breakpoint": {"level_index": 1, "node_id": -1, "hero_hp": 40, "gp": 5,
|
||||
"bonus": {"atk": 0, "max_hp": 0, "armor_class": 0}, "shop_purchased": {},
|
||||
"template_pick": -1, "master_seed": 777, "rng_counter": 3, "keystone": 0,
|
||||
"graph_gen_v2": 1, "master_seed": 777, "rng_counter": 3, "keystone": 0,
|
||||
"visited_ids": []}}))
|
||||
fv2f.close()
|
||||
Kit.check("v2 旧档迁移 v3:断点域与 meta 域原样保留(缺新附随件=合法空态)",
|
||||
|
||||
@@ -72,14 +72,6 @@ static func _errs_has_cross_field(errs: Array) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标)
|
||||
func _boss_depth(level_index: int) -> int:
|
||||
for n in _level_active_nodes(level_index):
|
||||
if String(n.get("type", "")) == "boss":
|
||||
return int(n.get("node_depth", -1))
|
||||
return -1
|
||||
|
||||
|
||||
# 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字
|
||||
func _first_encounter_band(level_index: int, enemy_id: String) -> int:
|
||||
var best := -1
|
||||
@@ -94,17 +86,6 @@ func _first_encounter_band(level_index: int, enemy_id: String) -> int:
|
||||
return best
|
||||
|
||||
|
||||
# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义)
|
||||
func _level_active_nodes(level_index: int) -> Array:
|
||||
for row in _db.level.rows:
|
||||
if int(row.level_index) == level_index:
|
||||
var tpls: Array = row.get("templates", [])
|
||||
if not tpls.is_empty():
|
||||
return tpls[0].get("nodes", [])
|
||||
return row.get("nodes", [])
|
||||
return []
|
||||
|
||||
|
||||
# ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸;TP0 令牌池:配额结构/件值对/池总量域检)
|
||||
func _smoke_adventure_table() -> void:
|
||||
print("--- 冒险表(AdventureTable——令牌池行值单点:回血/配额/件值对/同调位)---")
|
||||
@@ -222,26 +203,31 @@ func _smoke_adventure_table() -> void:
|
||||
v18_ok and locked_n == 12)
|
||||
|
||||
|
||||
# ---- 敌人表深度带键结构(迭代 34:20 行键迁移 · 键序=迁移表序 · hobgoblin 留任 · 三 Boss 行 kind/band)----
|
||||
# ---- 敌人表深度带键结构(迭代 34 建·迭代 35 覆盖批换代:28 行键序=覆盖表序·hobgoblin 留任·Boss 行集带键清单)----
|
||||
|
||||
func _smoke_enemy_bands() -> void:
|
||||
print("--- 敌人表深度带键结构(20 行键序 · hobgoblin 留任 · 三 Boss 行)---")
|
||||
# ① 20 行计数 + 键序 = 迁移表序(文件行序不变——行序即契约)
|
||||
print("--- 敌人表深度带键结构(28 行键序 · hobgoblin 留任 · Boss 带键覆盖表)---")
|
||||
# ① 28 行计数 + 键序 = 覆盖表序(文件行序不变——行序即契约)
|
||||
# 迭代 35 生成图深度扩面覆盖批随动:战斗带顶扩到各关最大生成深度(L1→6/L2→9/L3→13)、
|
||||
# Boss 行按终点深度分带(L1 6/L2 8·9/L3 11·12·13),模板时代带键 4/9/9 全部在册不动
|
||||
var expect_keys: Array[String] = [
|
||||
"1|battle|0|goblin", "1|battle|1|goblin", "1|battle|2|bugbear", "1|battle|3|hobgoblin",
|
||||
"1|boss|4|ogre_chief",
|
||||
"1|battle|6|bugbear",
|
||||
"1|boss|4|ogre_chief", "1|boss|6|ogre_chief",
|
||||
"2|battle|0|bugbear", "2|battle|1|bugbear", "2|battle|4|giant_spider",
|
||||
"2|battle|5|bugbear", "2|battle|5|giant_spider", "2|battle|6|giant_spider", "2|battle|7|giant_spider",
|
||||
"2|boss|9|drider",
|
||||
"2|battle|9|giant_spider",
|
||||
"2|boss|8|drider", "2|boss|9|drider",
|
||||
"3|battle|0|duergar", "3|battle|1|hook_horror", "3|battle|4|hook_horror", "3|battle|5|duergar",
|
||||
"3|battle|6|hook_horror", "3|battle|7|duergar", "3|boss|9|duergar_king",
|
||||
"3|battle|6|hook_horror", "3|battle|7|duergar", "3|battle|13|duergar",
|
||||
"3|boss|9|duergar_king", "3|boss|11|duergar_king", "3|boss|12|duergar_king", "3|boss|13|duergar_king",
|
||||
]
|
||||
var got_keys: Array[String] = []
|
||||
for row in _db.enemy.rows:
|
||||
got_keys.append("%d|%s|%d|%s" % [int(row.get("level_index", -1)), String(row.get("row_kind", "")),
|
||||
int(row.get("depth_band", -1)), String(row.get("enemy_id", ""))])
|
||||
Kit.check("敌人表 20 行且键序 = 迁移表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕",
|
||||
_db.enemy.rows.size() == 20 and got_keys == expect_keys)
|
||||
Kit.check("敌人表 28 行且键序 = 覆盖表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕",
|
||||
_db.enemy.rows.size() == 28 and got_keys == expect_keys)
|
||||
# ② hobgoblin 缺口行留任随迁(契约 9:不删行不补立绘——缺口登记随行)
|
||||
var hob := {}
|
||||
for row in _db.enemy.rows:
|
||||
@@ -252,23 +238,27 @@ func _smoke_enemy_bands() -> void:
|
||||
and int(hob.get("depth_band", -1)) == 3)
|
||||
Kit.check("hobgoblin 缺口登记随行(表文件注释在位——缺口不静默)〔fv25〕",
|
||||
FileAccess.get_file_as_string("res://data/tables/enemy.tres").contains("hobgoblin 行为缺口行"))
|
||||
# ③ 三 Boss 行 kind/band 就位(band = 该关图 Boss 节点深度——单终点图数据驱动)
|
||||
# ③ Boss 行集带键 = 覆盖表(旧判据「各恰一行=图 Boss 节点深度」属单终点模板时代;
|
||||
# 生成图多终点要求每终点深度解析到互异带键 ⇒ 每关 Boss 行必然多于一行——
|
||||
# 迭代 35 覆盖批随动改写为带键清单,「带键互异」的运行时把关在 level_shop_economy_test〔v11〕)
|
||||
var boss_bands := {}
|
||||
for row in _db.enemy.rows:
|
||||
if String(row.get("row_kind", "")) == "boss":
|
||||
var lk: int = int(row.get("level_index", -1))
|
||||
if not boss_bands.has(lk):
|
||||
boss_bands[lk] = []
|
||||
boss_bands[lk].append(int(row.get("depth_band", -1)))
|
||||
var expect_bands := {1: [4, 6], 2: [8, 9], 3: [9, 11, 12, 13]}
|
||||
var boss_ok := true
|
||||
var boss_detail := ""
|
||||
for L in [1, 2, 3]:
|
||||
var bdepth := _boss_depth(L)
|
||||
var hits := 0
|
||||
for row in _db.enemy.rows:
|
||||
if int(row.get("level_index", -1)) == L and String(row.get("row_kind", "")) == "boss":
|
||||
hits += 1
|
||||
if int(row.get("depth_band", -1)) != bdepth:
|
||||
boss_ok = false
|
||||
boss_detail += " L%d:%d≠%d" % [L, int(row.get("depth_band", -1)), bdepth]
|
||||
if hits != 1:
|
||||
var got: Array = boss_bands.get(L, [])
|
||||
got.sort()
|
||||
if got != expect_bands[L]:
|
||||
boss_ok = false
|
||||
boss_detail += " L%d:boss 行×%d" % [L, hits]
|
||||
Kit.check("三关 Boss 行各恰一行且 row_kind=boss、band=图 Boss 节点深度(%d/%d/%d%s)〔fv13〕" % [
|
||||
_boss_depth(1), _boss_depth(2), _boss_depth(3), boss_detail], boss_ok)
|
||||
boss_detail += " L%d:%s≠%s" % [L, str(got), str(expect_bands[L])]
|
||||
Kit.check("三关 Boss 行集带键 = 覆盖表(模板终点 4/9/9 在册+生成图终点 6/8/9/11/12/13 扩带)%s〔fv13〕" % boss_detail,
|
||||
boss_ok)
|
||||
|
||||
|
||||
# ② 免伤墙归零护栏泛化保留(BUG-0003 事故类永久免疫):hero_defense 池任何 percent 行
|
||||
|
||||
@@ -29,7 +29,7 @@ func _run() -> void:
|
||||
quit(Kit.finish("level_map_info_fog_test"))
|
||||
|
||||
|
||||
# 白盒 runner:夹具行集注入 db.level + level_row,入口停点态(node_index=-1,候选=入边首节点)
|
||||
# 白盒 runner:夹具行集注入 db.level + level_row,入口停点态(node_index=-1,候选=首层首节点句柄 1)
|
||||
func _runner(db, nodes: Array, candidates: Array, n: int = 2):
|
||||
var t := LevelTableScript.new()
|
||||
t.info_visible_layers = n
|
||||
@@ -37,7 +37,6 @@ func _runner(db, nodes: Array, candidates: Array, n: int = 2):
|
||||
db.level = t
|
||||
var r = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new())
|
||||
r.level_row.assign({"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes})
|
||||
r._graph_mode = true
|
||||
r.node_index = -1
|
||||
r._candidates.assign(candidates)
|
||||
return r
|
||||
@@ -46,7 +45,7 @@ func _runner(db, nodes: Array, candidates: Array, n: int = 2):
|
||||
# ---- ① 范围外只见结构:雾下节点类型图标与文字均不绘制〔fv2〕----
|
||||
|
||||
func _test_fog_hides_type() -> void:
|
||||
var node := {"node_depth": 3, "type": "battle"}
|
||||
var node := {"id": 3, "type": "battle", "depth": 3, "next": []}
|
||||
var shown: Dictionary = ScreenScript.node_display(node, 2, [2], false, false)
|
||||
Kit.check("可见集内节点:图标/类型文字均绘制〔fv2〕",
|
||||
bool(shown["draw_icon"]) and bool(shown["draw_label"]))
|
||||
@@ -60,24 +59,24 @@ func _test_fog_hides_type() -> void:
|
||||
|
||||
func _test_boss_exempt_and_multi_boss() -> void:
|
||||
var type_vis: Array = [1, 7, 9] # 双 Boss 图:7/9 均在可见集
|
||||
var boss_a: Dictionary = ScreenScript.node_display({"node_depth": 7, "type": "boss"}, 7, type_vis, false, false)
|
||||
var boss_b: Dictionary = ScreenScript.node_display({"node_depth": 9, "type": "boss"}, 9, type_vis, false, false)
|
||||
var boss_a: Dictionary = ScreenScript.node_display({"id": 7, "type": "boss", "depth": 7, "next": []}, 7, type_vis, false, false)
|
||||
var boss_b: Dictionary = ScreenScript.node_display({"id": 9, "type": "boss", "depth": 9, "next": []}, 9, type_vis, false, false)
|
||||
Kit.check("多终点:两个 Boss 节点均带 Boss 描边〔fv3〕",
|
||||
bool(boss_a["boss_border"]) and bool(boss_b["boss_border"]))
|
||||
Kit.check("Boss 不入雾:可见集与图标/文字三通道均在〔fv3〕",
|
||||
bool(boss_a["draw_icon"]) and bool(boss_a["draw_label"]) and bool(boss_b["draw_icon"]))
|
||||
var far_boss: Dictionary = ScreenScript.node_display({"node_depth": 9, "type": "boss"}, 9, [1], false, false)
|
||||
var far_boss: Dictionary = ScreenScript.node_display({"id": 9, "type": "boss", "depth": 9, "next": []}, 9, [1], false, false)
|
||||
Kit.check("Boss 豁免独立于可见集:不在 type_vis 的 Boss 仍出示类型〔fv3〕",
|
||||
bool(far_boss["draw_icon"]) and bool(far_boss["draw_label"]))
|
||||
var fogged_battle: Dictionary = ScreenScript.node_display({"node_depth": 5, "type": "battle"}, 5, type_vis, false, false)
|
||||
var fogged_battle: Dictionary = ScreenScript.node_display({"id": 5, "type": "battle", "depth": 5, "next": []}, 5, type_vis, false, false)
|
||||
Kit.check("Boss 豁免不扩散:雾下非 Boss 仍隐类型〔fv3〕", not bool(fogged_battle["draw_icon"]))
|
||||
|
||||
|
||||
# ---- ③ 节点内详情图上从不显示〔fv5〕----
|
||||
|
||||
func _test_details_never_shown() -> void:
|
||||
var plain := {"node_depth": 3, "type": "battle"}
|
||||
var loaded := {"node_depth": 3, "type": "battle", "enemy_id": "goblin_secret", "reward_id": "reward_secret"}
|
||||
var plain := {"id": 3, "type": "battle", "depth": 3, "next": [4]}
|
||||
var loaded := {"id": 3, "type": "battle", "depth": 3, "next": [4], "enemy_id": "goblin_secret", "reward_id": "reward_secret"}
|
||||
var a: Dictionary = ScreenScript.node_display(plain, 3, [3], false, true)
|
||||
var b: Dictionary = ScreenScript.node_display(loaded, 3, [3], false, true)
|
||||
var same := true
|
||||
@@ -92,7 +91,7 @@ func _test_details_never_shown() -> void:
|
||||
# ---- ④ label_text 三态文案〔fv2〕----
|
||||
|
||||
func _test_label_three_states() -> void:
|
||||
var node := {"node_depth": 3, "type": "battle"}
|
||||
var node := {"id": 3, "type": "battle", "depth": 3, "next": [4]}
|
||||
Kit.check("当前位文案=「当前位置」〔fv2〕",
|
||||
String(ScreenScript.node_display(node, 3, [3], true, false)["label_text"]) == "当前位置")
|
||||
Kit.check("候选文案=「战斗 · 可选」〔fv2〕",
|
||||
@@ -103,9 +102,10 @@ func _test_label_three_states() -> void:
|
||||
|
||||
# ---- 大图(11 层×6 槽含双 Boss)画布拟合与布局包络〔fv8〕----
|
||||
|
||||
# 大图夹具:编号按 12 层×6 槽构造,层 0 入口、末层双 Boss;含层 1 首节点→第 3 层节点的跨层跳边。
|
||||
# 跳边目标按 BFS 最短路径提前一层,其后继链随之提前——布局落 11 层(最大槽 7:
|
||||
# 跳边目标并入第 2 层槽组)。断言按布局实测取最大层/最大槽,画布公式与之同源。
|
||||
# 大图夹具:按 12 层×6 槽构造(id = 图内句柄 = 构造序号;depth 随构造序号严格前进——生成图连线铁律),
|
||||
# 层 0 入口、末层双 Boss(id 61/62,句柄互异、next 恒空);含层 1 首节点→第 3 层节点的跨层长跳边。
|
||||
# 跳边目标按 BFS 最短路径提前一层,其后继链随之逐层提前(目标并入第 2 层槽组)——布局落 11 层。
|
||||
# 断言按布局实测取最大层/最大槽,画布公式与之同源。
|
||||
func _big_graph_nodes() -> Array:
|
||||
var layers: Array = []
|
||||
var total := 0
|
||||
@@ -118,30 +118,25 @@ func _big_graph_nodes() -> Array:
|
||||
total += 1
|
||||
layers.append(ids)
|
||||
var nodes: Array = []
|
||||
var jump_from := -1
|
||||
var jump_to := -1
|
||||
for l in tiers:
|
||||
for i in layers[l].size():
|
||||
var nid: int = layers[l][i]
|
||||
var n := {"node_depth": nid}
|
||||
if l == 0:
|
||||
n["type"] = "battle"
|
||||
n["next"] = layers[1].duplicate() # 入口扇出到全层(层宽 6)
|
||||
elif l == tiers - 1:
|
||||
n["type"] = "boss"
|
||||
n["next"] = []
|
||||
var t := "battle"
|
||||
var next_ids: Array = []
|
||||
if l == tiers - 1:
|
||||
t = "boss"
|
||||
elif l == 0:
|
||||
next_ids = layers[1].duplicate() # 入口扇出到全层(层宽 6)
|
||||
else:
|
||||
if i % 5 == 0:
|
||||
n["type"] = "reward" if l % 2 == 0 else "rest"
|
||||
else:
|
||||
n["type"] = "battle"
|
||||
t = "reward" if l % 2 == 0 else "rest"
|
||||
var w2: int = layers[l + 1].size()
|
||||
n["next"] = [layers[l + 1][i % w2], layers[l + 1][(i + 1) % w2]]
|
||||
nodes.append(n)
|
||||
jump_from = layers[1][0]
|
||||
jump_to = layers[3][2]
|
||||
next_ids = [layers[l + 1][i % w2], layers[l + 1][(i + 1) % w2]]
|
||||
nodes.append({"id": nid, "type": t, "depth": nid, "next": next_ids})
|
||||
var jump_from: int = layers[1][0]
|
||||
var jump_to: int = layers[3][2]
|
||||
for n in nodes:
|
||||
if int(n["node_depth"]) == jump_from:
|
||||
if int(n["id"]) == jump_from:
|
||||
(n["next"] as Array).append(jump_to)
|
||||
break
|
||||
return nodes
|
||||
@@ -188,9 +183,9 @@ func _test_big_graph_fit(db) -> void:
|
||||
var boss_ids: Array = []
|
||||
for node in nodes:
|
||||
if String(node["type"]) == "boss":
|
||||
boss_ids.append(int(node["node_depth"]))
|
||||
boss_ids.append(int(node["id"]))
|
||||
Kit.check("大图呈现:两个终点 Boss 均有描边标记〔fv8〕",
|
||||
disp.has(boss_ids[0]) and disp.has(boss_ids[1])
|
||||
and bool(disp[boss_ids[0]]["boss_border"]) and bool(disp[boss_ids[1]]["boss_border"]))
|
||||
Kit.check("大图呈现:入口停点远处战斗节点(编号 32,层 6)类型入雾〔fv8〕", not bool(disp[32]["draw_icon"]))
|
||||
Kit.check("大图呈现:入口停点远处战斗节点(句柄 32,层 6)类型入雾〔fv8〕", not bool(disp[32]["draw_icon"]))
|
||||
screen.queue_free()
|
||||
@@ -0,0 +1,174 @@
|
||||
# tests/unit/presentation/player_copy_sweep_test.gd — 玩家面文案清扫直建断言宿主(迭代36 卡A)
|
||||
# 断言面 = 玩家面字符串字面量换代钉值(u1–u13 钉值表——分类词/渠道旧话/「雷」族禁字清零):
|
||||
# ① hero 面板两段头(专长/属性值提升——同屏分类撞车消除)
|
||||
# ② 奖励节点三选一头部(冒险牌库/渠道分流三机制事实,零「同权重/同上限/提升池」旧话)
|
||||
# ③ 专长卡兜底(feat_card_text 空行兜底「专长\n???」)
|
||||
# ④ 图例封印行(标空/受挖掘惩罚,零「插错/挖雷」)
|
||||
# ⑤ 状态槽来源名(mine_hit→危险格——tooltip 消费半)
|
||||
# ⑥ 结算来源名(settlement 表值经 ConfigDB 真装载+battle_log 行消费半)
|
||||
# ⑦ 已开危险格字形(glyph「危」+plate+RED_MINE——BoardView rebuild 真实刷新路径)
|
||||
# ⑧ 源 token 扫描一:board_view.gd 零 "雷" 字面量(哨兵族统一钉住)
|
||||
# ⑩ 源 token 扫描二:battle_orchestrator.gd 玩家面字符串字面量零「挖雷」(注释剥离=行首 # 判)
|
||||
# 扫域口径:玩家面字符串字面量(契约 2 保留面——注释/内部常量名与注记/测试消息串不入域)。
|
||||
# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload,
|
||||
# ConfigDB 显式实例化构造注入(统一流 master 种子 42 沿源)。
|
||||
# 运行:godot --headless --path game --script res://tests/unit/presentation/player_copy_sweep_test.gd
|
||||
extends SceneTree
|
||||
|
||||
const Kit := preload("res://tests/unit/test_kit.gd")
|
||||
const ConfigDBScript := preload("res://core/config_db.gd")
|
||||
const AutoPlayer := preload("res://sim/auto_player.gd") # 自动玩家策略单一实现(夹具开域驱动)
|
||||
|
||||
var _db
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_db = ConfigDBScript.new()
|
||||
var load_ok: bool = _db.load_tables("res://data/tables", true)
|
||||
if not load_ok:
|
||||
push_error("[player_copy_sweep_test] 表加载返回失败——诊断如下(红由具体断言落)")
|
||||
for d in _db.diagnostics():
|
||||
if d.severity == "error" or d.severity == "fatal":
|
||||
push_error(" [%s] %s: %s" % [d.severity, d.category, d.message])
|
||||
_sweep_hero_panel()
|
||||
_sweep_narrator()
|
||||
_sweep_legend()
|
||||
_sweep_source_names()
|
||||
_sweep_board_glyph()
|
||||
_sweep_token_scans()
|
||||
quit(Kit.finish("player_copy_sweep_test"))
|
||||
|
||||
|
||||
# ① C 区两段头(u1/u2——直建 hero 面板,adventure_feats_test ⑦c 族同构)
|
||||
func _sweep_hero_panel() -> void:
|
||||
var panel = load("res://presentation/views/hero_info_panel_view.gd").new()
|
||||
var feat_head := _first_label(panel._feat_section)
|
||||
var curio_head := _first_label(panel._curio_section)
|
||||
Kit.check("C 区专长段头==「专长」(u1 换代——段头文案钉值)〔wa1〕",
|
||||
feat_head != null and feat_head.text == "专长")
|
||||
Kit.check("C 区属性值提升段头==「属性值提升」(u2 换代——同屏两段同标撞车消除)〔wa1〕",
|
||||
curio_head != null and curio_head.text == "属性值提升")
|
||||
|
||||
|
||||
# ②③ 奖励节点头部+专长卡兜底(u3/u11——静态直调)
|
||||
func _sweep_narrator() -> void:
|
||||
var narrator = load("res://presentation/battle_narrator.gd")
|
||||
var head := String(narrator.node_curio_header())
|
||||
Kit.check("奖励点头部含「冒险牌库」(u3 机制事实①同库)〔wa4〕", head.contains("冒险牌库"))
|
||||
Kit.check("奖励点头部含「属性值提升与补给」(u3 机制事实②渠道分流)〔wa4〕", head.contains("属性值提升与补给"))
|
||||
Kit.check("奖励点头部含「同库分流」(u3 机制事实②措辞)〔wa4〕", head.contains("同库分流"))
|
||||
Kit.check("奖励点头部零「同权重」旧话(u3 清零)〔wa4〕", not head.contains("同权重"))
|
||||
Kit.check("奖励点头部零「同上限」旧话(u3 清零)〔wa4〕", not head.contains("同上限"))
|
||||
Kit.check("奖励点头部零「提升池」旧话(u3 清零)〔wa4〕", not head.contains("提升池"))
|
||||
Kit.check("专长卡兜底==「专长\n???」(u11 换代——数据缺行真实兜底路径)〔wa3〕",
|
||||
String(narrator.feat_card_text({})) == "专长\n???")
|
||||
|
||||
|
||||
# ④ 图例封印行(u4——直建 LegendOverlay 读 ENTRIES 常量)
|
||||
func _sweep_legend() -> void:
|
||||
load("res://presentation/views/legend_overlay.gd").new() # 直建(_init(rap_cost) 现签名)
|
||||
var seal_row: Array = []
|
||||
for e in load("res://presentation/views/legend_overlay.gd").ENTRIES:
|
||||
if String(e[1]) == "封印":
|
||||
seal_row = e
|
||||
break
|
||||
Kit.check("图例封印行存在(wa5 夹具前提)〔wa5〕", not seal_row.is_empty())
|
||||
Kit.check("图例封印行含「标空」(u4——与「标中」成对)〔wa5〕", String(seal_row[2] if seal_row.size() > 2 else "").contains("标空"))
|
||||
Kit.check("图例封印行含「受挖掘惩罚」(u4 雷字清零换代)〔wa5〕", String(seal_row[2] if seal_row.size() > 2 else "").contains("受挖掘惩罚"))
|
||||
Kit.check("图例封印行零「插错」(u4 旗族遗留词干清零)〔wa5〕", not String(seal_row[2] if seal_row.size() > 2 else "").contains("插错"))
|
||||
Kit.check("图例封印行零「挖雷」(u4 雷字清零)〔wa5〕", not String(seal_row[2] if seal_row.size() > 2 else "").contains("挖雷"))
|
||||
|
||||
|
||||
# ⑤⑥ 来源名双源(u5/u6——状态槽消费半+表值 ConfigDB 真装载+battle_log 行消费半)
|
||||
func _sweep_source_names() -> void:
|
||||
var row_view = load("res://presentation/views/status_slot_row.gd").new()
|
||||
row_view.set_effects([{"effect_id": "alert", "source_class": "mine_hit",
|
||||
"display_name": "危险格受击", "remaining_settlements": 2, "round_index": 1}])
|
||||
var slot: PanelContainer = null
|
||||
for child in row_view.get_children():
|
||||
if child is PanelContainer and child.has_meta("eff"):
|
||||
slot = child
|
||||
break
|
||||
Kit.check("状态槽 mine_hit 效果行在座(wa6 夹具前提)〔wa6〕", slot != null)
|
||||
if slot != null:
|
||||
row_view._on_slot_hover(slot)
|
||||
Kit.check("状态槽来源注记==「危险格」(u5 换代——meta tooltip 消费半)〔wa6〕",
|
||||
row_view._tip_label.text.contains("来源:危险格"))
|
||||
row_view._hide_tip()
|
||||
Kit.check("结算表 source_label_map[mine_hit]==「危险格」(u6 换代——ConfigDB 真装载)〔wa7〕",
|
||||
String(_db.settlement.source_label_map.get("mine_hit", "")) == "危险格")
|
||||
var log_view = load("res://presentation/views/battle_log_view.gd").new(_db)
|
||||
var row_txt := String(log_view._format_row({"type": "effect", "source_class": "mine_hit",
|
||||
"display_name": "挖掘反噬", "value": -5, "pool_value": -5, "round_index": 1}))
|
||||
Kit.check("战斗日志 mine_hit 来源行含「(危险格)」(u6 消费半——_format_row 生产路径)〔wa7〕",
|
||||
row_txt.contains("(危险格)"))
|
||||
|
||||
|
||||
# ⑦⑧ 已开危险格字形族(u7/u8/u9——BoardView rebuild 真实刷新路径+源 token 扫描)
|
||||
func _sweep_board_glyph() -> void:
|
||||
var board_generator = load("res://core/board_generator.gd").new()
|
||||
var b = board_generator.generate(1, 0, _mk_stream("board_gen", 4242), _db)
|
||||
var ae = load("res://core/action_economy.gd").new(_db)
|
||||
var ms = load("res://core/minesweeper_rules.gd").new(_db, ae)
|
||||
ms.install_board(b)
|
||||
ae.notify_round_start()
|
||||
# 邻接雷定位(挖掘面规则=须邻接英雄——novice 驱动开域至危险格入挖掘射程;上限 60 步防冻态)
|
||||
var mine_cell := Vector2i(-1, -1)
|
||||
var glow_steps := 0
|
||||
while glow_steps < 60:
|
||||
for c in b.neighbors8(ms.hero_pos()):
|
||||
if b.is_mine(c) and not ms.is_revealed(c):
|
||||
mine_cell = c
|
||||
break
|
||||
if mine_cell.x >= 0:
|
||||
break
|
||||
var act: Dictionary = AutoPlayer.novice_action(ms)
|
||||
if String(act.get("kind", "stop")) == "stop":
|
||||
break
|
||||
var op := String(act.get("kind", ""))
|
||||
if op == "seal":
|
||||
op = "seal_place"
|
||||
ms.settle_batch([{"action": op, "cell": act.get("cell")}])
|
||||
glow_steps += 1
|
||||
Kit.check("棋盘夹具含挖掘射程内未开危险格(wa8 前提——实测 %d 步)〔wa8〕" % glow_steps, mine_cell.x >= 0)
|
||||
if mine_cell.x >= 0:
|
||||
ms.settle_batch([{"action": "dig", "cell": mine_cell}]) # 挖开危险格(真实规则路径——受击开格)
|
||||
var view = load("res://presentation/views/board_view.gd").new()
|
||||
view.rebuild(b, ms) # theme_projection_test.gd:200 先例式直建(缺素材程序化回退态——fresh checkout 冒烟同纪律)
|
||||
var u: Node2D = view._cells[mine_cell]
|
||||
Kit.check("已开危险格 glyph==「危」(u7 换代)〔wa8〕", u.glyph == "危")
|
||||
Kit.check("已开危险格 plate==true(u9 牌载哨兵包含项随动)〔wa8〕", u.plate == true)
|
||||
Kit.check("已开危险格 glyph_color==RED_MINE(u7 墨色不随字形换代漂移)〔wa8〕",
|
||||
u.glyph_color == UiKit.RED_MINE)
|
||||
|
||||
|
||||
# ⑧⑩ 源 token 扫描(注释剥离=行首 # 判——契约 2 保留面不入域)
|
||||
func _sweep_token_scans() -> void:
|
||||
var board_src := FileAccess.get_file_as_string("res://presentation/views/board_view.gd")
|
||||
Kit.check("board_view.gd 源零 \"雷\" 字面量(u7/u8/u9 哨兵族统一钉住)〔wa8〕",
|
||||
board_src.count("\"雷\"") == 0)
|
||||
var orch_src := FileAccess.get_file_as_string("res://core/battle_orchestrator.gd")
|
||||
var orch_hits := 0
|
||||
for line in orch_src.split("\n"):
|
||||
if line.strip_edges().begins_with("#"):
|
||||
continue
|
||||
if line.contains("挖雷"):
|
||||
orch_hits += 1
|
||||
Kit.check("battle_orchestrator.gd 玩家面字符串字面量零「挖雷」(u13——注释保留面不入域)〔wa12〕",
|
||||
orch_hits == 0)
|
||||
|
||||
|
||||
func _first_label(node: Node) -> Label:
|
||||
for child in node.get_children():
|
||||
if child is Label:
|
||||
return child
|
||||
return null
|
||||
|
||||
|
||||
func _mk_stream(stream_name: String, scope: int) -> RandomNumberGenerator:
|
||||
# stream_name = 调用点自述标签(不参与派生),保留参数零改调用方(theme_projection_test 同构)
|
||||
var rg := RandomNumberGenerator.new()
|
||||
var svc = load("res://core/rng_service.gd").new()
|
||||
svc.set_master_seed(42)
|
||||
rg.seed = svc.derive_at(scope)
|
||||
return rg
|
||||
@@ -0,0 +1 @@
|
||||
uid://bavqeaorfbb1j
|
||||
@@ -1,7 +1,8 @@
|
||||
# tests/unit/presentation/theme_projection_test.gd — 呈现投影与主题单元测试宿主
|
||||
# 断言面 = 十套(批 B 断言迁移自 smoke_all.gd,G11 施工组;十套按源编排序迁入):
|
||||
# 断言面 = 十一套(批 B 断言迁移自 smoke_all.gd,G11 施工组;十套按源编排序迁入+开场行后缀尾接协议扩段):
|
||||
# 未开格类型微光(作用集恒等/九型归组封闭/零玩法写入——board_view 微光数据面)
|
||||
# 战斗日志视图行格式化(行协议 9 类呈现层同步闸——真人试玩卡断 bug 回归)
|
||||
# 开场行后缀尾接协议(level_open_line 第五参布尔直控「(含面板加成)」尾接——签名零变)
|
||||
# UI 全局主题(main_theme.tres 挂载/类型覆盖/滑轨两段对比/退役实现零残留/中文字体防御)
|
||||
# UI 按钮五态矩阵(四工厂五态样式盒逐实例 override)
|
||||
# UI 全局纹理过滤(工程 Nearest+平滑渐变件 LINEAR 白名单+光柱 alpha 结构)
|
||||
@@ -42,6 +43,7 @@ func _init() -> void:
|
||||
# 套执行序沿源编排序(_run_core_smoke 序 + _smoke_meta 薄壳尾序)
|
||||
_smoke_type_glow()
|
||||
_smoke_battle_log_view()
|
||||
_smoke_level_open_line()
|
||||
_smoke_global_theme()
|
||||
_smoke_button_five_states()
|
||||
_smoke_texture_filter()
|
||||
@@ -420,6 +422,19 @@ func _smoke_battle_log_view() -> void:
|
||||
view.parsed_text().length() > before)
|
||||
|
||||
|
||||
# ---- 开场行后缀尾接协议(迭代36 wc1 静态半——level_open_line 第五参布尔直控尾接,签名零变;
|
||||
# 该函数此前零既有断言,尾接行为由此钉住)----
|
||||
func _smoke_level_open_line() -> void:
|
||||
var NarratorScript := load("res://presentation/battle_narrator.gd")
|
||||
Kit.check("开场行 boosted=true 尾接「(含面板加成)」〔wc1〕",
|
||||
String(NarratorScript.level_open_line("第一层", 42, 5, 30, true)).contains("(含面板加成)"))
|
||||
Kit.check("开场行 boosted=false 零后缀(白板局不带)〔wc1〕",
|
||||
not String(NarratorScript.level_open_line("第一层", 42, 5, 30, false)).contains("(含面板加成)"))
|
||||
Kit.check("开场行种子/两值带入不受后缀位影响(数值带入回归)〔wc1〕",
|
||||
String(NarratorScript.level_open_line("第一层", 42, 5, 30, true)).contains("种子 42")
|
||||
and String(NarratorScript.level_open_line("第一层", 42, 5, 30, true)).contains("攻击 5 / 生命 30"))
|
||||
|
||||
|
||||
# ---- UI 全局主题冒烟(需求卡片 §7 验收清单 V1-V6)----
|
||||
# V1 挂载覆盖 / V2 滑轨两段 / V3 预盖件运行时证词 / V4 旧形态零残留 /
|
||||
# V5 回归(本冒烟全绿即回归网)/ V6 中文字体防御(挂主题后 default_font 顶掉 custom_font 也不丢字形)
|
||||
@@ -731,63 +746,64 @@ func _smoke_diag_button_retired() -> void:
|
||||
dbg_src.contains('UiKit.button("转储")'))
|
||||
|
||||
|
||||
# ---- 状态面板只读投影一致性(R4 数据只读同源:状态面板展示值逐项 == MetaProgression
|
||||
# 访问器读数;投影拼装与 main._status_data 同构(同访问器族同序)。只读性连接巡检与零动效
|
||||
# 断言归 ui_struct_probe 呈现侧,本段只管数据面)----
|
||||
# ---- 状态面板·本局信息面板投影一致性(迭代36 卡③——八键快照手拼夹具:展示值逐项 ==
|
||||
# 快照钉值;投影拼装口径=组合根 _status_data 八键(数据只读同源)。只读性连接巡检与零动效
|
||||
# 断言归 ui_struct_probe 呈现侧,本段只管数据面;跨关解锁概览断言族随面板换代退役——
|
||||
# 替换判据=sv5 零旧段+八键投影断言(实现报告 §8.3 申报))----
|
||||
func _status_panel_projection() -> void:
|
||||
print("--- 状态面板·只读投影一致性(隔离档 smoke_meta_buy 复用)---")
|
||||
_meta_smoke_cleanup()
|
||||
var sv = _meta_new_save(_META_SMOKE_PATHS[0])
|
||||
var mp = load("res://core/meta_progression.gd").new(_db, sv)
|
||||
# 已购态夹具:余额非零 + 秘宝匣首件解锁——投影可辨非全零白板
|
||||
sv.add_keystone(37)
|
||||
sv.unlock_item("morning_order")
|
||||
sv.write_to_disk()
|
||||
# 投影拼装(main._status_data 同构)+ 视图喂入
|
||||
var data := {"balance": mp.keystone_balance(), "workshop": mp.treasure_shop_status(),
|
||||
"tree": mp.tree_camp_rows(), "fork_groups": mp.tree_fork_groups()}
|
||||
print("--- 状态面板·本局信息面板投影一致性(迭代36 卡③——八键快照手拼夹具,同构口径不依赖 runner)---")
|
||||
_meta_smoke_cleanup() # 套件级夹具清场(生涯页 fixture 依赖此前置——旧状态段同款保留)
|
||||
# 夹具(代拍板 4——任意非零可辨值,禁读 data/tables 现值、禁与属性行断言共值致误判掩盖)
|
||||
var data := {"balance": 77, "hero_hp": 13, "max_hp": 29, "atk": 7, "ac": 11, "gp": 41,
|
||||
"curios": [{"name": "青苔密卷", "used": false}, {"name": "锈蚀怀表", "used": true}],
|
||||
"feats": ["疼痛记忆 ×2", "双管镐 ×1"]}
|
||||
var po = load("res://presentation/views/status_panel.gd").new()
|
||||
po.open_status(data)
|
||||
Kit.check("状态面板开层可见(open_status 直开——P4 甲零动效路径)", po.visible)
|
||||
Kit.check("余额徽投影 = keystone_balance() 读数(%s)" % po._balance_badge.value_label.text,
|
||||
String(po._balance_badge.value_label.text) == "钥石 %d" % mp.keystone_balance())
|
||||
var ws: Array = mp.treasure_shop_status()
|
||||
var ws_ok: bool = po._workshop_holder.get_child_count() == ws.size()
|
||||
var uikit_s = load("res://presentation/views/ui_kit.gd")
|
||||
for i in mini(ws.size(), po._workshop_holder.get_child_count()):
|
||||
var row: Dictionary = ws[i]
|
||||
var lbls2: Array = []
|
||||
for ch in po._workshop_holder.get_child(i).get_children():
|
||||
if ch is Label:
|
||||
lbls2.append(ch)
|
||||
var unlocked: bool = bool(row.get("unlocked", false))
|
||||
var expect_prog := "已入手"
|
||||
if not unlocked:
|
||||
if String(row.get("mode", "")) == "keystone":
|
||||
expect_prog = "钥石 %d" % int(row.get("price", 0))
|
||||
elif String(row.get("mode", "")) == "condition":
|
||||
var parts: Array[String] = []
|
||||
for req_v2 in (row.get("reqs", []) as Array):
|
||||
var req2: Dictionary = req_v2 as Dictionary
|
||||
var lbl2 := String(uikit_s.TREASURE_REQ_LABELS.get(String(req2.get("key", "")), "条件"))
|
||||
parts.append("%s %d/%d" % [lbl2, int(req2.get("progress", 0)), int(req2.get("threshold", 0))])
|
||||
expect_prog = "、".join(parts)
|
||||
var expect_st2 := "已解锁" if unlocked else "未解锁"
|
||||
ws_ok = ws_ok and lbls2.size() == 3 \
|
||||
and String(lbls2[0].text) == String(row.get("display_name", "")) \
|
||||
and String(lbls2[1].text) == expect_prog \
|
||||
and String(lbls2[2].text) == expect_st2
|
||||
Kit.check("秘宝匣 12 行投影 = treasure_shop_status() 逐项(名称/钥石价与条件进度/解锁态——首件解锁夹具在列)〔秘宝批 V19 呈现数据源〕",
|
||||
ws_ok)
|
||||
var owned := 0
|
||||
for trow in (mp.tree_camp_rows() as Array):
|
||||
if int(trow.get("level", 0)) > 0:
|
||||
owned += 1
|
||||
var tree_txt := String(po._tree_label.text)
|
||||
Kit.check("天赋树概览聚合文案与已购节点数一致(零已购=空态句)",
|
||||
tree_txt == "尚未购买任何节点(天赋树在营地主柜台)" or tree_txt.begins_with("已购 %d 项:" % owned))
|
||||
Kit.check("余额徽投影 = 快照 balance 读数(sv4 回归锚)〔sv4〕",
|
||||
String(po._balance_badge.value_label.text) == "钥石 77")
|
||||
Kit.check("属性行全值投影 = 契约4 钉值格式逐字(生命/上限/攻击/AC/GP 五现值)〔sv1〕",
|
||||
String(po._attrs_label.text) == "生命 13/29 · 攻击 7 · 护甲 AC 11 · GP 41")
|
||||
Kit.check("秘宝行投影 = 受理序 join+已用件「(已用)」尾缀〔sv2〕",
|
||||
String(po._curios_label.text) == "青苔密卷 · 锈蚀怀表(已用)")
|
||||
Kit.check("专长行投影 = 行串 join〔sv3〕",
|
||||
String(po._feats_label.text) == "疼痛记忆 ×2 · 双管镐 ×1")
|
||||
# 零旧段(sv5——跨关概览退役零残留:面板子树零旧文案+旧成员退役)
|
||||
var stale := ""
|
||||
for ch in _all_labels(po):
|
||||
if String(ch.text).contains("道具工坊") or String(ch.text).contains("天赋树"):
|
||||
stale += String(ch.text) + "|"
|
||||
Kit.check("面板子树零「道具工坊/天赋树」文案(跨关概览退役零残留)〔sv5〕", stale == "")
|
||||
Kit.check("旧段成员退役(_workshop_holder/_tree_label 零残留——契约2 数据/成员层面清零)〔sv5〕",
|
||||
po.get("_workshop_holder") == null and po.get("_tree_label") == null)
|
||||
# 空态句×2(curios/feats 空夹具各跑一遍——契约4 空态呈现)
|
||||
var data_empty := {"balance": 3, "hero_hp": 5, "max_hp": 6, "atk": 2, "ac": 9, "gp": 1,
|
||||
"curios": [], "feats": []}
|
||||
var po2 = load("res://presentation/views/status_panel.gd").new()
|
||||
po2.open_status(data_empty)
|
||||
Kit.check("空秘宝态句=「未携带秘宝」〔sv2〕", String(po2._curios_label.text) == "未携带秘宝")
|
||||
Kit.check("空专长态句=「未获专长」〔sv3〕", String(po2._feats_label.text) == "未获专长")
|
||||
# 提示行文案(sv8——新定位换代;旧「永久强化进度」半句随两段退役失效)
|
||||
var hint_txt := ""
|
||||
for ch in _all_labels(po):
|
||||
if String(ch.text).begins_with("查看本局"):
|
||||
hint_txt = String(ch.text)
|
||||
Kit.check("提示行=「查看本局冒险进度(只读)」(新定位换代)〔sv8〕",
|
||||
hint_txt == "查看本局冒险进度(只读)")
|
||||
po.visible = false
|
||||
po.free()
|
||||
po2.visible = false
|
||||
po2.free()
|
||||
|
||||
|
||||
# 面板子树 Label 收集(sv5 零旧段扫描辅助——递归全子树)
|
||||
func _all_labels(root: Node) -> Array:
|
||||
var out: Array = []
|
||||
if root is Label:
|
||||
out.append(root)
|
||||
for child in root.get_children():
|
||||
out.append_array(_all_labels(child))
|
||||
return out
|
||||
|
||||
|
||||
# ---- 生涯页只读投影一致性(汇总段六类九项逐行 == SaveManager 计数访问器/
|
||||
|
||||
@@ -17,6 +17,7 @@ func _init() -> void:
|
||||
if f != null:
|
||||
Kit.check("中文字形在集(攻/雷/旗/封)", f.has_char("攻".unicode_at(0)) and f.has_char("雷".unicode_at(0)) \
|
||||
and f.has_char("旗".unicode_at(0)) and f.has_char("封".unicode_at(0)))
|
||||
Kit.check("中文字形在集(危——已开危险格字形换代前置,迭代36 u12/wa9)", f.has_char("危".unicode_at(0)))
|
||||
Kit.check("ASCII/数字字形在集(A/0/%)", f.has_char("A".unicode_at(0)) and f.has_char("0".unicode_at(0)) and f.has_char("%".unicode_at(0)))
|
||||
var sz := 18
|
||||
var w_cn: float = f.get_string_size("战斗胜利", HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x
|
||||
|
||||
Reference in new issue
Block a user