iter41-卡1:核心数据面结构收敛批:85键全清——三族拆域+config_db归位容器+存档拆件+meta瘦身+adventure拆段+spawn束化;行为零变化,8新件在阈

This commit is contained in:
DKChan committed 2026-10-29 01:33:50 +08:00
1 parent bda377eb16
commit a613a3cb90
157 files changed
+7453 -4288

No files matched your search

+12 -11
View File
@@ -30,10 +30,7 @@ var _rap := 0
var rap: int:
get: return _rap
var _last_round_end: Dictionary[String, String] = {} # {path_tag, continuation_hint}——排雷玩法 组包 round_end 的合法数据源
var last_round_end: Dictionary[Variant, Variant]:
get: return _last_round_end
var _settlement_consumed := false # AC-14 门:notify_settlement_done 消费后才受理 round_start
var _next_round_mode := "" # 调试观测量(同板续轮 / 换新图)
var _pending_ap_delta := 0 # 下轮行动点修正排队(action 型 ±1 排队,轮开始兑现后清零)
# 蓄力流运行态:转蓄力=round_end grant_ap(mode:transfer) 订阅(finish_round_stop 定夺)、
# 深挖对价=dig_paid counter_spend 订阅(request_dig 施加面);charge_layers=蓄力层账本
@@ -44,7 +41,6 @@ var charge_layers: int:
var _fx: EffectEngine = null
var _ctx: EffectContext = null
var _last_stop_ap := 0 # 停手侧剩余 AP 记账(转存决策在 round_end 发射点——grant_ap(transfer) 订阅定夺)
var _ap_exhausted_last := false # 上轮 AP 耗尽封印(一滴不剩 round_start when 面)
func set_fx(fx: EffectEngine, ctx: EffectContext) -> void:
@@ -204,9 +200,9 @@ func request_refresh() -> bool:
if _phase != Phase.ACTIVE:
_reject("phase")
return false
if _rap < db.action_economy.rap_refresh_cost:
if _rap < db.run.action_economy.rap_refresh_cost:
return false # EC13 资源不足拒绝——合法相位内资源拒绝,非编排错误
_rap -= db.action_economy.rap_refresh_cost
_rap -= db.run.action_economy.rap_refresh_cost
return true
@@ -216,7 +212,6 @@ func notify_settlement_done(next_round_mode: String) -> bool:
_reject("phase")
return false
_settlement_consumed = true
_next_round_mode = next_round_mode
return true
@@ -249,7 +244,7 @@ func notify_round_start() -> bool:
else:
_reject("phase")
return false
_ap = maxi(0, db.action_economy.ap_per_round + _pending_ap_delta) # 每轮基线 + 排队修正(下限 0)
_ap = maxi(0, db.run.action_economy.ap_per_round + _pending_ap_delta) # 每轮基线 + 排队修正(下限 0)
_pending_ap_delta = 0
_phase = Phase.ACTIVE
_settlement_consumed = false
@@ -272,13 +267,19 @@ func _finish_round(path_tag: String, continuation_hint: String) -> void:
assert(path_tag != "ap_exhausted" or _ap == 0, "耗尽轮剩余行动点恒为 0(实现层断言兜底)")
assert(path_tag != "cleared" or _ap == 0, "清图轮剩余行动点当场清零(不进储备池)")
_last_round_end = {"path_tag": path_tag, "continuation_hint": continuation_hint}
_ap_exhausted_last = path_tag == "ap_exhausted" # 一滴不剩 when 面(轮始发射读数)
_phase = Phase.WAITING
# 上轮 AP 是否耗尽(round_start 发射 payload 读数面;轮始兑现后保持到本轮末)
# 上轮组包读数({path_tag, continuation_hint}——排雷玩法 组包 round_end 的合法数据源;
# 属性→方法 K5 换形,消费方 minesweeper_rules 单点改址)
func last_round_end() -> Dictionary[String, String]:
return _last_round_end
# 上轮 AP 是否耗尽(round_start 发射 payload 读数面;轮始兑现后保持到本轮末)——
# 派生读(同源同刻:_last_round_end 组包点即派生原点,K5 派生合并——存储成员退役)
func ap_exhausted_last_round() -> bool:
return _ap_exhausted_last
return _last_round_end.get("path_tag", "") == "ap_exhausted"
func _reject(source: String) -> void:
+7 -7
View File
@@ -105,7 +105,7 @@ func start_adventure(seed_val: int, params: Dictionary = {}) -> bool:
_stats.armor_type = String(params.get("armor_type", ""))
summary = {}
_reset_curio_state()
levels_total = db.level_count() # 表访问收口(关数读 level 表行数)
levels_total = db.run.level.level_count() # 表访问收口(关数读 level 表行数)
level_index = 1
_pending_next_level = false
_shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 缺省 legacy(off 对照零扰动)
@@ -120,7 +120,7 @@ func start_adventure(seed_val: int, params: Dictionary = {}) -> bool:
# 刷新练习开局注入(refresh_stock_grant 装配即得一次性加仓:
# 装配集 kind 分发扫描;只入存量账、临时账不动=V8 契约;resume 不重注入〔存量随断点已在〕)
for iid_v2 in _attunement:
var rp_row: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v2))
var rp_row: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v2))
if (rp_row.get("effect_kinds", []) as Array).has("refresh_stock_grant"):
_refresh_bank.add_refresh_stock(int(rp_row.get("magnitude", 0)))
# 特性开局注入(MC 成型画像载体:params.relics_seed 非空=开局持有集〔七流派
@@ -184,7 +184,7 @@ func _resume_ledger(bp: Dictionary, params: Dictionary) -> void:
_wallet.restore_fragment(bp, params)
_journal.restore_fragment(bp, params)
summary = {}
levels_total = db.level_count() # 表访问收口(关数读 level 表行数)
levels_total = db.run.level.level_count() # 表访问收口(关数读 level 表行数)
level_index = int(bp.get("level_index", 1))
_pending_next_level = false
@@ -223,7 +223,7 @@ func _resume_params(bp: Dictionary, params: Dictionary) -> void:
#(如调试浮窗调高期写出的值,实例:hero_hp=28 > 基础 26)——恢复后钳到生效上限,
# 否则首战 spawn「hero_hp 入参越界」拒收 → 「敌人系统 spawn 被拒」fatal。钳制式单点归
# 数值账 clamp_hp_to_resume_cap(保守口径不含加算区——与原钳制式逐字同)。
_stats.clamp_hp_to_resume_cap(int(db.hero.max_hp))
_stats.clamp_hp_to_resume_cap(int(db.combat.hero.max_hp))
_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
@@ -330,7 +330,7 @@ func present_node_curio(candidates: Array) -> void:
func _attune_refresh_x_total() -> int:
var total := 0
for iid_v in _attunement:
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v))
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v))
if (arow.get("effect_kinds", []) as Array).has("bonus_refresh"):
total += int(arow.get("magnitude", 0))
return total
@@ -369,8 +369,8 @@ func _loadout_on_level_inventory() -> void:
func _boss_keystone_drop(li: int) -> void:
var first := true
if _save_manager != null:
first = not _save_manager.boss_first_kill_marked(li)
var row: Dictionary[Variant, Variant] = db.meta_boss_keystone_row(li)
first = not _save_manager.meta_state.career_state().boss_first_kill.has(str(li))
var row: Dictionary[Variant, Variant] = db.crossrun.meta.boss_keystone_row(li)
var amount: int = int(row.get("first_kill", 0)) if first else int(row.get("repeat_kill", 0))
_journal.record_keystone_drop({"level": li, "first": first, "amount": amount}) # 逐杀明细+累计单写原语
+3 -3
View File
@@ -33,7 +33,7 @@ func item_ids() -> Array:
func seed_for_adventure(inventory: TreasureItems) -> void:
for iid_v in _ids:
var iid := String(iid_v)
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(iid)
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(iid)
if String(arow.get("form", "")) != "active_charged":
continue
_ensure_rows(iid, maxi(0, int(arow.get("charges_per_adventure", 0))), inventory)
@@ -44,7 +44,7 @@ func seed_for_adventure(inventory: TreasureItems) -> void:
func reset_cycle(cycle: String, inventory: TreasureItems) -> void:
for iid_v in _ids:
var iid := String(iid_v)
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(iid)
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(iid)
if String(arow.get("form", "")) != "active_charged":
continue
if String(arow.get("charge_reset", "adventure")) != cycle:
@@ -94,7 +94,7 @@ func breakpoint_fragment(inventory: TreasureItems) -> Dictionary[Variant, Varian
var charges: Dictionary[Variant, Variant] = {}
for iid_v in _ids:
var iid := String(iid_v)
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(iid)
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(iid)
if String(arow.get("form", "")) == "active_charged":
charges[iid] = charges_left(iid, inventory)
return {"attunement_charges": charges}
+1 -1
View File
@@ -12,6 +12,6 @@ static func slots(db: ConfigDb, save: Variant) -> int:
return 0
var n := 0
for i in range(1, 4):
if save.tree_level("att_socket%d" % i) >= 1:
if save.meta_state.tree_state().nodes.get("att_socket%d" % i, 0) >= 1:
n += 1
return n
+1 -1
View File
@@ -175,5 +175,5 @@ func _fx_fact(fact: String) -> Variant:
# 基线 = enc 口径,-1(表值兜底态)时解析为表值后叠加,守恒口径与 begin_battle 注入一致)
func _sync_hero_ac() -> void:
if int(ac.base) < 0:
ac.base = int(db.hero.armor_class)
ac.base = int(db.combat.hero.armor_class)
damage.set_hero_ac_override(int(ac.base) + int(ac.round) + int(ac.battle))
+20 -22
View File
@@ -180,7 +180,7 @@ func attune_has(item_id: String) -> bool:
func attune_param_sum(kind: String) -> int:
var total := 0
for iid_v in attunement:
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v))
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v))
if (arow.get("effect_kinds", []) as Array).has(kind):
total += int(arow.get("magnitude", 0))
return total
@@ -190,7 +190,7 @@ func attune_param_sum(kind: String) -> int:
# 首行语义=装配集内至多一件积点制〔试炼镐〕)
func _attune_charge_safe_row() -> Dictionary:
for iid_v in attunement:
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v))
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v))
if (arow.get("effect_kinds", []) as Array).has("ap_charge_safe"):
return arow
return {}
@@ -301,10 +301,8 @@ func _begin_battle_accept(enc: Dictionary) -> bool:
_fatal("敌人表 行集解析为空(%d,%d,%s)" % [int(enc.level_index), int(enc.node_depth), String(enc.get("node_type", "battle"))])
return false
# E4 收口:加成经 encounter 显式携带(hero_atk/hero_max_hp;缺省 = 表值——冒烟/工具直呼路径)
_hero_atk = int(enc.get("hero_atk", int(db.hero.atk)))
if not _enemy_system.spawn(int(enc.level_index), int(enc.node_depth), int(enc.hero_hp), dice[0],
_hero_atk, int(enc.get("hero_max_hp", -1)), dice[1],
String(enc.get("attack_type", "")), String(enc.get("armor_type", "")), String(enc.get("node_type", "battle"))): # 两根类型轴(空串=锻造配置默认值回退,行为连续)+行类维(battle/boss)
_hero_atk = int(enc.get("hero_atk", int(db.combat.hero.atk)))
if not _enemy_system.spawn({"level_index": int(enc.level_index), "node_depth": int(enc.node_depth), "hero_hp": int(enc.hero_hp), "unit_count_overrides": dice[0], "hero_atk_override": _hero_atk, "hero_max_hp_override": int(enc.get("hero_max_hp", -1)), "enemy_atk_overrides": dice[1], "hero_attack_type": String(enc.get("attack_type", "")), "hero_armor_type": String(enc.get("armor_type", "")), "row_kind": String(enc.get("node_type", "battle"))}): # 两根类型轴(空串=锻造配置默认值回退,行为连续)+行类维(battle/boss)
_fatal("敌人系统 spawn 被拒")
return false
return true
@@ -317,7 +315,7 @@ func _begin_battle_accept(enc: Dictionary) -> bool:
# 返回 [count_overrides, atk_overrides];行集为空 = 返回 [](fail 判据交受理段 _fatal)。
func _begin_battle_dice(enc: Dictionary) -> Array:
# 行类维度:节点身份 battle/boss 决定行集键域(缺省 battle 只服务测试直呼面——生产路径恒传 node_type)
var erowset_pre: Array[Variant] = db.resolve_enemy_rowset(int(enc.level_index), int(enc.node_depth), String(enc.get("node_type", "battle")))
var erowset_pre: Array[Variant] = db.combat.enemy.resolve_rowset(int(enc.level_index), int(enc.node_depth), String(enc.get("node_type", "battle")))
if erowset_pre.is_empty():
return []
var needs_count := false
@@ -462,7 +460,7 @@ func _install_new_board(during_round := false) -> bool:
func _resolve_global_variant(board: BoardData) -> void:
board.board_variants.clear()
_reward_economy.board_skew = 0
var row: Dictionary[Variant, Variant] = db.global_variant_row(int(encounter.level_index), String(board.board_type),
var row: Dictionary[Variant, Variant] = db.combat.variation.global_variant_row(int(encounter.level_index), String(board.board_type),
String(encounter.get("node_type", "")))
if row.is_empty():
return
@@ -482,7 +480,7 @@ func _resolve_global_variant(board: BoardData) -> void:
func _place_variants(board: BoardData) -> void:
if not board.board_variants.is_empty():
return
var pool: Array[Variant] = db.variant_pool(int(encounter.level_index), String(board.board_type))
var pool: Array[Variant] = db.combat.variation.variant_pool(int(encounter.level_index), String(board.board_type))
if pool.is_empty():
return
var r0 := {}
@@ -496,8 +494,8 @@ func _place_variants(board: BoardData) -> void:
return
var stream: RandomNumberGenerator = rng.open_stream("variant_place") # 一板恰一 scope(换图/刷新图各取号一次)
var vrow: Dictionary[Variant, Variant] = pool[stream.randi_range(0, pool.size() - 1)]
var total: int = int(round(float(candidates.size()) * float(db.variation.percent_of_cells) / 100.0)) \
+ (int(encounter.level_index) - 1) * int(db.variation.per_level_bonus)
var total: int = int(round(float(candidates.size()) * float(db.combat.variation.percent_of_cells) / 100.0)) \
+ (int(encounter.level_index) - 1) * int(db.combat.variation.per_level_bonus)
total = mini(total, candidates.size())
_place_variants_apply(board, candidates, stream, vrow, total)
@@ -506,7 +504,7 @@ func _place_variants(board: BoardData) -> void:
func _place_variants_apply(board: BoardData, candidates: Array[Vector2i], stream: RandomNumberGenerator, vrow: Dictionary, total: int) -> void:
var placed := {}
while placed.size() < total:
var pack_size: int = mini(total - placed.size(), stream.randi_range(2, int(db.variation.pack_max)))
var pack_size: int = mini(total - placed.size(), stream.randi_range(2, int(db.combat.variation.pack_max)))
var free: Array[Vector2i] = []
for c in candidates:
# 新片种子/生长格禁与其他已布片 8 邻贴接——否则两片粘连成一个超 pack_max 的连通分量
@@ -638,7 +636,7 @@ func _harmful_variants(vids: Array) -> Array:
var out: Array[Variant] = []
for vid_v in vids:
var vid := String(vid_v)
var vrow: Dictionary[Variant, Variant] = db.variation.row_by_id(vid)
var vrow: Dictionary[Variant, Variant] = db.combat.variation.row_by_id(vid)
if String(vrow.get("effect", "")) == "flat_damage":
out.append(vid)
return out
@@ -677,7 +675,7 @@ func set_treasure_unlocked(ids: Dictionary) -> void:
# 统一流:behavior_roll 无子流派生向量(位置交错,单点定位由事件日志承载)。
# 摇定后、兑现前死亡 → 本拍技能作废。
func _roll_round_actions() -> void:
var pity_m: int = int(db.skill.pity_threshold)
var pity_m: int = int(db.combat.skill.pity_threshold)
# facts 组包(开轮摇号前一次成型——last_round_end 空字典首轮映射 none 唯一落点;hero 事实不进 facts,
# 容器内直读主角行)
var facts := {
@@ -692,7 +690,7 @@ func _roll_round_actions() -> void:
# 技能名提示〔轻量 toast 非演出——D8 呈现面〕。门=确定性谓词,机制面零随机,摇号随机
# 消费仍在 behavior_roll 既有流——同种子逐位复现不破坏)
for sid in _enemy_system.check_trigger_unlocks(u, facts):
var srow_u: Dictionary[Variant, Variant] = db.skill_row(sid)
var srow_u: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(sid)
_emit_row({"type": "highlight", "source_class": "enemy_action", "origin_cell": Vector2i(-1, -1), "highlight": "skill_unlock"},
round_index, "%s 解锁了 %s" % [String(u.display_name), String(srow_u.get("display_name", sid))],
0, {"skill_id": sid})
@@ -733,7 +731,7 @@ func _defender_ac(unit: Dictionary) -> int:
func _pool_effective(effect_id: String) -> int:
var side := "hero"
var row: Dictionary[Variant, Variant] = db.status_effect_row(effect_id)
var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(effect_id)
if not row.is_empty():
side = String(row.target_side)
for eff in _status_effects.query_active(side):
@@ -818,9 +816,9 @@ func _exit_distribution() -> void:
# 呈现层查询面(HUD 刷新按钮可用性)——挖掘期唯一通道
func can_refresh() -> bool:
return battle_result == "" \
and _refresh_used_this_round < int(db.action_economy.refresh_cap_per_round) \
and _refresh_used_this_round < int(db.run.action_economy.refresh_cap_per_round) \
and action_economy.phase == action_economy.PHASE_ACTIVE \
and action_economy.rap >= int(db.action_economy.rap_refresh_cost)
and action_economy.rap >= int(db.run.action_economy.rap_refresh_cost)
# 玩家经 HUD框架发起、战斗结算 代理调用(禁止 RAP 满自动刷)。
@@ -870,14 +868,14 @@ func _battle_over(result: String) -> void:
# + level_gp_bonus(level_index) + escrow。掷定行缺位 = 数据面缺行(fail 报错按 0 兑付——不哑掉)。
func _roll_gp_drop() -> int:
var tier := "boss" if String(encounter.get("node_type", "")) == "boss" else "normal"
var drops: Dictionary[Variant, Variant] = db.shop_gp_drop_row(tier)
var drops: Dictionary[Variant, Variant] = db.run.shop.gp_drop_row(tier)
var roll := 0
if drops.is_empty():
push_error("[战斗结算] gp_drops 缺行 tier=%s——掉落掷定按 0 兑付" % tier)
else:
var stream: RandomNumberGenerator = rng.open_stream("gp_drop")
roll = stream.randi_range(int(drops.get("roll_min", 0)), int(drops.get("roll_max", 0)))
var drop := roll + db.shop_level_gp_bonus(int(encounter.get("level_index", 0)))
var drop := roll + db.run.shop.level_gp_bonus_at(int(encounter.get("level_index", 0)))
# 财富之道·聚(战斗掉落 GP 乘算·入账前:只乘战斗掉落两成分〔掷定+关卡加成〕,
# 店内 escrow 不乘〔范围=仅战斗掉落渠道〕;本函数返回值=结算框「GP 增量」行
# 显示源,落在入账前=显示即实得;乘数 provisional 150%)
@@ -899,14 +897,14 @@ func presentation_snapshot() -> Dictionary:
# 敌方增益投影(side-wide 状态——蓄力/硬化/削弱/暴走 percent 行 + 警觉 ac 行)
var enemy_effects: Array[Dictionary] = []
for eff in _status_effects.query_active("enemy"):
var erow: Dictionary[Variant, Variant] = db.status_effect_row(eff.effect_id)
var erow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff.effect_id)
if not erow.is_empty() and String(erow.unit) in ["percent", "ac"]:
enemy_effects.append({"effect_id": eff.effect_id, "display_name": String(eff.display_name), "layers": int(eff.layers)})
for i in all.size():
var u: Dictionary[Variant, Variant] = all[i]
var alive: bool = bool(u.get("alive", false))
var pending: String = String(u.get("pending_skill", "")) if alive else ""
var pending_row: Dictionary[Variant, Variant] = db.skill_row(pending)
var pending_row: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(pending)
enemies.append({
"order": i + 1,
"display_name": String(u.display_name),
+128 -83
View File
@@ -22,12 +22,19 @@ static func target_mine_count(params: Dictionary) -> int:
# rng_stream = 一次 open_stream("board_gen") 实例(一张图的全部取数;重试共用——attempt 边界非 scope 事件)。
# db 参数不标具体类型原因:冒烟工具注入 FakeDB 桩(experiment_density.gd 等)——typed 形参会拒收桩表
func generate(L: int, D: int, rng_stream: RandomNumberGenerator, db) -> BoardData: # type-hint: ignore(测量面 _BoardRowStub 冒烟夹具鸭子型注入——typed 参拒 stub)
var row: Dictionary[Variant, Variant] = db.resolve_board_gen(L, D)
var row: Dictionary[Variant, Variant] = db.combat.board_gen.resolve_row(L, D)
if row.is_empty():
_fatal("BoardGen 行缺失 (%d,%d)" % [L, D])
return null
# 生成期表值单次读取快照(防热重载撕裂 attempt 语义)
var params := {
var params := _board_params(row, L, D)
var outcome := _attempt_loop(params, rng_stream, db)
_finalize(outcome["board"], params, outcome["attempts"], outcome["mode"], outcome["r0_rej"])
return outcome["board"]
## 生成期表值单次读取快照(防热重载撕裂 attempt 语义;零 RNG)
func _board_params(row: Dictionary, L: int, D: int) -> Dictionary:
return {
"L": L, "D": D,
"w": int(row.board_width), "h": int(row.board_height),
"density": float(row.mine_density),
@@ -41,10 +48,11 @@ func generate(L: int, D: int, rng_stream: RandomNumberGenerator, db) -> BoardDat
"shave_r": int(row.get("shave_ring_radius", 1)),
"r0_cap": int(row.get("r0_max_cap", 0)),
}
var best_legal: BoardData = null
var best_legal_score := -1.0
var best_bad: BoardData = null
var best_badness := [999999, 999999, 999999]
## 重试主循环:逐尝试评分择优,产出三分出口结果(board/mode/attempts/r0_rej)
func _attempt_loop(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Dictionary: # type-hint: ignore(同 generate——测量面 stub 注入)
var best := {"legal": null, "legal_score": -1.0, "bad": null, "badness": [999999, 999999, 999999]}
var attempts_used := 0
var accepted: BoardData = null
var accept_mode := ""
@@ -53,35 +61,37 @@ func generate(L: int, D: int, rng_stream: RandomNumberGenerator, db) -> BoardDat
attempts_used = k
var b := _attempt(params, rng_stream, db)
var badness := _badness(b, params)
if best_bad == null or _lex_less(badness, best_badness):
best_bad = b
best_badness = badness
if best["bad"] == null or _lex_less(badness, best["badness"]):
best["bad"] = b
best["badness"] = badness
# R₀≤cap 重掷闸触发计数(迭代14① T3——尝试事实,与合法性判定独立记录;占比=T5 闸触发率读数)
if int(params.r0_cap) > 0 and b.compute_r0().size() > int(params.r0_cap):
r0_cap_rejected += 1
if legality(b, params):
var m: Dictionary[Variant, Variant] = measure_inferability(b) # 求解器每合法板恰一次(O3 收口——此前重复调用两次)
var inf: float = m["inferability"]
if best_legal == null or inf > best_legal_score:
best_legal = b
best_legal_score = inf
var inf: float = measure_inferability(b)["inferability"] # 求解器每合法板恰一次(O3 收口——此前重复调用两次)
if best["legal"] == null or inf > best["legal_score"]:
best["legal"] = b
best["legal_score"] = inf
if inf >= params.target: # 单一判据(棋盘生成:现行规则已回归"可推理率 ≥ 及格线")
accepted = b
accept_mode = "first_pass"
break
if accepted == null:
if best_legal != null:
accepted = best_legal
accept_mode = "accept_best"
accepted.degraded_product = true # 降级标记(独立于 illegal_product——三分出口表要求标记随产物上抛)
push_warning("[棋盘生成] 重试耗尽无达标——接受最优出图(强告警埋点,target=%.2f best=%.3f)" % [params.target, best_legal_score])
else:
accepted = best_bad
accept_mode = "accept_worst"
accepted.illegal_product = true
push_error("[棋盘生成] attempts 耗尽零合法——接受最不坏出图 + 非法产物标记(玩家永不卡死)")
_finalize(accepted, params, attempts_used, accept_mode, db, r0_cap_rejected)
return accepted
var fallback := _accept_fallback(params, best["legal"], best["legal_score"], best["bad"])
accepted = fallback[0]
accept_mode = fallback[1]
return {"board": accepted, "mode": accept_mode, "attempts": attempts_used, "r0_rej": r0_cap_rejected}
## 三分出口(重试耗尽):接受最优(降级标记)或接受最不坏(非法产物标记——玩家永不卡死)
func _accept_fallback(params: Dictionary, best_legal: BoardData, best_legal_score: float, best_bad: BoardData) -> Array:
if best_legal != null:
best_legal.degraded_product = true # 降级标记(独立于 illegal_product——三分出口表要求标记随产物上抛)
push_warning("[棋盘生成] 重试耗尽无达标——接受最优出图(强告警埋点,target=%.2f best=%.3f)" % [params.target, best_legal_score])
return [best_legal, "accept_best"]
best_bad.illegal_product = true
push_error("[棋盘生成] attempts 耗尽零合法——接受最不坏出图 + 非法产物标记(玩家永不卡死)")
return [best_bad, "accept_worst"]
# ==================== 五阶段:单次尝试 ====================
@@ -94,8 +104,20 @@ func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Boar
b.board_type = str(params.board_type)
b.level_index = params.L
b.node_depth = params.D
# ② 布雷:N = round(d×(W×H−9)) 先定数;域 = 全格 − 出生 3×3;均匀布雷后"先布后删"
# (−9 = 出生区格数,常量单点 BoardData.SPAWN_AREA_CELLS)
# ② 布雷:N = round(d×(W×H−9)) 先定数;域 = 全格 − 出生 3×3;均匀布雷后"先布后删"(_populate_mines 承载——Fisher-Yates 取号段整体随迁)
var removed := _populate_mines(b, params, rng_stream)
# ③ 变异布置(地形变异 框架面):布置约束与算法随首批变异类型定稿(生成期布置接口结构位),
# 定稿前零布置——清单为空是合法态(P0),静默产出无变异图。
b.metrics = {
"metric_version": METRIC_VERSION,
"ring_removed": removed,
}
return b
## 布雷与剃雷域:域 = 全格 − 出生 3×3;均匀布雷(部分 Fisher-Yates,确定性,消费 board_gen 流)
## 后剃出生环雷,返回 ring_removed(指标语义不变——现值变小=收窄批预期)
func _populate_mines(b: BoardData, params: Dictionary, rng_stream: RandomNumberGenerator) -> int:
var spawn := {}
for c in b.spawn_cells():
spawn[c] = true
@@ -104,7 +126,7 @@ func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Boar
if not spawn.has(c):
domain.append(c)
var n_mines: int = target_mine_count(params)
# 部分 Fisher-Yates(确定性,消费 board_gen 流)
# 部分 Fisher-Yates(确定性,消费 board_gen 流)——取号序冻结(契约:同种子逐位一致)
for i in range(0, mini(n_mines, domain.size())):
var j := rng_stream.randi_range(i, domain.size() - 1)
var tmp = domain[i]
@@ -116,9 +138,14 @@ func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Boar
# 先布后删:剃雷域 = 距 spawn_center 切比雪夫距离 ≤1+r 的格(迭代14①收窄批 T2——原「R₀ flood 域∪
# 外扩 1 环」全域剃雷=开局开阔地巨大根因〔10×10 板 R₀ 均值 68.6=板面 68.6%〕;收窄后开阔地=
# 出生邻域外的自然 flood。r 读表 shave_ring_radius(r=1 → 5×5=25 格;r=0 即只剃出生 3×3)。
# 只减不增、无迭代语义不变;ring_removed 指标语义不变(现值变小=预期);零 RNG 消费变化——
# 同种子布雷序列逐位一致,仅移除域收窄。
var shave_r: int = int(params.get("shave_r", 1))
# 只减不增、无迭代语义不变;零 RNG 消费变化。
var removed := _shave_ring(b, int(params.get("shave_r", 1)))
_compute_digits(b)
return removed
## 剃雷环域(零 RNG):距出生心切比雪夫 ≤1+r 的环内剃雷,返回剃除数
func _shave_ring(b: BoardData, shave_r: int) -> int:
var ring := {}
for c in b.all_cells():
if maxi(absi(c.x - b.spawn_center.x), absi(c.y - b.spawn_center.y)) <= 1 + shave_r:
@@ -128,14 +155,7 @@ func _attempt(params: Dictionary, rng_stream: RandomNumberGenerator, db) -> Boar
if b.mines.has(c):
b.mines.erase(c)
removed += 1
_compute_digits(b)
# ③ 变异布置(地形变异 框架面):布置约束与算法随首批变异类型定稿(生成期布置接口结构位),
# 定稿前零布置——清单为空是合法态(P0),静默产出无变异图。
b.metrics = {
"metric_version": METRIC_VERSION,
"ring_removed": removed,
}
return b
return removed
func _compute_digits(b: BoardData) -> void:
@@ -164,49 +184,12 @@ func measure_inferability(b: BoardData) -> Dictionary:
while changed:
changed = false
# 算子① 连锁:0 格的 8 邻安全格全入 R
var frontier: Array[Variant] = r.keys()
for x in frontier:
if safe_set.has(x) and b.digit_of(x) == 0:
for n in b.neighbors8(x):
if safe_set.has(n) and not r.has(n):
r[n] = true
changed = true
changed = _apply_chain_rule(b, r, safe_set) or changed
# 信息源:数字约束 single + subset(solver_rules=single_subset 冻结域)
var constraints := _build_constraints(b, r, known_mines, safe_set)
# single 规则
for con in constraints:
if con.mines == 0:
for c in con.unknown:
if not r.has(c):
r[c] = true
changed = true
elif con.mines == con.unknown.size():
for c in con.unknown:
if not known_mines.has(c):
known_mines[c] = true
changed = true
# subset 规则:U1 ⊂ U2 ⇒ (d2−d1, U2∖U1)
var derived := []
for i in constraints.size():
for j in range(i + 1, constraints.size()):
var a: Dictionary[Variant, Variant] = constraints[i]
var bb: Dictionary[Variant, Variant] = constraints[j]
var sub := _subset_of(a.unknown, bb.unknown)
if sub == 1:
derived.append(_diff_constraint(a, bb))
elif sub == 2:
derived.append(_diff_constraint(bb, a))
for con in derived:
if con.mines == 0:
for c in con.unknown:
if not r.has(c): # 同 no-peek 口径:推导结论不再用 safe_set 复核(约束本身可靠)
r[c] = true
changed = true
elif con.mines == con.unknown.size():
for c in con.unknown:
if not known_mines.has(c):
known_mines[c] = true
changed = true
changed = _apply_single_rules(constraints, r, known_mines) or changed
var derived := _derived_constraints(constraints)
changed = _apply_derived_rules(derived, r, known_mines) or changed
var r_inf := r.keys()
var s_count := safe_set.size()
var inferability: float = float(r_inf.size()) / float(maxi(1, s_count))
@@ -217,6 +200,68 @@ func measure_inferability(b: BoardData) -> Dictionary:
}
## 算子① 连锁域:0 格的 8 邻安全格全入 R(返回本轮是否有新增)
func _apply_chain_rule(b: BoardData, r: Dictionary, safe_set: Dictionary) -> bool:
var changed := false
var frontier: Array[Variant] = r.keys()
for x in frontier:
if safe_set.has(x) and b.digit_of(x) == 0:
for n in b.neighbors8(x):
if safe_set.has(n) and not r.has(n):
r[n] = true
changed = true
return changed
## single 规则域:零雷约束开格/满雷约束标雷(返回本轮是否有新增)
func _apply_single_rules(constraints: Array, r: Dictionary, known_mines: Dictionary) -> bool:
var changed := false
for con in constraints:
if con.mines == 0:
for c in con.unknown:
if not r.has(c):
r[c] = true
changed = true
elif con.mines == con.unknown.size():
for c in con.unknown:
if not known_mines.has(c):
known_mines[c] = true
changed = true
return changed
## subset 派生域:U1 ⊂ U2 ⇒ (d2−d1, U2∖U1)(对约束对做包含判定,产出派生约束集)
func _derived_constraints(constraints: Array) -> Array:
var derived := []
for i in constraints.size():
for j in range(i + 1, constraints.size()):
var a: Dictionary[Variant, Variant] = constraints[i]
var bb: Dictionary[Variant, Variant] = constraints[j]
var sub := _subset_of(a.unknown, bb.unknown)
if sub == 1:
derived.append(_diff_constraint(a, bb))
elif sub == 2:
derived.append(_diff_constraint(bb, a))
return derived
## 派生规则域:派生约束的零雷开格/满雷标雷(同 no-peek 口径:推导结论不再用 safe_set 复核——约束本身可靠)
func _apply_derived_rules(derived: Array, r: Dictionary, known_mines: Dictionary) -> bool:
var changed := false
for con in derived:
if con.mines == 0:
for c in con.unknown:
if not r.has(c):
r[c] = true
changed = true
elif con.mines == con.unknown.size():
for c in con.unknown:
if not known_mines.has(c):
known_mines[c] = true
changed = true
return changed
func _build_constraints(b: BoardData, r: Dictionary, known_mines: Dictionary, safe_set: Dictionary) -> Array:
var out: Array[Dictionary] = []
for c in r.keys():
@@ -337,7 +382,7 @@ func _neighbor_mine_count(b: BoardData, c: Vector2i) -> int:
# ==================== 产出上报(生成期指标集) ====================
func _finalize(b: BoardData, params: Dictionary, attempts_used: int, accept_mode: String, _db, r0_cap_rejected: int = 0) -> void: # type-hint: ignore(同 generate——测量面 stub 注入)
func _finalize(b: BoardData, params: Dictionary, attempts_used: int, accept_mode: String, r0_cap_rejected: int = 0) -> void:
var m := measure_inferability(b)
# 产物断言(棋盘生成 出生区安全与反退化布雷):实际雷数 ≤ 目标雷数 N——先布后删只减不增,失败即致命
var n_target: int = target_mine_count(params)
+39 -319
View File
@@ -1,6 +1,8 @@
## VERTICAL SLICE - NOT FOR PRODUCTION
## 数值表单点访问入口:全部 .tres 表的加载、校验诊断与类型化访问器(垂直切片验证:改表即改玩法,数值零硬编码)。
## 玩法代码禁止绕过本单点自建 load();消费面一律走访问器,禁直读行集。
## 数值表单点访问入口:全部 .tres 表的加载、校验诊断与门面(垂直切片验证:改表即改玩法,数值零硬编码)。
## 玩法代码禁止绕过本单点自建 load();行查询归各表类(表管自己的行——2026-10-27 全清设计定稿),
## 表持有按域收进三容器(combat 战斗编排域/run 冒险 runner 域/crossrun 跨局元进度域),
## 消费面经 db.<容器>.<表> 直连。「经 ConfigDB 单点读取」=经其持有的容器与表对象(架构 :88)。
## headless --script 模式无 autoload——冒烟脚本显式实例化本类;加载失败 is_ready=false 加 fatal 封印,调用方须查。
class_name ConfigDb
extends Node
@@ -29,39 +31,23 @@ const TABLE_MANIFEST := {
"forge": "forge.tres", # ForgeTable——两根类型轴默认值+六路线行族;攻防所有权切分唯一数据面
}
var action_economy: ActionEconomyTable
var variation: VariationTable
var status_effects: StatusEffectTable
var board_gen: BoardGenTable
var enemy: EnemyTable
var skill: SkillTable
var hero: HeroTable
var reward: RewardTable
var settlement: SettlementTable
var level: LevelTable
var difficulty: DifficultyTable
var level_gen_params: LevelGenParams # 参数档类型化持用(生成器只吃参数档、不认关卡号——卡① 契约 6)
# ShopTable 类型化持用(schema class_name 已已入库;
# 消费面一律走下方 shop_* 访问器,禁直读行集)
var shop: ShopTable = null
# MetaTable 类型化持用(schema 与表同源已入库)
var meta: MetaTable
var meta_tree: MetaTreeTable # 类型化持用(消费面走 tree_node 访问器)
var treasure_items: TreasureItemTable # 类型化持用(消费面走 treasure_* 访问器)
var adventure: AdventureTable
var feats: FeatTable # 类型化持用(消费面走 feat_* 访问器)
var forge: ForgeTable # 锻造工坊配置(默认两轴+六路线行族——消费面走 forge_* 访问器)
var combat: ConfigDomainCombat # 战斗编排域七表(board_gen/variation/status_effects/enemy/skill/hero/settlement)
var run: ConfigDomainRun # 冒险 runner 域七表(level/difficulty/level_gen_params/reward/adventure/action_economy/shop)
var crossrun: ConfigDomainCrossrun # 跨局元进度域五表(meta/meta_tree/treasure_items/feats/forge)
var is_ready := false
var _diagnostics: Array[Dictionary] = [] # {severity, category, message}
var _resolver: ConfigDbResolver # R1 难度行解析协作类(门面公有签名不变)
var _resolver: ConfigDbResolver # R1 难度行解析协作类(门面公有签名不变;load_tables 装配段注入三表)
var _validator := ConfigDbCrossValidator.new() # 跨表校验协作类(无状态——声明期即建)
var _reload_gate := false # 数据配置 EC8——战斗结算 唯一调用方
func _init() -> void:
combat = ConfigDomainCombat.new()
run = ConfigDomainRun.new()
crossrun = ConfigDomainCrossrun.new()
_resolver = ConfigDbResolver.new(push_diag,
func() -> Variant: return board_gen.rows, func() -> Variant: return enemy.rows, func() -> Variant: return difficulty) # 协作类持门面引用(诊断/致命通道回呼)
func() -> Variant: return combat.board_gen.rows, func() -> Variant: return combat.enemy.rows, func() -> Variant: return run.difficulty) # 协作类持门面引用(诊断/致命通道回呼)
func push_diag(severity: String, category: String, message: String) -> void:
@@ -132,312 +118,46 @@ func load_tables(root: String, collect_only: bool) -> bool:
if has_errors and not collect_only:
# 游戏通道:致命(Rule 6)——is_ready 恒 false,调用方(主场景)中止构建
return false
action_economy = loaded.get("action_economy")
variation = loaded.get("variation")
status_effects = loaded.get("status_effects")
board_gen = loaded.get("board_gen")
enemy = loaded.get("enemy")
skill = loaded.get("skill")
hero = loaded.get("hero")
reward = loaded.get("reward")
settlement = loaded.get("settlement")
level = loaded.get("level")
difficulty = loaded.get("difficulty")
level_gen_params = loaded.get("level_gen_params")
shop = loaded.get("shop")
meta = loaded.get("meta")
treasure_items = loaded.get("treasure_items")
adventure = loaded.get("adventure")
feats = loaded.get("feats")
forge = loaded.get("forge")
meta_tree = loaded.get("meta_tree")
# 容器装配(19 表成员按域收进三容器——2026-10-27 全清设计定稿)
combat.board_gen = loaded.get("board_gen")
combat.variation = loaded.get("variation")
combat.status_effects = loaded.get("status_effects")
combat.enemy = loaded.get("enemy")
combat.skill = loaded.get("skill")
combat.hero = loaded.get("hero")
combat.settlement = loaded.get("settlement")
run.level = loaded.get("level")
run.difficulty = loaded.get("difficulty")
run.level_gen_params = loaded.get("level_gen_params")
run.reward = loaded.get("reward")
run.adventure = loaded.get("adventure")
run.action_economy = loaded.get("action_economy")
run.shop = loaded.get("shop")
crossrun.meta = loaded.get("meta")
crossrun.meta_tree = loaded.get("meta_tree")
crossrun.treasure_items = loaded.get("treasure_items")
crossrun.feats = loaded.get("feats")
crossrun.forge = loaded.get("forge")
# resolver 协作件注入三表(resolve 委托承载——resolver 文件与键零搬移)
combat.board_gen._resolver = _resolver
combat.enemy._resolver = _resolver
run.difficulty._resolver = _resolver
is_ready = not has_errors
return not has_errors
# ---- relic 访问器(消费面一律走本组,禁直读行集)----
func feat_row(feat_id: String) -> Dictionary:
return feats.row_by_id(feat_id) if feats != null else {}
# 规则改写型特性带令牌构建源(池接线——CurioDeck.build 消费;行 rarity→投放档映射)
func feat_rows() -> Array:
return feats.rows if feats != null else []
# 天赋树节点行(MetaProgression 树段/营地树 UI 取数口)
func tree_node(node_id: String) -> Dictionary:
return meta_tree.row_by_id(node_id) if meta_tree != null else {}
func tree_rows() -> Array:
return meta_tree.rows if meta_tree != null else []
# 跨表校验(悬空引用 / 池护栏——全部加载期致命)本体:
# ConfigDbCrossValidator(core/config_db_cross_validator.gd)承载,门面公有签名不变。
func _cross_validate(loaded: Dictionary) -> void:
_validator.cross_validate(loaded, push_diag)
# 池模板 × 技能行跨表校验(冒烟直验独立入口——签名与消费面零变化,实现归协作类)
func validate_skill_pool_links(skill_table: SkillTable, enemy_table: EnemyTable, status_table: StatusEffectTable = null) -> Array:
# 池模板 × 技能行跨表校验(冒烟直验独立入口——签名与消费面零变化,实现归协作类;门面私有——
# 消费面经门面者改 _ 前缀直调,cross_validator 直验入口公开面不变)
func _validate_skill_pool_links(skill_table: SkillTable, enemy_table: EnemyTable, status_table: StatusEffectTable = null) -> Array:
return _validator.validate_skill_pool_links(skill_table, enemy_table, status_table)
# ---- R1 难度行解析(ceil 向上取整保证偏难;域越界与内容缺口 Fatal 退出)----
# 解析本体:ConfigDbResolver(core/config_db_resolver.gd)承载——
# 含 _resolve_progress/_ceil_key 与 R1 告警去重注册表;门面同名方法委托,越域 Fatal 语义一致。
func resolve_board_gen(L: int, D: int) -> Dictionary:
return _resolver.resolve_board_gen(L, D)
func resolve_enemy_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary]:
return _resolver.resolve_enemy_rowset(L, D, row_kind) # 行集数组形态(非单行 Dictionary;D 维带寻址)
# 难度曲线与Boss DifficultyTable 消费面(敌人系统.spawn 调用):(L,D) 精确匹配修饰乘数,越域值 clamp(GDD Edge Case——组合事故非配置事故)
func resolve_difficulty_modifiers(L: int, D: int) -> Dictionary:
return _resolver.resolve_difficulty_modifiers(L, D)
# ---- 地形变异 variant_pool_gate:候选池门控(∅ board_types = 通配;字段名对齐
# board_type 词表,无 biome_tags)----
# 门控第三维 = 节点身份(全局作用域变异):scope=global 行仅 boss/elite 节点入池,
# 其余节点(battle 等)静默跳过——门控兜底 P0 口径(零报错零警告);elite 系前向词位(首发零表行)。
# node_type 缺省 "" = 旧双参调用口径(global 行跳过)——既有测试/冒烟调用零扰动。
func variant_pool(level_index: int, board_type: String, node_type: String = "") -> Array:
var pool: Array[Dictionary] = []
for row in variation.rows:
if String(row.get("scope", "region")) == "global" \
and node_type != "boss" and node_type != "elite":
continue
if int(row.get("unlock_level", 999)) > level_index:
continue
var tags: Array[Variant] = row.get("board_types", [])
if tags.is_empty():
pool.append(row)
continue
if tags.has(board_type):
pool.append(row)
return pool
# 全局型单行决议(战斗编排装板期消费——访问器纪律:消费面禁直扫表):
# boss/elite 节点取候选池内首个 scope=global 行(首发批全局池恒恰一行;多行为配置越界态,
# 按表序取首行确定性兜底);空 = 无全局型(调用方回落 region 布置——兜底回落合法态)
func global_variant_row(level_index: int, board_type: String, node_type: String) -> Dictionary:
if node_type != "boss" and node_type != "elite":
return {}
for row in variant_pool(level_index, board_type, node_type):
if String(row.get("scope", "region")) == "global":
return row
return {}
func status_effect_row(effect_id: String) -> Dictionary:
return status_effects.row_by_id(effect_id)
# ---- 关卡行集访问器(表访问收口——level_runner/meta_progression/adventure_runner
# 不直扫 db.level.rows;消费面一律走本访问器,行集解析单点归 LevelTable)----
# 关卡行单点读取(键 = level_index,缺行返回空字典——消费方按既有空行防御路径处理)
func level_row(level_index: int) -> Dictionary:
return level.row_by_level_index(level_index) if level != null else {}
# 参数档单点读取(节点图生成器核心批 卡①——键 = level_index,缺行返回空字典;调用方据此产图,生成器本体不读关卡号)
func level_gen_params_row(level_index: int) -> Dictionary:
return level_gen_params.row_by_level_index(level_index) if level_gen_params != null else {}
# 序列总关数(AdventureRunner levels_total / MetaProgression 关卡完成累加域的取数口径)
func level_count() -> int:
return level.rows.size() if level != null else 0
# ---- ShopTable 消费面(行集解析单点——runner/战斗编排禁自扫表)----
# ── 秘宝访问器(消费面一律走访问器,禁直读行集)──
# 活行集(非 retired 非 deferred)——投放校验/奖励节点池/呈现可用性的唯一取数口径
func treasure_item_row(item_id: String) -> Dictionary:
return treasure_items.row_by_id(item_id) if treasure_items != null else {}
# 过渡渠道活行(form 空 = 切片期过渡形态)——掉落投放/停点池/工坊解锁取数口径;同调行(form 非空)
# 永不入过渡渠道
func treasure_transitional_rows() -> Array:
return treasure_items.transitional_rows() if treasure_items != null else []
# 同调行集(form 非空——装配覆盖层/效果接线的取数口径)
func treasure_attunement_rows() -> Array:
return treasure_items.attunement_rows() if treasure_items != null else []
# 活行词表(掉落投放 effect_id 白名单——过渡渠道口径)
# ── 同调槽位数=树同调位节点点亮数,判定源=Attunement.slots(无 attunement_growth 谓词表访问器);
# boss_kill_count/clear_count 计数域保留——非槽位硬消费在册 ──
# 行族四轴(数据契约,行集归 shop.tres):gp_drops {tier, mean, roll_min, roll_max} ·
# level_gp_bonus {level_index, bonus} · products {product_id, display_name, attr, flat_value,
# base_price, price_increment} · price_level_bonus {level_index, bonus}。
# 行族经 ShopTable 同名数组属性读取(.get 容缺——表已入库前查询恒空,掉落按 0 兑付的兜底在调用方)。
func shop_gp_drop_row(tier: String) -> Dictionary:
for row in _shop_rows("gp_drops"):
if String(row.get("tier", "")) == tier:
return row
return {}
func shop_level_gp_bonus(level_index: int) -> int:
for row in _shop_rows("level_gp_bonus"):
if int(row.get("level_index", -1)) == level_index:
return int(row.get("bonus", 0))
return 0
func shop_price_level_bonus(level_index: int) -> int:
for row in _shop_rows("price_level_bonus"):
if int(row.get("level_index", -1)) == level_index:
return int(row.get("bonus", 0))
return 0
func shop_products() -> Array:
return _shop_rows("products")
func shop_product_row(product_id: String) -> Dictionary:
for row in _shop_rows("products"):
if String(row.get("product_id", "")) == product_id:
return row
return {}
# ── 共享池货架访问器(M-4——消费面一律走访问器,禁直读行集)──
# 货架行族({kind_id, display_name, base_price}——行序即抽签/呈现稳定序)
func shop_pool_rows() -> Array:
return _shop_rows("pool_shelf")
func shop_pool_row(kind_id: String) -> Dictionary:
for row in _shop_rows("pool_shelf"):
if String(row.get("kind_id", "")) == kind_id:
return row
return {}
# 乘法价格因子(provisional 投值——0 = 表未投值,消费方按 fail-fast 域校验拦截)
func shop_price_a() -> float:
return float(shop.price_a) if shop != null else 0.0
func shop_price_b() -> float:
return float(shop.price_b) if shop != null else 0.0
# 行族数组读取(ShopTable 属性名 = 行族名;表未已入库/属性缺失 = 空集非错误态)
func _shop_rows(family: String) -> Array:
if shop == null:
return []
var arr = shop.get(family)
return arr if arr is Array else []
# ---- MetaTable 消费面(行集解析单点——MetaProgression/呈现层禁自扫表)----
# 行族四轴(数据契约,行集归 meta.tres,数值全 provisional):
# forge_products {attr, display_name, flat_value, base_price, price_ratio_pct} ·
# boss_keystone {level_index, first_kill, repeat_kill} ·
# workshop_items {item_id, display_name, clear_threshold} ·
# conversion {attr, per_level_shift_pct}。
func meta_boss_keystone_row(level_index: int) -> Dictionary:
for row in _meta_rows("boss_keystone"):
if int(row.get("level_index", -1)) == level_index:
return row
return {}
# 秘宝解锁混合制挂 treasure_items 行内(无 meta_workshop_items 访问器);
# 秘宝解锁投影面=MetaProgression.treasure_shop_status
func meta_conversion_row(attr: String) -> Dictionary:
for row in _meta_rows("conversion"):
if String(row.get("attr", "")) == attr:
return row
return {}
# Y 刷新升级行单点读取(键 = 等级,缺行返回空字典=未购/已满级合法态)
func meta_attune_refresh_row(level: int) -> Dictionary:
for row in _meta_rows("attune_refresh_upgrade"):
if int(row.get("level", -1)) == level:
return row
return {}
# 行族数组读取(MetaTable 属性名 = 行族名;表缺失/属性缺失 = 空集非错误态)
func _meta_rows(family: String) -> Array:
if meta == null:
return []
var arr = meta.get(family)
return arr if arr is Array else []
# ---- 锻造工坊(ForgeTable 消费面:两根类型轴默认值+六路线行族;禁自扫行集)----
# 设计权威 design/gdd/systems/跨局循环/锻造工坊.md v0.6:默认值归本表常驻持有(hero.tres 两轴已删——
# 契约 2);路线行族=攻防数值成长唯一数据面(契约 1)。数值全 provisional 归 M4。
# 两根类型轴默认值(表未已入库=裸体态兜底 normal/light——与 ForgeTable 缺省值同口径)
func forge_default_attack_type() -> String:
return String(forge.default_attack_type) if forge != null else "normal"
func forge_default_armor_type() -> String:
return String(forge.default_armor_type) if forge != null else "light"
# 六路线行族(副本——调用方禁改)
func forge_routes() -> Array:
return forge.routes.duplicate() if forge != null else []
# 路线行单点(键 route_id;缺行=空字典)
func forge_route(route_id: String) -> Dictionary:
for row in forge_routes():
if String(row.get("route_id", "")) == route_id:
return row
return {}
# 按类型轴反查武器路线(attack_type → 路线行;甲路线 id=甲型同值的显式化入口,避免消费侧依赖该巧合)
func forge_weapon_route_by_type(attack_type: String) -> Dictionary:
for row in forge_routes():
if String(row.get("kind", "")) == "weapon" and String(row.get("type_axis", "")) == attack_type:
return row
return {}
# 按类型轴反查护甲路线(armor_type → 路线行——入战 AC 三层之「甲型基础 AC」取数口)
func forge_armor_route_by_type(armor_type: String) -> Dictionary:
for row in forge_routes():
if String(row.get("kind", "")) == "armor" and String(row.get("type_axis", "")) == armor_type:
return row
return {}
# 技能行单点读取(敌人技能池批——单位容器执行上报 / 战斗结算兑现的消费面)
func skill_row(skill_id: String) -> Dictionary:
return skill.row_by_id(skill_id)
# ---- 数据配置 EC8:debug 热重载门(战斗结算 唯一调用方;切片实现 = 门标志 + 拒绝语义)----
func set_reload_gate(gated: bool) -> void:
+88 -47
View File
@@ -11,21 +11,43 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void:
var st_tab: SettlementTable = loaded.get("settlement")
if st == null or rt == null or st_tab == null:
return # 单表校验已报错,不连锁
# ①c F2 投放维度覆盖校验不启用:挖格不掉道具,秘宝
# 唯一来路=三选一共享池〔节点/关卡完成〕;共享池为「抽到道具位再动态掷定具体道具」,维度覆盖不可
# 静态校验。
var effect_ids := _effect_ids_of(st)
_cv_reward_dangling(rt, effect_ids, push_diag) # ① 增益词表悬空
_cv_reward_treasure(loaded, rt, push_diag) # ①b aux 投放行双表
_cv_poolmap(st_tab, effect_ids, push_diag) # ② 池映射悬空
_cv_source_labels(st_tab, push_diag) # ③ 来源标签键集
_cv_skill_pool(loaded, push_diag) # ⑤ 技能池引用完整性
_cv_shelf_pool(loaded, push_diag) # ⑥ 商店货架 × 冒险共享池
_cv_counter_closure(loaded, push_diag) # ⑦ 计数器引用闭合
## 增益状态词表全集(effect_id → true)
func _effect_ids_of(st: StatusEffectTable) -> Dictionary:
var effect_ids := {}
for row in st.rows:
effect_ids[row.get("effect_id", "")] = true
# ① RewardTable effect_id 悬空(percent 行引用 StatusEffectTable——悬空加载致命)。
# 例外:gp_gain/gp_loss = GP 战斗内缓冲(escrow)通道的路由键
# (战斗结算 _apply_gp_row 兑付——不入增益状态容器),词表封闭按字面豁免悬空引用校验。
return effect_ids
## ① RewardTable effect_id 悬空(percent 行引用 StatusEffectTable——悬空加载致命)。
## 例外:gp_gain/gp_loss = GP 战斗内缓冲(escrow)通道的路由键
## (战斗结算 _apply_gp_row 兑付——不入增益状态容器),词表封闭按字面豁免悬空引用校验。
func _cv_reward_dangling(rt: RewardTable, effect_ids: Dictionary, push_diag: Callable) -> void:
for row in rt.rows:
if String(row.get("cell_type", "")) == RewardTable.TREASURE_ITEM_TYPE:
continue # aux 行 effect_id=item_id——归 ①b 双表校验(reward_dangling_treasure),不入增益词表面
var eff: String = row.get("effect_id", "")
if eff != "" and eff != "gp_gain" and eff != "gp_loss" and not effect_ids.has(eff):
push_diag.call("error", "dangling_effect", "RewardTable %s 引用不存在的 effect_id: %s" % [row.get("reward_id"), eff])
# ①b RewardTable aux 投放行双表校验(reward_dangling_treasure):aux 行 effect_id = item_id,
# 须命中 TreasureItemTable 活行(非 retired 非 deferred——罗盘退役/失义件不投放)。retired 行被引用=退役
# 后仍投放(连带错误),同拦。
## ①b RewardTable aux 投放行双表校验(reward_dangling_treasure):aux 行 effect_id = item_id,
## 须命中 TreasureItemTable 活行(非 retired 非 deferred——罗盘退役/失义件不投放)。retired 行被引用=退役
## 后仍投放(连带错误),同拦。
func _cv_reward_treasure(loaded: Dictionary, rt: RewardTable, push_diag: Callable) -> void:
var treasure_live := {}
var treasure_tab = loaded.get("treasure_items")
if treasure_tab != null:
@@ -39,27 +61,36 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void:
continue
if not treasure_live.has(treasure_eff):
push_diag.call("error", "reward_dangling_treasure", "RewardTable %s aux 行引用无效道具 %s(retired/deferred/不存在): %s" % [row.get("reward_id"), treasure_eff, "双表校验"])
# ①c F2 投放维度覆盖校验不启用:挖格不掉道具,秘宝
# 唯一来路=三选一共享池〔节点/关卡完成〕;共享池为「抽到道具位再动态掷定具体道具」,维度覆盖不可
# 静态校验。
# ② SettlementTable.effect_pool_map 悬空
## ② SettlementTable.effect_pool_map 悬空
func _cv_poolmap(st_tab: SettlementTable, effect_ids: Dictionary, push_diag: Callable) -> void:
for eff in st_tab.core.effect_pool_map.keys():
if not effect_ids.has(eff):
push_diag.call("error", "poolmap_dangling", "SettlementTable.effect_pool_map 悬空 effect_id: %s" % eff)
# ③ source_label_map 键集封闭性(键 = 增益状态 source_class ∪ {enemy_action, hero_attack, seal_miss})
## ③ source_label_map 键集封闭性(键 = 增益状态 source_class ∪ {enemy_action, hero_attack, seal_miss})
func _cv_source_labels(st_tab: SettlementTable, push_diag: Callable) -> void:
var required_sources := ["safe_cell", "seal_hit", "mine_hit", "clear_reward", "hero_attack", "enemy_action", "seal_miss"]
for s in required_sources:
if not st_tab.core.source_label_map.has(s):
push_diag.call("error", "segorder_violation", "source_label_map 缺必需键: %s" % s)
# ⑤ 技能池引用完整性(敌人技能池批——敌人表池 × 技能表;独立入口 validate_skill_pool_links 供冒烟直验)
## ⑤ 技能池引用完整性(敌人技能池批——敌人表池 × 技能表;独立入口 validate_skill_pool_links 供冒烟直验)
func _cv_skill_pool(loaded: Dictionary, push_diag: Callable) -> void:
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")):
push_diag.call("error", e.category, e.message)
# ⑥ 商店货架 × 冒险共享池跨表校验(M-4——次数上限域):
# pool_shelf kind 须命中 adventure.CURIO_KINDS 且为有限库存种(cap ∈ [1,99]——gp 无上限种/牌库外种
# 入货架 = 库存账本无锚,加载致命;gp 袋负交易切边/规则改写型特性三轴切边由 ShopTable 词表封闭另拦)
## ⑥ 商店货架 × 冒险共享池跨表校验(M-4——次数上限域):
## pool_shelf kind 须命中 adventure.CURIO_KINDS 且为有限库存种(cap ∈ [1,99]——gp 无上限种/牌库外种
## 入货架 = 库存账本无锚,加载致命;gp 袋负交易切边/规则改写型特性三轴切边由 ShopTable 词表封闭另拦)
func _cv_shelf_pool(loaded: Dictionary, push_diag: Callable) -> void:
var adv = loaded.get("adventure")
var shp = loaded.get("shop")
if adv != null and shp != null:
@@ -78,34 +109,42 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void:
if quota < 1:
push_diag.call("error", "shelf_zero_quota", "ShopTable.pool_shelf kind %s 令牌配额为 0(牌库空种入货架 = 库存无锚)" % kind)
# ⑦ 计数器引用闭合(卡② V9——读点 id 必命中同名写入行,否则加载致命):
# 声明集 = feats + treasure 各行 triggers 内 kind=counter_add 的 do.counter_id;
# 读点 = when fact=counter 的 id、do.value_counter、kind=counter_spend 的 counter_id。
var feats_tab = loaded.get("feats")
## ⑦ 计数器引用闭合(卡② V9——读点 id 必命中同名写入行,否则加载致命):
## 声明集 = feats + treasure 各行 triggers 内 kind=counter_add 的 do.counter_id;
## 读点 = when fact=counter 的 id、do.value_counter、kind=counter_spend 的 counter_id。
func _cv_counter_closure(loaded: Dictionary, push_diag: Callable) -> void:
var trig_rows: Array[Variant] = []
var feats_tab = loaded.get("feats")
if feats_tab != null:
for r in feats_tab.rows:
trig_rows.append(r)
var treasure_tab = loaded.get("treasure_items")
if treasure_tab != null:
for r in treasure_tab.rows:
trig_rows.append(r)
var counter_decls := _counter_decls_of(trig_rows)
_cv_counter_reads(trig_rows, counter_decls, push_diag)
## 计数器声明集收集:任一 do.counter_id 行(counter_add 直写、forgive_seal_miss 经 _apply_counter_hit 写——皆写入行)
func _counter_decls_of(trig_rows: Array) -> Dictionary:
var counter_decls := {}
for row in trig_rows:
for tr_v in (row.get("triggers", []) as Array):
var dd: Dictionary[Variant, Variant] = (tr_v as Dictionary).get("do", {}) as Dictionary[Variant, Variant]
# 声明集=任一 do.counter_id 行(counter_add 直写、forgive_seal_miss 经 _apply_counter_hit 写——皆写入行)
if dd.has("counter_id"):
counter_decls[String(dd.counter_id)] = true
return counter_decls
## 计数器读点校验:when counter id/do.value_counter/counter_spend 的 counter_id 必命中声明集
func _cv_counter_reads(trig_rows: Array, counter_decls: Dictionary, push_diag: Callable) -> void:
for row in trig_rows:
var rid := String(row.get("feat_id", row.get("item_id", "")))
for tr_v in (row.get("triggers", []) as Array):
var tr: Dictionary[Variant, Variant] = tr_v as Dictionary[Variant, Variant]
for c_v in (tr.get("when", []) as Array):
var c: Dictionary[Variant, Variant] = c_v as Dictionary[Variant, Variant]
if String(c.get("fact", "")) == "counter" and c.has("id"):
var rd := String(c.id)
if not counter_decls.has(rd):
push_diag.call("error", "counter_dangling_ref", "%s when counter 读点无同名写入行: %s(引用闭合)" % [rid, rd])
_check_counter_when(rid, tr, counter_decls, push_diag)
var do2: Dictionary[Variant, Variant] = tr.get("do", {}) as Dictionary[Variant, Variant]
if do2.has("value_counter") and not counter_decls.has(String(do2.value_counter)):
push_diag.call("error", "counter_dangling_ref", "%s value_counter 读点无同名写入行: %s" % [rid, String(do2.value_counter)])
@@ -113,6 +152,16 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void:
push_diag.call("error", "counter_dangling_ref", "%s counter_spend 无同名写入行: %s" % [rid, String(do2.counter_id)])
## when counter 读点校验(fact=counter 条件的 id 必命中声明集)
func _check_counter_when(rid: String, tr: Dictionary, counter_decls: Dictionary, push_diag: Callable) -> void:
for c_v in (tr.get("when", []) as Array):
var c: Dictionary[Variant, Variant] = c_v as Dictionary[Variant, Variant]
if String(c.get("fact", "")) == "counter" and c.has("id"):
var rd := String(c.id)
if not counter_decls.has(rd):
push_diag.call("error", "counter_dangling_ref", "%s when counter 读点无同名写入行: %s(引用闭合)" % [rid, rd])
# 池模板 × 技能行跨表校验(加载期致命;返回错误列表——ConfigDB 诊断通道与冒烟直验共用):
# skill_id 悬空致命 + 池构成纪律(每池恰一行普攻、增益技 ≤1、妨害技 ≤1——proposal §2.1 结构保证 +
# proposal-status-vocabulary §1.2)+ 施放类 side 守卫(增益引用 enemy 侧 / 妨害引用 hero 侧状态行)。
@@ -141,33 +190,25 @@ func validate_skill_pool_links(skill_table: SkillTable, enemy_table: EnemyTable,
errs.append_array(_pool_cast_side_errs(skill_table, status_table)) # 施放类 side 守卫(buff→敌方侧/debuff→主角侧,防反向挂接——全表恰一次)
for row in enemy_table.rows:
var eid: String = String(row.get("enemy_id", ""))
var basic_n := 0
var buff_n := 0
var debuff_n := 0
var uncond_special_n := 0
var pool_n := {"basic": 0, "buff": 0, "debuff": 0, "uncond_special": 0}
for entry in row.get("pool", []):
var sid: String = String(entry.get("skill_id", ""))
if not skill_types.has(sid):
errs.append({"category": "dangling_skill", "message": "EnemyTable %s 池引用不存在的 skill_id: %s(悬空致命)" % [eid, sid]})
continue
match skill_types[sid]:
"basic":
basic_n += 1
"buff":
buff_n += 1
"debuff":
debuff_n += 1
if pool_n.has(skill_types[sid]):
pool_n[skill_types[sid]] += 1
# 无条件特殊技计数(普攻不计;trigger 缺省行计数——触发条件批 2026-10-26 池构成纪律)
if skill_types[sid] != "basic" and not (skill_table.row_by_id(sid) if skill_table.has_method("row_by_id") else {}).is_empty():
if skill_types[sid] != "basic":
var srow_c: Dictionary[Variant, Variant] = skill_table.row_by_id(sid) if skill_table.has_method("row_by_id") else {}
if not srow_c.has("trigger"):
uncond_special_n += 1
if basic_n != 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池普攻行数 ≠ 1 (%d)——普攻冷却恒 0 = 空池不可能的结构前提" % [eid, basic_n]})
if buff_n > 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池增益技 > 1 (%d)——每池增益 ≤1 纪律(纠偏三)" % [eid, buff_n]})
if debuff_n > 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池妨害技 > 1 (%d)——每池妨害 ≤1 纪律(新效应词汇批 2026-09-19)" % [eid, debuff_n]})
if uncond_special_n < 1:
if not srow_c.is_empty() and not srow_c.has("trigger"):
pool_n["uncond_special"] += 1
if pool_n["basic"] != 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池普攻行数 ≠ 1 (%d)——普攻冷却恒 0 = 空池不可能的结构前提" % [eid, pool_n["basic"]]})
if pool_n["buff"] > 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池增益技 > 1 (%d)——每池增益 ≤1 纪律(纠偏三)" % [eid, pool_n["buff"]]})
if pool_n["debuff"] > 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池妨害技 > 1 (%d)——每池妨害 ≤1 纪律(新效应词汇批 2026-09-19)" % [eid, pool_n["debuff"]]})
if pool_n["uncond_special"] < 1:
errs.append({"category": "pool_composition", "message": "EnemyTable %s 池无条件特殊技行数 = 0——每池至少一行无条件特殊技纪律(触发条件批 2026-10-26)" % [eid]})
return errs
+73 -41
View File
@@ -43,11 +43,7 @@ func resolve_enemy_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary]
return []
var rows: Array[Dictionary] = []
rows.assign(_enemy_rows_get.call())
var level_keys: Array[int] = []
for row in rows:
var lv := int(row.get("level_index", 0))
if not level_keys.has(lv):
level_keys.append(lv)
var level_keys: Array[int] = _level_keys_of(rows)
if level_keys.is_empty():
_diag.call("fatal", "structural", "enemy 行集为空——锚行校验应已拦截")
return []
@@ -58,12 +54,7 @@ func resolve_enemy_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary]
_warn_resolve("enemy", "resolve_level_clamp", "enemy 请求 (%d,%d) clamp 解析(超出最大关卡取最大值)→ 关卡 %d" % [L, D, l_star], l_star, D)
elif l_star != L:
_warn_resolve("enemy", "resolve_level_ceil", "enemy 请求 (%d,%d) ceil 解析(向上取整保证偏难)→ 关卡 %d" % [L, D, l_star], l_star, D)
var band_keys: Array[int] = []
for row in rows:
if int(row.get("level_index", 0)) == l_star and String(row.get("row_kind", "")) == row_kind:
var bv := int(row.get("depth_band", -1))
if not band_keys.has(bv):
band_keys.append(bv)
var band_keys: Array[int] = _band_keys_of(rows, l_star, row_kind)
if band_keys.is_empty():
return []
band_keys.sort()
@@ -73,6 +64,32 @@ func resolve_enemy_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary]
_warn_resolve("enemy", "resolve_depth_clamp", "enemy 请求 (%d,%d) clamp 解析(超出顶带取顶带)→ (%d,band %d)" % [L, D, l_star, d_star], l_star, d_star)
elif d_star != D:
_warn_resolve("enemy", "resolve_depth_ceil", "enemy 请求 (%d,%d) ceil 解析(向上取整保证偏难)→ (%d,band %d)" % [L, D, l_star, d_star], l_star, d_star)
return _band_rows(rows, l_star, row_kind, d_star)
## 行集关卡键收集(纯行扫描——告警发射序不变)
func _level_keys_of(rows: Array) -> Array[int]:
var level_keys: Array[int] = []
for row in rows:
var lv := int(row.get("level_index", 0))
if not level_keys.has(lv):
level_keys.append(lv)
return level_keys
## 带键收集(纯行扫描——l_star × row_kind 的 depth_band 去重集)
func _band_keys_of(rows: Array, l_star: int, row_kind: String) -> Array[int]:
var band_keys: Array[int] = []
for row in rows:
if int(row.get("level_index", 0)) == l_star and String(row.get("row_kind", "")) == row_kind:
var bv := int(row.get("depth_band", -1))
if not band_keys.has(bv):
band_keys.append(bv)
return band_keys
## 带行收集(纯行扫描——命中带全部行、按表文件行序)
func _band_rows(rows: Array, l_star: int, row_kind: String, d_star: int) -> Array[Dictionary]:
var all_out: Array[Dictionary] = []
for row in rows:
if int(row.get("level_index", 0)) == l_star and String(row.get("row_kind", "")) == row_kind \
@@ -108,11 +125,7 @@ func _resolve_progress(rows: Array, L: int, D: int, table_id: String) -> Variant
if L < 1 or D < 0:
_diag.call("fatal", "request_oob", "%s 请求越域 (%d,%d)——进度坐标低于合法域(关卡序号≥1、节点深度≥0)" % [table_id, L, D])
return {}
var level_keys: Array[int] = []
for row in rows:
var lv := int(row.get("level_index", 0))
if not level_keys.has(lv):
level_keys.append(lv)
var level_keys: Array[int] = _level_keys_of(rows)
if level_keys.is_empty():
_diag.call("fatal", "structural", "%s 行集为空——锚行校验应已拦截" % table_id)
return {}
@@ -121,40 +134,59 @@ func _resolve_progress(rows: Array, L: int, D: int, table_id: String) -> Variant
var l_clamped := l_star < 0
if l_clamped:
l_star = int(level_keys[-1]) # 超出最大行 → 取最大行封顶(可取范围内向上取整,超出用最大值)
var depth_keys: Array[int] = _depth_keys_of(rows, l_star)
depth_keys.sort()
var d_star := _ceil_key(depth_keys, D)
var d_clamped := d_star < 0
if d_clamped:
d_star = int(depth_keys[-1])
var missing := _missing_label(l_clamped, d_clamped, l_star != L, d_star != D)
if missing != "":
_emit_missing_warning(table_id, missing, [l_star, d_star], [L, D])
for row in rows:
if int(row.get("level_index", 0)) == l_star and int(row.get("node_depth", 0)) == d_star:
return row
return {}
## 层键收集(纯行扫描——l_star 行的 node_depth 去重集)
func _depth_keys_of(rows: Array, l_star: int) -> Array[int]:
var depth_keys: Array[int] = []
for row in rows:
if int(row.get("level_index", 0)) == l_star:
var dv := int(row.get("node_depth", 0))
if not depth_keys.has(dv):
depth_keys.append(dv)
depth_keys.sort()
var d_star := _ceil_key(depth_keys, D)
var d_clamped := d_star < 0
if d_clamped:
d_star = int(depth_keys[-1])
var missing := ""
return depth_keys
## 解析偏移形态标签(clamp/ceil × level/depth/both 六形态——空串 = 精确命中;
## l_ceil/d_ceil = 解析值 ≠ 请求值)
func _missing_label(l_clamped: bool, d_clamped: bool, l_ceil: bool, d_ceil: bool) -> String:
var label := ""
if l_clamped and d_clamped:
missing = "both_clamp"
label = "both_clamp"
elif l_clamped:
missing = "level_clamp"
label = "level_clamp"
elif d_clamped:
missing = "depth_clamp"
elif l_star != L and d_star != D:
missing = "both_ceil"
elif l_star != L:
missing = "level_ceil"
elif d_star != D:
missing = "depth_ceil"
if missing != "":
var dedup_key := "%s|%s|%d|%d" % [table_id, missing, l_star, d_star]
if not _warn_dedup.has(dedup_key):
_warn_dedup[dedup_key] = true
var verb := "clamp 解析(超出最大行取最大值)" if missing.contains("clamp") else "ceil 解析(向上取整保证偏难)"
_diag.call("warning", "resolve_" + missing, "%s 请求 (%d,%d) %s → (%d,%d) [%s]——2026-09-18 拍板" % [table_id, L, D, verb, l_star, d_star, missing])
for row in rows:
if int(row.get("level_index", 0)) == l_star and int(row.get("node_depth", 0)) == d_star:
return row
return {}
label = "depth_clamp"
elif l_ceil and d_ceil:
label = "both_ceil"
elif l_ceil:
label = "level_ceil"
elif d_ceil:
label = "depth_ceil"
return label
## 偏移告警发射(四元组去重注册表语义逐字——R1 既有去重;
## req/resolved = [L, D]/[l_star, d_star] 进度坐标对)
func _emit_missing_warning(table_id: String, missing: String, resolved: Array, req: Array) -> void:
var dedup_key := "%s|%s|%d|%d" % [table_id, missing, resolved[0], resolved[1]]
if not _warn_dedup.has(dedup_key):
_warn_dedup[dedup_key] = true
var verb := "clamp 解析(超出最大行取最大值)" if missing.contains("clamp") else "ceil 解析(向上取整保证偏难)"
_diag.call("warning", "resolve_" + missing, "%s 请求 (%d,%d) %s → (%d,%d) [%s]——2026-09-18 拍板" % [table_id, req[0], req[1], verb, resolved[0], resolved[1], missing])
# 取 sorted_keys 中第一个 >= request 的键;无(请求高于最大行)返回 -1 → 调用方 Fatal
+12
View File
@@ -0,0 +1,12 @@
## 战斗域容器(D2 域容器——config_db 表持有按域收敛,2026-10-27 全清设计定稿):战斗编排域七表类型化持有。
## 零方法零行为——纯持有结构;「经 ConfigDB 单点读取」=经其持有的容器与表对象(架构 :88 定稿注记)。
class_name ConfigDomainCombat
extends Resource
var board_gen: BoardGenTable
var variation: VariationTable
var status_effects: StatusEffectTable
var enemy: EnemyTable
var skill: SkillTable
var hero: HeroTable
var settlement: SettlementTable
+11
View File
@@ -0,0 +1,11 @@
## 跨局域容器(D2 域容器——config_db 表持有按域收敛,2026-10-27 全清设计定稿):跨局元进度域五表类型化持有
## (跨局循环=单局之外的长线进度域)。零方法零行为——纯持有结构;
## 「经 ConfigDB 单点读取」=经其持有的容器与表对象(架构 :88 定稿注记)。
class_name ConfigDomainCrossrun
extends Resource
var meta: MetaTable
var meta_tree: MetaTreeTable
var treasure_items: TreasureItemTable
var feats: FeatTable
var forge: ForgeTable
+12
View File
@@ -0,0 +1,12 @@
## 冒险域容器(D2 域容器——config_db 表持有按域收敛,2026-10-27 全清设计定稿):冒险 runner 域七表类型化持有。
## 零方法零行为——纯持有结构;「经 ConfigDB 单点读取」=经其持有的容器与表对象(架构 :88 定稿注记)。
class_name ConfigDomainRun
extends Resource
var level: LevelTable
var difficulty: DifficultyTable
var level_gen_params: LevelGenParams
var reward: RewardTable
var adventure: AdventureTable
var action_economy: ActionEconomyTable
var shop: ShopTable
+17 -17
View File
@@ -29,13 +29,13 @@ func reset() -> void:
# relic 令牌档 = 行 rarity 直映射,词表外 transitional 值回退映射 rarest→rare/其余→excellent)
func build() -> Array:
var out: Array[Variant] = []
for kind in db.adventure.CURIO_KINDS:
for tier in db.adventure.TIERS:
for i in int(db.adventure.curio_quota(String(kind), String(tier))):
for kind in db.run.adventure.CURIO_KINDS:
for tier in db.run.adventure.TIERS:
for i in int(db.run.adventure.curio_quota(String(kind), String(tier))):
out.append({"kind": String(kind), "tier": String(tier)})
for row in db.feat_rows():
for row in db.crossrun.feats.rows:
var rraw := String(row.get("rarity", ""))
var rtier := rraw if db.adventure.TIERS.has(rraw) else ("rare" if rraw == "rarest" else "excellent")
var rtier := rraw if db.run.adventure.TIERS.has(rraw) else ("rare" if rraw == "rarest" else "excellent")
out.append({"kind": "relic", "tier": rtier, "feat_id": String(row.get("feat_id", ""))})
return out
@@ -73,7 +73,7 @@ func pool_stock(kind: String) -> int:
# 读数面 = 商店买走 vs 奖励领走 vs 枯竭)
func stock_snapshot() -> Dictionary:
var out := {}
for row in db.shop_pool_rows():
for row in db.run.shop.pool_rows():
var kind := String(row.get("kind_id", ""))
out[kind] = pool_stock(kind)
return out
@@ -97,23 +97,23 @@ func consume_deck_token(kind: String, tier: String, feat_id: String = "") -> boo
func cap_ok(kind: String, feat_id: String = "") -> bool:
var cap := 99
if kind == "relic":
var row: Dictionary[Variant, Variant] = db.feat_row(feat_id)
var row: Dictionary[Variant, Variant] = db.crossrun.feats.row_by_id(feat_id)
cap = int(row.get("cap", 1)) if not row.is_empty() else 1
return taken.get("relic:" + feat_id, 0) < cap
if not db.adventure.CURIO_KINDS.has(kind):
if not db.run.adventure.CURIO_KINDS.has(kind):
return true
cap = int(db.adventure.curio_cap(kind))
cap = int(db.run.adventure.curio_cap(kind))
return int(taken.get(kind, 0)) < cap
# 专长凭证展示判定(四口径:一局限购一次/稀有只在概率不在价格/计步保底/触发清零。
# 调用即一次展示判定与记账——展示次数计 pity、配额与限购双闸)
func feat_ticket_consider(rg: RandomNumberGenerator) -> bool:
if ticket_sold >= int(db.adventure.feat_ticket_limit):
if ticket_sold >= int(db.run.adventure.feat_ticket_limit):
return false # 限购满——不再触发(保底冻结)
if ticket_appeared >= int(db.adventure.feat_ticket_quota):
if ticket_appeared >= int(db.run.adventure.feat_ticket_quota):
return false # 配额满——本局不再出现
var offer: bool = ticket_pity >= int(db.adventure.feat_ticket_pity_step) or rg.randi_range(1, 100) <= int(db.adventure.feat_ticket_chance)
var offer: bool = ticket_pity >= int(db.run.adventure.feat_ticket_pity_step) or rg.randi_range(1, 100) <= int(db.run.adventure.feat_ticket_chance)
if offer:
ticket_pity = 0
ticket_appeared += 1
@@ -160,7 +160,7 @@ func _candidate_pool(channel: String) -> Array[Variant]:
func _draw_weighted(pool: Array[Variant], rg: RandomNumberGenerator) -> Array:
var out: Array[Variant] = []
var seen: Array[Variant] = []
var want := int(db.adventure.curio_candidate_count)
var want := int(db.run.adventure.curio_candidate_count)
while out.size() < want and not pool.is_empty():
var total := _pool_weight(pool)
if total <= 0:
@@ -180,13 +180,13 @@ func _draw_weighted(pool: Array[Variant], rg: RandomNumberGenerator) -> Array:
func _pool_weight(pool: Array[Variant]) -> int:
var total := 0
for tok in pool:
total += db.adventure.boss_feat_weight(String((tok as Dictionary).get("tier", "")))
total += db.run.adventure.boss_feat_weight(String((tok as Dictionary).get("tier", "")))
return total
func _weighted_index(pool: Array[Variant], roll: int) -> int:
for i in pool.size():
roll -= db.adventure.boss_feat_weight(String((pool[i] as Dictionary).get("tier", "")))
roll -= db.run.adventure.boss_feat_weight(String((pool[i] as Dictionary).get("tier", "")))
if roll <= 0:
return i
return 0
@@ -198,12 +198,12 @@ func _draw_exposure(pool: Array[Variant], rg: RandomNumberGenerator) -> Array:
_shuffle(pool, rg)
var sub: Array[Variant] = []
for tok in pool:
if db.adventure.tier_rank(String((tok as Dictionary).get("tier", ""))) >= db.adventure.tier_rank(db.adventure.TIER_EXCELLENT):
if db.run.adventure.tier_rank(String((tok as Dictionary).get("tier", ""))) >= db.run.adventure.tier_rank(db.run.adventure.TIER_EXCELLENT):
sub.append(tok)
_shuffle(sub, rg)
var out: Array[Variant] = []
var seen: Array[Variant] = []
var want := int(db.adventure.curio_candidate_count)
var want := int(db.run.adventure.curio_candidate_count)
var n_full := maxi(1, want - 1) if want >= 2 else want
while out.size() < want:
var src: Array[Variant] = sub if out.size() >= n_full and not sub.is_empty() else pool
+9 -9
View File
@@ -56,7 +56,7 @@ func rebind_level_runner(runner: LevelRunner, level_index: int) -> void:
func open_level_clear(payload_base: Dictionary) -> void:
var hp_before := _stats.hero_hp
var max_eff := _stats.effective_max_hp() # 回血封顶口径含调试浮窗 pct(同战斗内上限)
var heal := mini(int(floor(float(max_eff) * float(int(db.adventure.clear_heal_pct)) / 100.0)), max_eff - hp_before)
var heal := mini(int(floor(float(max_eff) * float(int(db.run.adventure.clear_heal_pct)) / 100.0)), max_eff - hp_before)
_stats.hero_hp = hp_before + heal
_bank.reset_x() # 碰三选一重置 X 余量(不结转)
_bank.reset_paid() # 付费刷计数随停点清零(对齐商店进店清零)
@@ -109,7 +109,7 @@ func pick_curio(kind: String, tier: String = "", feat_id: String = "") -> bool:
return false
if kind == "feat_ticket":
return _redeem_feat_ticket() # T5 拿到即开——停点候选替换为专长三选一(零摘牌/不进持有集)
var value: int = int(db.adventure.curio_value(kind, tier))
var value: int = int(db.run.adventure.curio_value(kind, tier))
var hp_before := _stats.hero_hp
if not _apply_curio_pick(kind, tier, value, feat_id):
push_error("[停点件] pick_curio 拒绝——未知件: %s|%s|%s" % [kind, tier, feat_id])
@@ -122,7 +122,7 @@ func pick_curio(kind: String, tier: String = "", feat_id: String = "") -> bool:
func _feat_payload_keys(feat_id: String) -> Dictionary:
if feat_id == "":
return {}
var row: Dictionary[Variant, Variant] = db.feat_row(feat_id)
var row: Dictionary[Variant, Variant] = db.crossrun.feats.row_by_id(feat_id)
return {"feat_id": feat_id, "feat_name": String(row.get("display_name", "")), "feat_archetype": String(row.get("archetype", ""))}
@@ -133,7 +133,7 @@ func _feat_payload_keys(feat_id: String) -> Dictionary:
# 按挂点消费,档位质变由 FeatsEngine 求值)。兑付即从牌库摘除该令牌(选中踢池;
# 未选候选从未摘牌=自动回池)。道具件不入此体——未知件返回 false 由调用方按渠道名报错。
func _apply_curio_pick(kind: String, tier: String, value: int, feat_id: String = "") -> bool:
if kind == "relic" and db.feat_row(feat_id).is_empty():
if kind == "relic" and db.crossrun.feats.row_by_id(feat_id).is_empty():
push_error("[停点件] relic 兑付失败——表无此件: %s" % feat_id)
return false # 先校验后摘牌(令牌零丢失)
if not _deck.consume_deck_token(kind, tier, feat_id):
@@ -172,7 +172,7 @@ func _apply_resource_pick(kind: String, value: int, feat_id: String) -> bool:
# 路由加算段(match 收敛进效果类型:属性值提升四类走 adventure_table.CURIO_APPLY_ROUTES 声明
# 路由(bonus 落点键),加新加算类令牌零代码)
func _apply_route_pick(kind: String, value: int) -> bool:
var sink := db.adventure.curio_apply_route(kind)
var sink := db.run.adventure.curio_apply_route(kind)
if sink == "":
return false
_journal.gain_bonus(sink, value, "curio_pick", kind) # 加算区单写原语(获得日记明细+汇总同拍)
@@ -229,7 +229,7 @@ func apply_node_curio(kind: String, tier: String = "", feat_id: String = "") ->
if _deck.find_token(candidates, kind, tier, feat_id).is_empty():
push_error("[停点件] apply_node_curio 拒绝——令牌不在候选: %s|%s|%s" % [kind, tier, feat_id])
return false
var value: int = int(db.adventure.curio_value(kind, tier))
var value: int = int(db.run.adventure.curio_value(kind, tier))
if not _apply_curio_pick(kind, tier, value, feat_id):
push_error("[停点件] apply_node_curio 拒绝——未知件: %s|%s|%s" % [kind, tier, feat_id])
return false
@@ -348,10 +348,10 @@ func _finish_node_bonus(picked: String, value: int, tier: String = "", feat_id:
func consume_pool_purchase(kind: String, tier: String) -> bool:
if kind == "feat_ticket":
return _redeem_feat_ticket()
if not db.adventure.CURIO_KINDS.has(kind):
if not db.run.adventure.CURIO_KINDS.has(kind):
push_error("[停点件] 商店购买消费拒绝——件不在池词表: %s" % kind)
return false
return _apply_curio_pick(kind, tier, int(db.adventure.curio_value(kind, tier)))
return _apply_curio_pick(kind, tier, int(db.run.adventure.curio_value(kind, tier)))
# ---- 冒险级复位(宿主 _reset_curio_state 调用——停点半:池归 deck.reset、停点三态与刷新账清零)----
@@ -376,7 +376,7 @@ func restore_pending(bp_pending: Dictionary) -> void:
mig["feat_id"] = String((c as Dictionary).get("feat_id", ""))
migrated.append(mig)
else:
migrated.append({"kind": String(c), "tier": db.adventure.TIER_COMMON})
migrated.append({"kind": String(c), "tier": db.run.adventure.TIER_COMMON})
_pending = {"candidates": migrated}
var payload: Dictionary[Variant, Variant] = (bp_pending.get("payload", {}) as Dictionary).duplicate(true)
curio_choice_presented.emit(payload)
+25 -25
View File
@@ -294,7 +294,7 @@ func _hero_attack_ctx(t: Dictionary) -> Dictionary:
func _ctx_rewrites(t: Dictionary, target: Dictionary, armor: String, matchup: String) -> Dictionary:
var ac_hits: Array[Variant] = (t.context.get("fx_attack_hits", []) as Array)
var roll_read: Dictionary[Variant, Variant] = _armor_roll()
var threshold: int = defender_ac(target) + int(db.settlement.damage_roll.attack_bonus)
var threshold: int = defender_ac(target) + int(db.combat.settlement.damage_roll.attack_bonus)
# armor_roll 发射点(敌护甲掷骰判定;免掷恒折扣订阅面)
var ar_hits := _fx_hits("armor_roll", {"target_ac": threshold, "roll": int(roll_read.roll)})
var trade_hit := 0
@@ -383,7 +383,7 @@ func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> D
var ac_hits: Array[Variant] = ctx["ac_hits"]
var roll_read: Dictionary[Variant, Variant] = ctx["roll_read"]
var atk_type: String = ctx["atk_type"]
var rolled: int = DamageMath.rolled_component(base_value, int(ctx["discount"]), bool(ctx["crit"]), int(db.settlement.damage_roll.crit_multiplier), int(ctx["cdelta"]))
var rolled: int = DamageMath.rolled_component(base_value, int(ctx["discount"]), bool(ctx["crit"]), int(db.combat.settlement.damage_roll.crit_multiplier), int(ctx["cdelta"]))
var payload: Dictionary[Variant, Variant] = {"target": String(target.display_name), "target_id": String(target.get("enemy_id", ""))}
payload[atk_type] = rolled # 分量键 = 实际攻击类型(归因字段,单分量模型)
payload["roll"] = int(roll_read.roll)
@@ -392,7 +392,7 @@ func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> D
if bool(ctx["crit"]):
payload["crit"] = true
var pooled: int = DamageMath.pool_damage(rolled, clamped_pool("hero_offense"), clamped_pool("enemy_defense"))
var landed: int = maxi(int(db.settlement.damage_roll.damage_floor), pooled)
var landed: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), pooled)
# 加法族:英雄侧 rider(enemy_skill rider 对称位补齐口径)——后置必中附加,独立于掷点与乘区
# (奥术余烬「普攻命中后恒定结算」语义);猎巢人巢控光环(per nest_count)/矿工的执拗开格加成
# (per dig_streak、cap 封顶)同通道合计;档一质变 rider+1 / 档二(crit 时)rider 乘暴击倍率。
@@ -406,7 +406,7 @@ func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> D
hero_rider += contrib
if hero_rider > 0:
if bool(ctx["crit"]) and bool(inject["rider_tier_crit"]):
hero_rider = int(round(float(hero_rider) * float(db.settlement.damage_roll.crit_multiplier)))
hero_rider = int(round(float(hero_rider) * float(db.combat.settlement.damage_roll.crit_multiplier)))
landed += hero_rider
payload["hero_rider"] = hero_rider
units.apply_damage(target, landed)
@@ -432,7 +432,7 @@ func _kill_overflow(t: Dictionary, target: Dictionary, landed: int) -> void:
# source_class 用中性名(fx_overflow 泛用通道,不挂件名)
var of_t := open_ticket(DamageController.KIND_BOARD_TYPED, "fx_overflow", "贯穿弹射",
{"channel": "board",
"base_value": maxi(int(db.settlement.damage_roll.damage_floor), int(landed * int(h.get("value", 50)) / 100)),
"base_value": maxi(int(db.combat.settlement.damage_roll.damage_floor), int(landed * int(h.get("value", 50)) / 100)),
"target_rule": DamageController.TARGET_FRONT_ALIVE, "ri": int(t.round_index),
"origin_cell": t.origin_cell, "attack_type": "pierce"})
if policy == POLICY_SEGMENT:
@@ -492,13 +492,13 @@ func _resolve_enemy_skill(t: Dictionary) -> void:
func _enemy_roll(t: Dictionary) -> Dictionary:
var hero: Dictionary[Variant, Variant] = units.hero_unit()
var roll_read: Dictionary[Variant, Variant] = _armor_roll()
var threshold: int = defender_ac(hero) + int(db.settlement.damage_roll.attack_bonus)
var threshold: int = defender_ac(hero) + int(db.combat.settlement.damage_roll.attack_bonus)
var crit: bool = bool(roll_read.crit)
var discount: int = _roll_discount(int(roll_read.roll), threshold)
var cdelta: int = _counter_delta(String(t.attack_type), String(hero.get("armor_type", "light")))
var rolled: int = DamageMath.rolled_component(float(t.base_value), discount, crit, int(db.settlement.damage_roll.crit_multiplier), cdelta)
var rolled: int = DamageMath.rolled_component(float(t.base_value), discount, crit, int(db.combat.settlement.damage_roll.crit_multiplier), cdelta)
var pooled: int = DamageMath.pool_damage(rolled, clamped_pool("hero_defense"), clamped_pool("enemy_output"))
var total: int = maxi(int(db.settlement.damage_roll.damage_floor), pooled) + int(t.rider) # rider 后置于伤害下限——独立必中加项,不进乘区
var total: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), pooled) + int(t.rider) # rider 后置于伤害下限——独立必中加项,不进乘区
return {"roll": int(roll_read.roll), "threshold": threshold, "crit": crit, "discount": discount, "total": total}
@@ -520,7 +520,7 @@ func _enemy_shield_apply(hits: Array, total: int) -> Dictionary:
var set_v = h.get("set", null)
EffectEngine.counter_add(_ctx, String(h.get("counter_id", "")), int(h.get("value", 0)) if set_v == null else int(set_v), set_v != null)
if shield > 0:
total = maxi(int(db.settlement.damage_roll.damage_floor), total - shield)
total = maxi(int(db.combat.settlement.damage_roll.damage_floor), total - shield)
return {"total": total, "shield": shield}
@@ -541,7 +541,7 @@ func _resolve_board_typed(t: Dictionary) -> void:
# 无模式 delta 才入棋盘窗(mode 路由:clamp_bonus=主角攻击面专享、discount_shift=折扣档面)
if String(h.get("kind", "")) == "modify_ticket" and h.has("delta") and String(h.get("mode", "")) == "":
cdelta += int(h.delta)
var dealt: int = DamageMath.counter_component(int(t.base_value), cdelta, int(db.settlement.damage_roll.damage_floor))
var dealt: int = DamageMath.counter_component(int(t.base_value), cdelta, int(db.combat.settlement.damage_roll.damage_floor))
units.apply_damage(target, dealt)
emit_row_cb.call({"type": "damage", "source_class": String(t.source_class), "origin_cell": t.origin_cell, "highlight": String(t.highlight)}, int(t.round_index),
"%s → %s" % [String(t.display), String(target.display_name)], dealt,
@@ -556,7 +556,7 @@ func _resolve_board_blast(t: Dictionary) -> void:
_ineffective_row(t, "无存活敌人")
_outcomes[int(t.ticket_id)] = "zero"
return
var blast_dmg: int = maxi(int(db.settlement.damage_roll.damage_floor), int(t.base_value)) # 伤害下限全来源
var blast_dmg: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), int(t.base_value)) # 伤害下限全来源
for u in alive:
units.apply_damage(u, blast_dmg) # 恒定表值(下限保底),逐个;受击即死——死亡即不动手
emit_row_cb.call({"type": "damage", "source_class": String(t.source_class), "origin_cell": t.origin_cell, "highlight": String(t.highlight)}, int(t.round_index),
@@ -567,7 +567,7 @@ func _resolve_board_blast(t: Dictionary) -> void:
# 棋盘主角直伤(反噬/治疗包负向/地形变异):无类型必中,下限保底;context 键并入行载荷
func _resolve_board_flat_hero(t: Dictionary) -> void:
var dealt: int = maxi(int(db.settlement.damage_roll.damage_floor), int(t.base_value)) # 伤害下限全来源
var dealt: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), int(t.base_value)) # 伤害下限全来源
units.apply_damage(units.hero_unit(), dealt)
var extra: Dictionary[Variant, Variant] = {"target": "hero"}
for k in t.context.keys():
@@ -580,16 +580,16 @@ func _resolve_board_flat_hero(t: Dictionary) -> void:
# 护甲等级掷点(armor_roll 封闭流——逐次掷逐次取号、逐位可回放):返回 {roll, crit}
func _armor_roll() -> Dictionary:
var stream: RandomNumberGenerator = rng.open_stream(String(db.settlement.damage_roll.armor_roll_stream))
var roll: int = stream.randi_range(1, int(db.settlement.damage_roll.roll_faces))
return {"roll": roll, "crit": roll == int(db.settlement.damage_roll.crit_roll)}
var stream: RandomNumberGenerator = rng.open_stream(String(db.combat.settlement.damage_roll.armor_roll_stream))
var roll: int = stream.randi_range(1, int(db.combat.settlement.damage_roll.roll_faces))
return {"roll": roll, "crit": roll == int(db.combat.settlement.damage_roll.crit_roll)}
# 掷点折扣读数:nat 最大面恒全额(暴击分支前置),其余查档(缺口 = 阈值 − 掷点)
func _roll_discount(roll: int, threshold: int) -> int:
if roll == int(db.settlement.damage_roll.crit_roll):
if roll == int(db.combat.settlement.damage_roll.crit_roll):
return 100
return DamageMath.roll_discount_percent(roll, threshold, db.settlement.damage_roll.roll_tier_bounds, db.settlement.damage_roll.roll_tier_discounts)
return DamageMath.roll_discount_percent(roll, threshold, db.combat.settlement.damage_roll.roll_tier_bounds, db.combat.settlement.damage_roll.roll_tier_discounts)
# 防守方护甲等级读数(数值轴——与 armor_type 克制属性轴严格分离):敌人 = spawn 期行值快照;
@@ -599,15 +599,15 @@ func defender_ac(unit: Dictionary) -> int:
if bool(unit.get("is_hero", false)):
var total: int = _hero_ac_override
if total < 0:
total = int(db.hero.armor_class)
total = int(db.combat.hero.armor_class)
for eff in effects.query_active("hero"):
var row: Dictionary[Variant, Variant] = db.status_effect_row(eff.effect_id)
var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff.effect_id)
if not row.is_empty() and String(row.unit) == "ac":
total += int(eff.effective_value)
return total
var total_e: int = int(unit.get("armor_class", 0))
for eff_e in effects.query_active("enemy"):
var erow_ac: Dictionary[Variant, Variant] = db.status_effect_row(eff_e.effect_id)
var erow_ac: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff_e.effect_id)
if not erow_ac.is_empty() and String(erow_ac.unit) == "ac":
total_e += int(eff_e.effective_value)
return total_e
@@ -618,14 +618,14 @@ func defender_ac(unit: Dictionary) -> int:
func _counter_delta(attack_type: String, armor: String) -> int:
if attack_type == "":
return 0
var tbl: Dictionary[Variant, Variant] = db.settlement.damage_roll.armor_effectiveness
var tbl: Dictionary[Variant, Variant] = db.combat.settlement.damage_roll.armor_effectiveness
var row: Dictionary[Variant, Variant] = tbl.get(attack_type, {})
return int(row.get(armor, 0))
# 折扣档改善(猎弱本能——折扣表向全额方向移 value 档;查不到当前档 = 不动防御口径)
func _discount_shift_up(discount: int, shift: int) -> int:
var tbl: Array[Variant] = db.settlement.damage_roll.roll_tier_discounts
var tbl: Array[Variant] = db.combat.settlement.damage_roll.roll_tier_discounts
var idx := tbl.find(discount)
if idx < 0:
return discount
@@ -636,12 +636,12 @@ func _discount_shift_up(discount: int, shift: int) -> int:
func clamped_pool(pool: String) -> int:
var total := 0
for eff in effects.query_active("hero") + effects.query_active("enemy"):
var row: Dictionary[Variant, Variant] = db.status_effect_row(eff.effect_id)
var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff.effect_id)
if row.is_empty():
continue
if String(db.settlement.core.effect_pool_map.get(eff.effect_id, "")) == pool and String(row.unit) == "percent":
if String(db.combat.settlement.core.effect_pool_map.get(eff.effect_id, "")) == pool and String(row.unit) == "percent":
total += eff.effective_value # 池内加算(L×m 原样——越域不 clamp 由本处收口)
return clampi(total, int(db.settlement.core.pool_clamp_lo), int(db.settlement.core.pool_clamp_hi))
return clampi(total, int(db.combat.settlement.core.pool_clamp_lo), int(db.combat.settlement.core.pool_clamp_hi))
# 攻击行显示名(掷点档位随行文本可读;重档折扣 = "被压制")
+169 -109
View File
@@ -94,58 +94,76 @@ func _init(db_ref: ConfigDb = null) -> void:
## 在 dispatch 的 round_start 特判内整桶 clear,adventure 桶随宿主字典引用跨战持久——无副本搬运)。
func rebuild(feat_ids: Array, item_ids: Array, tree_rules: Array = []) -> void:
_triggers.clear()
_load_feat_triggers(feat_ids)
_load_attune_triggers(item_ids)
_push_tree_rules(tree_rules)
## 特性订阅装载域(feats 表序→按持有集过滤→行内数组序——装载声明序第一段)
func _load_feat_triggers(feat_ids: Array) -> void:
if db != null and db.feats != null:
for row in db.feats.rows:
for row in db.crossrun.feats.rows:
var fid := String(row.get("feat_id", ""))
if not feat_ids.has(fid):
continue
for tr in (row.get("triggers", []) as Array):
_push_trigger("feat", fid, tr, row)
## 同调秘宝订阅装载域(treasure 同调行序→按装备集过滤→行内数组序——装载声明序第二段)
func _load_attune_triggers(item_ids: Array) -> void:
if db != null:
for row in db.treasure_attunement_rows():
for row in db.crossrun.treasure_items.attunement_rows():
var iid := String(row.get("item_id", ""))
if not item_ids.has(iid):
continue
for tr in (row.get("triggers", []) as Array):
_push_trigger("attune", iid, tr, row)
# 树规则合成订阅(六键按 v0.8 三组双态):posture 两键=「每关首轮窗」语义
# 〔battle_index==1 且 round_index==1;battle_index=关卡域战斗序由 enc 携带〕;
# sense 两组=每关限次〔level 桶计数器 lt 上限闸 + 命中即 counter_add 记账双行——同事件双行同
# 预评估态见闸同记,序=装载序确定;寿命随 LevelRunner.level_counters 每关 new 天然按关清零〕;
# economy 两组零订阅行——消费点=GP 掉落乘算与商品计价折价的 tree_rules 成员判定。数值全 provisional 归 M4。
## 树规则合成订阅域(六键按 v0.8 三组双态——装载声明序第三段):posture 两键=「每关首轮窗」语义
## 〔battle_index==1 且 round_index==1;battle_index=关卡域战斗序由 enc 携带〕;
## sense 两组=每关限次〔level 桶计数器 lt 上限闸 + 命中即 counter_add 记账双行——同事件双行同
## 预评估态见闸同记,序=装载序确定;寿命随 LevelRunner.level_counters 每关 new 天然按关清零〕;
## economy 两组零订阅行——消费点=GP 掉落乘算与商品计价折价的 tree_rules 成员判定。数值全 provisional 归 M4。
func _push_tree_rules(tree_rules: Array) -> void:
for key_v in tree_rules:
var rk := String(key_v)
match rk:
"posture_first_strike":
_push_trigger("tree", rk, {"on": "round_start",
"when": [{"fact": "battle_index", "op": "eq", "value": 1},
{"fact": "round_index", "op": "eq", "value": 1}],
"do": {"kind": "grant_ap", "mode": "queue", "value": 1}}, {})
"posture_bulwark":
_push_trigger("tree", rk, {"on": "damage_taken",
"when": [{"fact": "battle_index", "op": "eq", "value": 1},
{"fact": "round_index", "op": "eq", "value": 1}],
"do": {"kind": "shield", "value": 1}}, {})
"sense_sharp":
# 锋=踩中危险格伤害减半·每关限次——mine_hit 挂点现成(damage_mult 收集→halve_pct,
# 与惊巢反制同路径;伤害在结算战斗段末兑付,探索阶段零改动)
_push_trigger("tree", rk, {"on": "mine_hit",
"when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}],
"do": {"kind": "damage_mult", "value": 50}}, {})
_push_trigger("tree", rk, {"on": "mine_hit",
"when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}],
"do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_sharp_used",
"value": 1}}, {})
"sense_guard":
# 守=标错返还行动点·每关限次(契约 12 失误补偿——seal_missed 由 _judge_seal 去重闸后
# 发射;queue=下一轮开轮兑现,末轮判出无下一轮=合法作废自动成立)
_push_trigger("tree", rk, {"on": "seal_missed",
"when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}],
"do": {"kind": "grant_ap", "mode": "queue", "value": 1}}, {})
_push_trigger("tree", rk, {"on": "seal_missed",
"when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}],
"do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_guard_used",
"value": 1}}, {})
_push_tree_rule(String(key_v))
## 单树规则合成行 push(六键 match 分派——词表封闭,未知键零订阅零扰动)
func _push_tree_rule(rk: String) -> void:
match rk:
"posture_first_strike":
_push_trigger("tree", rk, {"on": "round_start",
"when": [{"fact": "battle_index", "op": "eq", "value": 1},
{"fact": "round_index", "op": "eq", "value": 1}],
"do": {"kind": "grant_ap", "mode": "queue", "value": 1}}, {})
"posture_bulwark":
_push_trigger("tree", rk, {"on": "damage_taken",
"when": [{"fact": "battle_index", "op": "eq", "value": 1},
{"fact": "round_index", "op": "eq", "value": 1}],
"do": {"kind": "shield", "value": 1}}, {})
"sense_sharp":
# 锋=踩中危险格伤害减半·每关限次——mine_hit 挂点现成(damage_mult 收集→halve_pct,
# 与惊巢反制同路径;伤害在结算战斗段末兑付,探索阶段零改动)
_push_trigger("tree", rk, {"on": "mine_hit",
"when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}],
"do": {"kind": "damage_mult", "value": 50}}, {})
_push_trigger("tree", rk, {"on": "mine_hit",
"when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}],
"do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_sharp_used",
"value": 1}}, {})
"sense_guard":
# 守=标错返还行动点·每关限次(契约 12 失误补偿——seal_missed 由 _judge_seal 去重闸后
# 发射;queue=下一轮开轮兑现,末轮判出无下一轮=合法作废自动成立)
_push_trigger("tree", rk, {"on": "seal_missed",
"when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}],
"do": {"kind": "grant_ap", "mode": "queue", "value": 1}}, {})
_push_trigger("tree", rk, {"on": "seal_missed",
"when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}],
"do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_guard_used",
"value": 1}}, {})
func _push_trigger(source: String, id: String, tr: Dictionary, row: Dictionary) -> void:
@@ -203,13 +221,14 @@ func dispatch(ctx: EffectContext, event: String, payload: Dictionary, facts: Cal
return hits
## 计数器归属桶解析(前缀规则唯一出处——round_/lvl_/adv_ 显式桶,其余落 battle 缺省桶)。
## 计数器归属桶解析(前缀规则唯一出处——round_/lvl_/adv_ 显式桶,其余落 battle 缺省桶;
## 前缀判定序 round→lvl→adv→battle 缺省逐字)
static func bucket_of(ctx: EffectContext, counter_id: String) -> Dictionary[Variant, Variant]:
if counter_id.begins_with("round_"):
return ctx.round_bucket
elif counter_id.begins_with("lvl_"):
if counter_id.begins_with("lvl_"):
return ctx.level_bucket
elif counter_id.begins_with("adv_"):
if counter_id.begins_with("adv_"):
return ctx.adventure_bucket
return ctx.battle_bucket
@@ -262,41 +281,68 @@ func _cond_ok(ctx: EffectContext, c: Dictionary, payload: Dictionary, facts: Cal
var val = c.get("value", null)
var actual = _fact_value(ctx, fact, c, payload, facts)
match op:
"eq":
return str(actual) == str(val)
"neq":
return str(actual) != str(val)
"gt":
return int(actual) > int(val)
"gte":
return int(actual) >= int(val)
"lt":
return int(actual) < int(val)
"lte":
return int(actual) <= int(val)
"eq", "neq", "gt", "gte", "lt", "lte":
return _cmp_ok(op, actual, val)
"ends_with":
if val is Array:
for v in (val as Array):
if String(actual).ends_with(String(v)):
return true
return false
return String(actual).ends_with(String(val))
return _ends_with_ok(actual, val)
"has":
# 在场判定(fact 查询面落法——白名单覆盖):value 缺省=非空判定
# (terrain_kind fact:伤害型变异集非空=挖开伤害型地形格/邻格有伤害型地形/地形伤种类在场);
# value 给定=数组包含判定(保留泛用面)。
if c.has("value"):
if actual is Array:
return (actual as Array).has(val)
return false
if actual is Array:
return not (actual as Array).is_empty()
return actual != null and String(actual) != "" # null 安全(敌技伤害载荷无 terrain_kind 时事实面回落 null,判「不在场」而非抛错)
return _has_ok(c, actual)
"mod_zero", "mod_eq", "advantageous", "gte_ref", "lt_ref":
return _counter_op_ok(ctx, c, op, actual)
assert(false, "[效果引擎] when 算子出域: %s" % op)
return false
## 比较算子域(eq/neq/gt/gte/lt/lte 六比较——str 逐字与 int 投影语义逐字)
func _cmp_ok(op: String, actual: Variant, val: Variant) -> bool:
var out := false
match op:
"eq":
out = str(actual) == str(val)
"neq":
out = str(actual) != str(val)
"gt":
out = int(actual) > int(val)
"gte":
out = int(actual) >= int(val)
"lt":
out = int(actual) < int(val)
"lte":
out = int(actual) <= int(val)
return out
## 后缀匹配域(ends_with——值数组任一命中即真)
func _ends_with_ok(actual: Variant, val: Variant) -> bool:
if val is Array:
for v in (val as Array):
if String(actual).ends_with(String(v)):
return true
return false
return String(actual).ends_with(String(val))
## 在场判定域(has——fact 查询面落法,白名单覆盖):value 缺省=非空判定
## (terrain_kind fact:伤害型变异集非空=挖开伤害型地形格/邻格有伤害型地形/地形伤种类在场);
## value 给定=数组包含判定(保留泛用面)
func _has_ok(c: Dictionary, actual: Variant) -> bool:
if c.has("value"):
if actual is Array:
return (actual as Array).has(c.get("value"))
return false
if actual is Array:
return not (actual as Array).is_empty()
return actual != null and String(actual) != "" # null 安全(敌技伤害载荷无 terrain_kind 时事实面回落 null,判「不在场」而非抛错)
## 计数器与矩阵算子域(mod_zero/mod_eq 取模、advantageous 克制矩阵、gte_ref/lt_ref 计数器比较——读经 ctx)
func _counter_op_ok(ctx: EffectContext, c: Dictionary, op: String, actual: Variant) -> bool:
match op:
"mod_zero":
var m := int(val)
var m := int(c.get("value", null))
return m > 0 and int(actual) % m == 0
"mod_eq":
var me_m := int(val)
var me_m := int(c.get("value", null))
return me_m > 0 and int(actual) % me_m == int(c.get("ref", 0))
"advantageous":
# 克制矩阵正面判定(matchup = "{attack_type}_vs_{armor}"——矩阵值 > 0 = 克制成立)
@@ -305,7 +351,6 @@ func _cond_ok(ctx: EffectContext, c: Dictionary, payload: Dictionary, facts: Cal
return int(actual) >= counter(ctx, String(c.get("ref", "")))
"lt_ref":
return int(actual) < counter(ctx, String(c.get("ref", "")))
assert(false, "[效果引擎] when 算子出域: %s" % op)
return false
@@ -329,7 +374,7 @@ func _counter_delta_of(matchup: String) -> int:
var parts := matchup.split("_vs_")
if parts.size() != 2:
return 0
var tbl: Dictionary[Variant, Variant] = db.settlement.damage_roll.armor_effectiveness
var tbl: Dictionary[Variant, Variant] = db.combat.settlement.damage_roll.armor_effectiveness
var row: Dictionary[Variant, Variant] = tbl.get(String(parts[0]), {})
return int(row.get(String(parts[1]), 0))
@@ -350,44 +395,59 @@ static func validate_trigger_row(rid: String, row: Dictionary, triggers: Array,
errs.append({"category": "enum", "message": "%s 订阅事件出域(事件词汇封闭): %s" % [rid, on]})
elif MINE_EVENTS.has(on):
mine_covered = true
for c_v in (tr.get("when", []) as Array):
var c: Dictionary[Variant, Variant] = c_v as Dictionary
for k in c.keys():
if not WHEN_KEYS.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s when 键出白名单: %s" % [rid, String(k)]})
var fact := String(c.get("fact", ""))
if not FACTS.has(fact):
errs.append({"category": "enum", "message": "%s fact 出域(事实词汇封闭 %d 项): %s" % [rid, FACTS.size(), fact]})
if not OPS.has(String(c.get("op", "eq"))):
errs.append({"category": "enum", "message": "%s when 算子出域: %s" % [rid, String(c.get("op", ""))]})
var do_d: Dictionary[Variant, Variant] = tr.get("do", {}) as Dictionary
for k in do_d.keys():
if not DO_KEYS.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s do 键出白名单: %s(机制轴禁无条件属性加项)" % [rid, String(k)]})
var kind := String(do_d.get("kind", ""))
if not EFFECT_KINDS.has(kind):
errs.append({"category": "enum", "message": "%s 效果类型出域(解释器闭集): %s" % [rid, kind]})
# 计数器前缀归属合法性(.tres 行路径——写错前缀加载致命;判据单点 counter_id_legal,与 _push_trigger 同源)
if do_d.has("counter_id"):
var cid := String(do_d.counter_id)
if not counter_id_legal(cid):
errs.append({"category": "enum", "message": "%s counter_id 前缀非法(meta_ 出域/引擎不接存档级): %s" % [rid, cid]})
# value_from/delta_from/mult_from/cap_from 引用面:行字段存在性(悬空=加载致命)——
# do 与 when 两侧同面(when value_from=dawn_order effective_rounds 先例)
for vf_k in ["value_from", "delta_from", "mult_from", "cap_from"]:
if do_d.has(vf_k):
var fld := String(do_d.get(vf_k, ""))
if fld != "" and not row.has(fld):
errs.append({"category": "dangling", "message": "%s do.%s 悬空(行无字段 %s)" % [rid, vf_k, fld]})
for c_v in (tr.get("when", []) as Array):
var c_w: Dictionary[Variant, Variant] = c_v as Dictionary
if c_w.has("value_from"):
var wfld := String(c_w.get("value_from", ""))
if wfld != "" and not row.has(wfld):
errs.append({"category": "dangling", "message": "%s when.value_from 悬空(行无字段 %s)" % [rid, wfld]})
_vtr_when_keys(rid, tr, errs)
_vtr_do_keys(rid, tr, row, errs)
return mine_covered
## when 键域校验:键白名单/fact 词表/算子词表(追加序承原位——when 键→fact→op)
static func _vtr_when_keys(rid: String, tr: Dictionary, errs: Array) -> void:
for c_v in (tr.get("when", []) as Array):
var c: Dictionary[Variant, Variant] = c_v as Dictionary
for k in c.keys():
if not WHEN_KEYS.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s when 键出白名单: %s" % [rid, String(k)]})
var fact := String(c.get("fact", ""))
if not FACTS.has(fact):
errs.append({"category": "enum", "message": "%s fact 出域(事实词汇封闭 %d 项): %s" % [rid, FACTS.size(), fact]})
if not OPS.has(String(c.get("op", "eq"))):
errs.append({"category": "enum", "message": "%s when 算子出域: %s" % [rid, String(c.get("op", ""))]})
## do 键域校验:键白名单(零裸 flat 护栏)/效果类型闭集/计数器前缀合法性+value_from 引用悬空域
static func _vtr_do_keys(rid: String, tr: Dictionary, row: Dictionary, errs: Array) -> void:
var do_d: Dictionary[Variant, Variant] = tr.get("do", {}) as Dictionary
for k in do_d.keys():
if not DO_KEYS.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s do 键出白名单: %s(机制轴禁无条件属性加项)" % [rid, String(k)]})
var kind := String(do_d.get("kind", ""))
if not EFFECT_KINDS.has(kind):
errs.append({"category": "enum", "message": "%s 效果类型出域(解释器闭集): %s" % [rid, kind]})
# 计数器前缀归属合法性(.tres 行路径——写错前缀加载致命;判据单点 counter_id_legal,与 _push_trigger 同源)
if do_d.has("counter_id"):
var cid := String(do_d.counter_id)
if not counter_id_legal(cid):
errs.append({"category": "enum", "message": "%s counter_id 前缀非法(meta_ 出域/引擎不接存档级): %s" % [rid, cid]})
_vtr_from_refs(rid, tr, row, errs)
## value_from/delta_from/mult_from/cap_from 引用面:行字段存在性(悬空=加载致命)——
## do 与 when 两侧同面(when value_from=dawn_order effective_rounds 先例)
static func _vtr_from_refs(rid: String, tr: Dictionary, row: Dictionary, errs: Array) -> void:
var do_d: Dictionary[Variant, Variant] = tr.get("do", {}) as Dictionary
for vf_k in ["value_from", "delta_from", "mult_from", "cap_from"]:
if do_d.has(vf_k):
var fld := String(do_d.get(vf_k, ""))
if fld != "" and not row.has(fld):
errs.append({"category": "dangling", "message": "%s do.%s 悬空(行无字段 %s)" % [rid, vf_k, fld]})
for c_v in (tr.get("when", []) as Array):
var c_w: Dictionary[Variant, Variant] = c_v as Dictionary
if c_w.has("value_from"):
var wfld := String(c_w.get("value_from", ""))
if wfld != "" and not row.has(wfld):
errs.append({"category": "dangling", "message": "%s when.value_from 悬空(行无字段 %s)" % [rid, wfld]})
## 组合规则(需求卡片 §3.4 六族——效果类型的必填属性,发射点施加面引用)
## 加法族(数值相加,可交换):add_ticket_rider、attack_bonus、grant_material、grant_ap(非 transfer)、
+5 -5
View File
@@ -52,12 +52,12 @@ func _normalize_override(raw: Dictionary) -> Dictionary[Variant, Variant]:
# 调试浮窗百分比后乘;pct=0 时乘法恒等——MC 锚零漂移)----
func effective_max_hp() -> int:
var base: int = int(db.hero.max_hp) + int(_base_override.get("max_hp", 0)) + journal.bonus_total("max_hp")
var base: int = int(db.combat.hero.max_hp) + int(_base_override.get("max_hp", 0)) + journal.bonus_total("max_hp")
return maxi(1, int(round(float(base) * (100.0 + float(_override[1])) / 100.0)))
func effective_atk() -> int:
var base: int = int(db.hero.atk) + int(_base_override.get("atk", 0)) + journal.bonus_total("atk")
var base: int = int(db.combat.hero.atk) + int(_base_override.get("atk", 0)) + journal.bonus_total("atk")
return maxi(1, int(round(float(base) * (100.0 + float(_override[0])) / 100.0)))
@@ -70,10 +70,10 @@ func effective_ac() -> int:
# 甲型基础 AC(三层之第一层——选中甲路线行 base_ac;未注入/缺行=锻造配置默认值路线,
# 与 hero 表两轴删除前 armor_class=10 行为连续)
func _armor_base_ac() -> int:
var at: String = armor_type if armor_type != "" else db.forge_default_armor_type()
var row: Dictionary[Variant, Variant] = db.forge_armor_route_by_type(at)
var at: String = armor_type if armor_type != "" else db.crossrun.forge.default_armor_type
var row: Dictionary[Variant, Variant] = db.crossrun.forge.armor_route_by_type(at)
if row.is_empty():
return int(db.hero.armor_class) # 表缺失防御兜底(现役基准)
return int(db.combat.hero.armor_class) # 表缺失防御兜底(现役基准)
return int(row.get("base_ac", 0))
+4 -4
View File
@@ -318,7 +318,7 @@ func restore_fragment(resume_state: Dictionary) -> Dictionary:
func _find_level_row(level_index: int) -> Dictionary:
return db.level_row(level_index) # 表访问收口(行集直扫归 LevelTable/ConfigDB 访问器)
return db.run.level.row_by_level_index(level_index) # 表访问收口(行集直扫归 LevelTable/ConfigDB 访问器)
# ---- 图源装配(关卡节点图生成 v1.1——图 = f(参数档, 种子) 纯函数;无模板池、无兜底图)----
@@ -327,7 +327,7 @@ func _find_level_row(level_index: int) -> Dictionary:
# 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)
var params: Dictionary[Variant, Variant] = db.run.level_gen_params.row_by_level_index(level_index)
if params.is_empty():
push_error("[关卡节点地图] level %d 无参数档行(level_gen_params 缺行——配置错误)" % level_index)
return false
@@ -433,7 +433,7 @@ func visited_ids() -> Array:
# ---- 类型可见性读面(信息迷雾 v2.1:近处见类型,远处起雾)----
# 可见集 = Boss/终点(type=="boss")∪ 当前候选(下一跳恒可见——生成承诺)∪
# {id : |层(id) − 锚层| ≤ N};N = db.level.info_visible_layers(禁硬编码);
# {id : |层(id) − 锚层| ≤ N};N = db.run.level.info_visible_layers(禁硬编码);
# 锚层 = 当前节点层,入口停点(无当前节点)= 0;层 = 入口 BFS 深度(与呈现层分层同源口径)。
# 纯函数零状态:已过节点不豁免(单圈语义,零累积记忆);断点快照零新增字段。
func type_visible_ids() -> Array[int]:
@@ -443,7 +443,7 @@ func type_visible_ids() -> Array[int]:
var cur := current_node()
if not cur.is_empty():
anchor = int(layers.get(_node_id(cur), 0))
var n: int = int(db.level.info_visible_layers)
var n: int = int(db.run.level.info_visible_layers)
for node in nodes():
var nid := _node_id(node)
if String(node.get("type", "")) == "boss" \
+159 -170
View File
@@ -7,6 +7,7 @@ extends Node
var db: ConfigDb
var save: SaveManagerCore
var meta_state: MetaStateStore # 存档内元进度数据读写唯一层(K3′ 装配成员——架构 :90 直连)
# MetaProgression 是 autoload(仿 RngService 先例:类名加 Core 后缀、
@@ -16,6 +17,8 @@ var save: SaveManagerCore
func _init(db_ref: ConfigDb = null, save_ref: SaveManagerCore = null) -> void:
db = db_ref
save = save_ref
if save != null:
meta_state = save.meta_state
func _ready() -> void:
@@ -23,31 +26,23 @@ func _ready() -> void:
db = get_node("/root/ConfigDB")
if save == null:
save = get_node("/root/SaveManager")
func keystone_balance() -> int:
return save.keystone_balance()
meta_state = save.meta_state
# ---- 秘宝解锁服务(解锁混合制,契约 6)----
# 解锁条件单一事实源=treasure_items.tres 行内 unlock_keystone_cost/unlock_requires 谓词
# (C1 逐件分配锁定表)。
# 道具解锁读数访问器(组合根/呈现层解锁门控查询面——禁再穿透 meta.save 直读 save_manager)
func is_item_unlocked(item_id: String) -> bool:
return save.item_unlocked(item_id)
# (C1 逐件分配锁定表)。钥石余额/道具解锁读数消费方直连 meta.meta_state(纯转发读已随件退役)。
# 解锁条件计数读数单点(谓词 key → 生涯计数——封闭枚举=TreasureItemTable.UNLOCK_COUNTER_KEYS;
# 词表加键 = 此处增一 match 分支,阈值比较与解锁写档零改动)
func _treasure_unlock_progress(key: String) -> int:
match key:
"adventure_cleared":
return save.adventure_cleared()
return meta_state.career_state().adventure_cleared
"boss_kill_count":
return save.boss_kill_count()
return meta_state.career_state().boss_kill_count
"vision_used":
return save.vision_used()
return meta_state.career_state().vision_used
_:
return 0 # 未知计数键恒 0 = 永不解锁(fail-safe 非致命——schema 词表校验兜底,不静默误解锁)
@@ -55,15 +50,15 @@ func _treasure_unlock_progress(key: String) -> int:
# 钥石购买解锁(营地秘宝匣唯一购买写口):仅购买件(行带 unlock_keystone_cost)可购;
# 已解锁/余额不足/条件件误购 = 拒绝且零副作用(spend_keystone 内嵌余额闸,失败不扣币)。
func buy_treasure_unlock(item_id: String) -> bool:
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(item_id)
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(item_id)
var price := int(arow.get("unlock_keystone_cost", 0))
if arow.is_empty() or price < 1 or (arow.get("unlock_requires", []) as Array).size() > 0:
return false # 非购买件(条件件/无价行)拒绝
if save.item_unlocked(item_id):
if meta_state.item_unlocked(item_id):
return false # 已解锁重复购买拒绝
if not save.spend_keystone(price):
if not meta_state.spend_keystone(price):
return false # 余额不足(零副作用)
save.unlock_item(item_id)
meta_state.unlock_item(item_id)
return save.write_to_disk()
@@ -71,9 +66,9 @@ func buy_treasure_unlock(item_id: String) -> bool:
# 全部谓词达标才解锁(多谓词=AND——天眼双条件);幂等:已解锁跳过,重复刷新零重复入账。
func refresh_treasure_unlocks() -> Array:
var new_unlocks: Array[String] = []
for row in db.treasure_attunement_rows():
for row in db.crossrun.treasure_items.attunement_rows():
var iid := String(row.get("item_id", ""))
if save.item_unlocked(iid):
if meta_state.item_unlocked(iid):
continue
var ureqs: Array[Variant] = row.get("unlock_requires", []) as Array
if ureqs.is_empty():
@@ -85,7 +80,7 @@ func refresh_treasure_unlocks() -> Array:
met = false
break
if met:
save.unlock_item(iid)
meta_state.unlock_item(iid)
new_unlocks.append(iid)
if not new_unlocks.is_empty():
save.write_to_disk()
@@ -96,9 +91,9 @@ func refresh_treasure_unlocks() -> Array:
# {item_id, display_name, desc, tier, unlocked, mode(keystone|condition|owned), price, reqs:[{key,threshold,progress}]}
func treasure_shop_status() -> Array:
var out: Array[Dictionary] = []
for row in db.treasure_attunement_rows():
for row in db.crossrun.treasure_items.attunement_rows():
var iid := String(row.get("item_id", ""))
var unlocked := save.item_unlocked(iid)
var unlocked := meta_state.item_unlocked(iid)
var ureqs: Array[Variant] = row.get("unlock_requires", []) as Array
var req_view: Array[Variant] = []
for req_v in ureqs:
@@ -128,18 +123,18 @@ func treasure_shop_status() -> Array:
# 等级语义:未解锁/未锻造=0 加值(裸体态);锻造第一级=「获得装备」=首个 +1;逐级递进。
# 路线解锁态派生(不落档——初始持有∨直购∨练满前一档;派生单点只此一处)
func forge_route_unlocked(route_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.forge_route(route_id)
func _forge_route_unlocked(route_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.crossrun.forge.route(route_id)
if row.is_empty():
return false
if bool(row.get("initial_owned", false)):
return true
if save.forge_unlocked_purchases().has(route_id):
if meta_state.forge_state().unlocked.has(route_id):
return true
var prereq := String(row.get("prereq_route", ""))
if prereq != "":
var prow: Dictionary[Variant, Variant] = db.forge_route(prereq)
if not prow.is_empty() and save.forge_route_level(prereq) >= int(prow.get("cap", 0)):
var prow: Dictionary[Variant, Variant] = db.crossrun.forge.route(prereq)
if not prow.is_empty() and meta_state.forge_state().levels.get(prereq, 0) >= int(prow.get("cap", 0)):
return true
return false
@@ -147,86 +142,60 @@ func forge_route_unlocked(route_id: String) -> bool:
# 路线购买(几何价=基础价×150%^已购级数——锚表 20/30/45/67/101;等级+1 即时写盘)。
# 未解锁/已满级/余额不足/未知路线 = 拒绝且零副作用(spend_keystone 内嵌余额闸,失败不扣币不写盘)。
func forge_buy_level(route_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.forge_route(route_id)
if row.is_empty() or not forge_route_unlocked(route_id):
var row: Dictionary[Variant, Variant] = db.crossrun.forge.route(route_id)
if row.is_empty() or not _forge_route_unlocked(route_id):
return false
var lvl := save.forge_route_level(route_id)
var lvl := meta_state.forge_state().levels.get(route_id, 0)
if lvl >= int(row.get("cap", 0)):
return false
var price := ForgeTable.level_price(row, lvl)
if not save.spend_keystone(price):
if not meta_state.spend_keystone(price):
return false
save.set_forge_route_level(route_id, lvl + 1)
meta_state.set_forge_route_level(route_id, lvl + 1)
return save.write_to_disk()
# 一次性解锁价直购(并联双通道②)。已解锁(含练满前一档派生)= 拒绝零收费——两通道互不影响:
# 未练满也可直购;直购后练满不重复收(练满通道本就是派生、零收费)。初始持有行无解锁面。
func forge_buy_unlock(route_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.forge_route(route_id)
var row: Dictionary[Variant, Variant] = db.crossrun.forge.route(route_id)
if row.is_empty() or bool(row.get("initial_owned", false)):
return false
if forge_route_unlocked(route_id):
if _forge_route_unlocked(route_id):
return false
var price := int(row.get("unlock_price", 0))
if price < 1 or not save.spend_keystone(price):
if price < 1 or not meta_state.spend_keystone(price):
return false
save.add_forge_unlock(route_id)
meta_state.add_forge_unlock(route_id)
return save.write_to_disk()
# 当前武器/甲选中切换(免费——只受理已解锁路线;写存档域+即时写盘。冒险开始锁死归参数面快照,
# 契约 8;冒险中无切换入口=UI 面无路径可达本口)
func forge_select_weapon(route_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.forge_route(route_id)
if row.is_empty() or String(row.get("kind", "")) != "weapon" or not forge_route_unlocked(route_id):
# 契约 8;冒险中无切换入口=UI 面无路径可达本口)。并组入口:kind ∈ {weapon, armor}。
func forge_select(kind: String, route_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.crossrun.forge.route(route_id)
if row.is_empty() or String(row.get("kind", "")) != kind or not _forge_route_unlocked(route_id):
return false
save.set_forge_weapon(route_id)
if kind == "weapon":
meta_state.set_forge_weapon(route_id)
else:
meta_state.set_forge_armor(route_id)
return save.write_to_disk()
func forge_select_armor(route_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.forge_route(route_id)
if row.is_empty() or String(row.get("kind", "")) != "armor" or not forge_route_unlocked(route_id):
return false
save.set_forge_armor(route_id)
return save.write_to_disk()
# 当前选中武器攻击加值 / 当前甲 AC 加值(路线等级×每级增量——0=裸体态/未锻造合法态)。
# 选中态异常(未解锁/表缺行)防御回退 0——注入面只认合法态,禁把脏选中带进冒险。
func forge_weapon_atk_bonus() -> int:
var rid := save.forge_weapon()
var row: Dictionary[Variant, Variant] = db.forge_route(rid)
if row.is_empty() or String(row.get("kind", "")) != "weapon" or not forge_route_unlocked(rid):
return 0
return save.forge_route_level(rid) * int(row.get("per_level_gain", 0))
func forge_armor_ac_bonus() -> int:
var rid := save.forge_armor()
var row: Dictionary[Variant, Variant] = db.forge_route(rid)
if row.is_empty() or String(row.get("kind", "")) != "armor" or not forge_route_unlocked(rid):
return 0
return save.forge_route_level(rid) * int(row.get("per_level_gain", 0))
# 两根类型轴当前生效值(选中路线类型轴;选中态异常/未解锁=锻造配置默认值——契约 2 回退面)。
# 两根类型轴当前生效值({attack, armor} 并组承载;选中态异常/未解锁=锻造配置默认值——契约 2 回退面)。
# 默认值与柜台解锁状态解耦:树钥匙未点亮时本函数照常返回默认值(配置常驻可读)。
func hero_attack_type() -> String:
var rid := save.forge_weapon()
var row: Dictionary[Variant, Variant] = db.forge_route(rid)
if row.is_empty() or String(row.get("kind", "")) != "weapon" or not forge_route_unlocked(rid):
return db.forge_default_attack_type()
return String(row.get("type_axis", ""))
func hero_armor_type() -> String:
var rid := save.forge_armor()
var row: Dictionary[Variant, Variant] = db.forge_route(rid)
if row.is_empty() or String(row.get("kind", "")) != "armor" or not forge_route_unlocked(rid):
return db.forge_default_armor_type()
return String(row.get("type_axis", ""))
func hero_type_axes() -> Dictionary:
var fs := meta_state.forge_state()
var wrow: Dictionary[Variant, Variant] = db.crossrun.forge.route(String(fs["weapon"]))
var attack := db.crossrun.forge.default_attack_type
if not wrow.is_empty() and String(wrow.get("kind", "")) == "weapon" and _forge_route_unlocked(String(fs["weapon"])):
attack = String(wrow.get("type_axis", ""))
var arow: Dictionary[Variant, Variant] = db.crossrun.forge.route(String(fs["armor"]))
var armor := db.crossrun.forge.default_armor_type
if not arow.is_empty() and String(arow.get("kind", "")) == "armor" and _forge_route_unlocked(String(fs["armor"])):
armor = String(arow.get("type_axis", ""))
return {"attack": attack, "armor": armor}
# ---- 锻造工坊柜台只读投影(呈现层数据源:两区六路线行板;余额联动禁用归视图)----
@@ -235,16 +204,16 @@ func hero_armor_type() -> String:
# prereq_level, prereq_cap, prereq_maxed}——纯结构面,禁嵌余额判断(视图按 balance 禁用,同秘宝匣先例)。
func forge_shop_status() -> Array:
var out: Array[Dictionary] = []
for row in db.forge_routes():
for row in db.crossrun.forge.route_rows():
var rid := String(row.get("route_id", ""))
var lvl := save.forge_route_level(rid)
var lvl := meta_state.forge_state().levels.get(rid, 0)
var cap_v := int(row.get("cap", 0))
var unlocked := forge_route_unlocked(rid)
var unlocked := _forge_route_unlocked(rid)
var prereq := String(row.get("prereq_route", ""))
var plvl := save.forge_route_level(prereq) if prereq != "" else 0
var pcap := int(db.forge_route(prereq).get("cap", 0)) if prereq != "" else 0
var selected := (String(row.get("kind", "")) == "weapon" and save.forge_weapon() == rid) \
or (String(row.get("kind", "")) == "armor" and save.forge_armor() == rid)
var plvl := meta_state.forge_state().levels.get(prereq, 0) if prereq != "" else 0
var pcap := int(db.crossrun.forge.route(prereq).get("cap", 0)) if prereq != "" else 0
var selected := (String(row.get("kind", "")) == "weapon" and meta_state.forge_state().weapon == rid) \
or (String(row.get("kind", "")) == "armor" and meta_state.forge_state().armor == rid)
out.append({
"route_id": rid,
"display_name": String(row.get("display_name", "")),
@@ -258,7 +227,7 @@ func forge_shop_status() -> Array:
"next_price": ForgeTable.level_price(row, lvl) if (unlocked and lvl < cap_v) else 0,
"unlock_price": int(row.get("unlock_price", 0)),
"prereq_route": prereq,
"prereq_name": String(db.forge_route(prereq).get("display_name", "")) if prereq != "" else "",
"prereq_name": String(db.crossrun.forge.route(prereq).get("display_name", "")) if prereq != "" else "",
"prereq_level": plvl,
"prereq_cap": pcap,
"prereq_maxed": prereq != "" and plvl >= pcap,
@@ -274,10 +243,10 @@ func forge_shop_status() -> Array:
# apply_adventure_summary 逐关卡完成关次置位)。本函数只做标记+入账+归档。
func settle_adventure(summary: Dictionary) -> Dictionary:
# 首次冒险布尔位置位(关卡节点图生成 v0.4——完成/失败/放弃三结局同经本收束挂点,写入只在收束)
save.mark_first_adventure_done()
meta_state.mark_first_adventure_done()
var drops: Array[Variant] = (summary.get("keystone_drops", []) as Array).duplicate(true)
save.apply_adventure_summary(summary, drops)
save.add_vision_used(int(summary.get("vision_used_delta", 0))) # 真视使用计数收束聚合入档(信标网络行为条件)
meta_state.apply_adventure_summary(summary, drops)
meta_state.add_vision_used(int(summary.get("vision_used_delta", 0))) # 真视使用计数收束聚合入档(信标网络行为条件)
var new_unlocks := refresh_treasure_unlocks() # 条件解锁刷新
save.write_to_disk()
var total := int(summary.get("keystone_total", 0))
@@ -299,51 +268,75 @@ func settle_adventure(summary: Dictionary) -> Dictionary:
# 全零 = 白板合法态(无解锁不得改变任何局内规则);override 禁写表(表运行期只读)。
func hero_override() -> Dictionary:
# 锻造路线加值内联(原 forge_weapon_atk_bonus/forge_armor_ac_bonus 单调用点收编——输出逐字:
# 路线等级×每级增量,选中态异常/未解锁防御回退 0)
var fs := meta_state.forge_state()
var atk_bonus := _forge_axis_bonus(fs, "weapon", "weapon")
var ac_bonus := _forge_axis_bonus(fs, "armor", "armor")
# 树淬炼 flat 内联(原 tree_stat_flat 单调用点收编——四线 flat 汇总同轮扫描,三轴输出逐字等价)
var tree_atk := 0
var tree_hp := 0
var tree_ac := 0
var nodes: Dictionary[Variant, Variant] = meta_state.tree_state()["nodes"]
for row in db.crossrun.meta_tree.rows:
var lvl := int(nodes.get(String(row.get("node_id", "")), 0))
if lvl <= 0:
continue
var fx: Dictionary[Variant, Variant] = row.get("effects", {}) as Dictionary
tree_atk += int(fx.get("atk", 0)) * lvl
tree_hp += int(fx.get("max_hp", 0)) * lvl
tree_ac += int(fx.get("armor_class", 0)) * lvl
return {
"atk": forge_weapon_atk_bonus() + tree_stat_flat("atk"),
"max_hp": tree_stat_flat("max_hp"),
"armor_class": forge_armor_ac_bonus() + tree_stat_flat("armor_class"),
"atk": atk_bonus + tree_atk,
"max_hp": tree_hp,
"armor_class": ac_bonus + tree_ac,
}
# 锻造轴加值原语(选中路线等级×每级增量;选中态异常(未解锁/表缺行)防御回退 0——注入面只认合法态)
func _forge_axis_bonus(fs: Dictionary, sel_key: String, kind: String) -> int:
var rid := String(fs[sel_key])
var row: Dictionary[Variant, Variant] = db.crossrun.forge.route(rid)
if row.is_empty() or String(row.get("kind", "")) != kind or not _forge_route_unlocked(rid):
return 0
return int(fs["levels"].get(rid, 0)) * int(row.get("per_level_gain", 0))
# ---- 天赋树段(设计权威 整合定稿 §1.4 一树双币三脉一脊)----
# 全树已购总等级(row-gating 输入——「投入点数累计解锁新行」)
func tree_spent_total() -> int:
func _tree_spent_total() -> int:
var total := 0
for lvl in (save.tree_nodes() as Dictionary).values():
for lvl in (meta_state.tree_state().nodes as Dictionary).values():
total += int(lvl)
return total
# 节点可购校验(行门槛 + 显式前置 + 币 + cap;free 节点=钥匙不经购买,彩蛋=觉醒不经购买)
func tree_can_buy(node_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.tree_node(node_id)
if row.is_empty():
return false
var row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(node_id)
# 彩蛋不经购买(觉醒唯一入口=_check_easter_egg 特化判定——V8 反例:手动购亮=红)
if String(row.get("kind", "")) == "easter":
return false
var lvl := save.tree_level(node_id)
if lvl >= int(row.get("cap", 1)):
if row.is_empty() or String(row.get("kind", "")) == "easter":
return false
var nodes: Dictionary[Variant, Variant] = meta_state.tree_state()["nodes"]
var lvl := int(nodes.get(node_id, 0))
var blocked := lvl >= int(row.get("cap", 1))
# 刷新升级节点:cap 主语=Y 账本(单一事实源——树上入口与装配段同 cap 同价,防双账漂移)
if node_id == "ks_refresh" and db.meta_attune_refresh_row(save.attune_refresh_level() + 1).is_empty():
return false
if node_id == "ks_refresh" and db.crossrun.meta.attune_refresh_row(meta_state.attune_state().refresh_level + 1).is_empty():
blocked = true
# 行门槛(一线一节点:脊柱节点第 N 级需全树总投入 ≥4×(N−1))
if String(row.get("vein", "")) == "spine" and String(row.get("kind", "")) == "stat":
if tree_spent_total() < db.meta_tree.level_gate(lvl + 1):
return false
if String(row.get("vein", "")) == "spine" and String(row.get("kind", "")) == "stat" and _tree_spent_total() < db.crossrun.meta_tree.level_gate(lvl + 1):
blocked = true
for req in (row.get("requires", []) as Array):
if save.tree_level(String(req)) < 1:
return false
if int(nodes.get(String(req), 0)) < 1:
blocked = true
# 等级阈值前置(requires_level {node_id: n}:前置节点等级 ≥ n 才可购;
# 现役表零配置=空转零行为影响)
var req_lv: Dictionary[Variant, Variant] = row.get("requires_level", {}) as Dictionary
for req_id in req_lv.keys():
if save.tree_level(String(req_id)) < int(req_lv[req_id]):
return false
return true
if int(nodes.get(String(req_id), 0)) < int(req_lv[req_id]):
blocked = true
return not blocked
# 节点购买(营地树 UI 唯一写口):树全钥石单币。free=工作间钥匙零币点亮
@@ -353,13 +346,13 @@ func tree_can_buy(node_id: String) -> bool:
func tree_buy(node_id: String) -> bool:
if node_id == "ks_refresh":
return buy_attune_refresh()
var row: Dictionary[Variant, Variant] = db.tree_node(node_id)
var row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(node_id)
if row.is_empty() or not tree_can_buy(node_id):
return false
var price := int(row.get("price", 0))
if not save.spend_keystone(price):
if not meta_state.spend_keystone(price):
return false
save.set_tree_level(node_id, save.tree_level(node_id) + 1)
meta_state.set_tree_level(node_id, meta_state.tree_state().nodes.get(node_id, 0) + 1)
_check_easter_egg()
return save.write_to_disk()
@@ -367,45 +360,35 @@ func tree_buy(node_id: String) -> bool:
# Fork 切换(营地树 UI——一节点双态:购买解锁组默认 A 态,换态走本口零币;同态重复=零操作;
# 未解锁组切换=拒绝——解锁唯一入口=购买面 tree_buy_fork)
func tree_toggle_fork(group: String, side: String) -> bool:
if not db.meta_tree.FORK_GROUPS.has(group) or not (side == "a" or side == "b"):
if not db.crossrun.meta_tree.FORK_GROUPS.has(group) or not (side == "a" or side == "b"):
return false
var cur := save.fork_side(group)
var cur := meta_state.tree_state().fork_sides.get(group, "")
if cur == "" or cur == side:
return false # 未解锁走购买面;同态零操作
save.set_fork_side(group, side)
meta_state.set_fork_side(group, side)
return save.write_to_disk()
# Fork 节点购买入口(一节点双态——购=解锁开关并置默认 A 态;重复购买由 cap 经 can_buy 拦截)
func tree_buy_fork(node_id: String) -> bool:
var row: Dictionary[Variant, Variant] = db.tree_node(node_id)
var row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(node_id)
if row.is_empty() or String(row.get("kind", "")) != "fork":
return tree_buy(node_id)
if not tree_can_buy(node_id):
return false
var price := int(row.get("price", 0))
if not save.spend_keystone(price):
if not meta_state.spend_keystone(price):
return false
save.set_tree_level(node_id, 1)
save.set_fork_side(String(row.get("fork_group", "")), "a") # 解锁默认 A 态
meta_state.set_tree_level(node_id, 1)
meta_state.set_fork_side(String(row.get("fork_group", "")), "a") # 解锁默认 A 态
return save.write_to_disk()
# 树效果投影:四线 flat 汇总 + rule 键集(激活 Fork 侧 effects 并入)
func tree_stat_flat(key: String) -> int:
var total := 0
for row in db.tree_rows():
var lvl := save.tree_level(String(row.get("node_id", "")))
if lvl <= 0:
continue
total += int((row.get("effects", {}) as Dictionary).get(key, 0)) * lvl
return total
# 树规则键投影(激活 Fork 侧 effects 并入)
func tree_rule_keys() -> Array:
var out: Array[Variant] = []
var sides: Dictionary[Variant, Variant] = save.fork_sides()
for row in db.tree_rows():
var sides: Dictionary[Variant, Variant] = meta_state.tree_state().fork_sides
for row in db.crossrun.meta_tree.rows:
if String(row.get("kind", "")) != "fork":
continue
var active := String(sides.get(String(row.get("fork_group", "")), ""))
@@ -418,10 +401,15 @@ func tree_rule_keys() -> Array:
return out
# 营地树面投影(CampOverlay _rebuild_tree 数据源——行板只读;账面归本类、拼句归视图同款分工)
func tree_camp_rows() -> Array:
# 营地树面投影(并组入口——{rows, fork_groups};CampOverlay _rebuild_tree 数据源——行板只读;账面归本类、拼句归视图同款分工)
func tree_camp_status() -> Dictionary:
return {"rows": _tree_camp_rows(), "fork_groups": _tree_fork_groups()}
## 树行板投影段(原 tree_camp_rows)
func _tree_camp_rows() -> Array:
var out: Array[Variant] = []
for row in db.tree_rows():
for row in db.crossrun.meta_tree.rows:
if String(row.get("kind", "")) == "fork":
continue # Fork 行由 fork_groups 段承载
out.append({
@@ -430,32 +418,33 @@ func tree_camp_rows() -> Array:
"vein": String(row.get("vein", "")),
"kind": String(row.get("kind", "")),
"cap": int(row.get("cap", 1)),
"level": save.attune_refresh_level() if String(row.get("node_id", "")) == "ks_refresh" else save.tree_level(String(row.get("node_id", ""))), # 刷新升级行主语=Y 账本(同账路由 V15)
"level": meta_state.attune_state().refresh_level if String(row.get("node_id", "")) == "ks_refresh" else meta_state.tree_state().nodes.get(String(row.get("node_id", "")), 0), # 刷新升级行主语=Y 账本(同账路由 V15)
"price": int(row.get("price", 0)),
"currency": String(row.get("currency", "keystone")),
"can_buy": tree_can_buy(String(row.get("node_id", ""))),
"gate_text": tree_gate_text(String(row.get("node_id", ""))),
"gate_text": _tree_gate_text(String(row.get("node_id", ""))),
})
return out
# 行板门槛文案(R1 机制位呈现——requires_level 未达标项拼「需 {前置名} Lv{n}」;达标/未配置=空串。
# 只呈现未达标项:达标后锁标消失,不做全量前置罗列)
func tree_gate_text(node_id: String) -> String:
var row: Dictionary[Variant, Variant] = db.tree_node(node_id)
func _tree_gate_text(node_id: String) -> String:
var row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(node_id)
var rl: Dictionary[Variant, Variant] = row.get("requires_level", {}) as Dictionary
var parts: Array[String] = []
for req_id in rl.keys():
if save.tree_level(String(req_id)) < int(rl[req_id]):
var req_row: Dictionary[Variant, Variant] = db.tree_node(String(req_id))
if meta_state.tree_state().nodes.get(String(req_id), 0) < int(rl[req_id]):
var req_row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(String(req_id))
parts.append("需 %s Lv%d" % [String(req_row.get("display_name", req_id)), int(rl[req_id])])
return "、".join(parts)
func tree_fork_groups() -> Array:
## Fork 行板投影段(原 tree_fork_groups)
func _tree_fork_groups() -> Array:
var out: Array[Variant] = []
var sides: Dictionary[Variant, Variant] = save.fork_sides()
for row in db.tree_rows():
var sides: Dictionary[Variant, Variant] = meta_state.tree_state().fork_sides
for row in db.crossrun.meta_tree.rows:
if String(row.get("kind", "")) != "fork":
continue
var fg := String(row.get("fork_group", ""))
@@ -464,7 +453,7 @@ func tree_fork_groups() -> Array:
"node_id": String(row.get("node_id", "")),
"display_name": String(row.get("display_name", "")),
"active_side": String(sides.get(fg, "")),
"owned": save.tree_level(String(row.get("node_id", ""))) > 0,
"owned": meta_state.tree_state().nodes.get(String(row.get("node_id", "")), 0) > 0,
"can_buy": tree_can_buy(String(row.get("node_id", ""))),
"price": int(row.get("price", 0)),
"sides": [
@@ -478,16 +467,16 @@ func tree_fork_groups() -> Array:
# 彩蛋觉醒检查(特化判定——生命/命中两线拉满 且 同调位 1/2/3 全解锁 → easter_ley 免费点亮;
# 不依赖 requires/requires_level 前置形态)
func _check_easter_egg() -> void:
if save.tree_level("easter_ley") > 0:
if meta_state.tree_state().nodes.get("easter_ley", 0) > 0:
return
for nid in ["sp_hp", "sp_hit"]:
var row: Dictionary[Variant, Variant] = db.tree_node(nid)
if row.is_empty() or save.tree_level(nid) < int(row.get("cap", 1)):
var row: Dictionary[Variant, Variant] = db.crossrun.meta_tree.row_by_id(nid)
if row.is_empty() or meta_state.tree_state().nodes.get(nid, 0) < int(row.get("cap", 1)):
return
for i in range(1, 4):
if save.tree_level("att_socket%d" % i) < 1:
if meta_state.tree_state().nodes.get("att_socket%d" % i, 0) < 1:
return
save.set_tree_level("easter_ley", 1)
meta_state.set_tree_level("easter_ley", 1)
# ---- 同调位账本(装配关系/同调位求值/Y 刷新升级;设计权威 proposal §2)----
@@ -498,30 +487,30 @@ func _check_easter_egg() -> void:
# 格 1=普通格/格 2=优秀格/格 3=稀有格递增向下兼容;槽位来源=树同调位节点点亮数,树接口接缝现役)。
# 装配关系写存档持久。
func toggle_attunement(item_id: String) -> bool:
var equipped: Array[Variant] = save.attunement_equipped()
var equipped: Array[Variant] = meta_state.attune_state().equipped
if not equipped.has(item_id):
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(item_id)
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(item_id)
if arow.is_empty() or String(arow.get("form", "")) == "":
return false # 非同调行(过渡/退役行)禁装配
if not save.item_unlocked(item_id):
if not meta_state.item_unlocked(item_id):
return false # 未解锁禁装配(解锁混合制契约 6——装配以解锁为前置)
if equipped.size() >= Attunement.slots(db, save):
return false # 满位禁选
var rank: int = int(db.treasure_items.TIER_RANK.get(String(arow.get("tier", "")), 99))
var rank: int = int(db.crossrun.treasure_items.TIER_RANK.get(String(arow.get("tier", "")), 99))
if rank > equipped.size() + 1:
return false # 件档高于将占格档(第 k 格档=k——普通/优秀/稀有递增)
equipped.append(item_id)
else:
equipped.erase(item_id)
save.set_attunement_equipped(equipped)
meta_state.set_attunement_equipped(equipped)
return save.write_to_disk()
# 营地装配面投影(账面归本类、拼句归 CampOverlay——_camp_data 同款分工)
func attune_camp_status() -> Dictionary:
var equipped: Array[Variant] = save.attunement_equipped()
var equipped: Array[Variant] = meta_state.attune_state().equipped
var items: Array[Dictionary] = []
for row in db.treasure_attunement_rows():
for row in db.crossrun.treasure_items.attunement_rows():
var iid := String(row.get("item_id", ""))
items.append({
"item_id": iid,
@@ -530,10 +519,10 @@ func attune_camp_status() -> Dictionary:
"tier": String(row.get("tier", "")),
"form": String(row.get("form", "")),
"equipped": equipped.has(iid),
"unlocked": save.item_unlocked(iid),
"unlocked": meta_state.item_unlocked(iid),
})
var lvl := save.attune_refresh_level()
var next_row: Dictionary[Variant, Variant] = db.meta_attune_refresh_row(lvl + 1)
var lvl := meta_state.attune_state().refresh_level
var next_row: Dictionary[Variant, Variant] = db.crossrun.meta.attune_refresh_row(lvl + 1)
return {
"slots": Attunement.slots(db, save),
"equipped": equipped,
@@ -549,15 +538,15 @@ func attune_camp_status() -> Dictionary:
# 每冒险开局刷新次数 Y(当前等级行表值;0 级 = 0 次即无刷新能力——P1 甲初始 0 同构)
func attune_refresh_charges() -> int:
return int(db.meta_attune_refresh_row(save.attune_refresh_level()).get("refresh_charges", 0))
return int(db.crossrun.meta.attune_refresh_row(meta_state.attune_state().refresh_level).get("refresh_charges", 0))
# Y 升级购买(直购价 = 下一级行表值 keystone_cost;已满级/余额不足 false 零副作用——buy_forge 同款)
func buy_attune_refresh() -> bool:
var next_row: Dictionary[Variant, Variant] = db.meta_attune_refresh_row(save.attune_refresh_level() + 1)
var next_row: Dictionary[Variant, Variant] = db.crossrun.meta.attune_refresh_row(meta_state.attune_state().refresh_level + 1)
if next_row.is_empty():
return false
if not save.spend_keystone(int(next_row.get("keystone_cost", 0))):
if not meta_state.spend_keystone(int(next_row.get("keystone_cost", 0))):
return false
save.set_attune_refresh_level(save.attune_refresh_level() + 1)
meta_state.set_attune_refresh_level(meta_state.attune_state().refresh_level + 1)
return save.write_to_disk()
+176
View File
@@ -0,0 +1,176 @@
## 存档内元进度数据件(D3 拆件——自 save_manager 收编元进度族):存档内元进度数据的读写唯一层。
## 数据读写原语族(写口 13)+只读投影(读 6——tree/forge/attune/career 四域值拷贝出户);
## 业务规则与写口编排归 MetaProgression,本件只承载键域读写(照单承载,零业务判定)。
## 零 _data 副本零缓存——全部读写经注入 save 的内存数据模型单点。
class_name MetaStateStore
extends RefCounted
var _save # SaveManagerCore 注入引用(_data 唯一取数点)
func _init(save_ref) -> void:
_save = save_ref
# ---- 写口原语族(13)----
# 钥石消费唯一口:余额不足拒绝且零副作用(钥石只增不减——消费除外,元进度验收判据)
func spend_keystone(n: int) -> bool:
if keystone_balance() < n:
return false
_save._data["meta"]["keystone_balance"] = keystone_balance() - n
return true
func unlock_item(item_id: String) -> void:
_save._data["meta"]["item_unlocks"][item_id] = true
func set_tree_level(node_id: String, level: int) -> void:
var tn: Dictionary[Variant, Variant] = _save._data["meta"].get("tree_nodes", {}) as Dictionary
tn[node_id] = maxi(0, level)
_save._data["meta"]["tree_nodes"] = tn
func set_fork_side(group: String, side: String) -> void:
var fs: Dictionary[Variant, Variant] = _save._data["meta"].get("fork_sides", {}) as Dictionary
fs[group] = side
_save._data["meta"]["fork_sides"] = fs
func set_forge_route_level(route_id: String, level: int) -> void:
var dom: Dictionary[Variant, Variant] = _forge_domain()
var lv: Dictionary[Variant, Variant] = dom.get("levels", {}) as Dictionary
lv[route_id] = maxi(0, level)
dom["levels"] = lv
_save._data["meta"]["forge_routes"] = dom
func add_forge_unlock(route_id: String) -> void:
var dom: Dictionary[Variant, Variant] = _forge_domain()
var un: Array[Variant] = dom.get("unlocked", []) as Array
if not un.has(route_id):
un.append(route_id)
dom["unlocked"] = un
_save._data["meta"]["forge_routes"] = dom
func set_forge_weapon(route_id: String) -> void:
var dom: Dictionary[Variant, Variant] = _forge_domain()
dom["weapon"] = route_id
_save._data["meta"]["forge_routes"] = dom
func set_forge_armor(route_id: String) -> void:
var dom: Dictionary[Variant, Variant] = _forge_domain()
dom["armor"] = route_id
_save._data["meta"]["forge_routes"] = dom
func set_attunement_equipped(item_ids: Array) -> void:
var out: Array[Variant] = []
for v in item_ids:
out.append(String(v))
_save._data["meta"]["attunement_equipped"] = out
func set_attune_refresh_level(n: int) -> void:
_save._data["meta"]["attune_refresh_level"] = maxi(0, n)
# 真视类工具累计使用入账(写入唯一经本口冒险收束聚合,受理计数点=battle 层经冒险层聚合)
func add_vision_used(n: int) -> void:
_save._data["meta"]["vision_used"] = maxi(0, career_state()["vision_used"] + maxi(0, n))
# 域更新原语(正式冒险收束唯一入账口——组合根经本函数入账,禁直改内部结构):
# 钥石入账(summary.keystone_total——逐杀掉落已在冒险内计入,boss_drops 参数为
# 摘要归档副本不重复入账)+ 逐关卡完成关次 clear_count +1 与 Boss 首杀标记 + Boss 击杀总数累计
# (同调位里程碑计数源)+ 摘要头部插入(cap 10,最新在前,元素附
# keystone_drops 副本——复盘可归因)+ 生涯终身计数自增(总局数开局即计:
# 三结局同经本口恰计一次;冒险成功局数按 summary.cleared 判定三关全通才计 1。终身计数写入
# 单口=本函数,禁第二写入点)。
func apply_adventure_summary(summary: Dictionary, boss_drops: Array = []) -> void:
_save._data["meta"]["adventure_total"] = career_state()["adventure_total"] + 1
if bool(summary.get("cleared", false)):
_save._data["meta"]["adventure_cleared"] = career_state()["adventure_cleared"] + 1
_add_keystone(int(summary.get("keystone_total", 0)))
var cleared := int(summary.get("levels_cleared", 0))
for li in range(1, cleared + 1):
var key := str(li)
_save._data["meta"]["level_clear_counts"][key] = int(_save._data["meta"]["level_clear_counts"].get(key, 0)) + 1
_save._data["meta"]["boss_first_kill"][key] = true
_save._data["meta"]["boss_kill_count"] = career_state()["boss_kill_count"] + cleared # Boss 恒唯一终点:levels_cleared 即本次冒险 Boss 击杀数(含重复)
var entry: Dictionary[Variant, Variant] = summary.duplicate(true)
entry["boss_drops"] = boss_drops.duplicate(true)
var arr: Array[Variant] = _save._data["meta"]["recent_summaries"]
arr.push_front(entry)
while arr.size() > _save.SUMMARY_CAP:
arr.pop_back()
func mark_first_adventure_done() -> void:
_save._data["meta"]["first_adventure_done"] = true
# 钥石入账原语(生产消费零——apply_adventure_summary 专用;测量面经 _ 前缀直调)
func _add_keystone(n: int) -> void:
_save._data["meta"]["keystone_balance"] = keystone_balance() + n
# ---- 只读投影族(6——值拷贝出户,禁碰内部结构契约同款)----
func keystone_balance() -> int:
return int(_save._data["meta"]["keystone_balance"])
func item_unlocked(item_id: String) -> bool:
return _save._data["meta"]["item_unlocks"].has(item_id)
## 天赋树域只读投影:{nodes: node_id→已购等级, fork_sides: fork_group→"a"|"b"}
func tree_state() -> Dictionary:
return {
"nodes": (_save._data["meta"].get("tree_nodes", {}) as Dictionary).duplicate(),
"fork_sides": (_save._data["meta"].get("fork_sides", {}) as Dictionary).duplicate(),
}
## 锻造域只读投影:{levels: 六路线等级, unlocked: 直购清单, weapon/armor: 当前选中}
## (解锁态不落档——派生判定单点在 MetaProgression.forge_route_unlocked)
func forge_state() -> Dictionary:
var dom: Dictionary[Variant, Variant] = _forge_domain()
return {
"levels": (dom.get("levels", {}) as Dictionary).duplicate(),
"unlocked": (dom.get("unlocked", []) as Array).duplicate(),
"weapon": String(dom.get("weapon", "sword")),
"armor": String(dom.get("armor", "light")),
}
## 同调域只读投影:{equipped: 装配 item_id 有序数组(装配序即呈现序), refresh_level: Y 升级等级}
func attune_state() -> Dictionary:
return {
"equipped": (_save._data["meta"].get("attunement_equipped", []) as Array).duplicate(),
"refresh_level": int(_save._data["meta"].get("attune_refresh_level", 0)),
}
## 生涯域只读投影:{clear_counts, boss_first_kill, boss_kill_count, adventure_total,
## adventure_cleared, vision_used, recent_summaries, first_adventure_done}
func career_state() -> Dictionary:
return {
"clear_counts": (_save._data["meta"]["level_clear_counts"] as Dictionary).duplicate(),
"boss_first_kill": (_save._data["meta"]["boss_first_kill"] as Dictionary).duplicate(),
"boss_kill_count": int(_save._data["meta"].get("boss_kill_count", 0)),
"adventure_total": int(_save._data["meta"].get("adventure_total", 0)),
"adventure_cleared": int(_save._data["meta"].get("adventure_cleared", 0)),
"vision_used": int(_save._data["meta"].get("vision_used", 0)),
"recent_summaries": (_save._data["meta"]["recent_summaries"] as Array).duplicate(true),
"first_adventure_done": bool(_save._data["meta"].get("first_adventure_done", true)),
}
# 锻造域取数(本件内部单点)
func _forge_domain() -> Dictionary:
return _save._data["meta"].get("forge_routes", {}) as Dictionary
+13 -9
View File
@@ -304,15 +304,22 @@ func _do_dig(c: Vector2i) -> Dictionary:
if was_sealed:
_seals.erase(c)
var reveal_order: Array[Variant] = _chain_reveal_order(c, eff_seals)
_reveal_chain(c, reveal_order, was_sealed)
if action_economy.phase != action_economy.PHASE_ACTIVE:
_emit_round_end_seals_and_payload()
return _outcome(true, "executed", "", "")
## 连锁揭示域:首格+连锁新格揭示(BFS 规范序——cell_revealed 发射序即链序,整体随段搬迁)。
## 格面资源资格:chain/dig 恒 true——安全格与危险格一视同仁(危险格亦携带,
## 负向兑付归 战斗结算 极性路由);initial(封锁格)恒 false。资源本体与极性不在本系统。
## dug_under_seal = 首格带封印直挖标记(战斗结算 情况④兑付改判消费;连锁格恒 false——连锁不越封印)
func _reveal_chain(c: Vector2i, reveal_order: Array, was_sealed: bool) -> void:
for rc in reveal_order:
if _revealed.has(rc):
continue # 闭包可穿过已开区——已开格零事件(cell_revealed × |O′ ∖ O|)
var is_mine := _board.is_mine(rc)
var channel := "dig" if rc == c else "chain"
# 格面资源资格:chain/dig 恒 true——安全格与危险格一视同仁(危险格亦携带,
# 负向兑付归 战斗结算 极性路由);initial(封锁格)恒 false。资源本体与极性不在本系统。
# dug_under_seal = 首格带封印直挖标记(战斗结算 情况④兑付改判消费;连锁格恒 false——连锁不越封印)
if is_mine:
if _board.is_mine(rc):
_revealed[rc] = {"kind": "mine", "digit": -1}
cell_revealed.emit({
"cell": rc, "kind": "mine", "digit": -1,
@@ -326,9 +333,6 @@ func _do_dig(c: Vector2i) -> Dictionary:
"variant_ids": _board.variant_ids(rc), "channel": channel, "resource": true,
"dug_under_seal": was_sealed and rc == c,
})
if action_economy.phase != action_economy.PHASE_ACTIVE:
_emit_round_end_seals_and_payload()
return _outcome(true, "executed", "", "")
func _board_face_dig(c: Vector2i) -> String:
@@ -492,7 +496,7 @@ func _emit_seals_batch() -> void:
func _emit_round_end_seals_and_payload() -> void:
_emit_seals_batch()
var payload: Dictionary[Variant, Variant] = action_economy.last_round_end # 逐字段取自 行动经济(读取权 = 接口契约)
var payload: Dictionary[Variant, Variant] = action_economy.last_round_end() # 逐字段取自 行动经济(读取权 = 接口契约;属性→方法 K5)
round_end_board.emit({"path_tag": payload.get("path_tag", ""), "continuation_hint": payload.get("continuation_hint", "")})
+219 -124
View File
@@ -66,6 +66,14 @@ func _setup_and_build(params: Dictionary, seed_val: int) -> Dictionary:
## 参数档可行性判定(执行边界③:参数无解=配置错误)——域校验外加深度预算与战斗保底可放性。
static func check_params(params: Dictionary) -> Array:
var errs: Array[Dictionary] = _cp_domain(params)
if not errs.is_empty():
return errs
return _cp_structural(params)
## 域校验段:五旋钮值域/岔口去路数锁值/配比四类合计 1
static func _cp_domain(params: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var L := int(params.get("layers", 0))
var e := int(params.get("exit_count", 0))
@@ -89,8 +97,16 @@ static func check_params(params: Dictionary) -> Array:
sum += float(params.get(rk, -1.0))
if not is_equal_approx(sum, 1.0):
errs.append({"category": "domain", "message": "配比四类合计 %f ≠ 1" % sum})
if not errs.is_empty():
return errs
return errs
## 结构可行性段:主链段容量与战斗保底可放性(多出口按区域签名累加可放槽)
static func _cp_structural(params: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var L := int(params.get("layers", 0))
var e := int(params.get("exit_count", 0))
var en := int(params.get("entry_count", 0))
var bf := int(params.get("battle_floor", -1))
var t := 0 if e == 1 else _trunk_len(L, e)
if e > 1 and t < 1:
errs.append({"category": "structural", "message": "层深 %d 与出口数 %d 无解(主链段放不下归属区域)" % [L, e]})
@@ -116,11 +132,33 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array:
return errs
var by_id := {}
var indeg := {}
if not _vo_node_fields(nodes, errs, by_id, indeg):
return errs
if not _vo_edges(nodes, by_id, indeg, errs):
return errs
var flow := _vo_flow_facts(nodes, indeg, errs)
if not flow["ok"]:
return errs
if not _vo_depth_reach(nodes, by_id, flow["entries"], flow["terminals"], errs):
return errs
_vo_shop_counts(nodes, flow["terminals"], errs)
_vo_terminal_shops(nodes, flow["terminals"], errs)
if not _all_paths_one_shop(by_id, flow["entries"], flow["terminals"]):
errs.append({"category": "structural", "message": "存在入口→终点路径穿店数 ≠ 1(必经恰一店破)"})
_check_fork_rules(nodes, by_id, errs)
_vo_terminal_depths(flow["terminals"], by_id, errs)
_vo_width_rules(nodes, errs)
_vo_params_check(params, nodes, by_id, [flow["entries"], flow["terminals"]], errs)
return errs
## 阶段一·节点字段域:字段齐备/句柄唯一/类型词表/深度非负(by_id/indeg 引用填充)
func _vo_node_fields(nodes: Array, errs: Array, by_id: Dictionary, indeg: Dictionary) -> bool:
for n in nodes:
for k in ["id", "type", "depth", "next"]:
if not (n as Dictionary).has(k):
errs.append({"category": "structural", "message": "节点字段缺失: %s" % k})
return errs
return false
var id: int = int(n["id"])
if by_id.has(id):
errs.append({"category": "dup_key", "message": "节点句柄重复: %d" % id})
@@ -130,8 +168,11 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array:
if int(n["depth"]) < 0:
errs.append({"category": "domain", "message": "节点深度为负: %d" % n["depth"]})
indeg[id] = 0
if not errs.is_empty():
return errs
return errs.is_empty()
## 阶段二·边域:出边悬空与环/回边判据(indeg 引用累计)
func _vo_edges(nodes: Array, by_id: Dictionary, indeg: Dictionary, errs: Array) -> bool:
for n in nodes:
for oid in (n["next"] as Array):
if not by_id.has(int(oid)):
@@ -141,9 +182,11 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array:
errs.append({"category": "structural", "message": "连线未朝前进方向(目标深度 %d 不严格大于源 %d)——环/回边" % [
int(by_id[int(oid)]["depth"]), int(n["depth"])]})
indeg[int(oid)] = int(indeg[int(oid)]) + 1
if not errs.is_empty():
return errs
return errs.is_empty()
## 阶段三·流事实域:入口集(入度 0)/终点集(无出边)与 Boss 终点判据、去路数形态
func _vo_flow_facts(nodes: Array, indeg: Dictionary, errs: Array) -> Dictionary:
var entries := []
var terminals := []
for n in nodes:
@@ -159,11 +202,12 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array:
errs.append({"category": "structural", "message": "Boss 节点带出边: %d" % id0})
elif outs.size() != 1 and not (outs.size() >= 2 and outs.size() <= 3):
errs.append({"category": "domain", "message": "节点 %d 去路数 %d 非〔恰一条或岔口 2–3〕" % [id0, outs.size()]})
if not errs.is_empty():
return errs
return {"entries": entries, "terminals": terminals, "ok": errs.is_empty()}
# 深度=自入口最长路径步数(契约 2 赋值语义——出边严格前进的无环判据成立的前提)
var longest := _longest_paths(nodes, by_id, entries, indeg)
## 阶段四·深度与可达域:深度=自入口最长路径步数(契约 2)+全节点可达某终点
func _vo_depth_reach(nodes: Array, by_id: Dictionary, entries: Array, terminals: Array, errs: Array) -> bool:
var longest := _longest_paths(nodes, by_id, entries)
for n in nodes:
var id1: int = int(n["id"])
if int(n["depth"]) != int(longest.get(id1, -1)):
@@ -175,16 +219,21 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array:
for n in nodes:
if not reaches.has(int(n["id"])):
errs.append({"category": "structural", "message": "节点 %d 不可达任何终点(断头/孤儿)" % n["id"]})
if not errs.is_empty():
return errs
return errs.is_empty()
# 摆放约束①:每终点前一位恰一店、商店总数=终点数、终点前一层去路集合恰含一个终点
## 摆放约束①前半·商店总数:商店总数=终点数(商店数=出口数已锁)
func _vo_shop_counts(nodes: Array, terminals: Array, errs: Array) -> void:
var shop_count := 0
for n in nodes:
if String(n["type"]) == "shop":
shop_count += 1
if shop_count != terminals.size():
errs.append({"category": "structural", "message": "商店总数 %d ≠ 终点数 %d(商店数=出口数已锁)" % [shop_count, terminals.size()]})
## 摆放约束①后半·终点前驱:每终点前一位恰一店且为唯一前驱、终点前一层去路集合恰含一个终点
func _vo_terminal_shops(nodes: Array, terminals: Array, errs: Array) -> void:
for tid in terminals:
var preds := []
for n in nodes:
@@ -204,16 +253,19 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array:
if t_hits != 1:
errs.append({"category": "structural", "message": "终点 %d 前一层去路集合含终点 %d 个(归属锁定破)" % [tid, t_hits]})
if not _all_paths_one_shop(by_id, entries, terminals):
errs.append({"category": "structural", "message": "存在入口→终点路径穿店数 ≠ 1(必经恰一店破)"})
_check_fork_rules(nodes, by_id, errs)
## 计数规则①·多终点深度互异(推论条款 A——定位信息只有两样)
func _vo_terminal_depths(terminals: Array, by_id: Dictionary, errs: Array) -> void:
var tdepths := {}
for tid in terminals:
var dd: int = int(by_id[tid]["depth"])
if tdepths.has(dd):
errs.append({"category": "structural", "message": "多终点同深度 %d(推论条款 A 破——定位信息只有两样)" % dd})
tdepths[dd] = true
## 计数规则②·宽度上限:同深度节点数 ≤ 分叉生长上限 4(禁平行层摆放)
func _vo_width_rules(nodes: Array, errs: Array) -> void:
var per_depth := {}
for n in nodes:
var d2: int = int(n["depth"])
@@ -222,23 +274,28 @@ func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array:
if int(per_depth[d3]) > MAX_WIDTH:
errs.append({"category": "structural", "message": "深度 %d 节点数 %d 超分叉生长上限 4(平行层摆放)" % [d3, per_depth[d3]]})
## 计数规则③·参数档对照:战斗保底/入口数/终点数/层高(params 为空时跳过)
func _vo_params_check(params: Dictionary, nodes: Array, by_id: Dictionary, flow_ends: Array, errs: Array) -> void:
var entries: Array = flow_ends[0]
var terminals: Array = flow_ends[1]
var battles := 0
for n in nodes:
if String(n["type"]) == "battle":
battles += 1
if not params.is_empty():
if battles < int(params.get("battle_floor", 0)):
errs.append({"category": "domain", "message": "战斗节点数 %d 低于保底 %d" % [battles, params.get("battle_floor", 0)]})
if entries.size() != int(params.get("entry_count", entries.size())):
errs.append({"category": "structural", "message": "入口数 %d ≠ 参数档 entry_count %d" % [entries.size(), params.get("entry_count", 0)]})
if terminals.size() != int(params.get("exit_count", terminals.size())):
errs.append({"category": "structural", "message": "终点数 %d ≠ 参数档 exit_count %d" % [terminals.size(), params.get("exit_count", 0)]})
var maxd := 0
for tid in terminals:
maxd = maxi(maxd, int(by_id[tid]["depth"]))
if maxd != int(params.get("layers", maxd)):
errs.append({"category": "domain", "message": "层高(最长路径步数)%d ≠ 参数档 layers %d" % [maxd, params.get("layers", 0)]})
return errs
if params.is_empty():
return
if battles < int(params.get("battle_floor", 0)):
errs.append({"category": "domain", "message": "战斗节点数 %d 低于保底 %d" % [battles, params.get("battle_floor", 0)]})
if entries.size() != int(params.get("entry_count", entries.size())):
errs.append({"category": "structural", "message": "入口数 %d ≠ 参数档 entry_count %d" % [entries.size(), params.get("entry_count", 0)]})
if terminals.size() != int(params.get("exit_count", terminals.size())):
errs.append({"category": "structural", "message": "终点数 %d ≠ 参数档 exit_count %d" % [terminals.size(), params.get("exit_count", 0)]})
var maxd := 0
for tid in terminals:
maxd = maxi(maxd, int(by_id[tid]["depth"]))
if maxd != int(params.get("layers", maxd)):
errs.append({"category": "domain", "message": "层高(最长路径步数)%d ≠ 参数档 layers %d" % [maxd, params.get("layers", 0)]})
# ---- 构型(长图算法 HOW 归实现——D1:28;合法性由 validate_output 强制)----
@@ -252,28 +309,11 @@ func _build(rg: RandomNumberGenerator) -> Dictionary:
var entries: Array[int] = []
for i in range(en):
entries.append(_mk("battle", 0))
var prevs: Array[int] = entries.duplicate()
for d in range(1, t + 1):
var nid := _mk("battle", d)
for p in prevs:
(_by_id[p]["next"] as Array).append(nid)
prevs = [nid]
var prevs: Array = _build_trunk(t, entries.duplicate())
var terminals: Array[int] = []
for r in range(e):
var bdepth := L - r
var bid := _mk("boss", bdepth)
var sid := _mk("shop", bdepth - 1)
(_by_id[sid]["next"] as Array).append(bid)
var cur := sid
var sig := String(REGION_SIGNATURES[r]) if e > 1 else "battle"
for d in range(bdepth - 2, t, -1):
var fid := _mk(sig, d)
(_by_id[fid]["next"] as Array).append(cur)
cur = fid
terminals.append(bid)
for p in prevs:
(_by_id[p]["next"] as Array).append(cur)
_build_region(r, e, L, prevs, terminals)
# e>1 时 prevs 收束为主链段末节点一处 ⇒ 该节点即归属大岔口(各区域互不连达);
# e==1 时 prevs 为入口数组,直接接单区域头(无大岔口——L1 出口恒 1)。
@@ -281,41 +321,61 @@ func _build(rg: RandomNumberGenerator) -> Dictionary:
_assign_types(rg, e)
var nodes: Array[Dictionary] = []
for n in _pool:
nodes.append(n)
nodes.assign(_pool)
return {"nodes": nodes, "entries": entries, "terminals": terminals}
## 主链段(深度 1..t 逐层单链,主链段零 RNG):返回链末节点集(e>1 时收束为一处即归属大岔口)
func _build_trunk(t: int, prevs: Array) -> Array:
for d in range(1, t + 1):
var nid := _mk("battle", d)
for p in prevs:
(_by_id[p]["next"] as Array).append(nid)
prevs = [nid]
return prevs
## 归属区域段(每出口一条支路:Boss 终点+店+签名填充链;区域段零 RNG):
## Boss 深度 = L−r、店在其前一位、签名链自店向主链段方向回铺,支路头接入 prevs(归属大岔口)
func _build_region(r: int, e: int, L: int, prevs: Array, terminals: Array) -> void:
var t := 0 if e == 1 else _trunk_len(L, e)
var bdepth := L - r
var bid := _mk("boss", bdepth)
var sid := _mk("shop", bdepth - 1)
(_by_id[sid]["next"] as Array).append(bid)
var cur := sid
var sig := String(REGION_SIGNATURES[r]) if e > 1 else "battle"
for d in range(bdepth - 2, t, -1):
var fid := _mk(sig, d)
(_by_id[fid]["next"] as Array).append(cur)
cur = fid
terminals.append(bid)
for p in prevs:
(_by_id[p]["next"] as Array).append(cur)
## 岔口布置:主链段(深度 ≤ t-2)或单出口图链上(深度 ≤ L-3)的节点扩为 2–3 去路岔口;
## 侧支节点与主去路同深(保持「深度=自入口最长路径步数」不变量),两支各自汇入主去路的后继,
## 于是支路互斥段={主去路节点} 与 {侧支节点}——两支类型互异即同时满足「下一跳互异」与「各有独有类型」。
func _add_forks(rg: RandomNumberGenerator, L: int, t: int, e: int) -> void:
var wanted := clampi(int(round(float(_params["branch_density"]) * float(L))), 1, 24)
var anchors := []
for n in _pool:
if int(n["depth"]) > (t - 2 if e > 1 else L - 3):
continue
if (n["next"] as Array).size() != 1:
continue
anchors.append(n)
var anchors := _collect_fork_anchors(L, t, e)
anchors = _shuffle(rg, anchors)
var selected := {} # 已扩岔节点 id
var succ_selected := {} # 已扩岔节点的主去路 id(禁再作锚点)
for a in anchors:
if wanted <= 0:
break
var node = a
var nid := int(node["id"])
if (node["next"] as Array).size() != 1:
var nid := int(a["id"])
if (a["next"] as Array).size() != 1:
continue # 同一节点不重复扩岔(去路数上限 3)
var v = _by_id[int((node["next"] as Array)[0])]
var v = _by_id[int((a["next"] as Array)[0])]
var vid := int(v["id"])
# 相邻两层不得同时扩岔:若主去路 v 也是岔口,v 的侧支会落进本岔口主支路的互斥段,
# 使侧支自己的类型不再独有(契约 5③)——两个方向都要拦。
if selected.has(vid) or succ_selected.has(nid):
continue
var v_outs = v["next"]
if v_outs.is_empty():
if (v["next"] as Array).is_empty():
continue # 主去路无后继(无可汇入点)——该锚点弃用
var extra := 2 if (int(_params["fork_ways_max"]) >= 3 and rg.randf() < 0.35) else 1
var made := 0
@@ -323,8 +383,8 @@ func _add_forks(rg: RandomNumberGenerator, L: int, t: int, e: int) -> void:
if _depth_count(int(v["depth"])) >= MAX_WIDTH:
break
var wid := _mk("battle", int(v["depth"]))
(node["next"] as Array).append(wid)
(_by_id[wid]["next"] as Array).append(int(v_outs[0]))
(a["next"] as Array).append(wid)
(_by_id[wid]["next"] as Array).append(int((v["next"] as Array)[0]))
_detour_heads.append(_by_id[wid])
_detour_ids[wid] = true
made += 1
@@ -334,6 +394,18 @@ func _add_forks(rg: RandomNumberGenerator, L: int, t: int, e: int) -> void:
wanted -= 1
## 锚收集段(零 RNG):主链段(深度 ≤ t-2)或单出口图链上(深度 ≤ L-3)的去路恰一节点
func _collect_fork_anchors(L: int, t: int, e: int) -> Array:
var anchors := []
for n in _pool:
if int(n["depth"]) > (t - 2 if e > 1 else L - 3):
continue
if (n["next"] as Array).size() != 1:
continue
anchors.append(n)
return anchors
# 确定性洗牌(Fisher-Yates,取数只经注入的纯流 RNG——Array.shuffle() 走引擎全局随机、
# 破「同(种子,参数档)⇒ 同图」的纯函数复现前提,禁用于生成器)
func _shuffle(rg: RandomNumberGenerator, arr: Array) -> Array:
@@ -358,31 +430,42 @@ func _depth_count(d: int) -> int:
## 单出口图的配比填余(摆放优先、配比填余、战斗保底兜底——§8.1 次序),再统一修岔口下一跳互异。
func _assign_types(rg: RandomNumberGenerator, e: int) -> void:
if e == 1:
var free: Array[Dictionary] = []
var chain_battles := 0
for n in _pool:
if String(n["type"]) == "shop" or String(n["type"]) == "boss":
continue
if _detour_ids.has(int(n["id"])):
continue # 侧链节点类型由岔口互异修型决定,不占配比填余面
if int(n["depth"]) == 1:
continue # 区域首节点留战斗——配比填余不破坏岔口下一跳互异的基准形
if String(n["type"]) == "battle":
chain_battles += 1
free.append(n)
free = _shuffle(rg, free)
var want := int(round(float(_pool.size()) * (float(_params["reward_ratio"]) + float(_params["rest_ratio"]))))
var floor_n := int(_params["battle_floor"])
var rest_first := rg.randf() < 0.5
for i in range(want):
if i >= free.size():
break
# 战斗保底优先:链上战斗数降到保底下限即停(侧链修型可能再让出战斗位,故保底只看链上)
if chain_battles - 1 < floor_n:
break
var node = free[i]
node["type"] = "rest" if ((i % 2 == 0) == rest_first) else "reward"
chain_battles -= 1
_fill_ratios(rg)
_fix_detour_heads()
## 配比填余段(单出口图;shuffle+randf 取号序整体搬迁——战斗保底优先、侧链不占填余面):
## 摆放优先、配比填余、战斗保底兜底——§8.1 次序
func _fill_ratios(rg: RandomNumberGenerator) -> void:
var free: Array[Dictionary] = []
var chain_battles := 0
for n in _pool:
if String(n["type"]) == "shop" or String(n["type"]) == "boss":
continue
if _detour_ids.has(int(n["id"])):
continue # 侧链节点类型由岔口互异修型决定,不占配比填余面
if int(n["depth"]) == 1:
continue # 区域首节点留战斗——配比填余不破坏岔口下一跳互异的基准形
if String(n["type"]) == "battle":
chain_battles += 1
free.append(n)
free = _shuffle(rg, free)
var want := int(round(float(_pool.size()) * (float(_params["reward_ratio"]) + float(_params["rest_ratio"]))))
var floor_n := int(_params["battle_floor"])
var rest_first := rg.randf() < 0.5
for i in range(want):
if i >= free.size():
break
# 战斗保底优先:链上战斗数降到保底下限即停(侧链修型可能再让出战斗位,故保底只看链上)
if chain_battles - 1 < floor_n:
break
var node = free[i]
node["type"] = "rest" if ((i % 2 == 0) == rest_first) else "reward"
chain_battles -= 1
## 岔口修型段(零 RNG):侧链首节点类型取岔口各去路下一节点类型的补集首件——下一跳互异承面
func _fix_detour_heads() -> void:
for head in _detour_heads:
var h = head
var fork = null
@@ -416,12 +499,15 @@ static func _trunk_len(L: int, e: int) -> int:
# ---- 自证辅助 ----
func _longest_paths(nodes: Array, by_id: Dictionary, entries: Array, indeg: Dictionary) -> Dictionary:
func _longest_paths(nodes: Array, by_id: Dictionary, entries: Array) -> Dictionary:
var out := {}
var din := {}
var q := []
for id in indeg:
din[int(id)] = int(indeg[int(id)])
for n in nodes:
din[int(n["id"])] = 0
for n in nodes:
for oid in (n["next"] as Array):
din[int(oid)] = int(din[int(oid)]) + 1
for eid in entries:
out[int(eid)] = 0
q.append(int(eid))
@@ -476,44 +562,53 @@ func _check_fork_rules(nodes: Array, by_id: Dictionary, errs: Array) -> void:
if head_types.has(ty):
errs.append({"category": "structural", "message": "岔口 %d 各去路下一节点类型重复: %s" % [n["id"], ty]})
head_types[ty] = true
var reach_sets := []
for oid in outs:
reach_sets.append(_descendants(int(oid), by_id))
var comps := []
for i in range(reach_sets.size()):
var excl := {}
for rid in reach_sets[i]:
var shared := false
for j in range(reach_sets.size()):
if j != i and reach_sets[j].has(rid):
shared = true
break
if not shared:
excl[int(rid)] = String(by_id[int(rid)]["type"])
var arr := []
for v in excl.values():
arr.append(v)
arr.sort()
comps.append(arr)
var comps := _fork_signatures(outs, by_id)
for i in range(comps.size()):
for j in range(i + 1, comps.size()):
if comps[i] == comps[j]:
errs.append({"category": "structural", "message": "岔口 %d 支路 %d/%d 类型构成相同" % [n["id"], i, j]})
for i in range(comps.size()):
var uniq := false
for ty in comps[i]:
var in_other := false
for j in range(comps.size()):
if j != i and (comps[j] as Array).has(ty):
in_other = true
break
if not in_other:
uniq = true
break
if not uniq:
if not _fork_has_unique(comps, i):
errs.append({"category": "structural", "message": "岔口 %d 支路 %d 无独有类型" % [n["id"], i]})
## 支路签名构造:各去路的独占后代类型集(他人不可达的后代类型,排序数组——支路互斥段签名)
func _fork_signatures(outs: Array, by_id: Dictionary) -> Array:
var reach_sets := []
for oid in outs:
reach_sets.append(_descendants(int(oid), by_id))
var comps := []
for i in range(reach_sets.size()):
var excl := {}
for rid in reach_sets[i]:
var shared := false
for j in range(reach_sets.size()):
if j != i and reach_sets[j].has(rid):
shared = true
break
if not shared:
excl[int(rid)] = String(by_id[int(rid)]["type"])
var arr := []
for v in excl.values():
arr.append(v)
arr.sort()
comps.append(arr)
return comps
## 独有类型判定:支路 i 的签名中存在他支路不含的类型
func _fork_has_unique(comps: Array, i: int) -> bool:
for ty in comps[i]:
var in_other := false
for j in range(comps.size()):
if j != i and (comps[j] as Array).has(ty):
in_other = true
break
if not in_other:
return true
return false
func _descendants(start: int, by_id: Dictionary) -> Dictionary:
var out := {start: true}
var q := [start]
+2 -2
View File
@@ -81,8 +81,8 @@ func reset_paid() -> void:
# 本次 GP 付费刷新价(与商店货架刷新同源:ceil(基价 × 曲线^本次停点已付费刷))
func gp_cost() -> int:
return int(ceil(float(db.adventure.shop_refresh_cost_base)
* pow(db.adventure.shop_refresh_cost_curve, float(_paid_refreshes))))
return int(ceil(float(db.run.adventure.shop_refresh_cost_base)
* pow(db.run.adventure.shop_refresh_cost_curve, float(_paid_refreshes))))
# 冒险级复位(宿主 reset_adventure_state 调用——刷新三源与付费计数全零)
+40 -24
View File
@@ -40,18 +40,8 @@ func roll_board(eligible: Array) -> bool:
if eligible.is_empty():
_fatal("roll_board 空资格清单(E1 兜底 fail-fast——排雷玩法 不变式④前置)")
return false
# 行主序规范化 + 去重(排序责任不外推给 战斗结算;排序键单点 BoardData.row_major_key)
var seen := {}
var list: Array[Dictionary] = []
for e in eligible:
var c: Vector2i = e.cell
if seen.has(c):
push_warning("[地图战斗资源] 资格清单重复格已去重: %s" % str(c))
continue
seen[c] = true
list.append({"cell": c})
list.sort_custom(func(a, b): return BoardData.row_major_key(a.cell) < BoardData.row_major_key(b.cell))
_eligible = list
_eligible = _normalize_eligible(eligible)
var list: Array[Dictionary] = _eligible
_granted.clear() # 新批重置映射受理面(旧图快照消亡)
_power_roll.clear()
_clear_power = -1
@@ -85,6 +75,21 @@ func roll_board(eligible: Array) -> bool:
return true
## 资格清单规范化域(开流前——零 RNG):行主序 + 去重(排序责任不外推给 战斗结算;排序键单点 BoardData.row_major_key)
func _normalize_eligible(eligible: Array) -> Array[Dictionary]:
var seen := {}
var list: Array[Dictionary] = []
for e in eligible:
var c: Vector2i = e.cell
if seen.has(c):
push_warning("[地图战斗资源] 资格清单重复格已去重: %s" % str(c))
continue
seen[c] = true
list.append({"cell": c})
list.sort_custom(func(a, b): return BoardData.row_major_key(a.cell) < BoardData.row_major_key(b.cell))
return list
# ---- 查询面(Ready 只读;未开格恒可查——秘宝 桥接结构前提)----
# 相位只读出口(诊断观测面——unrolled/ready/retired;消费方禁直读 _phase 下划线字段)
@@ -167,7 +172,7 @@ func retire_mapping() -> bool:
# ---- 极性路由字典面(战斗结算 授予段消费):(类型, 极性)→ 格面池字典行(拷贝出口)----
func polarity_row(cell_type: String, polarity: String) -> Dictionary:
for row in db.reward.rows:
for row in db.run.reward.rows:
if String(row.get("pool_tag", "")) == "cell" \
and String(row.get("cell_type", "")) == cell_type \
and String(row.get("polarity", "")) == polarity:
@@ -217,6 +222,20 @@ func _skewed_cell_rows() -> Array:
if board_skew <= 0:
return _rollable_rows()
var base: Array[Variant] = _rollable_rows()
var families := _skew_families(base)
if families["down"].is_empty() or families["up"].is_empty():
return base # 结构缺族 = 无偏斜作用面(保守直通——池结构变更须走表加载校验另批)
# 下调总量 = round_half_up(Σ生存×v/100);逐行精确份额 w×v/100 最大余数取整(行序平局)
var drops := _largest_remainder_split(families["down"], board_skew, 100, (2 * _pool_weight(families["down"]) * board_skew + 100) / 200)
# 上浮总量 = 下调总量(守恒),逐行精确份额 drop×w/Σ进攻 最大余数取整
var gains := _largest_remainder_split(families["up"], drops_total(drops), _pool_weight(families["up"]), drops_total(drops))
var out := _apply_skew(base, families["down"], families["up"], drops, gains)
assert(_pool_weight(out) == _pool_weight(base), "图级偏斜守恒破(偏斜后合计须等于原池)")
return out
## 分族域:生存下调族与进攻上浮族行集拆分(池结构不变式零触碰)
func _skew_families(base: Array) -> Dictionary:
var down: Array[Variant] = []
var up: Array[Variant] = []
for row in base:
@@ -225,13 +244,11 @@ func _skewed_cell_rows() -> Array:
down.append(row)
elif SKEW_UP_KINDS.has(k):
up.append(row)
if down.is_empty() or up.is_empty():
return base # 结构缺族 = 无偏斜作用面(保守直通——池结构变更须走表加载校验另批)
var v := board_skew
# 下调总量 = round_half_up(Σ生存×v/100);逐行精确份额 w×v/100 最大余数取整(行序平局)
var drops := _largest_remainder_split(down, v, 100, (2 * _pool_weight(down) * v + 100) / 200)
# 上浮总量 = 下调总量(守恒),逐行精确份额 drop×w/Σ进攻 最大余数取整
var gains := _largest_remainder_split(up, drops_total(drops), _pool_weight(up), drops_total(drops))
return {"down": down, "up": up}
## 权重装配域:逐行精确份额增减(下调族扣 drops、上浮族加 gains——守恒 assert 随段迁移)
func _apply_skew(base: Array, down: Array, up: Array, drops: Array, gains: Array) -> Array:
var di := 0
var ui := 0
var out: Array[Variant] = []
@@ -248,7 +265,6 @@ func _skewed_cell_rows() -> Array:
var skewed: Dictionary[Variant, Variant] = row.duplicate()
skewed["weight"] = w
out.append(skewed)
assert(_pool_weight(out) == _pool_weight(base), "图级偏斜守恒破(偏斜后合计须等于原池)")
return out
@@ -309,7 +325,7 @@ func set_treasure_unlocked(ids: Dictionary) -> void:
func _rollable_rows() -> Array:
# 摇号池 = cell 池正向行 weight>0(负向行纯字典不消费权重;封锁格不在本函数职责域——清单由调用方裁剪)
var out: Array[Dictionary] = []
for row in db.reward.rows:
for row in db.run.reward.rows:
if String(row.get("cell_type", "")) == RewardTable.TREASURE_ITEM_TYPE:
var iid := String(row.get("effect_id", ""))
if iid == "" or not _treasure_unlocked.has(iid):
@@ -321,7 +337,7 @@ func _rollable_rows() -> Array:
func _pool_rows(pool_tag: String) -> Array:
var out: Array[Dictionary] = []
for row in db.reward.rows:
for row in db.run.reward.rows:
if row.pool_tag == pool_tag and int(row.weight) > 0:
out.append(row)
return out
@@ -335,7 +351,7 @@ func _pool_weight(rows: Array) -> int:
func _row_by_id(rid: String) -> Dictionary:
for row in db.reward.rows:
for row in db.run.reward.rows:
if row.reward_id == rid:
return row
return {}
+4 -4
View File
@@ -120,7 +120,7 @@ func _charge_gate(v: Dictionary, target: Vector2i) -> Dictionary:
for h in tu_hits:
if String(h.get("kind", "")) == "counter_spend":
if not EffectEngine.counter_spend(fx_host.counters_ctx, String(h.get("counter_id", "")), int(h.get("value", 1))):
var vrow: Dictionary[Variant, Variant] = db.treasure_item_row(String(v.item_id))
var vrow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(v.item_id))
var cn_info: int = int(vrow.get("charge_n", 0)) # 读受理行自身
var seen := int(attune_safe_seen_cb.call())
return {"ok": false, "reason": "%s:安全格积点未达(%d/%d)" % [String(vrow.get("display_name", "")), seen % cn_info if cn_info > 0 else 0, cn_info]}
@@ -199,8 +199,8 @@ func grant_routed(snap: Dictionary, polarity: String, att: Dictionary) -> bool:
# premium_pct 贯穿 _apply_percent_row 应用与呈现同乘)——标空/挖格/清图不乘,雷安同池冻结不动。
# 取整=四舍五入(与伤害管线 round_direction=floor 分域——registry clause3_inequality 登记;
# 1 点量纲行 ac_up/action 取整后不变=整数粒度地板)。
if source_class == "seal_hit" and int(db.settlement.core.seal_hit_premium) != 100:
var prem_pct: int = int(db.settlement.core.seal_hit_premium)
if source_class == "seal_hit" and int(db.combat.settlement.core.seal_hit_premium) != 100:
var prem_pct: int = int(db.combat.settlement.core.seal_hit_premium)
row = row.duplicate()
row["power"] = int(round(int(row.power) * prem_pct / 100.0))
row["premium_pct"] = prem_pct
@@ -229,7 +229,7 @@ func _apply_percent_row(row: Dictionary, att: Dictionary) -> bool:
var eff := String(row.effect_id)
if eff == "gp_gain" or eff == "gp_loss":
return _apply_gp_row(row, att)
var srow: Dictionary[Variant, Variant] = db.status_effect_row(eff)
var srow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff)
# 封印溢价贯穿(仅封印命中正向行携带 premium_pct;其余授予路径缺省 100 零扰动):
# 应用幅度与呈现行值同乘,容器经 magnitude_override 拿同一快照(抗表重载口径保持)。
var prem_pct: int = int(row.get("premium_pct", 100))
+143 -519
View File
@@ -4,6 +4,8 @@
## 禁 preload/import 任何玩法脚本、禁碰 ConfigDB/RngService——须可独立实例化独立验证。
## 新增存档键先在序列化函数登记并升版本号;键域内容口径归各 GDD 文档,本类照单承载。
## 存档键清单的事实源=本类序列化/读回代码,不在任何文档维护第二份键清单。
## 拆件形态(D3 存档域):槽位文件域归 SlotStore(slots)、存档内元进度数据读写归 MetaStateStore
## (meta_state)——本类留磁盘 IO+版本迁移链+断点域+门面装配,消费方经 save.slots / save.meta_state 直连。
class_name SaveManagerCore
extends Node
@@ -18,18 +20,18 @@ const SLOT_PATH_TEMPLATE := "user://save_slot_%d.json" # 槽文件名唯一派
const LEGACY_SAVE_PATH := "user://save.json" # 旧隐式单档路径(Q1 甲:首次启动一次性字节迁入槽 1,此后无任何隐式读写)
const LEGACY_MIGRATED_SUFFIX := ".migrated.bak" # 迁移后旧档改名留痕后缀(字节级留盘不删——处置留痕)
var _path: String # 当前写盘目标=激活槽文件(或 attach_path 注入路径);""=无激活态——一切写盘路径拒绝
var _active_slot := 0 # 激活槽号 1..SLOT_COUNT;0=无激活态(注入非槽派生名路径时亦 0——写盘判定以 _path 非空为准)
var _read_only := false # 高版本拒读置位——write_to_disk 一律拒绝(契约 4 禁写)
var _data: Dictionary[String, Variant] = {} # 内存数据模型(_init 即内置默认;load 后为档案内容)
var _migrations: Dictionary[int, Callable] = {} # from_version → Callable(data)->data(升一版;v1→v6 生产迁移链现行——_init 自注册)
var _slot_template := SLOT_PATH_TEMPLATE # 槽文件名模板(生产=常量;测试经 attach_slot_template 注入隔离命名空间,禁在呈现层/探针各自拼槽名)
var slots: SlotStore # 槽位文件域件(槽位挂载与模板/激活与建删/摘要窥视——架构 :89)
var meta_state: MetaStateStore # 存档内元进度数据读写唯一层(写口原语+只读投影——架构 :90)
var _migration_fail_reason := "" # 迁移循环失败原因带出(_peek_migrated 返 null 时 load 侧落 corrupt 兜底文案单点)
func _init(path: String = "") -> void:
_path = path
_active_slot = _slot_index_of(path)
_data = _default_data()
slots = SlotStore.new(self, path)
meta_state = MetaStateStore.new(self)
# 生产迁移链自注册(v2 起——测试钩 register_migration 仍可叠挂更早版本步骤)
register_migration(1, _migrate_v1_v2)
register_migration(2, _migrate_v2_v3)
@@ -43,176 +45,103 @@ func _init(path: String = "") -> void:
# 类名加 Core 后缀、autoload 占净名)。独立 .new() 实例(单测/工具)不入树零 _ready,
# 读盘仍由调用方显式触发。
# 启动态钉死(卡① sv2):autoload 启动不读任何槽——内存=默认合法空态(_init 置好)、
# 无激活态(_path="",一切写盘路径拒绝并返回 false);玩家侧经选档界面显式进档/新档后才可写。
# 无激活态(注入路径空,一切写盘路径拒绝并返回 false);玩家侧经选档界面显式进档/新档后才可写。
# 旧隐式单档一次性迁入槽 1(Q1 甲,sv12)在本口执行。
func _ready() -> void:
_migrate_legacy_save(LEGACY_SAVE_PATH, slot_path(1), LEGACY_SAVE_PATH + LEGACY_MIGRATED_SUFFIX)
slots._migrate_legacy_save(LEGACY_SAVE_PATH, slots.slot_path(1), LEGACY_SAVE_PATH + LEGACY_MIGRATED_SUFFIX)
# 原地重置(探针隔离面,仿 RngService.set_master_seed 单例原地重置:
# 换路径+回默认数据+重读盘;对象引用恒定,MetaProgression 持有的 save 引用零失效)。
# 三槽化后单路径语义(契约 7)=注入路径即激活槽文件(写盘落该路径、无激活态时注入即激活);
# 注入槽派生名时激活号同步记账,注入任意隔离路径时激活号 0(写盘仍合法——隔离面行为零变化)。
func attach_path(path: String) -> void:
_path = path
# 迁移注册口:from_version → from_version+1 的升级函数(读档时链式调用)。fn 契约:
# Dictionary → Dictionary,须写回 version = from_version + 1。生产迁移链现行(v1→v6,_init 自注册),
# 冒烟经本口验证链式升级。
func register_migration(from_version: int, fn: Callable) -> void:
_migrations[from_version] = fn
# ---- 读盘(进档/注入时调用;启动态不读——_ready 已钉死不隐式激活)----
# 返回 true = 已就绪可用(含新档/损坏兜底/迁移成功);false = 高版本拒读(默认数据运行、禁写盘)。
# 损坏兜底:原文件改名 <path>.corrupt.bak(已存在则覆盖)+ push_warning + 默认数据继续——不静默丢档。
func load_from_disk() -> bool:
_read_only = false
_active_slot = _slot_index_of(path)
_data = _default_data()
load_from_disk()
# 测试隔离注入口(契约 7 同源——槽文件名模板整体换注,探针/单测经此隔离到自命名空间,
# 禁各自拼槽文件名;换注后回无激活态,槽原语全部落注入命名空间)
func attach_slot_template(template: String) -> void:
_slot_template = template
_path = ""
_active_slot = 0
_read_only = false
_data = _default_data()
# ---- 三存档位承载(卡①:隐式单档 → 3 存档位;位数与文件名口径=SLOT_COUNT/SLOT_PATH_TEMPLATE)----
# 槽文件路径唯一派生口(模板单点——禁第二处拼名)
func slot_path(slot: int) -> String:
return _slot_template % slot
# 激活槽号查询(0 = 无激活态——启动态钉死后 boot 需经选档显式进档/新档,卡③ Q1 甲)
func active_slot() -> int:
return _active_slot
# 写盘目标在位与否(激活槽或注入路径任一;无激活态一切写盘路径拒绝——sv3 判定口)
func has_write_target() -> bool:
return _path != ""
# 进档原语(sv5,契约 6/8):激活槽 N+读盘,复用 load_from_disk 迁移/兜底链按槽成立。
# 高版本拒读槽:激活不成立、恢复切换前三态(路径/激活号/禁写位/数据)——防误写他槽;
# 损坏槽:兜底链接管(.corrupt.bak 改名+空态继续,激活成立)。
func activate_slot(slot: int) -> bool:
if slot < 1 or slot > SLOT_COUNT:
return false
var prev_path := _path
var prev_slot := _active_slot
var prev_ro := _read_only
var prev_data := _data
_path = slot_path(slot)
_active_slot = slot
_read_only = false
_data = _default_data()
if not load_from_disk():
_path = prev_path
_active_slot = prev_slot
_read_only = prev_ro
_data = prev_data
return false
return true
# 新档原语(sv4):空槽创建=合法白板态(_default_data 全零可玩纪律),创建即激活并落盘。
# 生产路径只在空槽调用(有档槽开新局=先删档回空再新档,卡③ 契约 4);写盘失败回退切换前三态。
func create_slot(slot: int) -> bool:
if slot < 1 or slot > SLOT_COUNT:
return false
var prev_path := _path
var prev_slot := _active_slot
var prev_ro := _read_only
var prev_data := _data
_path = slot_path(slot)
_active_slot = slot
_read_only = false
_data = _default_data()
if not write_to_disk():
_path = prev_path
_active_slot = prev_slot
_read_only = prev_ro
_data = prev_data
return false
return true
# 删档原语(sv6,契约 8):该槽文件删除+盘面即时生效(卡内工程规定,挪用现役断点覆盖流语义)
# +无 .tmp 残留;删激活槽时激活指针连清除(内存回默认空态,该槽不再可写——激活态生命周期终点)。
# .corrupt.bak 损坏备份不追删(用户数据留盘可考;同槽再损坏时兜底流按槽覆盖同名备份)。
# 删不存在文件的空槽=幂等成功;越界槽号拒绝。
func delete_slot(slot: int) -> bool:
if slot < 1 or slot > SLOT_COUNT:
return false
var p := slot_path(slot)
if FileAccess.file_exists(p) and not _remove_file(p):
push_error("[存档序列化] 删档失败(文件删除被拒):%s" % p)
return false
var tmp := p + ".tmp"
if FileAccess.file_exists(tmp):
_remove_file(tmp) # 残留清偿尽力而为(主文件已删即达成空槽语义)
if _active_slot == slot:
_path = ""
_active_slot = 0
_data = _default_data()
return true
# 摘要三项按槽窥视读器(sv7/sv8,契约 5/9):钥石余额/冒险成功局数/冒险总局数。
# 只读零副作用:不激活、不写盘、不触发 _corrupt_fallback 改名(.corrupt.bak 只在正式进档路径产生)。
# 返回 {"readable", "has_file", "keystone", "cleared", "total"}——readable=true=可读槽(空槽=零值合法态,
# .tmp 残留按空槽——主文件不在即空);readable=false=不可读槽(损坏/高版本/缺版本字段/打开失败,
# 三值无意义——卡③ Q2 裁甲:UI 按空槽呈现,进档时走正式 load 兜底/拒读链);
# has_file=主文件在盘与否(空/有档操作集二分判定来处——卡③ T0 对齐结论;非摘要第四项,零 UI 语义)。
# 迁移链在此只读推演(生产迁移 Callable 为纯函数,零盘面副作用)。
func peek_slot_summary(slot: int) -> Dictionary:
if slot < 1 or slot > SLOT_COUNT:
return {"readable": false, "has_file": false, "keystone": 0, "cleared": 0, "total": 0} # 越界=非槽位,非空槽
return _peek_summary_at(slot_path(slot))
# 窥视注入口(契约 7 隔离面同款——测试/探针经路径注入隔离,禁各自拼槽文件名)
func peek_summary_at(path: String) -> Dictionary:
return _peek_summary_at(path)
func _peek_summary_at(path: String) -> Dictionary:
var out := {"readable": false, "has_file": false, "keystone": 0, "cleared": 0, "total": 0}
if not FileAccess.file_exists(path):
out["readable"] = true # 空槽=可读零值合法态
return out
out["has_file"] = true # 主文件在盘(可读性与占档判定分离——白板档与空槽自此可区分)
var f := FileAccess.open(path, FileAccess.READ)
if not FileAccess.file_exists(slots._path):
_data = _default_data() # 新档合法态
return true
var f := FileAccess.open(slots._path, FileAccess.READ)
if f == null:
return out
return _corrupt_fallback("读打开失败(%s)" % error_string(FileAccess.get_open_error()))
return _load_parsed(f)
## 读盘解析域(load_from_disk 主体段提取——解析/版本/迁移/规整;corrupt_fallback 调用点随段迁移,副作用序逐字)
func _load_parsed(f: FileAccess) -> bool:
var text := f.get_as_text()
f.close()
var parsed = JSON.parse_string(text)
if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("version"):
return out
if typeof(parsed) != TYPE_DICTIONARY:
return _corrupt_fallback("JSON 解析失败或顶层结构出域(非对象)")
if not parsed.has("version"):
return _corrupt_fallback("缺版本字段")
var v := int(parsed["version"])
if v > SAVE_FORMAT_VERSION:
return out
while v < SAVE_FORMAT_VERSION:
if not _migrations.has(v):
return out
var stepped = _migrations[v].call(parsed)
if typeof(stepped) != TYPE_DICTIONARY:
return out
parsed = stepped
v = int(parsed.get("version", v + 1))
var norm := _normalize(parsed)
out["readable"] = true
out["keystone"] = int(norm["meta"].get("keystone_balance", 0))
out["cleared"] = int(norm["meta"].get("adventure_cleared", 0))
out["total"] = int(norm["meta"].get("adventure_total", 0))
return out
_read_only = true
_data = _default_data()
push_error("[存档序列化] 档案版本 %d 高于当前 %d——拒读禁写(保护未来档不被覆写,内存默认新档运行)" % [v, SAVE_FORMAT_VERSION])
return false
var fail := []
var migrated = _peek_migrated(parsed, fail)
if migrated == null:
return _corrupt_fallback(fail[0])
_data = _normalize(migrated)
# 老档迁移(关卡节点图生成 v0.4 布尔位——缺字段 = 已有游玩史,不重放教学脚本)
if not _data["meta"].has("first_adventure_done"):
_data["meta"]["first_adventure_done"] = true
return true
# 注入路径的槽号反查(命中槽派生名返回槽号,否则 0——激活号记账用,写盘判定以 _path 非空为准)
func _slot_index_of(path: String) -> int:
for i in range(1, SLOT_COUNT + 1):
if path == slot_path(i):
return i
return 0
# ---- 写盘(tmp + rename 原子写——写一半断电不留烂档)----
# 无激活态一律拒绝并返回 false(sv3:启动态不隐式激活,盘面零触碰);
# 高版本拒读态一律拒绝(契约 4 禁写);写失败返回 false。
func write_to_disk() -> bool:
if slots._path == "":
push_error("[存档序列化] 写盘拒绝——无激活存档位(启动态不隐式激活;经选档进档/新档后落盘)")
return false
if _read_only:
push_error("[存档序列化] 写盘拒绝——档案版本高于当前(拒读禁写态)")
return false
var tmp := slots._path + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE)
if f == null:
push_error("[存档序列化] 临时文件打开失败:%s(%s)" % [tmp, error_string(FileAccess.get_open_error())])
return false
f.store_string(JSON.stringify(_data, "\t", true))
f.close()
if not _rename_overwrite(tmp, slots._path):
push_error("[存档序列化] 原子改名失败(tmp 保留待查):%s → %s" % [tmp, slots._path])
return false
return true
# ---- 冒险断点域(停点粒度续玩快照;内容装配归 AdventureRunner,本类照单承载)----
# 空字典 = 无断点合法态。值拷贝进出(禁碰内部结构契约同款)——快照写入后调用方再改不渗入存档态。
func set_breakpoint(bp: Dictionary) -> void:
_data["breakpoint"] = bp.duplicate(true)
func clear_breakpoint() -> void:
_data["breakpoint"] = {}
func has_breakpoint() -> bool:
return not (_data.get("breakpoint", {}) as Dictionary).is_empty()
func breakpoint_snapshot() -> Dictionary:
return (_data.get("breakpoint", {}) as Dictionary).duplicate(true)
# ---- 私有:迁移链 / 损坏兜底 / 规整 / 文件原语 ----
# v1→v2:断点域出生(旧档无断点 = 合法空态——「继续上局」隐藏,非损坏非债务)
func _migrate_v1_v2(data: Dictionary) -> Dictionary:
if not data.has("breakpoint"):
@@ -266,6 +195,53 @@ func _migrate_v6_v7(data: Dictionary) -> Dictionary:
return data
# 迁移循环提取(load/peek 两调用方同构共享):成功=迁移完成 Dictionary;失败=null
# (原因经 fail 数组带出——load 侧落 corrupt 兜底文案、peek 侧保持「返回未填充 out」逐字)
func _peek_migrated(parsed: Variant, fail: Array) -> Variant:
var v := int(parsed["version"])
while v < SAVE_FORMAT_VERSION:
if not _migrations.has(v):
fail.append("迁移链缺步骤 %d→%d" % [v, v + 1])
return null
var stepped = _migrations[v].call(parsed)
if typeof(stepped) != TYPE_DICTIONARY:
fail.append("迁移步骤 %d→%d 执行失败" % [v, v + 1])
return null
parsed = stepped
v = int(parsed.get("version", v + 1))
return parsed
## 摘要窥视内核(只读推演单点——SlotStore 两 peek 方法经槽路径派生委托至此;
## 迁移链在此只读推演——生产迁移 Callable 为纯函数,零盘面副作用)
func _peek_summary_at(path: String) -> Dictionary:
var out := {"readable": false, "has_file": false, "keystone": 0, "cleared": 0, "total": 0}
if not FileAccess.file_exists(path):
out["readable"] = true # 空槽=可读零值合法态
return out
out["has_file"] = true # 主文件在盘(可读性与占档判定分离——白板档与空槽自此可区分)
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return out
var text := f.get_as_text()
f.close()
var parsed = JSON.parse_string(text)
if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("version"):
return out
if int(parsed["version"]) > SAVE_FORMAT_VERSION:
return out
var fail := []
var migrated = _peek_migrated(parsed, fail)
if migrated == null:
return out
var norm := _normalize(migrated)
out["readable"] = true
out["keystone"] = int(norm["meta"].get("keystone_balance", 0))
out["cleared"] = int(norm["meta"].get("adventure_cleared", 0))
out["total"] = int(norm["meta"].get("adventure_total", 0))
return out
# ---- 默认数据模型(新档合法态——全零;白板存档 = 完整可玩〔元进度 单局循环零依赖〕)----
func _default_data() -> Dictionary[String, Variant]:
@@ -299,368 +275,15 @@ func _default_data() -> Dictionary[String, Variant]:
}
# ---- 首次冒险布尔位(关卡节点图生成 v1.1 确定性承载)----
# false = 本档尚无游玩史 → 首局走固定种子 42 + 参数档生成图(教学与存档复现的固定图由此承载,无兜底模板);
# 置位只在冒险收束挂点(MetaProgression.settle_adventure)。
# 老档迁移:缺字段 = 该档已有游玩史 → true(不重放首局固定图)。
# 原地重置默认态(SlotStore 槽位原语的协作口——激活/建删/换注回默认数据)
func _reset_to_default() -> void:
_data = _default_data()
func is_first_adventure_done() -> bool:
return bool(_data["meta"].get("first_adventure_done", true))
func mark_first_adventure_done() -> void:
_data["meta"]["first_adventure_done"] = true
# ---- 读盘(进档/注入时调用;启动态不读——_ready 已钉死不隐式激活)----
# 返回 true = 已就绪可用(含新档/损坏兜底/迁移成功);false = 高版本拒读(默认数据运行、禁写盘)。
# 损坏兜底:原文件改名 <path>.corrupt.bak(已存在则覆盖)+ push_warning + 默认数据继续——不静默丢档。
func load_from_disk() -> bool:
_read_only = false
if not FileAccess.file_exists(_path):
_data = _default_data() # 新档合法态
return true
var f := FileAccess.open(_path, FileAccess.READ)
if f == null:
return _corrupt_fallback("读打开失败(%s)" % error_string(FileAccess.get_open_error()))
var text := f.get_as_text()
f.close()
var parsed = JSON.parse_string(text)
if typeof(parsed) != TYPE_DICTIONARY:
return _corrupt_fallback("JSON 解析失败或顶层结构出域(非对象)")
if not parsed.has("version"):
return _corrupt_fallback("缺版本字段")
var v := int(parsed["version"])
if v > SAVE_FORMAT_VERSION:
_read_only = true
_data = _default_data()
push_error("[存档序列化] 档案版本 %d 高于当前 %d——拒读禁写(保护未来档不被覆写,内存默认新档运行)" % [v, SAVE_FORMAT_VERSION])
return false
while v < SAVE_FORMAT_VERSION:
if not _migrations.has(v):
return _corrupt_fallback("迁移链缺步骤 %d→%d" % [v, v + 1])
var stepped = _migrations[v].call(parsed)
if typeof(stepped) != TYPE_DICTIONARY:
return _corrupt_fallback("迁移步骤 %d→%d 执行失败" % [v, v + 1])
parsed = stepped
v = int(parsed.get("version", v + 1))
_data = _normalize(parsed)
# 老档迁移(关卡节点图生成 v0.4 布尔位——缺字段 = 已有游玩史,不重放教学脚本)
if not _data["meta"].has("first_adventure_done"):
_data["meta"]["first_adventure_done"] = true
return true
# ---- 写盘(tmp + rename 原子写——写一半断电不留烂档)----
# 无激活态一律拒绝并返回 false(sv3:启动态不隐式激活,盘面零触碰);
# 高版本拒读态一律拒绝(契约 4 禁写);写失败返回 false。
func write_to_disk() -> bool:
if _path == "":
push_error("[存档序列化] 写盘拒绝——无激活存档位(启动态不隐式激活;经选档进档/新档后落盘)")
return false
if _read_only:
push_error("[存档序列化] 写盘拒绝——档案版本高于当前(拒读禁写态)")
return false
var tmp := _path + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE)
if f == null:
push_error("[存档序列化] 临时文件打开失败:%s(%s)" % [tmp, error_string(FileAccess.get_open_error())])
return false
f.store_string(JSON.stringify(_data, "\t", true))
f.close()
if not _rename_overwrite(tmp, _path):
push_error("[存档序列化] 原子改名失败(tmp 保留待查):%s → %s" % [tmp, _path])
return false
return true
# 迁移注册口:from_version → from_version+1 的升级函数(读档时链式调用)。fn 契约:
# Dictionary → Dictionary,须写回 version = from_version + 1。生产迁移链现行(v1→v6,_init 自注册),
# 冒烟经本口验证链式升级。
func register_migration(from_version: int, fn: Callable) -> void:
_migrations[from_version] = fn
# ---- 类型化域访问器(消费面——只经访问器操作,禁碰内部结构)----
func keystone_balance() -> int:
return int(_data["meta"]["keystone_balance"])
func add_keystone(n: int) -> void:
_data["meta"]["keystone_balance"] = keystone_balance() + n
# 钥石消费唯一口:余额不足拒绝且零副作用(钥石只增不减——消费除外,元进度验收判据)
func spend_keystone(n: int) -> bool:
if keystone_balance() < n:
return false
_data["meta"]["keystone_balance"] = keystone_balance() - n
return true
func item_unlocked(item_id: String) -> bool:
return _data["meta"]["item_unlocks"].has(item_id)
func unlock_item(item_id: String) -> void:
_data["meta"]["item_unlocks"][item_id] = true
func clear_count(level_index: int) -> int:
return int(_data["meta"]["level_clear_counts"].get(str(level_index), 0))
func boss_first_kill_marked(level_index: int) -> bool:
return _data["meta"]["boss_first_kill"].has(str(level_index))
# ---- 同调位账本域(里程碑谓词表计数源 + 装配关系 + Y 升级态)----
# Boss 击杀总数(含重复——底稿裁决 4 计数源;收束入账 = apply_adventure_summary 逐关卡完成关次 +1,
# Boss 恒唯一终点故 levels_cleared 即本次冒险 Boss 击杀数)
func boss_kill_count() -> int:
return int(_data["meta"].get("boss_kill_count", 0))
# ---- 生涯终身计数域(跨局汇总页数据源;只读面经访问器,
# 写入唯一经 apply_adventure_summary 收束口,禁第二写入点)----
# 冒险总局数(口径=开局即计:三结局 completed/failed/abandoned 同经收束恰计一次,含放弃与失败)
func adventure_total() -> int:
return int(_data["meta"].get("adventure_total", 0))
# 冒险成功局数(口径=三关全通才计 1——判定字段 summary.cleared,adventure_runner「completed」
# 唯一出口=末关的关卡完成)
func adventure_cleared() -> int:
return int(_data["meta"].get("adventure_cleared", 0))
# 真视类工具累计使用次数(信标网络解锁行为条件;写入唯一经 add_vision_used
# 冒险收束聚合入账,受理计数点=battle 层经冒险层聚合)
func vision_used() -> int:
return int(_data["meta"].get("vision_used", 0))
func add_vision_used(n: int) -> void:
_data["meta"]["vision_used"] = maxi(0, vision_used() + maxi(0, n))
# ---- 天赋树域(MetaProgression 树段读写面)----
func tree_nodes() -> Dictionary:
return (_data["meta"].get("tree_nodes", {}) as Dictionary).duplicate()
func tree_level(node_id: String) -> int:
return int((_data["meta"].get("tree_nodes", {}) as Dictionary).get(node_id, 0))
func set_tree_level(node_id: String, level: int) -> void:
var tn: Dictionary[Variant, Variant] = _data["meta"].get("tree_nodes", {}) as Dictionary
tn[node_id] = maxi(0, level)
_data["meta"]["tree_nodes"] = tn
func fork_sides() -> Dictionary:
return (_data["meta"].get("fork_sides", {}) as Dictionary).duplicate()
func fork_side(group: String) -> String:
return String((_data["meta"].get("fork_sides", {}) as Dictionary).get(group, ""))
func set_fork_side(group: String, side: String) -> void:
var fs: Dictionary[Variant, Variant] = _data["meta"].get("fork_sides", {}) as Dictionary
fs[group] = side
_data["meta"]["fork_sides"] = fs
# ---- 锻造工坊域 forge_routes(MetaProgression 锻造写口读写面;无特效域)----
# 解锁态不落档(派生):初始持有=表 initial_owned、练满前一档=levels[prereq]≥cap——判定单点在
# MetaProgression.forge_route_unlocked(读表+读档);本类只承载直购清单 unlocked 与选中标记。
# 域结构:{levels:{六路线 id→等级}, unlocked:[直购路线 id], weapon:当前武器路线 id, armor:当前甲路线 id}
func _forge_domain() -> Dictionary:
return _data["meta"].get("forge_routes", {}) as Dictionary
func forge_routes_snapshot() -> Dictionary:
return _forge_domain().duplicate(true) # 值拷贝出户(本类契约「禁碰内部结构」——消费方直改不得渗入存档内存态)
func forge_route_level(route_id: String) -> int:
var lv: Dictionary[Variant, Variant] = _forge_domain().get("levels", {}) as Dictionary
return int(lv.get(route_id, 0))
func set_forge_route_level(route_id: String, level: int) -> void:
var dom: Dictionary[Variant, Variant] = _forge_domain()
var lv: Dictionary[Variant, Variant] = dom.get("levels", {}) as Dictionary
lv[route_id] = maxi(0, level)
dom["levels"] = lv
_data["meta"]["forge_routes"] = dom
func forge_unlocked_purchases() -> Array:
return (_forge_domain().get("unlocked", []) as Array).duplicate()
func add_forge_unlock(route_id: String) -> void:
var dom: Dictionary[Variant, Variant] = _forge_domain()
var un: Array[Variant] = dom.get("unlocked", []) as Array
if not un.has(route_id):
un.append(route_id)
dom["unlocked"] = un
_data["meta"]["forge_routes"] = dom
func forge_weapon() -> String:
return String(_forge_domain().get("weapon", "sword"))
func set_forge_weapon(route_id: String) -> void:
var dom: Dictionary[Variant, Variant] = _forge_domain()
dom["weapon"] = route_id
_data["meta"]["forge_routes"] = dom
func forge_armor() -> String:
return String(_forge_domain().get("armor", "light"))
func set_forge_armor(route_id: String) -> void:
var dom: Dictionary[Variant, Variant] = _forge_domain()
dom["armor"] = route_id
_data["meta"]["forge_routes"] = dom
# 装配关系(同调目录 item_id 有序数组——装配序即呈现序;跨冒险持久,冒险中途不可换装〔底稿 §8.5〕)。
# 局外进度事实(元进度域同族),非契约 2 反面清单的「局内道具库存」——口径见 存档序列化.md 契约 2。
func attunement_equipped() -> Array:
return (_data["meta"].get("attunement_equipped", []) as Array).duplicate()
func set_attunement_equipped(item_ids: Array) -> void:
var out: Array[Variant] = []
for v in item_ids:
out.append(String(v))
_data["meta"]["attunement_equipped"] = out
# Y 刷新升级购买等级(0 = 未购 → 每冒险刷新次数 0;等级行值归营地升级行族)
func attune_refresh_level() -> int:
return int(_data["meta"].get("attune_refresh_level", 0))
func set_attune_refresh_level(n: int) -> void:
_data["meta"]["attune_refresh_level"] = maxi(0, n)
# 同调位计数源组装(Attunement 服务取数口——clear_count = 逐关完成次数合计〔含重复关,底稿 §2.1 口径〕;
# clear_count(level_index) 逐关访问器零改动复用)
func recent_summaries() -> Array:
return _data["meta"]["recent_summaries"].duplicate(true) # 值拷贝出户(本类契约「禁碰内部结构」——消费方直改不得渗入存档内存态)
# 域更新原语(正式冒险收束唯一入账口——组合根经本函数入账,禁直改内部结构):
# 钥石入账(summary.keystone_total——逐杀掉落已在冒险内计入,boss_drops 参数为
# 摘要归档副本不重复入账)+ 逐关卡完成关次 clear_count +1 与 Boss 首杀标记 + Boss 击杀总数累计
# (同调位里程碑计数源)+ 摘要头部插入(cap 10,最新在前,元素附
# keystone_drops 副本——复盘可归因)+ 生涯终身计数自增(总局数开局即计:
# 三结局同经本口恰计一次;冒险成功局数按 summary.cleared 判定三关全通才计 1。终身计数写入
# 单口=本函数,禁第二写入点)。
func apply_adventure_summary(summary: Dictionary, boss_drops: Array = []) -> void:
_data["meta"]["adventure_total"] = adventure_total() + 1
if bool(summary.get("cleared", false)):
_data["meta"]["adventure_cleared"] = adventure_cleared() + 1
add_keystone(int(summary.get("keystone_total", 0)))
var cleared := int(summary.get("levels_cleared", 0))
for li in range(1, cleared + 1):
var key := str(li)
_data["meta"]["level_clear_counts"][key] = int(_data["meta"]["level_clear_counts"].get(key, 0)) + 1
_data["meta"]["boss_first_kill"][key] = true
_data["meta"]["boss_kill_count"] = boss_kill_count() + cleared # Boss 恒唯一终点:levels_cleared 即本次冒险 Boss 击杀数(含重复)
var entry: Dictionary[Variant, Variant] = summary.duplicate(true)
entry["boss_drops"] = boss_drops.duplicate(true)
var arr: Array[Variant] = _data["meta"]["recent_summaries"]
arr.push_front(entry)
while arr.size() > SUMMARY_CAP:
arr.pop_back()
# ---- 冒险断点域(停点粒度续玩快照;内容装配归 AdventureRunner,本类照单承载)----
# 空字典 = 无断点合法态。值拷贝进出(禁碰内部结构契约同款)——快照写入后调用方再改不渗入存档态。
func set_breakpoint(bp: Dictionary) -> void:
_data["breakpoint"] = bp.duplicate(true)
func clear_breakpoint() -> void:
_data["breakpoint"] = {}
func has_breakpoint() -> bool:
return not (_data.get("breakpoint", {}) as Dictionary).is_empty()
func breakpoint_snapshot() -> Dictionary:
return (_data.get("breakpoint", {}) as Dictionary).duplicate(true)
# ---- 私有:损坏兜底与文件原语 ----
# 旧隐式单档一次性迁移(Q1 甲,sv12):src 字节级原样搬运到 dst(零解析零改造——损坏/高版本
# 旧档字节原样落槽 1,读取时由正式进档路径的兜底/拒读链接管,不静默丢数据);
# dst 已存在不重迁(幂等判据——判别锚④:去判据则重复迁移覆盖槽 1);迁移成功后 src 改名
# bak 留痕(不删——处置字节留痕,防再次隐式读写)。任一步失败:源保留原地,下次启动重试。
# tmp+rename 原子形态与 write_to_disk 同纪律。生产调用=src/dst/bak 为 旧单档/槽 1/留痕名。
func _migrate_legacy_save(src: String, dst: String, bak: String) -> void:
if not FileAccess.file_exists(src):
return # 执行前置:旧档不存在=无操作
if FileAccess.file_exists(dst):
return # 幂等判据:槽 1 已存在不重迁
var tmp := dst + ".tmp"
if not _copy_file(src, tmp):
push_warning("[存档序列化] 旧单档迁移失败(源保留,下次启动重试):%s → %s" % [src, tmp])
return
if not _rename_overwrite(tmp, dst):
push_warning("[存档序列化] 旧单档迁移改名失败(tmp 保留待查,源保留重试):%s → %s" % [tmp, dst])
return
_rename_overwrite(src, bak)
push_warning("[存档序列化] 旧单档已迁入 %s,原件留痕于 %s" % [dst, bak])
# 字节级文件拷贝(迁移专用——buffer 直拷,零解析零改造)
func _copy_file(from: String, to: String) -> bool:
var src := FileAccess.open(from, FileAccess.READ)
if src == null:
return false
var dst := FileAccess.open(to, FileAccess.WRITE)
if dst == null:
src.close()
return false
while not src.eof_reached():
var chunk := src.get_buffer(65536)
if chunk.size() > 0:
dst.store_buffer(chunk)
src.close()
dst.close()
return true
# 文件删除(删档原语专用——呈现层禁 DirAccess 直删,承载面单点)
func _remove_file(p: String) -> bool:
var dir := DirAccess.open(p.get_base_dir())
if dir == null:
return false
return dir.remove(p.get_file()) == OK
# 损坏兜底:坏档改名 .corrupt.bak(已存在则覆盖)→ 默认数据继续。不静默丢档(警告留痕)、不 crash。
func _corrupt_fallback(reason: String) -> bool:
var bak := _path + ".corrupt.bak"
if not _rename_overwrite(_path, bak):
var bak := slots._path + ".corrupt.bak"
if not _rename_overwrite(slots._path, bak):
push_warning("[存档序列化] 坏档备份改名失败(原文件保留原地):%s——%s" % [bak, reason])
else:
push_warning("[存档序列化] 检测到损坏档案,已备份为 %s——%s(用默认新档继续)" % [bak, reason])
@@ -689,7 +312,8 @@ func _normalize(data: Dictionary) -> Dictionary[String, Variant]:
return merged
# 覆盖式改名(Windows 原生 rename 目标已存在时会失败——先删后改,显式覆盖语义)
# 覆盖式改名(Windows 原生 rename 目标已存在时会失败——先删后改,显式覆盖语义;IO 原语单点——
# SlotStore 迁移流经本件调用)
func _rename_overwrite(from: String, to: String) -> bool:
var dir := DirAccess.open(from.get_base_dir())
if dir == null:
+2 -2
View File
@@ -78,7 +78,7 @@ func _enemy_actions(ri: int) -> void:
if int(oc._enemy_system.hero_unit().hp) <= 0:
break # hero hp=0 立即短路(死尸挨打行是归因噪音——用户拍板)
var skill_id := oc._enemy_system.pending_action(u)
var srow: Dictionary[Variant, Variant] = oc.db.skill_row(skill_id)
var srow: Dictionary[Variant, Variant] = oc.db.combat.skill.row_by_id(skill_id)
if srow.is_empty():
oc._fatal("敌实例待发动作悬空: %s" % skill_id)
continue
@@ -97,7 +97,7 @@ func _enemy_actions(ri: int) -> void:
var baseline: int = int(round(float(u.atk) * float(srow.get("damage_mult", 1.0))))
var rider := 0
if srow.has("rider_mult"):
rider = maxi(int(oc.db.settlement.damage_roll.damage_floor), int(floor(float(u.atk) * float(srow.rider_mult))))
rider = maxi(int(oc.db.combat.settlement.damage_roll.damage_floor), int(floor(float(u.atk) * float(srow.rider_mult))))
var et := oc._damage.open_ticket(DamageController.KIND_ENEMY_SKILL, "enemy_action",
"%s %s" % [u.display_name, String(srow.display_name)],
{"channel": "combat", "base_value": baseline, "target_rule": DamageController.TARGET_HERO,
+2 -2
View File
@@ -147,7 +147,7 @@ func _reveal_mine_apply(mh_hits: Array, snap: Dictionary, cell: Vector2i, ri: in
if fx_host._fx_bonus_delta.has(axis):
fx_host._fx_bonus_delta[axis] += int(h.get("delta", 0))
"blast_scaled":
var blast_v := maxi(int(oc.db.settlement.damage_roll.damage_floor), int(float(int(snap.get("power", 0))) * float(h.get("mult", 1))))
var blast_v := maxi(int(oc.db.combat.settlement.damage_roll.damage_floor), int(float(int(snap.get("power", 0))) * float(h.get("mult", 1))))
var bs_t := oc._damage.open_ticket(DamageController.KIND_BOARD_BLAST, "feat_blast_scaled", "陷阱爆发",
{"channel": "board", "base_value": blast_v, "target_rule": DamageController.TARGET_ALL_ALIVE,
"ri": ri, "origin_cell": cell})
@@ -163,7 +163,7 @@ func _terrain_batch(ri: int) -> void:
for evt in oc._facts_reveals:
var vids: Array[Variant] = evt.get("variant_ids", [])
for vid in vids:
var vrow: Dictionary[Variant, Variant] = oc.db.variation.row_by_id(String(vid))
var vrow: Dictionary[Variant, Variant] = oc.db.combat.variation.row_by_id(String(vid))
if String(vrow.get("effect", "")) != "flat_damage":
continue # 触发词表按 effect 路由:flat_damage=on_open 兑现;hide_number/on_open 外组合 = 数据行新词表未接线(显式忽略面)
# 地形变异伤害单据——无类型必中,下限保底在控制器管线内
+16 -16
View File
@@ -34,7 +34,7 @@ func bind(refs: Dictionary, hooks: Dictionary) -> void:
# 商店覆盖层数据源(只读投影——现价/已购档数在此现算,呈现层禁复算定价公式)
func shop_products() -> Array:
var out: Array[Dictionary] = []
for prow in db.shop_products():
for prow in db.run.shop.product_rows():
out.append({
"product_id": String(prow.get("product_id", "")),
"display_name": String(prow.get("display_name", "")),
@@ -49,7 +49,7 @@ func shop_products() -> Array:
# 现价 = base_price + price_level_bonus(level_index) + price_increment × 该商品已购档数
# (散折价:economy_spend 激活时商品标价 ×80%——见 _spend_discounted)
func current_price(prow: Dictionary) -> int:
return _spend_discounted(int(prow.get("base_price", 0)) + db.shop_price_level_bonus(_level_index()) \
return _spend_discounted(int(prow.get("base_price", 0)) + db.run.shop.price_level_bonus_at(_level_index()) \
+ int(prow.get("price_increment", 0)) * purchased_tier(String(prow.get("product_id", ""))))
@@ -58,7 +58,7 @@ func current_price(prow: Dictionary) -> int:
# + 池货架面 pool_price 同折〕——商店刷新价 shop_refresh_cost 与三选一付费刷新不折)
func _spend_discounted(price: int) -> int:
if _rules().has("economy_spend"):
return maxi(1, int(floor(float(price) * float(db.shop.spend_discount))))
return maxi(1, int(floor(float(price) * float(db.run.shop.spend_discount))))
return price
@@ -69,7 +69,7 @@ func purchased_tier(product_id: String) -> int:
# 购买(未知商品/属性键出域/余额不足拒绝——返回 false 零副作用):
# 出账 → 加算区 += flat_value → 购血同步当前生命(封顶新上限)→ 已购档数 +1
func buy_product(product_id: String) -> bool:
var prow: Dictionary[Variant, Variant] = db.shop_product_row(product_id)
var prow: Dictionary[Variant, Variant] = db.run.shop.product_row(product_id)
if prow.is_empty():
push_error("[关卡节点地图] 商店房未知商品: %s" % product_id)
return false
@@ -133,7 +133,7 @@ func leave() -> void:
func _draw_shelf() -> void:
shelf = []
var sellable: Array[Variant] = []
for row in db.shop_pool_rows():
for row in db.run.shop.pool_rows():
var shelf_kind := String(row.get("kind_id", ""))
if _pool_cap.is_valid() and not bool(_pool_cap.call(shelf_kind, "")):
continue # cap 满剔除(货架候选视图剔除;牌库本体不动)
@@ -184,14 +184,14 @@ func pool_stock(kind: String) -> int:
# A/b 表 knob provisional;核心现算,呈现层禁复算——契约 5)。relic 卡(保底占位——shop 词表外)
# 基价 provisional 15(表 knob relic_base_price——shop.tres 词表扩列归数值批)。
func pool_price(kind: String) -> int:
var row: Dictionary[Variant, Variant] = db.shop_pool_row(kind)
var row: Dictionary[Variant, Variant] = db.run.shop.pool_row(kind)
if row.is_empty():
if kind == "relic":
return _spend_discounted(int(ceil(float(db.shop.relic_base_price) * pow(db.shop_price_b(), float(maxi(0, _level_index() - 1))) * pow(db.shop_price_a(), float(visit_buys)))))
return _spend_discounted(int(ceil(float(db.run.shop.relic_base_price) * pow(db.run.shop.price_b, float(maxi(0, _level_index() - 1))) * pow(db.run.shop.price_a, float(visit_buys)))))
return 999999
var base := float(maxi(1, int(row.get("base_price", 1))))
var price_f: float = base * pow(db.shop_price_b(), float(maxi(0, _level_index() - 1))) \
* pow(db.shop_price_a(), float(visit_buys))
var price_f: float = base * pow(db.run.shop.price_b, float(maxi(0, _level_index() - 1))) \
* pow(db.run.shop.price_a, float(visit_buys))
return _spend_discounted(int(ceil(price_f)))
@@ -202,11 +202,11 @@ func shop_shelf() -> Array:
for tok in shelf:
var kind := String((tok as Dictionary).get("kind", ""))
var tier := String((tok as Dictionary).get("tier", ""))
var row: Dictionary[Variant, Variant] = db.shop_pool_row(kind)
var row: Dictionary[Variant, Variant] = db.run.shop.pool_row(kind)
var display := String(row.get("display_name", kind))
var value := int(db.adventure.curio_value(kind, tier))
var value := int(db.run.adventure.curio_value(kind, tier))
if row.is_empty() and kind == "relic":
var rrow: Dictionary[Variant, Variant] = db.feat_row(String(tok.get("feat_id", "")))
var rrow: Dictionary[Variant, Variant] = db.crossrun.feats.row_by_id(String(tok.get("feat_id", "")))
display = String(rrow.get("display_name", "特性"))
value = 0
out.append({
@@ -227,22 +227,22 @@ func shop_shelf() -> Array:
func shop_refresh_free_left() -> int:
# 刷新执照 shop_refresh_bonus 装配合计叠加(进店入账语义=读数即本店可用,
# 与基础 knob 同生命周期;未装配=基值)
return maxi(0, int(db.adventure.shop_refresh_free) + _attune_shop_refresh_bonus())
return maxi(0, int(db.run.adventure.shop_refresh_free) + _attune_shop_refresh_bonus())
# 刷新执照装配合计(装配集 kind 分发扫描;零装配=0)
func _attune_shop_refresh_bonus() -> int:
var total := 0
for iid_v in _attunement():
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v))
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v))
if (arow.get("effect_kinds", []) as Array).has("shop_refresh_bonus"):
total += int(arow.get("magnitude", 0))
return total
func shop_refresh_cost() -> int:
return int(ceil(float(db.adventure.shop_refresh_cost_base)
* pow(db.adventure.shop_refresh_cost_curve, float(paid_refreshes))))
return int(ceil(float(db.run.adventure.shop_refresh_cost_base)
* pow(db.run.adventure.shop_refresh_cost_curve, float(paid_refreshes))))
func shop_refresh() -> bool:
+189
View File
@@ -0,0 +1,189 @@
## 存档槽位文件域件(D3 拆件——自 save_manager 收编槽位族):槽位挂载与模板、激活与建删、
## 摘要窥视(peek 族)的唯一所有者。槽位态(路径/激活号/模板)随件承载;数据重置与读写盘
## 经注入的 save 内部协作家族(_reset_to_default/_load_parsed/_rename_overwrite)完成——
## 本件零 _data 副本零缓存(load 整体换新不陈旧)。
class_name SlotStore
extends RefCounted
var _save # SaveManagerCore 注入引用(IO 与数据域协作单点)
var _path: String = "" # 当前写盘目标=激活槽文件(或 attach_path 注入路径);""=无激活态——一切写盘路径拒绝
var _active_slot := 0 # 激活槽号 1..SLOT_COUNT;0=无激活态(注入非槽派生名路径时亦 0——写盘判定以 _path 非空为准)
var _slot_template: String # 槽文件名模板(生产=常量;测试经 attach_slot_template 注入隔离命名空间,禁在呈现层/探针各自拼槽名)
func _init(save_ref, path: String = "") -> void:
_save = save_ref
_slot_template = _save.SLOT_PATH_TEMPLATE
_path = path
_active_slot = _slot_index_of(path)
## 进档注入口:换路径+回默认数据+重读盘(对象引用恒定,MetaProgression 持有的 save 引用零失效)。
## 三槽化后单路径语义(契约 7)=注入路径即激活槽文件(写盘落该路径、无激活态时注入即激活);
## 注入槽派生名时激活号同步记账,注入任意隔离路径时激活号 0(写盘仍合法——隔离面行为零变化)。
func attach_path(path: String) -> void:
_path = path
_save._read_only = false
_active_slot = _slot_index_of(path)
_save._reset_to_default()
_save.load_from_disk()
## 测试隔离注入口(契约 7 同源——槽文件名模板整体换注,探针/单测经此隔离到自命名空间,
## 禁各自拼槽文件名;换注后回无激活态,槽原语全部落注入命名空间)
func attach_slot_template(template: String) -> void:
_slot_template = template
_path = ""
_active_slot = 0
_save._read_only = false
_save._reset_to_default()
# 槽文件路径唯一派生口(模板单点——禁第二处拼名)
func slot_path(slot: int) -> String:
return _slot_template % slot
# 激活槽号查询(0 = 无激活态——启动态钉死后 boot 需经选档显式进档/新档)
func active_slot() -> int:
return _active_slot
# 写盘目标在位与否(激活槽或注入路径任一;无激活态一切写盘路径拒绝——sv3 判定口)
func has_write_target() -> bool:
return _path != ""
# 进档原语(sv5,契约 6/8):激活槽 N+读盘,复用 load_from_disk 迁移/兜底链按槽成立。
# 高版本拒读槽:激活不成立、恢复切换前三态(路径/激活号/禁写位/数据)——防误写他槽;
# 损坏槽:兜底链接管(.corrupt.bak 改名+空态继续,激活成立)。
func activate_slot(slot: int) -> bool:
if slot < 1 or slot > _save.SLOT_COUNT:
return false
var prev_path := _path
var prev_slot := _active_slot
var prev_ro: bool = _save._read_only
var prev_data = _save._data
_path = slot_path(slot)
_active_slot = slot
_save._read_only = false
_save._reset_to_default()
if not _save.load_from_disk():
_path = prev_path
_active_slot = prev_slot
_save._read_only = prev_ro
_save._data = prev_data
return false
return true
# 新档原语(sv4):空槽创建=合法白板态(_default_data 全零可玩纪律),创建即激活并落盘。
# 生产路径只在空槽调用(有档槽开新局=先删档回空再新档);写盘失败回退切换前三态。
func create_slot(slot: int) -> bool:
if slot < 1 or slot > _save.SLOT_COUNT:
return false
var prev_path := _path
var prev_slot := _active_slot
var prev_ro: bool = _save._read_only
var prev_data = _save._data
_path = slot_path(slot)
_active_slot = slot
_save._read_only = false
_save._reset_to_default()
if not _save.write_to_disk():
_path = prev_path
_active_slot = prev_slot
_save._read_only = prev_ro
_save._data = prev_data
return false
return true
# 删档原语(sv6,契约 8):该槽文件删除+盘面即时生效+无 .tmp 残留;删激活槽时激活指针连清除
# (内存回默认空态,该槽不再可写——激活态生命周期终点)。
# .corrupt.bak 损坏备份不追删(用户数据留盘可考;同槽再损坏时兜底流按槽覆盖同名备份)。
# 删不存在文件的空槽=幂等成功;越界槽号拒绝。
func delete_slot(slot: int) -> bool:
if slot < 1 or slot > _save.SLOT_COUNT:
return false
var p := slot_path(slot)
if FileAccess.file_exists(p) and not _remove_file(p):
push_error("[存档序列化] 删档失败(文件删除被拒):%s" % p)
return false
var tmp := p + ".tmp"
if FileAccess.file_exists(tmp):
_remove_file(tmp) # 残留清偿尽力而为(主文件已删即达成空槽语义)
if _active_slot == slot:
_path = ""
_active_slot = 0
_save._reset_to_default()
return true
# 摘要三项按槽窥视读器(sv7/sv8,契约 5/9):钥石余额/冒险成功局数/冒险总局数。
# 只读零副作用:不激活、不写盘、不触发 _corrupt_fallback 改名(.corrupt.bak 只在正式进档路径产生)。
# 迁移链在此只读推演(内核 _peek_summary_at 归 save IO 域——本方法经槽路径派生委托)。
func peek_slot_summary(slot: int) -> Dictionary:
if slot < 1 or slot > _save.SLOT_COUNT:
return {"readable": false, "has_file": false, "keystone": 0, "cleared": 0, "total": 0} # 越界=非槽位,非空槽
return _save._peek_summary_at(slot_path(slot))
# 窥视注入口(契约 7 隔离面同款——测试/探针经路径注入隔离,禁各自拼槽文件名)
func peek_summary_at(path: String) -> Dictionary:
return _save._peek_summary_at(path)
# 注入路径的槽号反查(命中槽派生名返回槽号,否则 0——激活号记账用,写盘判定以 _path 非空为准)
func _slot_index_of(path: String) -> int:
for i in range(1, _save.SLOT_COUNT + 1):
if path == slot_path(i):
return i
return 0
# 旧隐式单档一次性迁移(Q1 甲,sv12):src 字节级原样搬运到 dst(零解析零改造——损坏/高版本
# 旧档字节原样落槽 1,读取时由正式进档路径的兜底/拒读链接管,不静默丢数据);
# dst 已存在不重迁(幂等判据——判别锚④:去判据则重复迁移覆盖槽 1);迁移成功后 src 改名
# bak 留痕(不删——处置字节留痕,防再次隐式读写)。任一步失败:源保留原地,下次启动重试。
# tmp+rename 原子形态与 write_to_disk 同纪律(改名原语 _rename_overwrite 归 save IO 域单点)。生产调用=src/dst/bak 为 旧单档/槽 1/留痕名。
func _migrate_legacy_save(src: String, dst: String, bak: String) -> void:
if not FileAccess.file_exists(src):
return # 执行前置:旧档不存在=无操作
if FileAccess.file_exists(dst):
return # 幂等判据:槽 1 已存在不重迁
var tmp := dst + ".tmp"
if not _copy_file(src, tmp):
push_warning("[存档序列化] 旧单档迁移失败(源保留,下次启动重试):%s → %s" % [src, tmp])
return
if not _save._rename_overwrite(tmp, dst):
push_warning("[存档序列化] 旧单档迁移改名失败(tmp 保留待查,源保留重试):%s → %s" % [tmp, dst])
return
_save._rename_overwrite(src, bak)
push_warning("[存档序列化] 旧单档已迁入 %s,原件留痕于 %s" % [dst, bak])
# 字节级文件拷贝(迁移专用——buffer 直拷,零解析零改造)
func _copy_file(from: String, to: String) -> bool:
var src := FileAccess.open(from, FileAccess.READ)
if src == null:
return false
var dst := FileAccess.open(to, FileAccess.WRITE)
if dst == null:
src.close()
return false
while not src.eof_reached():
var chunk := src.get_buffer(65536)
if chunk.size() > 0:
dst.store_buffer(chunk)
src.close()
dst.close()
return true
# 文件删除(删档原语专用——呈现层禁 DirAccess 直删,承载面单点)
func _remove_file(p: String) -> bool:
var dir := DirAccess.open(p.get_base_dir())
if dir == null:
return false
return dir.remove(p.get_file()) == OK
+41 -27
View File
@@ -29,44 +29,58 @@ func apply_effect(effect_id: String, source: Dictionary, duration_override: int
if not _open:
push_warning("[增益状态] 域闭授予(EC1)")
return false
var row: Dictionary[Variant, Variant] = db.status_effect_row(effect_id)
var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(effect_id)
if row.is_empty():
push_warning("[增益状态] 未知 effect_id(EC2): %s" % effect_id)
return false
var policy: String = row.stack_policy
var dur: int = int(row.duration_settlements) if duration_override < 1 else duration_override
var max_stack: int = int(row.max_stack)
# 授予时快照(抗表重载——EC9)
var snap_m: int = int(row.magnitude)
if source.has("magnitude_override"):
snap_m = int(source["magnitude_override"]) # 封印溢价贯穿(迭代14② T4——封印命中 percent 形态同乘快照;缺省路径零扰动)
var snap_unit: String = String(row.unit)
var snap_display: String = String(row.display_name)
var snap := _grant_snapshot(row, source)
match policy:
"independent":
_instances.append(_new_instance(effect_id, 1, dur, source, snap_m, snap_unit, snap_display))
_instances.append(_new_instance(effect_id, 1, dur, source, snap))
return true
"refresh":
for inst in _instances:
if inst.effect_id == effect_id:
inst.remaining = dur # R ← D(取实例快照 D——同表值);L 不变;空刷新恒 true
return true
_instances.append(_new_instance(effect_id, 1, dur, source, snap_m, snap_unit, snap_display))
return true
return _apply_refresh(effect_id, dur, snap, source)
"stack":
for inst in _instances:
if inst.effect_id == effect_id:
if int(inst.layers) >= max_stack:
return false # 满层拒绝原子——容器零写入(拒收由战斗结算记"奖励未生效"行)
inst.layers = int(inst.layers) + 1 # stack 授予不触碰 R(spec 决议)
return true
_instances.append(_new_instance(effect_id, 1, dur, source, snap_m, snap_unit, snap_display))
return true
return _apply_stack(effect_id, dur, snap, source, max_stack)
push_warning("[增益状态] 未知 stack_policy")
return false
func _new_instance(effect_id: String, layers: int, dur: int, source: Dictionary, m: int, unit: String, display: String) -> Dictionary:
## 授予时快照域(抗表重载——EC9;封印溢价贯穿——迭代14② T4 percent 形态同乘快照、缺省路径零扰动)
func _grant_snapshot(row: Dictionary, source: Dictionary) -> Dictionary:
var snap_m: int = int(row.magnitude)
if source.has("magnitude_override"):
snap_m = int(source["magnitude_override"])
return {"m": snap_m, "unit": String(row.unit), "display": String(row.display_name)}
## refresh 臂:命中改 R 未命中追加(R ← D 取实例快照 D——同表值;L 不变;空刷新恒 true)
func _apply_refresh(effect_id: String, dur: int, snap: Dictionary, source: Dictionary) -> bool:
for inst in _instances:
if inst.effect_id == effect_id:
inst.remaining = dur
return true
_instances.append(_new_instance(effect_id, 1, dur, source, snap))
return true
## stack 臂:满层拒零写入·未满加层·未命中追加(stack 授予不触碰 R——spec 决议;
## 满层拒绝原子——容器零写入,拒收由战斗结算记"奖励未生效"行)
func _apply_stack(effect_id: String, dur: int, snap: Dictionary, source: Dictionary, max_stack: int) -> bool:
for inst in _instances:
if inst.effect_id == effect_id:
if int(inst.layers) >= max_stack:
return false
inst.layers = int(inst.layers) + 1
return true
_instances.append(_new_instance(effect_id, 1, dur, source, snap))
return true
func _new_instance(effect_id: String, layers: int, dur: int, source: Dictionary, snap: Dictionary) -> Dictionary:
return {
"effect_id": effect_id,
"layers": layers,
@@ -74,9 +88,9 @@ func _new_instance(effect_id: String, layers: int, dur: int, source: Dictionary,
"source_class": String(source.get("source_class", "")),
"origin_cell": source.get("origin_cell", Vector2i(-1, -1)),
"round_index": int(source.get("round_index", 0)),
"snap_m": m,
"snap_unit": unit,
"snap_display": display,
"snap_m": snap["m"],
"snap_unit": snap["unit"],
"snap_display": snap["display"],
}
@@ -84,7 +98,7 @@ func _new_instance(effect_id: String, layers: int, dur: int, source: Dictionary,
func query_active(side: String) -> Array:
var out: Array[Dictionary] = []
for inst in _instances:
var row: Dictionary[Variant, Variant] = db.status_effect_row(inst.effect_id)
var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(inst.effect_id)
if row.is_empty():
continue
if String(row.target_side) != side:
+44 -26
View File
@@ -68,9 +68,10 @@ func _usable_now(item_id: String, board_has_preview_info: Callable) -> bool:
func _item_row(item_id: String) -> Dictionary:
if _db == null:
return {}
if _db.has_method("treasure_item_row"):
return _db.treasure_item_row(item_id) # 表访问收口(正式面走 ConfigDB 访问器)
return _db.treasure_items.row_by_id(item_id) # 桩表面(单测 StubDB 无访问器——直读保留,随测试面演进退役)
var db_crossrun = _db.get("crossrun")
if db_crossrun != null:
return db_crossrun.treasure_items.row_by_id(item_id) # 表访问收口(正式面走 ConfigDB 容器直连)
return _db.treasure_items.row_by_id(item_id) # 桩表面(单测 StubDB 无容器——直读保留,随测试面演进退役)
# 不标具体类型原因:单元测试注入 StubDB 桩表(treasure_items_test.gd)——typed 赋值会运行期拒收,保守保持 null 注
var _db = null # ConfigDB(行定义读取——_init 注入;测试可注入桩表)
@@ -97,31 +98,16 @@ func validate_use(handle: int, target: Vector2i, board_size: Vector2i, cell_unre
var kinds: Array[Variant] = row.get("effect_kinds", [])
var resolved: Array[Variant] = []
if kinds.has("mine_reveal") or kinds.has("reward_preview") or kinds.has("reveal_tiles") or kinds.has("safe_dig"):
# 空间效果(真视 reveal_tiles/天眼复合 safe_dig 入空间解析;
# 真视窄域护栏 REVEAL_KINDS 同守):
if target.x < 0 or target.y < 0 or target.x >= int(board_size.x) or target.y >= int(board_size.y):
return _reject("目标越板")
if String(row.get("target", "")) == "cell":
# 单格档(线香真视单格;≤3×3 窄域契约内的点目标)
resolved.append(target)
else:
# 3×3 窗口 ∩ 板内格(边缘受理——护栏只缩不扩)
for dy in range(-1, 2):
for dx in range(-1, 2):
var c := target + Vector2i(dx, dy)
if c.x >= 0 and c.y >= 0 and c.x < int(board_size.x) and c.y < int(board_size.y):
resolved.append(c)
# 空间效果(真视 reveal_tiles/天眼复合 safe_dig 入空间解析;真视窄域护栏 REVEAL_KINDS 同守)
var spatial := _spatial_reject_reason(row, target, board_size)
if spatial != "":
return _reject(spatial)
resolved = _window_cells(row, target, board_size)
# 点击级零信息:纯预读行窗口全已开 = 没东西可看(拒绝零消耗);
# 真视恒可用(零雷窗口=「这 9 格没雷」信息非空——GDD 裁定)
if kinds.has("reward_preview") and not kinds.has("mine_reveal"):
var any_unrevealed := false
if cell_unrevealed_cb.is_valid():
for c in resolved:
if bool(cell_unrevealed_cb.call(c)):
any_unrevealed = true
break
if not any_unrevealed:
return _reject("窗口内无可预读的未开格")
if kinds.has("reward_preview") and not kinds.has("mine_reveal") \
and _preview_window_dead(resolved, cell_unrevealed_cb):
return _reject("窗口内无可预读的未开格")
return {
"ok": true,
"reason": "",
@@ -132,6 +118,38 @@ func validate_use(handle: int, target: Vector2i, board_size: Vector2i, cell_unre
}
## 空间解析域:目标越板拒(cell/3×3 两档共用板界)——返回拒绝原因,空串=通过
func _spatial_reject_reason(row: Dictionary, target: Vector2i, board_size: Vector2i) -> String:
if target.x < 0 or target.y < 0 or target.x >= int(board_size.x) or target.y >= int(board_size.y):
return "目标越板"
return ""
## 预读窗口域:单格档=点目标(线香真视单格;≤3×3 窄域契约内);其余=3×3 窗口 ∩ 板内格(边缘受理——护栏只缩不扩)
func _window_cells(row: Dictionary, target: Vector2i, board_size: Vector2i) -> Array:
var resolved: Array[Variant] = []
if String(row.get("target", "")) == "cell":
resolved.append(target)
else:
for dy in range(-1, 2):
for dx in range(-1, 2):
var c := target + Vector2i(dx, dy)
if c.x >= 0 and c.y >= 0 and c.x < int(board_size.x) and c.y < int(board_size.y):
resolved.append(c)
return resolved
## 预读死窗判定:纯预读行窗口全已开 = 没东西可看(cell_unrevealed_cb = 排雷玩法 只读面)
func _preview_window_dead(resolved: Array, cell_unrevealed_cb: Callable) -> bool:
var any_unrevealed := false
if cell_unrevealed_cb.is_valid():
for c in resolved:
if bool(cell_unrevealed_cb.call(c)):
any_unrevealed = true
break
return not any_unrevealed
# ---- 叠加层(战斗结算逐 kind 应用——空间先、ap 后的序在编排侧钉死)----
func apply_overlay(kind: String, cells: Array) -> void:
+59 -30
View File
@@ -33,42 +33,58 @@ func _init(db_ref: ConfigDb) -> void:
# (下标=行集行序,长度短于行集时缺省 -1 = 该行行值路径;空数组 = 全行定值)。
# 逐行实例化、生成序 = 文件行序(行序即契约,敌人系统 v0.15);行集实例总数(各行传入数量求和)
# ≤ ENEMY_ROWSET_INSTANCE_MAX 守卫 fail-fast。
func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_overrides: Array = [],
atk_override: int = -1, max_hp_override: int = -1, enemy_atk_overrides: Array = [],
hero_attack_type: String = "", hero_armor_type: String = "", row_kind: String = "battle") -> bool:
# spawn 引用束(K6′ 签名束化——10 形参收单 Dictionary 束):束键封闭词表——
# 必载 level_index/node_depth/hero_hp;可选 unit_count_overrides=[]/enemy_atk_overrides=[]/
# hero_atk_override=-1/hero_max_hp_override=-1/hero_attack_type=""/hero_armor_type=""/
# row_kind="battle"(缺省语义与现行形参缺省逐字同值)。
# enemy_atk_override:遭遇骰同流的 spawn 期整战持久 roll 结果
# (-1 = 表值定值路径;≥0 = 行值替换后再乘关卡 atk_mult)。敌人系统 零随机纪律保持。
# 行集受理:unit_count_overrides / enemy_atk_overrides 为**逐行数组**
# (下标=行集行序,长度短于行集时缺省 -1 = 该行行值路径;空数组 = 全行定值)。
# 逐行实例化、生成序 = 文件行序(行序即契约,敌人系统 v0.15);行集实例总数(各行传入数量求和)
# ≤ ENEMY_ROWSET_INSTANCE_MAX 守卫 fail-fast。
func spawn(req: Dictionary) -> bool:
if _spawned:
push_warning("[敌人系统] Alive 相位重复 spawn(相位守卫)")
return false
# 行集数组口径(resolver 按 (L,band) 带寻址返回全部行、文件序)
var rowset: Array[Variant] = db.resolve_enemy_rowset(level_index, node_depth, row_kind)
var rowset: Array[Variant] = db.combat.enemy.resolve_rowset(int(req.level_index), int(req.node_depth), String(req.get("row_kind", "battle")))
if rowset.is_empty():
push_error("[敌人系统] 遭遇行解析失败/越域 (%d,%d,%s)" % [level_index, node_depth, row_kind])
push_error("[敌人系统] 遭遇行解析失败/越域 (%s,%s,%s)" % [str(req.get("level_index")), str(req.get("node_depth")), String(req.get("row_kind", "battle"))])
assert(false, "遭遇行悬空(R1 前置守卫)")
return false
var eff_atk: int = atk_override if atk_override >= 1 else int(db.hero.atk)
var eff_max_hp: int = max_hp_override if max_hp_override >= 1 else int(db.hero.max_hp)
if not _spawn_hero_unit(int(req.hero_hp), int(req.get("hero_atk_override", -1)), int(req.get("hero_max_hp_override", -1)), String(req.get("hero_attack_type", "")), String(req.get("hero_armor_type", ""))):
return false
var mod: Dictionary[Variant, Variant] = db.run.difficulty.resolve_modifiers(int(req.level_index), int(req.node_depth))
if not _spawn_rowset(rowset, req.get("unit_count_overrides", []), req.get("enemy_atk_overrides", []), float(mod.get("hp_mult", 1.0)), float(mod.get("atk_mult", 1.0))):
return false
_spawned = true
_despawned = false
return true
## 英雄单位构建域:生效上限与类型轴快照(读注入值,空串回退锻造工坊配置默认值 normal/light)。
## max_hp = 生效上限(非入参 hero_hp)——跨战斗携带 HP 时上限不得压扁
## (恢复点/治疗包的满血守卫与治疗上限随之失效)
func _spawn_hero_unit(hero_hp: int, atk_override: int, max_hp_override: int, hero_attack_type: String, hero_armor_type: String) -> bool:
var eff_atk: int = atk_override if atk_override >= 1 else int(db.combat.hero.atk)
var eff_max_hp: int = max_hp_override if max_hp_override >= 1 else int(db.combat.hero.max_hp)
if hero_hp > eff_max_hp or hero_hp <= 0:
push_warning("[敌人系统] hero_hp 入参越界(EC16): %d" % hero_hp)
return false
# max_hp = 生效上限(非入参 hero_hp)——跨战斗携带 HP 时上限不得压扁
# (恢复点/治疗包的满血守卫与治疗上限随之失效)
_hero = {"hp": hero_hp, "max_hp": eff_max_hp, "atk": eff_atk, "is_hero": true, "alive": true, "display_name": String(db.hero.display_name), "enemy_id": "hero", "armor_type": hero_armor_type if hero_armor_type != "" else db.forge_default_armor_type(), "attack_type": hero_attack_type if hero_attack_type != "" else db.forge_default_attack_type()} # 护甲/攻击类型轴快照(读注入值,空串回退锻造工坊配置默认值 normal/light;甲效矩阵消费面)
_enemies.clear() # 难度曲线与Boss DifficultyTable 修饰:施加于行值、行值快照之前——MVP 空表 = 恒 1.0 无行为(AC-10);混编行集内全部敌种同乘((L,D) 精确匹配一次取值——F9 同乘不改变异,敌人系统 v0.15)
var mod: Dictionary[Variant, Variant] = db.resolve_difficulty_modifiers(level_index, node_depth)
var hp_mult: float = float(mod.get("hp_mult", 1.0))
var atk_mult: float = float(mod.get("atk_mult", 1.0))
# 逐行数量定值(敌人系统 零随机纪律——骰定在 战斗结算 遭遇骰层按行完成,本层收确定值;
# 行 i 数量 = overrides[i](≥1 时)否则该行 unit_count 行值)
var per_row_counts: Array[int] = []
_hero = {"hp": hero_hp, "max_hp": eff_max_hp, "atk": eff_atk, "is_hero": true, "alive": true, "display_name": String(db.combat.hero.display_name), "enemy_id": "hero", "armor_type": hero_armor_type if hero_armor_type != "" else db.crossrun.forge.default_armor_type, "attack_type": hero_attack_type if hero_attack_type != "" else db.crossrun.forge.default_attack_type} # 护甲/攻击类型轴快照(甲效矩阵消费面)
return true
## 行集实例化域(难度曲线修饰施加于行值、行值快照之前——MVP 空表 = 恒 1.0 无行为〔AC-10〕;
## 混编行集内全部敌种同乘——(L,D) 精确匹配一次取值,F9 同乘不改变异,敌人系统 v0.15)
func _spawn_rowset(rowset: Array, unit_count_overrides: Array, enemy_atk_overrides: Array, hp_mult: float, atk_mult: float) -> bool:
_enemies.clear()
var per_row_counts := _row_instance_counts(rowset, unit_count_overrides)
if per_row_counts.is_empty():
return false
var total_instances := 0
for row_i in range(rowset.size()):
var row_i_dict: Dictionary[Variant, Variant] = rowset[row_i]
var ovr: int = int(unit_count_overrides[row_i]) if row_i < unit_count_overrides.size() else -1
var count: int = ovr if ovr >= 1 else int(row_i_dict.unit_count)
if count < EnemyTable.UNIT_COUNT_MIN or count > EnemyTable.UNIT_COUNT_MAX:
push_warning("[敌人系统] 行 %d 实例化数量越域(单种数域 [%d,%d]): %d" % [row_i, EnemyTable.UNIT_COUNT_MIN, EnemyTable.UNIT_COUNT_MAX, count])
return false
per_row_counts.append(count)
for count in per_row_counts:
total_instances += count
# 行集实例总数守卫(行集构成纪律③——各行骰后数量求和 ≤4;骰后值加载期不可判,此处 fail-fast)
if total_instances > EnemyTable.ROWSET_INSTANCE_MAX:
@@ -99,11 +115,24 @@ func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_overrides
"is_hero": false,
})
handle += 1
_spawned = true
_despawned = false
return true
## 逐行数量定值(敌人系统 零随机纪律——骰定在 战斗结算 遭遇骰层按行完成,本层收确定值;
## 行 i 数量 = overrides[i](≥1 时)否则该行 unit_count 行值)。越域行 = 空数组(受理拒绝信号)
func _row_instance_counts(rowset: Array, unit_count_overrides: Array) -> Array[int]:
var per_row_counts: Array[int] = []
for row_i in range(rowset.size()):
var row_i_dict: Dictionary[Variant, Variant] = rowset[row_i]
var ovr: int = int(unit_count_overrides[row_i]) if row_i < unit_count_overrides.size() else -1
var count: int = ovr if ovr >= 1 else int(row_i_dict.unit_count)
if count < EnemyTable.UNIT_COUNT_MIN or count > EnemyTable.UNIT_COUNT_MAX:
push_warning("[敌人系统] 行 %d 实例化数量越域(单种数域 [%d,%d]): %d" % [row_i, EnemyTable.UNIT_COUNT_MIN, EnemyTable.UNIT_COUNT_MAX, count])
return []
per_row_counts.append(count)
return per_row_counts
# 实例池快照(proposal §2.2:开战把模板快照一份,冷却全零——与"生成拿到当时的表值快照"同一条
# 快照纪律。触发条件:技能行 trigger 是字段权威——快照随读;带 latch 行写单向闩标记
# trigger_latched 初始锁定;缺省无 trigger=无条件技)
@@ -115,7 +144,7 @@ func _pool_snapshot(row: Dictionary) -> Array[Dictionary]:
"weight": int(entry.get("weight", 0)),
"cooldown_left": 0,
}
var srow_snap: Dictionary[Variant, Variant] = db.skill_row(String(entry.get("skill_id", "")))
var srow_snap: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(String(entry.get("skill_id", "")))
if srow_snap.has("trigger"):
snap_entry["trigger"] = (srow_snap.get("trigger", {}) as Dictionary).duplicate(true)
if bool((snap_entry["trigger"] as Dictionary).get("latch", false)):
@@ -218,7 +247,7 @@ func special_skills(u: Dictionary, facts: Dictionary = {}) -> Array:
for e in u.pool:
if _entry_blocked(e, u, facts):
continue
var srow: Dictionary[Variant, Variant] = db.skill_row(String(e.skill_id))
var srow: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(String(e.skill_id))
if not srow.is_empty() and String(srow.type) != "basic":
out.append({"skill_id": String(e.skill_id), "weight": int(e.weight)})
return out
@@ -276,7 +305,7 @@ func commit_action(u: Dictionary) -> void:
if sid == "":
_diag("commit_action 无待发动作(兑现序违例)")
return
var srow: Dictionary[Variant, Variant] = db.skill_row(sid)
var srow: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(sid)
var cd: int = int(srow.get("cooldown_beats", 0)) if not srow.is_empty() else 0
for e in u.pool:
if String(e.skill_id) == sid:
@@ -0,0 +1,15 @@
## 冒险表·已拿上限段(K4′ 拆段——curio_cap_* 9 槽随段件嵌套;段内槽名逐字不动;
## 概率与上限解耦〔奇物.md 池结构案D〕——概率位=档+令牌数、上限=本字段〔拿了才计、刷出不计〕;
## cap 满后该 kind 从候选视图剔除〔提前退役〕。99=实质无上限占位,实数归调参;专长 cap 在 feats.tres 行字段)。
class_name AdventureCapSegment
extends Resource
@export var curio_cap_attack: int = 99 # [1,99]
@export var curio_cap_max_hp: int = 99 # [1,99]
@export var curio_cap_armor: int = 99 # [1,99]
@export var curio_cap_gp: int = 99 # [1,99]
@export var curio_cap_ap: int = 99 # [1,99]
@export var curio_cap_heal: int = 99 # [1,99]
@export var curio_cap_refresh: int = 99 # [1,99]
@export var curio_cap_rap: int = 99 # [1,99]
@export var curio_cap_time_sand: int = 99 # [1,99]
@@ -0,0 +1,18 @@
## 冒险表·令牌配额段(K4′ 拆段——curio_quota_* 13 槽随段件嵌套;段内槽名逐字不动;
## 嵌套段单 .tres 保持——形态同 settlement_table 拆段既裁)。kind/tier 词表与配额结构锚留宿主。
class_name AdventureQuotaSegment
extends Resource
@export var curio_quota_attack_common: int = 18 # [0,99]
@export var curio_quota_attack_excellent: int = 5 # [0,99]
@export var curio_quota_max_hp_common: int = 17 # [0,99]
@export var curio_quota_max_hp_excellent: int = 5 # [0,99]
@export var curio_quota_armor_common: int = 17 # [0,99]
@export var curio_quota_armor_excellent: int = 5 # [0,99]
@export var curio_quota_gp_common: int = 18 # [0,99]
@export var curio_quota_gp_excellent: int = 5 # [0,99]
@export var curio_quota_heal_excellent: int = 3 # [0,99]——恢复件(refresh 挤占 2 席位,总量 102 锚不变)
@export var curio_quota_refresh_excellent: int = 2 # [0,99]——刷新次数件(补给类成员;provisional 归曲线调参)
@export var curio_quota_ap_rare: int = 3 # [0,99]——AP 件(稀有档)
@export var curio_quota_rap_rare: int = 2 # [0,99]——储备金袋(投放 provisional 归调参)
@export var curio_quota_time_sand_rare: int = 2 # [0,99]——时沙(投放 provisional 归调参)
+40 -78
View File
@@ -45,49 +45,11 @@ const TIER_QUOTAS := {"common": 70, "excellent": 25, "rare": 7, "legendary": 0}
@export var shop_refresh_cost_base: int = 20 # [1,999] knob(MC-1 provisional——GP 刷新基价)
@export var shop_refresh_cost_curve: float = 1.8 # [1.0,4.0] knob(MC-1 provisional——按已付费刷新数幂曲线,A 链同族)
# —— 令牌配额(kind × tier 计数;未列组合 = 0。四级档名 common/excellent/rare/legendary
# 承袭同一物理位序〔70/25/7/0〕;各 kind 现值见行注默认)——
@export var curio_quota_attack_common: int = 18 # [0,99]
@export var curio_quota_attack_excellent: int = 5 # [0,99]
@export var curio_quota_max_hp_common: int = 17 # [0,99]
@export var curio_quota_max_hp_excellent: int = 5 # [0,99]
@export var curio_quota_armor_common: int = 17 # [0,99]
@export var curio_quota_armor_excellent: int = 5 # [0,99]
@export var curio_quota_gp_common: int = 18 # [0,99]
@export var curio_quota_gp_excellent: int = 5 # [0,99]
@export var curio_quota_heal_excellent: int = 3 # [0,99]——恢复件(refresh 挤占 2 席位,总量 102 锚不变)
@export var curio_quota_refresh_excellent: int = 2 # [0,99]——刷新次数件(补给类成员;provisional 归曲线调参)
@export var curio_quota_ap_rare: int = 3 # [0,99]——AP 件(稀有档)
@export var curio_quota_rap_rare: int = 2 # [0,99]——储备金袋(投放 provisional 归调参)
@export var curio_quota_time_sand_rare: int = 2 # [0,99]——时沙(投放 provisional 归调参)
# —— 令牌件值(小/大数值对——v2.2 令牌池口径定稿:20/50、+1/+2、+1/+2、+5/+10、恢复+6、AP+1)——
@export var curio_value_attack_common: int = 1 # [0,999] knob——小增伤
@export var curio_value_attack_excellent: int = 2 # [0,999] knob——大增伤
@export var curio_value_max_hp_common: int = 5 # [0,999] knob——小生命
@export var curio_value_max_hp_excellent: int = 10 # [0,999] knob——大生命
@export var curio_value_armor_common: int = 1 # [0,999] knob——小护甲
@export var curio_value_armor_excellent: int = 2 # [0,999] knob——大护甲
@export var curio_value_gp_common: int = 20 # [0,999] knob——小GP
@export var curio_value_gp_excellent: int = 50 # [0,999] knob——大GP
@export var curio_value_heal_excellent: int = 6 # [0,999] knob——恢复(当即回血,封顶生效上限)
@export var curio_value_refresh_excellent: int = 1 # [0,9] knob(provisional)——刷新次数件兑付值(三选一重抽次数 +N——_attune_refresh_x 增量,不进加算区)
@export var curio_value_ap_rare: int = 1 # [0,10] knob——AP 件每场战斗首轮行动点基线增量
@export var curio_value_rap_rare: int = 3 # [0,99] knob(provisional)——储备金袋 RAP +N
@export var curio_value_time_sand_rare: int = 2 # [0,99] knob(provisional)——时沙 下轮 AP +N
# —— 已拿上限 cap(概率与上限解耦〔奇物.md 池结构案D〕——案②令牌池+提前退役:
# 概率位=档+令牌数〔牌库物理张数〕、上限=本字段〔拿了才计、刷出不计〕;cap 满后该 kind 从候选
# 视图剔除〔提前退役〕。99=实质无上限占位,实数归调参;专长 cap 在 feats.tres 行字段〕)——
@export var curio_cap_attack: int = 99 # [1,99]
@export var curio_cap_max_hp: int = 99 # [1,99]
@export var curio_cap_armor: int = 99 # [1,99]
@export var curio_cap_gp: int = 99 # [1,99]
@export var curio_cap_ap: int = 99 # [1,99]
@export var curio_cap_heal: int = 99 # [1,99]
@export var curio_cap_refresh: int = 99 # [1,99]
@export var curio_cap_rap: int = 99 # [1,99]
@export var curio_cap_time_sand: int = 99 # [1,99]
# —— 列族三段件(K4′ 拆段——36 个列族标量槽随段件嵌套,段内槽名逐字不动;
# 嵌套段单 .tres 保持——形态同 settlement_table 拆段既裁)——
@export var quota_segment: AdventureQuotaSegment = AdventureQuotaSegment.new() # 令牌配额段(curio_quota_* 13)
@export var value_segment: AdventureValueSegment = AdventureValueSegment.new() # 令牌件值段(curio_value_* 13)
@export var cap_segment: AdventureCapSegment = AdventureCapSegment.new() # 已拿上限段(curio_cap_* 9)
# —— 专长获取专长凭证(非 Boss 渠道的专长入口形态,奇物.md v2.7 渠道轴定位;
# 四口径契约④:拿到即开不可囤积/罕见只在概率不在价格/一局限购一次/计步保底连续 5 展示未出
@@ -120,53 +82,53 @@ func curio_apply_route(kind: String) -> String:
## heal/refresh excellent、ap rare、凭证 kind rare 档)
func curio_quota(kind: String, tier: String) -> int:
match [kind, tier]:
["attack", TIER_COMMON]: return curio_quota_attack_common
["attack", TIER_EXCELLENT]: return curio_quota_attack_excellent
["max_hp", TIER_COMMON]: return curio_quota_max_hp_common
["max_hp", TIER_EXCELLENT]: return curio_quota_max_hp_excellent
["armor", TIER_COMMON]: return curio_quota_armor_common
["armor", TIER_EXCELLENT]: return curio_quota_armor_excellent
["gp", TIER_COMMON]: return curio_quota_gp_common
["gp", TIER_EXCELLENT]: return curio_quota_gp_excellent
["heal", TIER_EXCELLENT]: return curio_quota_heal_excellent
["refresh", TIER_EXCELLENT]: return curio_quota_refresh_excellent
["ap", TIER_RARE]: return curio_quota_ap_rare
["rap", TIER_RARE]: return curio_quota_rap_rare
["time_sand", TIER_RARE]: return curio_quota_time_sand_rare
["attack", TIER_COMMON]: return quota_segment.curio_quota_attack_common
["attack", TIER_EXCELLENT]: return quota_segment.curio_quota_attack_excellent
["max_hp", TIER_COMMON]: return quota_segment.curio_quota_max_hp_common
["max_hp", TIER_EXCELLENT]: return quota_segment.curio_quota_max_hp_excellent
["armor", TIER_COMMON]: return quota_segment.curio_quota_armor_common
["armor", TIER_EXCELLENT]: return quota_segment.curio_quota_armor_excellent
["gp", TIER_COMMON]: return quota_segment.curio_quota_gp_common
["gp", TIER_EXCELLENT]: return quota_segment.curio_quota_gp_excellent
["heal", TIER_EXCELLENT]: return quota_segment.curio_quota_heal_excellent
["refresh", TIER_EXCELLENT]: return quota_segment.curio_quota_refresh_excellent
["ap", TIER_RARE]: return quota_segment.curio_quota_ap_rare
["rap", TIER_RARE]: return quota_segment.curio_quota_rap_rare
["time_sand", TIER_RARE]: return quota_segment.curio_quota_time_sand_rare
return 0
## 令牌件值(kind × tier;四级构成随配额表)
func curio_value(kind: String, tier: String = TIER_COMMON) -> int:
match [kind, tier]:
["attack", TIER_COMMON]: return curio_value_attack_common
["attack", TIER_EXCELLENT]: return curio_value_attack_excellent
["max_hp", TIER_COMMON]: return curio_value_max_hp_common
["max_hp", TIER_EXCELLENT]: return curio_value_max_hp_excellent
["armor", TIER_COMMON]: return curio_value_armor_common
["armor", TIER_EXCELLENT]: return curio_value_armor_excellent
["gp", TIER_COMMON]: return curio_value_gp_common
["gp", TIER_EXCELLENT]: return curio_value_gp_excellent
["heal", TIER_EXCELLENT]: return curio_value_heal_excellent
["refresh", TIER_EXCELLENT]: return curio_value_refresh_excellent
["ap", TIER_RARE]: return curio_value_ap_rare
["rap", TIER_RARE]: return curio_value_rap_rare
["time_sand", TIER_RARE]: return curio_value_time_sand_rare
["attack", TIER_COMMON]: return value_segment.curio_value_attack_common
["attack", TIER_EXCELLENT]: return value_segment.curio_value_attack_excellent
["max_hp", TIER_COMMON]: return value_segment.curio_value_max_hp_common
["max_hp", TIER_EXCELLENT]: return value_segment.curio_value_max_hp_excellent
["armor", TIER_COMMON]: return value_segment.curio_value_armor_common
["armor", TIER_EXCELLENT]: return value_segment.curio_value_armor_excellent
["gp", TIER_COMMON]: return value_segment.curio_value_gp_common
["gp", TIER_EXCELLENT]: return value_segment.curio_value_gp_excellent
["heal", TIER_EXCELLENT]: return value_segment.curio_value_heal_excellent
["refresh", TIER_EXCELLENT]: return value_segment.curio_value_refresh_excellent
["ap", TIER_RARE]: return value_segment.curio_value_ap_rare
["rap", TIER_RARE]: return value_segment.curio_value_rap_rare
["time_sand", TIER_RARE]: return value_segment.curio_value_time_sand_rare
return 0
## 已拿上限读数(案②——kind 维度;专长 cap 走 feats.tres 行字段不走本面)
func curio_cap(kind: String) -> int:
match kind:
"attack": return curio_cap_attack
"max_hp": return curio_cap_max_hp
"armor": return curio_cap_armor
"gp": return curio_cap_gp
"ap": return curio_cap_ap
"heal": return curio_cap_heal
"refresh": return curio_cap_refresh
"rap": return curio_cap_rap
"time_sand": return curio_cap_time_sand
"attack": return cap_segment.curio_cap_attack
"max_hp": return cap_segment.curio_cap_max_hp
"armor": return cap_segment.curio_cap_armor
"gp": return cap_segment.curio_cap_gp
"ap": return cap_segment.curio_cap_ap
"heal": return cap_segment.curio_cap_heal
"refresh": return cap_segment.curio_cap_refresh
"rap": return cap_segment.curio_cap_rap
"time_sand": return cap_segment.curio_cap_time_sand
return 99
@@ -0,0 +1,18 @@
## 冒险表·令牌件值段(K4′ 拆段——curio_value_* 13 槽随段件嵌套;段内槽名逐字不动;
## 小/大数值对——v2.2 令牌池口径定稿:20/50、+1/+2、+1/+2、+5/+10、恢复+6、AP+1)。
class_name AdventureValueSegment
extends Resource
@export var curio_value_attack_common: int = 1 # [0,999] knob——小增伤
@export var curio_value_attack_excellent: int = 2 # [0,999] knob——大增伤
@export var curio_value_max_hp_common: int = 5 # [0,999] knob——小生命
@export var curio_value_max_hp_excellent: int = 10 # [0,999] knob——大生命
@export var curio_value_armor_common: int = 1 # [0,999] knob——小护甲
@export var curio_value_armor_excellent: int = 2 # [0,999] knob——大护甲
@export var curio_value_gp_common: int = 20 # [0,999] knob——小GP
@export var curio_value_gp_excellent: int = 50 # [0,999] knob——大GP
@export var curio_value_heal_excellent: int = 6 # [0,999] knob——恢复(当即回血,封顶生效上限)
@export var curio_value_refresh_excellent: int = 1 # [0,9] knob(provisional)——刷新次数件兑付值(三选一重抽次数 +N——_attune_refresh_x 增量,不进加算区)
@export var curio_value_ap_rare: int = 1 # [0,10] knob——AP 件每场战斗首轮行动点基线增量
@export var curio_value_rap_rare: int = 3 # [0,99] knob(provisional)——储备金袋 RAP +N
@export var curio_value_time_sand_rare: int = 2 # [0,99] knob(provisional)——时沙 下轮 AP +N
+93 -56
View File
@@ -19,63 +19,100 @@ func validate() -> Array:
var levels := {}
var anchor_ok := {}
for row in rows:
var L: int = int(row.get("level_index", 0))
var D: int = int(row.get("node_depth", -1))
var key := "%d_%d" % [L, D]
if levels.has(key):
errs.append({"category": "dup_key", "message": "BoardGen 键重复: %s" % key})
levels[key] = true
if L < 1:
errs.append({"category": "domain", "message": "level_index 须 ≥1"})
if D < 0:
errs.append({"category": "domain", "message": "node_depth 须 ≥0"})
var w: int = int(row.get("board_width", 0))
var h: int = int(row.get("board_height", 0))
if w < 8 or w > 64 or h < 8 or h > 64:
errs.append({"category": "domain", "message": "(%d,%d) 尺寸越域 [8,64]" % [L, D]})
var d: float = float(row.get("mine_density", 0.0))
if d <= 0.0 or d > 0.5:
errs.append({"category": "domain", "message": "(%d,%d) mine_density 越域 (0,0.5]" % [L, D]})
var t: float = float(row.get("inferability_target", 0.0))
if t < TARGET_ABS_FLOOR or t > 1.0:
errs.append({"category": "domain", "message": "(%d,%d) inferability_target 越域 [0.85,1](P1 绝对锚)" % [L, D]})
if not SOLVER_RULES.has(row.get("solver_rules", "")):
errs.append({"category": "enum", "message": "(%d,%d) solver_rules 出域" % [L, D]})
if row.get("solver_rules", "") != "single_subset":
errs.append({"category": "frozen", "message": "(%d,%d) solver_rules 冻结 single_subset" % [L, D]})
var ms: int = int(row.get("min_safe_count", 0))
if ms < 1 or ms > 999:
errs.append({"category": "domain", "message": "(%d,%d) min_safe_count 越域 [1,999]" % [L, D]})
var att: int = int(row.get("max_gen_attempts", 0))
if att < 1 or att > 32:
errs.append({"category": "domain", "message": "(%d,%d) max_gen_attempts 越域 [1,32]" % [L, D]})
# 布雷数下限(R3)——零雷板拒绝(−9 = 出生区格数,常量单点 BoardData.SPAWN_AREA_CELLS)
var n_mines := int(round(d * float(w * h - BoardData.SPAWN_AREA_CELLS)))
if n_mines < 1:
errs.append({"category": "cross_field", "message": "(%d,%d) 布雷数下限违例 round(d×(W×H−9)) ≥ 1" % [L, D]})
# F2b min_safe 可行性
if w * h - n_mines < ms:
errs.append({"category": "cross_field", "message": "(%d,%d) min_safe_feasible 违例" % [L, D]})
# F2c 成本可行性
if att * w * h > COST_BUDGET:
errs.append({"category": "cross_field", "message": "(%d,%d) cost_feasible 违例 attempts×W×H ≤ %d" % [L, D, COST_BUDGET]})
# 图类型词表 v1.0(dungeon=地牢基线 / underdark=幽暗地域;扩词走本表修订)
var bt: String = str(row.get("board_type", ""))
if not BOARD_TYPE_VOCABULARY.has(bt):
errs.append({"category": "cross_field", "message": "(%d,%d) board_type 越词表: %s" % [L, D, bt]})
# 初始展开收窄两参数(加载期校验违例致命;缺省 shave=1 现行投值 / cap=0 闸关防御口径)
var sr: int = int(row.get("shave_ring_radius", 1))
if sr < 0 or sr > 2:
errs.append({"category": "domain", "message": "(%d,%d) shave_ring_radius 越域 [0,2]: %d" % [L, D, sr]})
var r0_cap: int = int(row.get("r0_max_cap", 0))
if r0_cap > 0:
# 护栏(需求卡片 §二):cap ≥ 出生格数 9(BoardData.SPAWN_AREA_CELLS)、cap ≤ 板格数一半
if r0_cap < BoardData.SPAWN_AREA_CELLS:
errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 低于出生保障面 %d: %d" % [L, D, BoardData.SPAWN_AREA_CELLS, r0_cap]})
if r0_cap > (w * h) / 2:
errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 超板格数一半 %d: %d" % [L, D, (w * h) / 2, r0_cap]})
anchor_ok[L] = anchor_ok.get(L, false) or D == 0
errs.append_array(_validate_row(row, levels, anchor_ok))
errs.append_array(_validate_row_constraints(row))
errs.append_array(_validate_shave_params(row))
for L in anchor_ok.keys():
if not anchor_ok[L]:
errs.append({"category": "anchor", "message": "level %d 缺 depth-0 锚行(EC15)" % L})
return errs
## 行域:键唯一与进度/尺寸/密度/推理目标/solver 规则冻结(levels/anchor_ok 引用累计)
func _validate_row(row: Dictionary, levels: Dictionary, anchor_ok: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var L: int = int(row.get("level_index", 0))
var D: int = int(row.get("node_depth", -1))
var key := "%d_%d" % [L, D]
if levels.has(key):
errs.append({"category": "dup_key", "message": "BoardGen 键重复: %s" % key})
levels[key] = true
if L < 1:
errs.append({"category": "domain", "message": "level_index 须 ≥1"})
if D < 0:
errs.append({"category": "domain", "message": "node_depth 须 ≥0"})
var w: int = int(row.get("board_width", 0))
var h: int = int(row.get("board_height", 0))
if w < 8 or w > 64 or h < 8 or h > 64:
errs.append({"category": "domain", "message": "(%d,%d) 尺寸越域 [8,64]" % [L, D]})
var d: float = float(row.get("mine_density", 0.0))
if d <= 0.0 or d > 0.5:
errs.append({"category": "domain", "message": "(%d,%d) mine_density 越域 (0,0.5]" % [L, D]})
var t: float = float(row.get("inferability_target", 0.0))
if t < TARGET_ABS_FLOOR or t > 1.0:
errs.append({"category": "domain", "message": "(%d,%d) inferability_target 越域 [0.85,1](P1 绝对锚)" % [L, D]})
if not SOLVER_RULES.has(row.get("solver_rules", "")):
errs.append({"category": "enum", "message": "(%d,%d) solver_rules 出域" % [L, D]})
if row.get("solver_rules", "") != "single_subset":
errs.append({"category": "frozen", "message": "(%d,%d) solver_rules 冻结 single_subset" % [L, D]})
anchor_ok[L] = anchor_ok.get(L, false) or D == 0
return errs
## 约束域:min_safe/生成尝试预算/布雷数下限与可行性/成本护栏/图类型词表
func _validate_row_constraints(row: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var L: int = int(row.get("level_index", 0))
var D: int = int(row.get("node_depth", -1))
var w: int = int(row.get("board_width", 0))
var h: int = int(row.get("board_height", 0))
var d: float = float(row.get("mine_density", 0.0))
var ms: int = int(row.get("min_safe_count", 0))
if ms < 1 or ms > 999:
errs.append({"category": "domain", "message": "(%d,%d) min_safe_count 越域 [1,999]" % [L, D]})
var att: int = int(row.get("max_gen_attempts", 0))
if att < 1 or att > 32:
errs.append({"category": "domain", "message": "(%d,%d) max_gen_attempts 越域 [1,32]" % [L, D]})
# 布雷数下限(R3)——零雷板拒绝(−9 = 出生区格数,常量单点 BoardData.SPAWN_AREA_CELLS)
var n_mines := int(round(d * float(w * h - BoardData.SPAWN_AREA_CELLS)))
if n_mines < 1:
errs.append({"category": "cross_field", "message": "(%d,%d) 布雷数下限违例 round(d×(W×H−9)) ≥ 1" % [L, D]})
# F2b min_safe 可行性
if w * h - n_mines < ms:
errs.append({"category": "cross_field", "message": "(%d,%d) min_safe_feasible 违例" % [L, D]})
# F2c 成本可行性
if att * w * h > COST_BUDGET:
errs.append({"category": "cross_field", "message": "(%d,%d) cost_feasible 违例 attempts×W×H ≤ %d" % [L, D, COST_BUDGET]})
# 图类型词表 v1.0(dungeon=地牢基线 / underdark=幽暗地域;扩词走本表修订)
var bt: String = str(row.get("board_type", ""))
if not BOARD_TYPE_VOCABULARY.has(bt):
errs.append({"category": "cross_field", "message": "(%d,%d) board_type 越词表: %s" % [L, D, bt]})
return errs
## 初始展开收窄域(加载期校验违例致命;缺省 shave=1 现行投值 / cap=0 闸关防御口径)
func _validate_shave_params(row: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var L: int = int(row.get("level_index", 0))
var D: int = int(row.get("node_depth", -1))
var w: int = int(row.get("board_width", 0))
var h: int = int(row.get("board_height", 0))
var sr: int = int(row.get("shave_ring_radius", 1))
if sr < 0 or sr > 2:
errs.append({"category": "domain", "message": "(%d,%d) shave_ring_radius 越域 [0,2]: %d" % [L, D, sr]})
var r0_cap: int = int(row.get("r0_max_cap", 0))
if r0_cap > 0:
# 护栏(需求卡片 §二):cap ≥ 出生格数 9(BoardData.SPAWN_AREA_CELLS)、cap ≤ 板格数一半
if r0_cap < BoardData.SPAWN_AREA_CELLS:
errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 低于出生保障面 %d: %d" % [L, D, BoardData.SPAWN_AREA_CELLS, r0_cap]})
if r0_cap > (w * h) / 2:
errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 超板格数一半 %d: %d" % [L, D, (w * h) / 2, r0_cap]})
return errs
var _resolver # R1 解析协作件(ConfigDB load_tables 装配段注入——resolver 文件与键零搬移)
## 进度行解析委托(消费面 db.combat.board_gen.resolve_row——R1 ceil/clamp 语义归 ConfigDbResolver)
func resolve_row(L: int, D: int) -> Dictionary:
return _resolver.combat.board_gen.resolve_row(L, D)
+8
View File
@@ -28,3 +28,11 @@ func validate() -> Array:
if v < MULT_MIN or v > MULT_MAX:
errs.append({"category": "mult_oor_warning", "message": "%s (%s) 越域 [0.1,10] 使用时 clamp: %f" % [key, k, v]})
return errs
var _resolver # R1 解析协作件(ConfigDB load_tables 装配段注入——resolver 文件与键零搬移)
## 难度修饰解析委托(消费面 db.run.difficulty.resolve_modifiers——clamp 语义归 ConfigDbResolver)
func resolve_modifiers(L: int, D: int) -> Dictionary:
return _resolver.run.difficulty.resolve_modifiers(L, D)
+142 -85
View File
@@ -44,98 +44,147 @@ func validate() -> Array:
var anchor_ok := {}
var boss_ok := {}
for row in rows:
var L: int = int(row.get("level_index", 0))
var rk: String = String(row.get("row_kind", ""))
var band: int = int(row.get("depth_band", -1))
var eid: String = row.get("enemy_id", "")
var key := "%d_%s_%d_%s" % [L, rk, band, eid]
if seen.has(key):
errs.append({"category": "dup_key", "message": "EnemyTable 键重复: %s" % key})
seen[key] = true
if L < 1 or band < 0:
errs.append({"category": "domain", "message": "进度键越域 (%d,band %d)" % [L, band]})
if not ROW_KINDS.has(rk):
errs.append({"category": "domain", "message": "%s row_kind 未知枚举: \"%s\"(域 {battle, boss})" % [eid, rk]})
if eid == "":
errs.append({"category": "domain", "message": "enemy_id 空"})
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % eid})
var uc: int = int(row.get("unit_count", 0))
if uc < UNIT_COUNT_MIN or uc > UNIT_COUNT_MAX:
errs.append({"category": "domain", "message": "%s unit_count 越域 [%d,%d]" % [eid, UNIT_COUNT_MIN, UNIT_COUNT_MAX]})
# unit_count_max(可省略——遭遇随机化批,难度曲线与Boss GDD Core Rule 8):区间 [unit_count, max],缺省 = 定值
if row.has("unit_count_max"):
var ucm: int = int(row.get("unit_count_max", uc))
if ucm < uc or ucm > UNIT_COUNT_MAX:
errs.append({"category": "cross_field", "message": "%s unit_count_max(%d) 越域 [unit_count(%d),%d]" % [eid, ucm, uc, UNIT_COUNT_MAX]})
var hp: int = int(row.get("hp", 0))
if hp < 1 or hp > 999:
errs.append({"category": "domain", "message": "%s hp 越域 [1,999]" % eid})
var atk: int = int(row.get("atk", -1))
if atk < 0 or atk > 999:
errs.append({"category": "domain", "message": "%s atk 越域 [0,999]" % eid})
# atk_min/atk_max(可省略配对):遭遇骰同流 spawn 期整战持久 roll;
# 中点锚 = 行动经济 条款①同款期望当量纪律(调区间必同步调 atk)
var has_amin: bool = row.has("atk_min")
var has_amax: bool = row.has("atk_max")
if has_amin != has_amax:
errs.append({"category": "cross_field", "message": "%s atk_min/atk_max 必须成对出现" % eid})
elif has_amin:
var amin: int = int(row.get("atk_min", 0))
var amax: int = int(row.get("atk_max", 0))
if amin < 0 or amax > 999 or amin > amax:
errs.append({"category": "domain", "message": "%s atk 区间越域 [0,999] 且 min≤max: %d..%d" % [eid, amin, amax]})
if atk != int((amin + amax) / 2.0):
errs.append({"category": "cross_field", "message": "%s atk(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [eid, atk, (amin + amax) / 2]})
# 池模板(单表结构校验;跨表构成纪律在 ConfigDB):
# 非空池 + 条目 {skill_id 非空, weight > 0} + 池内 skill_id 不重复。
# 普攻冷却恒 0(SkillTable schema 锁死)→ 可摇集合恒非空,"空池不可能"由此结构保证。
var pool: Array[Variant] = row.get("pool", [])
if pool.is_empty():
errs.append({"category": "domain", "message": "%s pool 空池(禁——技能池行必带池模板)" % eid})
var pool_ids := {}
for entry_v in pool:
if not (entry_v is Dictionary):
errs.append({"category": "domain", "message": "%s pool 条目非字典: %s" % [eid, str(entry_v)]})
continue
var entry: Dictionary[Variant, Variant] = entry_v
var sid: String = String(entry.get("skill_id", ""))
if sid == "":
errs.append({"category": "domain", "message": "%s pool 条目 skill_id 空" % eid})
elif pool_ids.has(sid):
errs.append({"category": "dup_key", "message": "%s pool 内 skill_id 重复: %s" % [eid, sid]})
pool_ids[sid] = true
var w: int = int(entry.get("weight", 0))
if w <= 0 or w > 9999:
errs.append({"category": "domain", "message": "%s pool 权重越域 (0,9999]: %d" % [eid, w]})
# armor_type(可省略——缺省 light 向后兼容)
var at: String = String(row.get("armor_type", "light"))
if not ARMOR_TYPES.has(at):
errs.append({"category": "enum", "message": "%s armor_type 未知枚举: %s(域 {light, ward, heavy})" % [eid, at]})
# attack_type(可省略,缺省 normal 向后兼容;写法沿用 armor_type 同款)
var att: String = String(row.get("attack_type", "normal"))
if not ATTACK_TYPES.has(att):
errs.append({"category": "enum", "message": "%s attack_type 未知枚举: %s(域 {normal, pierce, magic})" % [eid, att]})
# armor_class(可省略——缺省 0 向后兼容;防守方掷点判定阈值基数)
var ac: int = int(row.get("armor_class", 0))
if ac < 0 or ac > 999:
errs.append({"category": "domain", "message": "%s armor_class 越域 [0,999]: %d" % [eid, ac]})
errs.append_array(_validate_row_base(row, seen))
var eid2 := String(row.get("enemy_id", ""))
errs.append_array(_validate_row_numbers(row, eid2))
errs.append_array(_validate_row_atk_range(row, eid2))
errs.append_array(_validate_row_pool(row, eid2))
errs.append_array(_validate_row_types(row, eid2))
# 行集构成纪律②(行序即契约,敌人系统 v0.15):同带键多行必须
# 文件内相邻。迭代中记录上一行行集键;当前行集键曾出现过但 ≠ 上一行行集键 = 同带键行被隔断,报错。
# 同带键多敌种混编合法;同带键同敌种第二行仍由
# dup_key 拦截(键含 enemy_id)。错误类别沿用 rowset_rule(定位面不变)。
var rowset_key := "%d_%s_%d" % [L, rk, band]
var rowset_key := "%d_%s_%d" % [int(row.get("level_index", 0)), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]
if rowset_key != last_rowset_key:
if closed_rowset.has(rowset_key):
errs.append({"category": "rowset_rule", "message": "(%d,%s,%d) 行集行序违例:同带键多行必须文件内相邻(行序即契约——resolver 返回序/生成序/叙述取首行全依赖文件行序)" % [L, rk, band]})
errs.append_array(_validate_rowset_closed(rowset_key, closed_rowset, row))
closed_rowset[rowset_key] = true
last_rowset_key = rowset_key
if rk == "battle" and band == 0:
anchor_ok[L] = true
elif rk == "boss":
boss_ok[L] = true
if not anchor_ok.has(L):
anchor_ok[L] = false
var L2: int = int(row.get("level_index", 0))
var rk2: String = String(row.get("row_kind", ""))
if rk2 == "battle" and int(row.get("depth_band", -1)) == 0:
anchor_ok[L2] = true
elif rk2 == "boss":
boss_ok[L2] = true
if not anchor_ok.has(L2):
anchor_ok[L2] = false
errs.append_array(_validate_anchor_rows(anchor_ok, boss_ok))
return errs
## 行头基面域:键提取/查重与登记/进度键越域/row_kind 枚举/enemy_id 非空/display_name
func _validate_row_base(row: Dictionary, seen: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var L: int = int(row.get("level_index", 0))
var rk: String = String(row.get("row_kind", ""))
var band: int = int(row.get("depth_band", -1))
var eid: String = row.get("enemy_id", "")
var key := "%d_%s_%d_%s" % [L, rk, band, eid]
if seen.has(key):
errs.append({"category": "dup_key", "message": "EnemyTable 键重复: %s" % key})
seen[key] = true
if L < 1 or band < 0:
errs.append({"category": "domain", "message": "进度键越域 (%d,band %d)" % [L, band]})
if not ROW_KINDS.has(rk):
errs.append({"category": "domain", "message": "%s row_kind 未知枚举: \"%s\"(域 {battle, boss})" % [eid, rk]})
if eid == "":
errs.append({"category": "domain", "message": "enemy_id 空"})
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % eid})
return errs
## 数值域:unit_count(含可省略上限)/hp/atk 基值
func _validate_row_numbers(row: Dictionary, eid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var uc: int = int(row.get("unit_count", 0))
if uc < UNIT_COUNT_MIN or uc > UNIT_COUNT_MAX:
errs.append({"category": "domain", "message": "%s unit_count 越域 [%d,%d]" % [eid, UNIT_COUNT_MIN, UNIT_COUNT_MAX]})
# unit_count_max(可省略——遭遇随机化批,难度曲线与Boss GDD Core Rule 8):区间 [unit_count, max],缺省 = 定值
if row.has("unit_count_max"):
var ucm: int = int(row.get("unit_count_max", uc))
if ucm < uc or ucm > UNIT_COUNT_MAX:
errs.append({"category": "cross_field", "message": "%s unit_count_max(%d) 越域 [unit_count(%d),%d]" % [eid, ucm, uc, UNIT_COUNT_MAX]})
var hp: int = int(row.get("hp", 0))
if hp < 1 or hp > 999:
errs.append({"category": "domain", "message": "%s hp 越域 [1,999]" % eid})
var atk: int = int(row.get("atk", -1))
if atk < 0 or atk > 999:
errs.append({"category": "domain", "message": "%s atk 越域 [0,999]" % eid})
return errs
## atk 区间域(可省略配对):遭遇骰同流 spawn 期整战持久 roll;
## 中点锚 = 行动经济 条款①同款期望当量纪律(调区间必同步调 atk)
func _validate_row_atk_range(row: Dictionary, eid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var atk: int = int(row.get("atk", -1))
var has_amin: bool = row.has("atk_min")
var has_amax: bool = row.has("atk_max")
if has_amin != has_amax:
errs.append({"category": "cross_field", "message": "%s atk_min/atk_max 必须成对出现" % eid})
elif has_amin:
var amin: int = int(row.get("atk_min", 0))
var amax: int = int(row.get("atk_max", 0))
if amin < 0 or amax > 999 or amin > amax:
errs.append({"category": "domain", "message": "%s atk 区间越域 [0,999] 且 min≤max: %d..%d" % [eid, amin, amax]})
if atk != int((amin + amax) / 2.0):
errs.append({"category": "cross_field", "message": "%s atk(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [eid, atk, (amin + amax) / 2]})
return errs
## 池模板域(单表结构校验;跨表构成纪律在 ConfigDB):
## 非空池 + 条目 {skill_id 非空, weight > 0} + 池内 skill_id 不重复。
## 普攻冷却恒 0(SkillTable schema 锁死)→ 可摇集合恒非空,"空池不可能"由此结构保证。
func _validate_row_pool(row: Dictionary, eid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var pool: Array[Variant] = row.get("pool", [])
if pool.is_empty():
errs.append({"category": "domain", "message": "%s pool 空池(禁——技能池行必带池模板)" % eid})
var pool_ids := {}
for entry_v in pool:
if not (entry_v is Dictionary):
errs.append({"category": "domain", "message": "%s pool 条目非字典: %s" % [eid, str(entry_v)]})
continue
var entry: Dictionary[Variant, Variant] = entry_v
var sid: String = String(entry.get("skill_id", ""))
if sid == "":
errs.append({"category": "domain", "message": "%s pool 条目 skill_id 空" % eid})
elif pool_ids.has(sid):
errs.append({"category": "dup_key", "message": "%s pool 内 skill_id 重复: %s" % [eid, sid]})
pool_ids[sid] = true
var w: int = int(entry.get("weight", 0))
if w <= 0 or w > 9999:
errs.append({"category": "domain", "message": "%s pool 权重越域 (0,9999]: %d" % [eid, w]})
return errs
## 攻防类型域(均可省略——armor_type 缺省 light/attack_type 缺省 normal/armor_class 缺省 0 向后兼容)
func _validate_row_types(row: Dictionary, eid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var at: String = String(row.get("armor_type", "light"))
if not ARMOR_TYPES.has(at):
errs.append({"category": "enum", "message": "%s armor_type 未知枚举: %s(域 {light, ward, heavy})" % [eid, at]})
var att: String = String(row.get("attack_type", "normal"))
if not ATTACK_TYPES.has(att):
errs.append({"category": "enum", "message": "%s attack_type 未知枚举: %s(域 {normal, pierce, magic})" % [eid, att]})
# armor_class(防守方掷点判定阈值基数)
var ac: int = int(row.get("armor_class", 0))
if ac < 0 or ac > 999:
errs.append({"category": "domain", "message": "%s armor_class 越域 [0,999]: %d" % [eid, ac]})
return errs
## 行序违例域:当前行集键曾出现且 ≠ 上一行行集键 = 同带键行被隔断(行序即契约——resolver 返回序/生成序/叙述取首行全依赖文件行序)
func _validate_rowset_closed(rowset_key: String, closed_rowset: Dictionary, row: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
if closed_rowset.has(rowset_key):
errs.append({"category": "rowset_rule", "message": "(%d,%s,%d) 行集行序违例:同带键多行必须文件内相邻(行序即契约——resolver 返回序/生成序/叙述取首行全依赖文件行序)" % [int(row.get("level_index", 0)), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]})
return errs
## 锚行尾段:每 level 必有 (battle, band 0) 锚行与 Boss 行集
func _validate_anchor_rows(anchor_ok: Dictionary, boss_ok: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
for L in anchor_ok.keys():
if not anchor_ok[L]:
errs.append({"category": "anchor", "message": "EnemyTable level %d 缺 (battle, band 0) 锚行" % L})
@@ -158,3 +207,11 @@ static func band_of(band_keys: Array, D: int) -> int:
# 进度键解析单点收口(全仓统走
# ConfigDbResolver.resolve_enemy_rowset 带寻址行集数组:D 维经本表 band_of 落带(偏难一侧),
# 禁双算法并存〔状态记录 312〕;L 维 ceil/clamp 与越顶 clamp 语义归 resolver)。
var _resolver # R1 解析协作件(ConfigDB load_tables 装配段注入——resolver 文件与键零搬移)
## 带寻址行集解析委托(消费面 db.combat.enemy.resolve_rowset——D 维 band_of 落带语义归 ConfigDbResolver)
func resolve_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary]:
return _resolver.combat.enemy.resolve_rowset(L, D, row_kind)
+118 -60
View File
@@ -45,66 +45,101 @@ func validate() -> Array:
for row in routes: # 预扫:route_id→kind 全集(前置反查需前向引用——轻甲行在长枪行之后)
kind_by_id[String(row.get("route_id", ""))] = String(row.get("kind", ""))
for row in routes:
var rid := String(row.get("route_id", ""))
if not ROUTE_IDS.has(rid):
errs.append({"category": "enum", "message": "锻造路线 route_id 出域 %s: %s" % [ROUTE_IDS, rid]})
if seen.has(rid):
errs.append({"category": "dup_key", "message": "锻造路线 route_id 重复: %s" % rid})
seen[rid] = true
var kind := String(row.get("kind", ""))
if not ["weapon", "armor"].has(kind):
errs.append({"category": "enum", "message": "%s kind 出域 {weapon, armor}: %s" % [rid, kind]})
var head_out := _validate_route_head(row, seen, type_seen)
errs.append_array(head_out["errs"])
if head_out["skip"]:
continue
# 类型轴:武器=attack_type 域、甲=armor_type 域;每型恰一路线(三武器三甲各一)
var axis := String(row.get("type_axis", ""))
var domain: Array[Variant] = ATTACK_TYPES if kind == "weapon" else ARMOR_TYPES
if not domain.has(axis):
errs.append({"category": "enum", "message": "%s type_axis 出域 %s: %s" % [rid, domain, axis]})
var type_key := "%s|%s" % [kind, axis]
if type_seen.has(type_key):
errs.append({"category": "dup_key", "message": "%s 类型轴重复(每型恰一路线): %s %s" % [kind, axis, rid]})
type_seen[type_key] = true
# 等级/增量/价格/基础 AC 值域
var cap_v: int = int(row.get("cap", -1))
if cap_v < 1 or cap_v > 9:
errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [rid, cap_v]})
var gain: int = int(row.get("per_level_gain", -1))
if gain < 0 or gain > 9:
errs.append({"category": "domain", "message": "%s per_level_gain 越域 [0,9]: %d" % [rid, gain]})
var base_ac: int = int(row.get("base_ac", -1))
if kind == "armor":
if base_ac < 0 or base_ac > 999:
errs.append({"category": "domain", "message": "%s base_ac 越域 [0,999]: %d" % [rid, base_ac]})
elif base_ac != 0:
errs.append({"category": "cross_field", "message": "%s 武器行 base_ac 须为 0(甲型基础 AC 只属护甲路线): %d" % [rid, base_ac]})
var base_price: int = int(row.get("base_price", -1))
if base_price < 1 or base_price > 999:
errs.append({"category": "domain", "message": "%s base_price 越域 [1,999]: %d" % [rid, base_price]})
var ratio: int = int(row.get("price_ratio_pct", -1))
if ratio < 100 or ratio > 500:
errs.append({"category": "domain", "message": "%s price_ratio_pct 越域 [100,500]: %d" % [rid, ratio]})
var unlock_price: int = int(row.get("unlock_price", -1))
if unlock_price < 0 or unlock_price > 999:
errs.append({"category": "domain", "message": "%s unlock_price 越域 [0,999]: %d" % [rid, unlock_price]})
# 解锁通道(并联双通道):初始持有行无前置无解锁价;待解锁行须有一次性解锁价+合法前置
var initial: bool = bool(row.get("initial_owned", false))
var prereq := String(row.get("prereq_route", ""))
if initial:
if prereq != "":
errs.append({"category": "cross_field", "message": "%s 初始持有行不得带前置: %s" % [rid, prereq]})
if unlock_price != 0:
errs.append({"category": "cross_field", "message": "%s 初始持有行解锁价须为 0: %d" % [rid, unlock_price]})
initial_ids[rid] = kind
else:
if unlock_price < 1:
errs.append({"category": "cross_field", "message": "%s 待解锁行须有一次性解锁价(并联双通道②): %d" % [rid, unlock_price]})
if not ROUTE_IDS.has(prereq):
errs.append({"category": "enum", "message": "%s prereq_route 出域 %s: %s" % [rid, ROUTE_IDS, prereq]})
elif prereq == rid:
errs.append({"category": "cross_field", "message": "%s prereq_route 不得自指" % rid})
elif kind_by_id.has(prereq) and String(kind_by_id[prereq]) != kind:
errs.append({"category": "cross_field", "message": "%s 前置 %s 类型不符(武器←武器/甲←甲): %s vs %s" % [rid, prereq, kind, String(kind_by_id[prereq])]})
# 初始持有恰两行且一武器一甲(长剑/轻甲)
var rid := String(row.get("route_id", ""))
errs.append_array(_validate_route_values(row, rid))
errs.append_array(_validate_route_unlock(row, rid, kind_by_id, initial_ids))
errs.append_array(_validate_initial_coverage(initial_ids))
return errs
## 行头域:route_id 词表与查重/kind 出域(skip=跳过行剩余判据)/类型轴域与每型恰一路线
func _validate_route_head(row: Dictionary, seen: Dictionary, type_seen: Dictionary) -> Dictionary:
var errs: Array[Dictionary] = []
var skip := false
var rid := String(row.get("route_id", ""))
if not ROUTE_IDS.has(rid):
errs.append({"category": "enum", "message": "锻造路线 route_id 出域 %s: %s" % [ROUTE_IDS, rid]})
if seen.has(rid):
errs.append({"category": "dup_key", "message": "锻造路线 route_id 重复: %s" % rid})
seen[rid] = true
var kind := String(row.get("kind", ""))
if not ["weapon", "armor"].has(kind):
errs.append({"category": "enum", "message": "%s kind 出域 {weapon, armor}: %s" % [rid, kind]})
skip = true
return {"errs": errs, "skip": skip}
# 类型轴:武器=attack_type 域、甲=armor_type 域;每型恰一路线(三武器三甲各一)
var axis := String(row.get("type_axis", ""))
var domain: Array[Variant] = ATTACK_TYPES if kind == "weapon" else ARMOR_TYPES
if not domain.has(axis):
errs.append({"category": "enum", "message": "%s type_axis 出域 %s: %s" % [rid, domain, axis]})
var type_key := "%s|%s" % [kind, axis]
if type_seen.has(type_key):
errs.append({"category": "dup_key", "message": "%s 类型轴重复(每型恰一路线): %s %s" % [kind, axis, rid]})
type_seen[type_key] = true
return {"errs": errs, "skip": skip}
## 数值域:等级/增量/价格/基础 AC 值域(甲型基础 AC 只属护甲路线)
func _validate_route_values(row: Dictionary, rid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var kind := String(row.get("kind", ""))
var cap_v: int = int(row.get("cap", -1))
if cap_v < 1 or cap_v > 9:
errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [rid, cap_v]})
var gain: int = int(row.get("per_level_gain", -1))
if gain < 0 or gain > 9:
errs.append({"category": "domain", "message": "%s per_level_gain 越域 [0,9]: %d" % [rid, gain]})
var base_ac: int = int(row.get("base_ac", -1))
if kind == "armor":
if base_ac < 0 or base_ac > 999:
errs.append({"category": "domain", "message": "%s base_ac 越域 [0,999]: %d" % [rid, base_ac]})
elif base_ac != 0:
errs.append({"category": "cross_field", "message": "%s 武器行 base_ac 须为 0(甲型基础 AC 只属护甲路线): %d" % [rid, base_ac]})
var base_price: int = int(row.get("base_price", -1))
if base_price < 1 or base_price > 999:
errs.append({"category": "domain", "message": "%s base_price 越域 [1,999]: %d" % [rid, base_price]})
var ratio: int = int(row.get("price_ratio_pct", -1))
if ratio < 100 or ratio > 500:
errs.append({"category": "domain", "message": "%s price_ratio_pct 越域 [100,500]: %d" % [rid, ratio]})
var unlock_price: int = int(row.get("unlock_price", -1))
if unlock_price < 0 or unlock_price > 999:
errs.append({"category": "domain", "message": "%s unlock_price 越域 [0,999]: %d" % [rid, unlock_price]})
return errs
## 解锁通道域(并联双通道):初始持有行无前置无解锁价;待解锁行须有一次性解锁价+合法前置(武器←武器/甲←甲)
func _validate_route_unlock(row: Dictionary, rid: String, kind_by_id: Dictionary, initial_ids: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var kind := String(row.get("kind", ""))
var unlock_price: int = int(row.get("unlock_price", -1))
var initial: bool = bool(row.get("initial_owned", false))
var prereq := String(row.get("prereq_route", ""))
if initial:
if prereq != "":
errs.append({"category": "cross_field", "message": "%s 初始持有行不得带前置: %s" % [rid, prereq]})
if unlock_price != 0:
errs.append({"category": "cross_field", "message": "%s 初始持有行解锁价须为 0: %d" % [rid, unlock_price]})
initial_ids[rid] = kind
else:
if unlock_price < 1:
errs.append({"category": "cross_field", "message": "%s 待解锁行须有一次性解锁价(并联双通道②): %d" % [rid, unlock_price]})
if not ROUTE_IDS.has(prereq):
errs.append({"category": "enum", "message": "%s prereq_route 出域 %s: %s" % [rid, ROUTE_IDS, prereq]})
elif prereq == rid:
errs.append({"category": "cross_field", "message": "%s prereq_route 不得自指" % rid})
elif kind_by_id.has(prereq) and String(kind_by_id[prereq]) != kind:
errs.append({"category": "cross_field", "message": "%s 前置 %s 类型不符(武器←武器/甲←甲): %s vs %s" % [rid, prereq, kind, String(kind_by_id[prereq])]})
return errs
## 初始持有覆盖域(两尾段):初始持有恰两行且一武器一甲(长剑/轻甲)+
## 默认轴=初始持有路线类型轴(Q1 裸体态语义:未锻造=0 加值且类型=普通/轻甲)
func _validate_initial_coverage(initial_ids: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var init_weapons := 0
var init_armors := 0
for k in initial_ids.keys():
@@ -114,7 +149,6 @@ func validate() -> Array:
init_armors += 1
if init_weapons != 1 or init_armors != 1:
errs.append({"category": "coverage", "message": "初始持有须恰一武器一甲(现 sword/light): 武器 %d / 甲 %d" % [init_weapons, init_armors]})
# 默认轴=初始持有路线类型轴(Q1 裸体态语义:未锻造=0 加值且类型=普通/轻甲)
if init_weapons == 1 and init_armors == 1:
for row in routes:
if not bool(row.get("initial_owned", false)):
@@ -124,3 +158,27 @@ func validate() -> Array:
if String(row.get("kind", "")) == "armor" and String(row.get("type_axis", "")) != default_armor_type:
errs.append({"category": "cross_field", "message": "default_armor_type 须=初始甲路线类型轴(裸体态轻甲): %s vs %s" % [default_armor_type, String(row.get("type_axis", ""))]})
return errs
# ---- 行族访问器(自 ConfigDB 归位;消费面 db.crossrun.forge.<m> 直连)——
# routes 属性在册故行族读取命名 route_rows(避开成员名冲突)----
## 六路线行族(副本——调用方禁改)
func route_rows() -> Array:
return routes.duplicate()
## 路线行单点(键 route_id;缺行=空字典)
func route(route_id: String) -> Dictionary:
for row in routes:
if String(row.get("route_id", "")) == route_id:
return row
return {}
## 按类型轴反查护甲路线(armor_type → 路线行——入战 AC 三层之「甲型基础 AC」取数口)
func armor_route_by_type(armor_type: String) -> Dictionary:
for row in routes:
if String(row.get("kind", "")) == "armor" and String(row.get("type_axis", "")) == armor_type:
return row
return {}
+69 -44
View File
@@ -1,5 +1,5 @@
## 关卡节点图参数档 schema(节点图生成器核心批 卡① T1):五旋钮+战斗保底+岔口去路数的行式数据表。
## 键 = level_index(关卡行集单点),消费面经 ConfigDB.level_gen_params_row() 读取;生成器只吃参数档、不认关卡号(契约 6)。
## 键 = level_index(关卡行集单点),消费面经 ConfigDB.run.level_gen_params.row_by_level_index() 读取;生成器只吃参数档、不认关卡号(契约 6)。
## 数值全 provisional(定值批落终值);域校验沿关卡节点图生成.md v1.1 调参表已锁条款——层深 L1≥6 递增增幅变大、
## 配比四类合计 1、出口 L1 恒 1/L2-L3 1-3、岔口去路数恒 2/3、战斗保底≥0。
class_name LevelGenParams
@@ -17,50 +17,13 @@ func validate() -> Array:
var seen := {}
var layers_by_level := {}
for row in rows:
var L: int = int(row.get("level_index", 0))
var key := "level %d" % L
if seen.has(L):
errs.append({"category": "dup_key", "message": "LevelGenParams 键重复: %d" % L})
seen[L] = true
if L < 1:
errs.append({"category": "domain", "message": "%s 关卡序号越域(≥1)" % key})
var ks := (row as Dictionary).keys()
ks.sort()
var expect := ROW_KEYS.duplicate()
expect.sort()
if ks != expect:
errs.append({"category": "structural", "message": "%s 字段键集非 ROW_KEYS 闭集" % key})
var head := _validate_row_head(row, seen)
errs.append_array(head["errs"])
if not head["ok"]:
continue
var layers: int = int(row.get("layers", 0))
if layers < 6:
errs.append({"category": "domain", "message": "%s 层深 %d 低于下限 6(层深 L1≥6 已锁)" % [key, layers]})
layers_by_level[L] = layers
var bd: float = float(row.get("branch_density", -1.0))
if bd < 0.0 or bd > 1.0:
errs.append({"category": "domain", "message": "%s 分支密度 %f 越域 [0,1]" % [key, bd]})
var ratio_sum := 0.0
for rk in RATIO_KEYS:
var v: float = float(row.get(rk, -1.0))
if v < 0.0 or v > 1.0:
errs.append({"category": "domain", "message": "%s %s=%f 越域 [0,1]" % [key, rk, v]})
ratio_sum += v
if not is_equal_approx(ratio_sum, 1.0):
errs.append({"category": "domain", "message": "%s 配比四类合计 %f ≠ 1(已锁)" % [key, ratio_sum]})
var entry_count: int = int(row.get("entry_count", 0))
if entry_count < 1:
errs.append({"category": "domain", "message": "%s 入口个数 %d 越域(≥1)" % [key, entry_count]})
var exit_count: int = int(row.get("exit_count", 0))
if exit_count < 1 or exit_count > 3:
errs.append({"category": "domain", "message": "%s 出口个数 %d 越域 [1,3](已锁范围)" % [key, exit_count]})
if L == 1 and exit_count != 1:
errs.append({"category": "domain", "message": "%s 出口个数 %d ≠ 1(L1 出口恒 1 已锁)" % [key, exit_count]})
var bf: int = int(row.get("battle_floor", -1))
if bf < 0:
errs.append({"category": "domain", "message": "%s 战斗保底 %d 越域(≥0)" % [key, bf]})
var fmin: int = int(row.get("fork_ways_min", 0))
var fmax: int = int(row.get("fork_ways_max", 0))
if fmin != 2 or fmax != 3:
errs.append({"category": "domain", "message": "%s 岔口去路数 (%d,%d) ≠ (2,3)(已锁)" % [key, fmin, fmax]})
errs.append_array(_validate_row_shape(row, head["L"], head["key"], layers_by_level))
errs.append_array(_validate_row_counts(row, head["L"], head["key"]))
# 层深递进尾段(L1<L2<L3 且增幅变大——层深 L1≥6 递增增幅变大已锁)
if layers_by_level.has(1) and layers_by_level.has(2) and layers_by_level.has(3):
var d12: int = int(layers_by_level[2]) - int(layers_by_level[1])
var d23: int = int(layers_by_level[3]) - int(layers_by_level[2])
@@ -72,6 +35,68 @@ func validate() -> Array:
return errs
## 行头域:level_index 查重/关卡序号越域/字段键集闭集(ok=跳过行剩余判据)
func _validate_row_head(row: Dictionary, seen: Dictionary) -> Dictionary:
var errs: Array[Dictionary] = []
var L: int = int(row.get("level_index", 0))
var key := "level %d" % L
if seen.has(L):
errs.append({"category": "dup_key", "message": "LevelGenParams 键重复: %d" % L})
seen[L] = true
if L < 1:
errs.append({"category": "domain", "message": "%s 关卡序号越域(≥1)" % key})
var ks := (row as Dictionary).keys()
ks.sort()
var expect := ROW_KEYS.duplicate()
expect.sort()
if ks != expect:
errs.append({"category": "structural", "message": "%s 字段键集非 ROW_KEYS 闭集" % key})
return {"errs": errs, "L": L, "key": key, "ok": false}
return {"errs": errs, "L": L, "key": key, "ok": true}
## 行形态域:层深下限(layers_by_level 引用登记——层深递进尾段取数)/分支密度/配比四类合计 1
func _validate_row_shape(row: Dictionary, L: int, key: String, layers_by_level: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var layers: int = int(row.get("layers", 0))
if layers < 6:
errs.append({"category": "domain", "message": "%s 层深 %d 低于下限 6(层深 L1≥6 已锁)" % [key, layers]})
layers_by_level[L] = layers
var bd: float = float(row.get("branch_density", -1.0))
if bd < 0.0 or bd > 1.0:
errs.append({"category": "domain", "message": "%s 分支密度 %f 越域 [0,1]" % [key, bd]})
var ratio_sum := 0.0
for rk in RATIO_KEYS:
var v: float = float(row.get(rk, -1.0))
if v < 0.0 or v > 1.0:
errs.append({"category": "domain", "message": "%s %s=%f 越域 [0,1]" % [key, rk, v]})
ratio_sum += v
if not is_equal_approx(ratio_sum, 1.0):
errs.append({"category": "domain", "message": "%s 配比四类合计 %f ≠ 1(已锁)" % [key, ratio_sum]})
return errs
## 行计数域:入口/出口(L1 恒 1)/战斗保底/岔口去路数锁值
func _validate_row_counts(row: Dictionary, L: int, key: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var entry_count: int = int(row.get("entry_count", 0))
if entry_count < 1:
errs.append({"category": "domain", "message": "%s 入口个数 %d 越域(≥1)" % [key, entry_count]})
var exit_count: int = int(row.get("exit_count", 0))
if exit_count < 1 or exit_count > 3:
errs.append({"category": "domain", "message": "%s 出口个数 %d 越域 [1,3](已锁范围)" % [key, exit_count]})
if L == 1 and exit_count != 1:
errs.append({"category": "domain", "message": "%s 出口个数 %d ≠ 1(L1 出口恒 1 已锁)" % [key, exit_count]})
var bf: int = int(row.get("battle_floor", -1))
if bf < 0:
errs.append({"category": "domain", "message": "%s 战斗保底 %d 越域(≥0)" % [key, bf]})
var fmin: int = int(row.get("fork_ways_min", 0))
var fmax: int = int(row.get("fork_ways_max", 0))
if fmin != 2 or fmax != 3:
errs.append({"category": "domain", "message": "%s 岔口去路数 (%d,%d) ≠ (2,3)(已锁)" % [key, fmin, fmax]})
return errs
# 参数档单点读取(键 = level_index,缺行返回空字典——沿 level_row() 体例)
func row_by_level_index(level_index: int) -> Dictionary:
for row in rows:
+5
View File
@@ -170,3 +170,8 @@ static func _mark_backward(target: int, outs: Dictionary, seen: Dictionary) -> v
if (outs[nid] as Array).has(cur) and not seen.has(int(nid)):
seen[int(nid)] = true
queue.append(int(nid))
## 序列总关数(AdventureRunner levels_total / MetaProgression 关卡完成累加域的取数口径)
func level_count() -> int:
return rows.size()
+40 -5
View File
@@ -18,7 +18,15 @@ func validate() -> Array:
for family in ["boss_keystone", "conversion", "attune_refresh_upgrade"]:
if get(family).is_empty():
errs.append({"category": "empty_table", "message": "MetaTable 行族为空: %s" % family})
# boss_keystone:level_index 唯一 + 连续从 1 + 两档域 + first ≥ repeat(首杀全量、重复小额恒定)
errs.append_array(_validate_boss_keystone())
errs.append_array(_validate_conversion())
errs.append_array(_validate_attune_refresh())
return errs
## boss_keystone 行族域:level_index 唯一 + 连续从 1 + 两档域 + first ≥ repeat(首杀全量、重复小额恒定)
func _validate_boss_keystone() -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var seen_li := {}
for row in boss_keystone:
var li: int = int(row.get("level_index", 0))
@@ -36,9 +44,13 @@ func validate() -> Array:
if li_at != i + 1:
errs.append({"category": "structural", "message": "boss_keystone level_index 不连续(须从 1 连续递增): 位 %d = %d" % [i + 1, li_at]})
break
# 无 workshop_items 行族(解锁混合制——条件谓词挂 treasure_items 行内;
# 本表不校验秘宝解锁,单点=TreasureItemTable.validate)
# conversion:attr 词表与 forge 一致 + 唯一 + 覆盖恰三 + 平移量域 [0,100]
return errs
## conversion 行族域(属性换算):attr 词表与 forge 一致 + 唯一 + 覆盖恰三 + 平移量域 [0,100]
## (无 workshop_items 行族——解锁混合制:条件谓词挂 treasure_items 行内;本表不校验秘宝解锁,单点=TreasureItemTable.validate)
func _validate_conversion() -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var seen_conv := {}
for row in conversion:
var cattr: String = row.get("attr", "")
@@ -54,7 +66,12 @@ func validate() -> Array:
for conv_key in FORGE_ATTRS:
if not seen_conv.has(conv_key):
errs.append({"category": "coverage", "message": "conversion 缺属性行: %s(三行与 forge 词表一致)" % conv_key})
# attune_refresh_upgrade:level 唯一 + 连续从 1 + charges/cost 域
return errs
## attune_refresh_upgrade 行族域(Y 刷新升级):level 唯一 + 连续从 1 + charges/cost 域
func _validate_attune_refresh() -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var seen_al := {}
for row in attune_refresh_upgrade:
var al: int = int(row.get("level", -1))
@@ -73,3 +90,21 @@ func validate() -> Array:
errs.append({"category": "structural", "message": "attune_refresh_upgrade level 不连续(须从 1 连续递增): 位 %d = %d" % [i + 1, al_at]})
break
return errs
# ---- 行族访问器(自 ConfigDB 归位——行集解析单点;消费面 db.crossrun.meta.<m> 直连)----
## Boss 钥石两档行单点(键 level_index;缺行=空字典)
func boss_keystone_row(level_index: int) -> Dictionary:
for row in boss_keystone:
if int(row.get("level_index", -1)) == level_index:
return row
return {}
## Y 刷新升级行单点(键 = 等级,缺行返回空字典=未购/已满级合法态)
func attune_refresh_row(level: int) -> Dictionary:
for row in attune_refresh_upgrade:
if int(row.get("level", -1)) == level:
return row
return {}
+94 -53
View File
@@ -39,57 +39,105 @@ func validate() -> Array:
errs.append({"category": "dup_key", "message": "node_id 重复: %s" % nid})
continue
seen[nid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % nid})
if not VEINS.has(String(row.get("vein", ""))):
errs.append({"category": "enum", "message": "%s vein 出域" % nid})
var kind := String(row.get("kind", ""))
if not KINDS.has(kind):
errs.append({"category": "enum", "message": "%s kind 出域" % nid})
if not CURRENCIES.has(String(row.get("currency", ""))):
errs.append({"category": "enum", "message": "%s currency 出域" % nid})
for k in row.keys():
if not KEY_WHITELIST.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s" % [nid, String(k)]})
var cap := int(row.get("cap", 1))
if cap < 1 or cap > 9:
errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [nid, cap]})
if kind in ["unlock", "socket", "fork", "easter"] and cap != 1:
errs.append({"category": "cross_field", "message": "%s %s 行 cap 恒 1" % [nid, kind]})
var price := int(row.get("price", 0))
if price < 0 or price > 9999:
errs.append({"category": "domain", "message": "%s price 越域 [0,9999]: %d" % [nid, price]})
errs.append_array(_validate_row_head(row, nid))
errs.append_array(_validate_row_numbers(row, nid))
errs.append_array(_validate_row_effects(row, nid))
errs.append_array(_validate_row_fork(row, nid, fork_seen))
errs.append_array(_validate_requires_dangling(rows, seen))
errs.append_array(_validate_requires_level(rows, seen))
# 彩蛋 ≤3
var easter := 0
for row in rows:
if String(row.get("kind", "")) == "easter":
easter += 1
if easter > 3:
errs.append({"category": "load_bearing", "message": "彩蛋节点 %d > 3(上限 3)" % easter})
return errs
## 行头域:命名与枚举判据(display_name/vein/kind/currency/行键白名单)
func _validate_row_head(row: Dictionary, nid: String) -> Array[Variant]:
var errs: Array[Variant] = []
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % nid})
if not VEINS.has(String(row.get("vein", ""))):
errs.append({"category": "enum", "message": "%s vein 出域" % nid})
var kind := String(row.get("kind", ""))
if not KINDS.has(kind):
errs.append({"category": "enum", "message": "%s kind 出域" % nid})
if not CURRENCIES.has(String(row.get("currency", ""))):
errs.append({"category": "enum", "message": "%s currency 出域" % nid})
for k in row.keys():
if not KEY_WHITELIST.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s" % [nid, String(k)]})
return errs
## 数值域:cap 越域/kind×cap 交叉/price 越域
func _validate_row_numbers(row: Dictionary, nid: String) -> Array[Variant]:
var errs: Array[Variant] = []
var kind := String(row.get("kind", ""))
var cap := int(row.get("cap", 1))
if cap < 1 or cap > 9:
errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [nid, cap]})
if kind in ["unlock", "socket", "fork", "easter"] and cap != 1:
errs.append({"category": "cross_field", "message": "%s %s 行 cap 恒 1" % [nid, kind]})
var price := int(row.get("price", 0))
if price < 0 or price > 9999:
errs.append({"category": "domain", "message": "%s price 越域 [0,9999]: %d" % [nid, price]})
return errs
## 效果域:effects 键词表封闭与局外树层级纪律(archetype 禁入)
func _validate_row_effects(row: Dictionary, nid: String) -> Array[Variant]:
var errs: Array[Variant] = []
var fx: Dictionary[Variant, Variant] = row.get("effects", {}) as Dictionary
for ek in fx.keys():
if not (STAT_KEYS.has(String(ek)) or RULE_KEYS.has(String(ek))):
errs.append({"category": "zero_flat", "message": "%s effects 键出域(stat 线/rule 词表封闭): %s" % [nid, String(ek)]})
# 🔴 层级纪律:局外树禁规则改写型特性(archetype 键出域=加载致命)
if fx.has("archetype"):
errs.append({"category": "load_bearing", "message": "%s effects 含 archetype(🔴 层级纪律——特性禁入局外树)" % nid})
return errs
## Fork 结构域:一节点双态结构判据(fork_group 词表/组唯一/主 effects 须空/a/b 双键规则面)
func _validate_row_fork(row: Dictionary, nid: String, fork_seen: Dictionary) -> Array[Variant]:
var errs: Array[Variant] = []
var kind := String(row.get("kind", ""))
if kind == "fork":
var fg := String(row.get("fork_group", ""))
if not FORK_GROUPS.has(fg):
errs.append({"category": "enum", "message": "%s fork_group 出域" % nid})
if fork_seen.has(fg):
errs.append({"category": "dup_key", "message": "%s fork 组重复(一组恰一节点): %s" % [nid, fg]})
fork_seen[fg] = true
var fx: Dictionary[Variant, Variant] = row.get("effects", {}) as Dictionary
for ek in fx.keys():
if not (STAT_KEYS.has(String(ek)) or RULE_KEYS.has(String(ek))):
errs.append({"category": "zero_flat", "message": "%s effects 键出域(stat 线/rule 词表封闭): %s" % [nid, String(ek)]})
# 🔴 层级纪律:局外树禁规则改写型特性(archetype 键出域=加载致命)
if fx.has("archetype"):
errs.append({"category": "load_bearing", "message": "%s effects 含 archetype(🔴 层级纪律——特性禁入局外树)" % nid})
# Fork 一节点双态结构:fork 行必带 fork_group(每组恰一节点);主 effects 须空,效果只放
# a/b 双键且键 ⊆ RULE_KEYS(A/B 只放规则面——层级纪律)
if kind == "fork":
var fg := String(row.get("fork_group", ""))
if not FORK_GROUPS.has(fg):
errs.append({"category": "enum", "message": "%s fork_group 出域" % nid})
if fork_seen.has(fg):
errs.append({"category": "dup_key", "message": "%s fork 组重复(一组恰一节点): %s" % [nid, fg]})
fork_seen[fg] = true
if not fx.is_empty():
errs.append({"category": "cross_field", "message": "%s fork 行主 effects 须空(效果只放 a/b 双键)" % nid})
for skey in ["effects_a", "effects_b"]:
var sd: Dictionary[Variant, Variant] = row.get(skey, {}) as Dictionary
if sd.is_empty():
errs.append({"category": "domain", "message": "%s %s 缺失或空(双态节点须双侧效果)" % [nid, skey]})
for k in sd.keys():
if not RULE_KEYS.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s %s 键出域(A/B 只放规则面): %s" % [nid, skey, String(k)]})
# requires 悬空引用
if not fx.is_empty():
errs.append({"category": "cross_field", "message": "%s fork 行主 effects 须空(效果只放 a/b 双键)" % nid})
for skey in ["effects_a", "effects_b"]:
var sd: Dictionary[Variant, Variant] = row.get(skey, {}) as Dictionary
if sd.is_empty():
errs.append({"category": "domain", "message": "%s %s 缺失或空(双态节点须双侧效果)" % [nid, skey]})
for k in sd.keys():
if not RULE_KEYS.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s %s 键出域(A/B 只放规则面): %s" % [nid, skey, String(k)]})
return errs
## requires 悬空引用域
func _validate_requires_dangling(rows: Array, seen: Dictionary) -> Array[Variant]:
var errs: Array[Variant] = []
for row in rows:
for req in (row.get("requires", []) as Array):
if not seen.has(String(req)):
errs.append({"category": "dangling", "message": "%s requires 悬空: %s" % [String(row.get("node_id", "")), String(req)]})
# requires_level 等级阈值前置(R1 机制位——键须为在册节点、阈值整数 ≥1;类型错即拒)
return errs
## requires_level 等级阈值前置域(R1 机制位——键须为在册节点、阈值整数 ≥1;类型错即拒)
func _validate_requires_level(rows: Array, seen: Dictionary) -> Array[Variant]:
var errs: Array[Variant] = []
for row in rows:
var nid2: String = String(row.get("node_id", ""))
var rl_v: Variant = row.get("requires_level", {})
@@ -101,13 +149,6 @@ func validate() -> Array:
errs.append({"category": "domain", "message": "%s requires_level 阈值须 ≥1: %s=%d" % [nid2, String(req_id), int(rl_v[req_id])]})
elif row.has("requires_level"):
errs.append({"category": "domain", "message": "%s requires_level 须为 Dictionary {node_id: n}" % nid2})
# 彩蛋 ≤3
var easter := 0
for row in rows:
if String(row.get("kind", "")) == "easter":
easter += 1
if easter > 3:
errs.append({"category": "load_bearing", "message": "彩蛋节点 %d > 3(上限 3)" % easter})
return errs
+114 -69
View File
@@ -30,84 +30,129 @@ func validate() -> Array:
if seen.has(rid):
errs.append({"category": "dup_key", "message": "reward_id 重复: %s" % rid})
seen[rid] = true
var ctype: String = row.get("cell_type", "")
if not CELL_TYPES.has(ctype):
errs.append({"category": "enum", "message": "%s cell_type 出域(五型封闭): %s" % [rid, ctype]})
var pol: String = row.get("polarity", "")
if not POLARITIES.has(pol):
errs.append({"category": "enum", "message": "%s polarity 出域: %s" % [rid, pol]})
var atk_type: String = String(row.get("attack_type", ""))
if atk_type != "" and not ["normal", "pierce", "magic"].has(atk_type):
errs.append({"category": "enum", "message": "%s attack_type 出域 {normal, pierce, magic}: %s" % [rid, atk_type]})
# aux 投放行专属校验:极性恒正/加权入池/effect_id=item_id 非空(悬空在 config_db 跨表层
# 对秘宝词表校验——reward_dangling_treasure)/无 power 无区间(数值语义不复用,道具效果读 TreasureItemTable)
if ctype == TREASURE_ITEM_TYPE:
# 负向行 = 危险格极性字典占位(雷安同池摇号下危险格可摇中 aux 身份——负路由零兑付兜底,
# effect_id 空/power 0/weight 0 不入池)
if pol == "negative":
if String(row.get("effect_id", "")) != "" or int(row.get("power", 0)) != 0 or int(row.get("weight", 0)) != 0:
errs.append({"category": "cross_field", "message": "%s aux 负占位行仅允许 空effect_id/0power/0weight" % rid})
continue
if String(row.get("effect_id", "")) == "":
errs.append({"category": "domain", "message": "%s aux 正行 effect_id 空(应=item_id)" % rid})
if int(row.get("power", 0)) != 0 or row.has("power_min"):
errs.append({"category": "cross_field", "message": "%s aux 正行禁 power/区间数值(道具数值读 TreasureItemTable)" % rid})
var pool: String = row.get("pool_tag", "")
var pool_reserved: bool = pool.begins_with("clear_") and pool != "clear_default"
if not POOL_TAGS.has(pool) and not pool_reserved:
errs.append({"category": "enum", "message": "%s pool_tag 未知: %s" % [rid, pool]})
elif pool_reserved:
errs.append({"category": "enum", "message": "%s pool_tag clear_<biome> 命名空间预留拒绝: %s" % [rid, pool]})
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % rid})
var power: int = int(row.get("power", 0))
var weight: int = int(row.get("weight", -1))
var tier: int = int(row.get("tier", 0))
if weight < 0 or weight > 999:
errs.append({"category": "domain", "message": "%s weight 越域 [0,999]" % rid})
if tier < 1 or tier > 8:
errs.append({"category": "domain", "message": "%s tier 越域 [1,8]" % rid})
var stacks: int = int(row.get("stacks", 1))
if stacks < 1 or stacks > 16:
errs.append({"category": "domain", "message": "%s stacks 越域 [1,16]: %d" % [rid, stacks]})
# 跨字段:effect_id 非空 = percent 行(引用悬空校验归 ConfigDB 跨表通道);空 = flat 行。
# flat 行 power ≥ 1(直付/直兑行:治疗/群伤/直伤/行动点)——action 型 = 排队点数行(power = ±点数量级)。
var eff: String = row.get("effect_id", "")
if power < 0 or power > 999:
errs.append({"category": "domain", "message": "%s power 越域 [0,999]: %d" % [rid, power]})
if eff == "" and power < 1:
errs.append({"category": "cross_field", "message": "%s flat 行 power ≥ 1" % rid})
# 区间配对(缺省 = 定值行向后兼容):必成对 · percent 行禁止 · power = 中点锚
var has_min: bool = row.has("power_min")
var has_max: bool = row.has("power_max")
if has_min != has_max:
errs.append({"category": "cross_field", "message": "%s power_min/power_max 必须成对出现" % rid})
elif has_min:
var pmin: int = int(row.get("power_min", 0))
var pmax: int = int(row.get("power_max", 0))
if pmin < 1 or pmax > 999 or pmin > pmax:
errs.append({"category": "domain", "message": "%s power 域违例 [1,999] 且 min≤max: %d..%d" % [rid, pmin, pmax]})
if eff != "":
errs.append({"category": "cross_field", "message": "%s percent 行禁区间字段" % rid})
if power != int((pmin + pmax) / 2.0):
# 中点锚:power = 区间中点(行动经济 条款①期望当量锚——调区间必同步调 power)
errs.append({"category": "cross_field", "message": "%s power(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [rid, power, (pmin + pmax) / 2]})
errs.append_array(_validate_row_axes(row, rid))
var aux_out := _validate_row_aux(row, rid)
errs.append_array(aux_out["errs"])
if aux_out["skip"]:
continue
errs.append_array(_validate_row_pool_tag(row, rid))
errs.append_array(_validate_row_values(row, rid))
errs.append_array(_validate_row_power_range(row, rid))
# 字典完整性 / 池护栏累计(cell 池行)
if pool == "cell" and CELL_TYPES.has(ctype) and POLARITIES.has(pol):
cell_dict["%s|%s" % [ctype, pol]] = true
if pol == "positive" and weight > 0:
if String(row.get("pool_tag", "")) == "cell" and CELL_TYPES.has(String(row.get("cell_type", ""))) and POLARITIES.has(String(row.get("polarity", ""))):
cell_dict["%s|%s" % [String(row.get("cell_type", "")), String(row.get("polarity", ""))]] = true
if String(row.get("polarity", "")) == "positive" and int(row.get("weight", -1)) > 0:
cell_positive_live += 1
if pool == "clear_default" and eff == "" and power >= 1:
if String(row.get("pool_tag", "")) == "clear_default" and String(row.get("effect_id", "")) == "" and int(row.get("power", 0)) >= 1:
clear_value_ok = true
# ① 字典完整性:五型 × 正负两行齐备(缺任一即致命)
errs.append_array(_validate_table_closure(cell_dict, cell_positive_live, clear_value_ok))
return errs
## 轴域:cell_type 五型封闭/polarity 两极/attack_type 可省略枚举
func _validate_row_axes(row: Dictionary, rid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var ctype: String = row.get("cell_type", "")
if not CELL_TYPES.has(ctype):
errs.append({"category": "enum", "message": "%s cell_type 出域(五型封闭): %s" % [rid, ctype]})
var pol: String = row.get("polarity", "")
if not POLARITIES.has(pol):
errs.append({"category": "enum", "message": "%s polarity 出域: %s" % [rid, pol]})
var atk_type: String = String(row.get("attack_type", ""))
if atk_type != "" and not ["normal", "pierce", "magic"].has(atk_type):
errs.append({"category": "enum", "message": "%s attack_type 出域 {normal, pierce, magic}: %s" % [rid, atk_type]})
return errs
## aux 投放行域(掉落投放行):极性恒正/加权入池/effect_id=item_id 非空(悬空在 config_db 跨表层
## 对秘宝词表校验——reward_dangling_treasure)/无 power 无区间(数值语义不复用,道具效果读 TreasureItemTable);
## 负占位行返回 skip=true(跳过行剩余判据——原 continue 语义)
func _validate_row_aux(row: Dictionary, rid: String) -> Dictionary:
var errs: Array[Dictionary] = []
var skip := false
if String(row.get("cell_type", "")) == TREASURE_ITEM_TYPE:
# 负向行 = 危险格极性字典占位(雷安同池摇号下危险格可摇中 aux 身份——负路由零兑付兜底,
# effect_id 空/power 0/weight 0 不入池)
if String(row.get("polarity", "")) == "negative":
if String(row.get("effect_id", "")) != "" or int(row.get("power", 0)) != 0 or int(row.get("weight", 0)) != 0:
errs.append({"category": "cross_field", "message": "%s aux 负占位行仅允许 空effect_id/0power/0weight" % rid})
skip = true
return {"errs": errs, "skip": skip}
if String(row.get("effect_id", "")) == "":
errs.append({"category": "domain", "message": "%s aux 正行 effect_id 空(应=item_id)" % rid})
if int(row.get("power", 0)) != 0 or row.has("power_min"):
errs.append({"category": "cross_field", "message": "%s aux 正行禁 power/区间数值(道具数值读 TreasureItemTable)" % rid})
return {"errs": errs, "skip": skip}
## 池标签域:pool_tag 词表与 clear_<biome> 命名空间预留拒绝/display_name 非空
func _validate_row_pool_tag(row: Dictionary, rid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var pool: String = row.get("pool_tag", "")
var pool_reserved: bool = pool.begins_with("clear_") and pool != "clear_default"
if not POOL_TAGS.has(pool) and not pool_reserved:
errs.append({"category": "enum", "message": "%s pool_tag 未知: %s" % [rid, pool]})
elif pool_reserved:
errs.append({"category": "enum", "message": "%s pool_tag clear_<biome> 命名空间预留拒绝: %s" % [rid, pool]})
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % rid})
return errs
## 数值域:weight/tier/stacks/power 越域与 flat 行 power ≥ 1
## (effect_id 非空 = percent 行(引用悬空校验归 ConfigDB 跨表通道);空 = flat 行。
## flat 行 power ≥ 1(直付/直兑行:治疗/群伤/直伤/行动点)——action 型 = 排队点数行(power = ±点数量级))
func _validate_row_values(row: Dictionary, rid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var power: int = int(row.get("power", 0))
var weight: int = int(row.get("weight", -1))
var tier: int = int(row.get("tier", 0))
if weight < 0 or weight > 999:
errs.append({"category": "domain", "message": "%s weight 越域 [0,999]" % rid})
if tier < 1 or tier > 8:
errs.append({"category": "domain", "message": "%s tier 越域 [1,8]" % rid})
var stacks: int = int(row.get("stacks", 1))
if stacks < 1 or stacks > 16:
errs.append({"category": "domain", "message": "%s stacks 越域 [1,16]: %d" % [rid, stacks]})
var eff: String = row.get("effect_id", "")
if power < 0 or power > 999:
errs.append({"category": "domain", "message": "%s power 越域 [0,999]: %d" % [rid, power]})
if eff == "" and power < 1:
errs.append({"category": "cross_field", "message": "%s flat 行 power ≥ 1" % rid})
return errs
## 区间配对域(缺省 = 定值行向后兼容):必成对 · percent 行禁止 · power = 中点锚
func _validate_row_power_range(row: Dictionary, rid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var power: int = int(row.get("power", 0))
var eff: String = row.get("effect_id", "")
var has_min: bool = row.has("power_min")
var has_max: bool = row.has("power_max")
if has_min != has_max:
errs.append({"category": "cross_field", "message": "%s power_min/power_max 必须成对出现" % rid})
elif has_min:
var pmin: int = int(row.get("power_min", 0))
var pmax: int = int(row.get("power_max", 0))
if pmin < 1 or pmax > 999 or pmin > pmax:
errs.append({"category": "domain", "message": "%s power 域违例 [1,999] 且 min≤max: %d..%d" % [rid, pmin, pmax]})
if eff != "":
errs.append({"category": "cross_field", "message": "%s percent 行禁区间字段" % rid})
if power != int((pmin + pmax) / 2.0):
# 中点锚:power = 区间中点(行动经济 条款①期望当量锚——调区间必同步调 power)
errs.append({"category": "cross_field", "message": "%s power(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [rid, power, (pmin + pmax) / 2]})
return errs
## 表闭合域(三尾段):①九型字典完整性(类型×极性齐备)②池空护栏 ③clear_default 恒正当量
func _validate_table_closure(cell_dict: Dictionary, cell_positive_live: int, clear_value_ok: bool) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
for ctype in CELL_TYPE_KEYS:
for pol in POLARITIES:
if not cell_dict.has("%s|%s" % [ctype, pol]):
errs.append({"category": "cross_field", "message": "九型字典缺行: %s %s——(类型, 极性) 齐备为加载前提" % [ctype, pol]})
# ② 池空护栏:cell 池 ≥1 正向行 weight>0(摇号池非空)
if cell_positive_live < 1:
errs.append({"category": "cross_field", "message": "cell 池零活正向行(weight>0)——摇号池空"})
# ③ clear_default 恒正当量(行动经济 条款④——清图奖励 > 0 的行级兑现口径)
if not clear_value_ok:
errs.append({"category": "cross_field", "message": "clear_default 池无正当量活行(清图奖励 > 0 违例)"})
return errs
+52
View File
@@ -133,3 +133,55 @@ func _validate_price_factors(errs: Array[Dictionary]) -> void:
errs.append({"category": "domain", "message": "relic_base_price 越域 [1.0,99.0]: %f" % relic_base_price})
if spend_discount <= 0.0 or spend_discount > 1.0:
errs.append({"category": "domain", "message": "spend_discount 越域 (0.0,1.0]: %f" % spend_discount})
# ---- 行族访问器(自 ConfigDB 归位——行集解析单点;消费面 db.run.shop.<m> 直连)----
## GP 掉落行单点(键 tier;缺行=空字典——掉落按 0 兑付的兜底在调用方)
func gp_drop_row(tier: String) -> Dictionary:
for row in gp_drops:
if String(row.get("tier", "")) == tier:
return row
return {}
## 关卡 GP 加成单点(键 level_index;缺行=0)
func level_gp_bonus_at(level_index: int) -> int:
for row in level_gp_bonus:
if int(row.get("level_index", -1)) == level_index:
return int(row.get("bonus", 0))
return 0
## 价格关卡加成单点(键 level_index;缺行=0)
func price_level_bonus_at(level_index: int) -> int:
for row in price_level_bonus:
if int(row.get("level_index", -1)) == level_index:
return int(row.get("bonus", 0))
return 0
## 商品行族({product_id, display_name, attr, flat_value, base_price, price_increment})
func product_rows() -> Array:
return products
## 商品行单点(键 product_id;缺行=空字典)
func product_row(product_id: String) -> Dictionary:
for row in products:
if String(row.get("product_id", "")) == product_id:
return row
return {}
## 货架行族({kind_id, display_name, base_price}——行序即抽签/呈现稳定序)
func pool_rows() -> Array:
return pool_shelf
## 货架行单点(键 kind_id;缺行=空字典)
func pool_row(kind_id: String) -> Dictionary:
for row in pool_shelf:
if String(row.get("kind_id", "")) == kind_id:
return row
return {}
+92 -57
View File
@@ -31,73 +31,108 @@ func validate() -> Array:
var seen := {}
var has_basic := false
for row in rows:
var sid: String = row.get("skill_id", "")
if sid == "":
errs.append({"category": "domain", "message": "skill_id 缺失/空"})
var head := _validate_row_head(row, seen)
errs.append_array(head["errs"])
if not head["ok"]:
continue
if seen.has(sid):
errs.append({"category": "dup_key", "message": "skill_id 重复: %s" % sid})
seen[sid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % sid})
var type: String = row.get("type", "")
if not SKILL_TYPES.has(type):
errs.append({"category": "enum", "message": "%s type 未知枚举: %s(域 %s)" % [sid, type, str(SKILL_TYPES)]})
continue
_validate_trigger(sid, row, errs) # trigger(触发条件,可省略——四键白名单/词表出域/类型分派/普攻禁配;加载期报错止动)
if type == "basic" and row.has("trigger"):
errs.append({"category": "cross_field", "message": "%s 普攻行禁配 trigger(普攻恒可摇的结构前提)" % sid})
# 冷却域(全类型):施放后连续 N 拍不可摇中
var cd: int = int(row.get("cooldown_beats", -1))
if cd < 0 or cd > 99:
errs.append({"category": "domain", "message": "%s cooldown_beats 越域 [0,99]: %d" % [sid, cd]})
# 普通攻击两条域校验锁死特殊性:倍率恒 1、冷却恒 0——想配的就是伤害技(proposal §2.2)
var sid: String = head["sid"]
var type: String = head["type"]
_validate_common_fields(sid, row, type, errs)
if type == "basic":
has_basic = true
_validate_basic_row(sid, row, cd, errs)
# 伤害技:存倍率不存固定值——伤害 = 施放者攻击基线 × 技能倍率(三乘法链第一乘);
# 可选 rider_mult = 必中附加分量(后置加法、不吃骰不出暴击——proposal-status-vocabulary §2)
_validate_basic_row(sid, row, int(row.get("cooldown_beats", -1)), errs)
if type == "damage":
if not row.has("damage_mult"):
errs.append({"category": "cross_field", "message": "%s 伤害技缺 damage_mult(存倍率不存固定值)" % sid})
else:
var m: float = float(row.damage_mult)
if m < MULT_DOMAIN_MIN or m > MULT_DOMAIN_MAX:
errs.append({"category": "domain", "message": "%s damage_mult 越域 [%.1f, %.1f]: %s" % [sid, MULT_DOMAIN_MIN, MULT_DOMAIN_MAX, str(m)]})
if row.has("buff_ref") or row.has("buff_duration"):
errs.append({"category": "cross_field", "message": "%s 伤害技不带增益字段" % sid})
if row.has("rider_mult"):
var rm: float = float(row.rider_mult)
if rm <= 0.0 or rm > RIDER_MULT_MAX:
errs.append({"category": "domain", "message": "%s rider_mult 越域 (0, %.1f]: %s" % [sid, RIDER_MULT_MAX, str(rm)]})
# 增益技:0 伤害动作(无倍率),效果引用 + 持续回合;悬空引用由 ConfigDB 跨表校验兜底
_validate_damage_row(sid, row, errs)
if type == "buff":
if row.has("damage_mult"):
errs.append({"category": "cross_field", "message": "%s 增益技为 0 伤害动作,不带 damage_mult" % sid})
if row.has("rider_mult"):
errs.append({"category": "cross_field", "message": "%s 增益技不带必中附加分量(rider_mult 仅伤害技)" % sid})
if String(row.get("buff_ref", "")) == "":
errs.append({"category": "cross_field", "message": "%s 增益技缺 buff_ref(效果引用)" % sid})
var bd: int = int(row.get("buff_duration", 0))
if bd < 1 or bd > 99:
errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, bd]})
# 妨害技:0 伤害施放动作,对主角施加负面状态——
# 字段复用 buff_ref/buff_duration(落点由状态行 target_side 路由,ConfigDB side 守卫兜底);池纪律妨害 ≤1 在跨表通道
_validate_buff_row(sid, row, errs)
if type == "debuff":
if row.has("damage_mult"):
errs.append({"category": "cross_field", "message": "%s 妨害技为 0 伤害动作,不带 damage_mult" % sid})
if row.has("rider_mult"):
errs.append({"category": "cross_field", "message": "%s 妨害技不带必中附加分量(rider_mult 仅伤害技)" % sid})
if String(row.get("buff_ref", "")) == "":
errs.append({"category": "cross_field", "message": "%s 妨害技缺 buff_ref(效果引用)" % sid})
var dbd: int = int(row.get("buff_duration", 0))
if dbd < 1 or dbd > 99:
errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, dbd]})
_validate_debuff_row(sid, row, errs)
_validate_table_invariants(has_basic, errs)
return errs
## 行头域:skill_id 非空与查重/display_name/type 枚举(ok=跳过行剩余判据)
func _validate_row_head(row: Dictionary, seen: Dictionary) -> Dictionary:
var errs: Array[Dictionary] = []
var sid: String = row.get("skill_id", "")
if sid == "":
errs.append({"category": "domain", "message": "skill_id 缺失/空"})
return {"errs": errs, "sid": sid, "type": "", "ok": false}
if seen.has(sid):
errs.append({"category": "dup_key", "message": "skill_id 重复: %s" % sid})
seen[sid] = true
return {"errs": errs, "sid": sid, "type": "", "ok": false}
seen[sid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % sid})
var type: String = row.get("type", "")
if not SKILL_TYPES.has(type):
errs.append({"category": "enum", "message": "%s type 未知枚举: %s(域 %s)" % [sid, type, str(SKILL_TYPES)]})
return {"errs": errs, "sid": sid, "type": type, "ok": false}
return {"errs": errs, "sid": sid, "type": type, "ok": true}
## 公共域(全类型):trigger 触发条件校验/普攻行禁配 trigger/冷却域
func _validate_common_fields(sid: String, row: Dictionary, type: String, errs: Array[Dictionary]) -> void:
_validate_trigger(sid, row, errs) # trigger(触发条件,可省略——四键白名单/词表出域/类型分派/普攻禁配;加载期报错止动)
if type == "basic" and row.has("trigger"):
errs.append({"category": "cross_field", "message": "%s 普攻行禁配 trigger(普攻恒可摇的结构前提)" % sid})
# 冷却域(全类型):施放后连续 N 拍不可摇中
var cd: int = int(row.get("cooldown_beats", -1))
if cd < 0 or cd > 99:
errs.append({"category": "domain", "message": "%s cooldown_beats 越域 [0,99]: %d" % [sid, cd]})
## 伤害技域:存倍率不存固定值——伤害 = 施放者攻击基线 × 技能倍率(三乘法链第一乘);
## 可选 rider_mult = 必中附加分量(后置加法、不吃骰不出暴击——proposal-status-vocabulary §2)
func _validate_damage_row(sid: String, row: Dictionary, errs: Array[Dictionary]) -> void:
if not row.has("damage_mult"):
errs.append({"category": "cross_field", "message": "%s 伤害技缺 damage_mult(存倍率不存固定值)" % sid})
else:
var m: float = float(row.damage_mult)
if m < MULT_DOMAIN_MIN or m > MULT_DOMAIN_MAX:
errs.append({"category": "domain", "message": "%s damage_mult 越域 [%.1f, %.1f]: %s" % [sid, MULT_DOMAIN_MIN, MULT_DOMAIN_MAX, str(m)]})
if row.has("buff_ref") or row.has("buff_duration"):
errs.append({"category": "cross_field", "message": "%s 伤害技不带增益字段" % sid})
if row.has("rider_mult"):
var rm: float = float(row.rider_mult)
if rm <= 0.0 or rm > RIDER_MULT_MAX:
errs.append({"category": "domain", "message": "%s rider_mult 越域 (0, %.1f]: %s" % [sid, RIDER_MULT_MAX, str(rm)]})
## 增益技域:0 伤害动作(无倍率),效果引用 + 持续回合;悬空引用由 ConfigDB 跨表校验兜底
func _validate_buff_row(sid: String, row: Dictionary, errs: Array[Dictionary]) -> void:
if row.has("damage_mult"):
errs.append({"category": "cross_field", "message": "%s 增益技为 0 伤害动作,不带 damage_mult" % sid})
if row.has("rider_mult"):
errs.append({"category": "cross_field", "message": "%s 增益技不带必中附加分量(rider_mult 仅伤害技)" % sid})
if String(row.get("buff_ref", "")) == "":
errs.append({"category": "cross_field", "message": "%s 增益技缺 buff_ref(效果引用)" % sid})
var bd: int = int(row.get("buff_duration", 0))
if bd < 1 or bd > 99:
errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, bd]})
## 妨害技域:0 伤害施放动作,对主角施加负面状态——
## 字段复用 buff_ref/buff_duration(落点由状态行 target_side 路由,ConfigDB side 守卫兜底);池纪律妨害 ≤1 在跨表通道
func _validate_debuff_row(sid: String, row: Dictionary, errs: Array[Dictionary]) -> void:
if row.has("damage_mult"):
errs.append({"category": "cross_field", "message": "%s 妨害技为 0 伤害动作,不带 damage_mult" % sid})
if row.has("rider_mult"):
errs.append({"category": "cross_field", "message": "%s 妨害技不带必中附加分量(rider_mult 仅伤害技)" % sid})
if String(row.get("buff_ref", "")) == "":
errs.append({"category": "cross_field", "message": "%s 妨害技缺 buff_ref(效果引用)" % sid})
var dbd: int = int(row.get("buff_duration", 0))
if dbd < 1 or dbd > 99:
errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, dbd]})
## 表不变量域:普攻行存在性(池构成纪律的结构前提)与保底旋钮值域
func _validate_table_invariants(has_basic: bool, errs: Array[Dictionary]) -> void:
if not has_basic:
errs.append({"category": "structural", "message": "SkillTable 无普攻行(basic)——池构成纪律的结构前提"})
if pity_threshold < 1 or pity_threshold > 99:
errs.append({"category": "domain", "message": "pity_threshold 越域 [1,99]: %d" % pity_threshold})
return errs
# trigger 域校验(敌人系统 v1.2 触发条件:四键白名单/词表出域/类型分派——枚举事实算子限域 eq/neq
+43 -23
View File
@@ -18,30 +18,12 @@ func validate() -> Array:
return errs
var seen := {}
for row in rows:
var eid: String = row.get("effect_id", "")
if eid == "":
errs.append({"category": "domain", "message": "effect_id 缺失/空"})
var head := _validate_row_head(row, seen)
errs.append_array(head["errs"])
if not head["ok"]:
continue
if seen.has(eid):
errs.append({"category": "dup_key", "message": "effect_id 重复: %s" % eid})
seen[eid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % eid})
if not TARGET_SIDES.has(row.get("target_side", "")):
errs.append({"category": "enum", "message": "%s target_side 出域" % eid})
var unit: String = row.get("unit", "")
if not UNITS.has(unit):
errs.append({"category": "enum", "message": "%s unit 出域" % eid})
if not STACK_POLICIES.has(row.get("stack_policy", "")):
errs.append({"category": "enum", "message": "%s stack_policy 出域" % eid})
var m: int = int(row.get("magnitude", 0))
# 跨字段:percent ∈ [-100,100] 且 ≠0 / flat ≥ 1 / ac ∈ [-20,20] 且 ≠0(AC 阈值轴——幅度域独立,防呆上限 20)
if unit == "percent" and (m == 0 or m < -100 or m > 100):
errs.append({"category": "cross_field", "message": "%s percent magnitude 域错: %d" % [eid, m]})
if unit == "flat" and m < 1:
errs.append({"category": "cross_field", "message": "%s flat magnitude 须 ≥1: %d" % [eid, m]})
if unit == "ac" and (m == 0 or m < -20 or m > 20):
errs.append({"category": "cross_field", "message": "%s ac magnitude 域错([-20,20] 且 ≠0): %d" % [eid, m]})
var eid: String = head["eid"]
errs.append_array(_validate_magnitude_domain(row, eid, head["unit"]))
var dur: int = int(row.get("duration_settlements", 0))
if dur < 1 or dur > 99:
errs.append({"category": "domain", "message": "%s duration_settlements 越域 [1,99]: %d" % [eid, dur]})
@@ -51,6 +33,44 @@ func validate() -> Array:
return errs
## 行头域:effect_id 非空与查重/display_name/target_side/unit/stack_policy 词表
func _validate_row_head(row: Dictionary, seen: Dictionary) -> Dictionary:
var errs: Array[Dictionary] = []
var eid: String = row.get("effect_id", "")
if eid == "":
errs.append({"category": "domain", "message": "effect_id 缺失/空"})
return {"errs": errs, "eid": eid, "unit": "", "ok": false}
if seen.has(eid):
errs.append({"category": "dup_key", "message": "effect_id 重复: %s" % eid})
seen[eid] = true
return {"errs": errs, "eid": eid, "unit": "", "ok": false}
seen[eid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % eid})
if not TARGET_SIDES.has(row.get("target_side", "")):
errs.append({"category": "enum", "message": "%s target_side 出域" % eid})
var unit: String = row.get("unit", "")
if not UNITS.has(unit):
errs.append({"category": "enum", "message": "%s unit 出域" % eid})
if not STACK_POLICIES.has(row.get("stack_policy", "")):
errs.append({"category": "enum", "message": "%s stack_policy 出域" % eid})
return {"errs": errs, "eid": eid, "unit": unit, "ok": true}
## 幅度量纲域(三段量纲判据——percent/flat/ac 各自值域;
## ac = AC 阈值轴效果,非池百分比非定点值——幅度域独立,防呆上限 20)
func _validate_magnitude_domain(row: Dictionary, eid: String, unit: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var m: int = int(row.get("magnitude", 0))
if unit == "percent" and (m == 0 or m < -100 or m > 100):
errs.append({"category": "cross_field", "message": "%s percent magnitude 域错: %d" % [eid, m]})
if unit == "flat" and m < 1:
errs.append({"category": "cross_field", "message": "%s flat magnitude 须 ≥1: %d" % [eid, m]})
if unit == "ac" and (m == 0 or m < -20 or m > 20):
errs.append({"category": "cross_field", "message": "%s ac magnitude 域错([-20,20] 且 ≠0): %d" % [eid, m]})
return errs
func row_by_id(effect_id: String) -> Dictionary:
for row in rows:
if row.get("effect_id", "") == effect_id:
+185 -126
View File
@@ -73,136 +73,195 @@ func validate() -> Array:
errs.append({"category": "dup_key", "message": "item_id 重复: %s" % iid})
continue
seen[iid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % iid})
var retired: bool = bool(row.get("retired", false))
var deferred: bool = bool(row.get("deferred", false))
# dimension_tags:五维词表 + 非空(退役/失义行仍须登记维度——语义面保留)
var tags: Array[Variant] = row.get("dimension_tags", [])
if tags.is_empty():
errs.append({"category": "domain", "message": "%s dimension_tags 空" % iid})
for tag in tags:
if not DIMENSIONS.has(String(tag)):
errs.append({"category": "enum", "message": "%s dimension_tag 出域 %s: %s" % [iid, str(DIMENSIONS), str(tag)]})
var kinds: Array[Variant] = row.get("effect_kinds", [])
var target: String = String(row.get("target", "none"))
if not TARGETS.has(target):
errs.append({"category": "enum", "message": "%s target 出域 %s: %s" % [iid, str(TARGETS), target]})
var phase: String = String(row.get("use_phase", "active_dig"))
if not USE_PHASES.has(phase):
errs.append({"category": "enum", "message": "%s use_phase 出域 %s: %s" % [iid, str(USE_PHASES), phase]})
# 🔴 零裸 flat 结构护栏:键白名单封闭(同调批 T1——机制轴禁无条件属性加项)
for k in row.keys():
if not KEY_WHITELIST.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s(KEY_WHITELIST 外键禁入)" % [iid, String(k)]})
# triggers 事件订阅行(被动件形态;与 effect_kinds 互斥校验
# 面=passive 行(单形态——效果行为只走订阅);active_charged 行两形合法并存
# (effect_kinds=主动分发通道 + triggers=受理闸订阅,掘进蓄能先例))
if row.has("triggers"):
if (row.get("effect_kinds", []) as Array).size() > 0 and String(row.get("form", "")) != "active_charged" and not bool(row.get("retired", false)):
errs.append({"category": "domain", "message": "%s triggers 与 effect_kinds 并存(换代单形态——被动件迁移期禁双挂)" % iid})
EffectEngine.validate_trigger_row(iid, row, row.get("triggers", []) as Array, errs)
var form: String = String(row.get("form", ""))
if not FORMS.has(form):
errs.append({"category": "enum", "message": "%s form 出域 %s: %s" % [iid, str(FORMS), form]})
if retired or deferred:
errs.append_array(_validate_row_head(row, iid))
if bool(row.get("retired", false)) or bool(row.get("deferred", false)):
# 退役/失义行:免 effect_kinds 非空与承重护栏校验(登记面保留,不入投放与 F2)
continue
# ---- 活行校验 ----
if kinds.is_empty() and not row.has("triggers"):
errs.append({"category": "domain", "message": "%s effect_kinds 空(活行;失义件应标 deferred,被动件应挂 triggers 订阅)" % iid})
var kind_seen := {}
for k in kinds:
var kind: String = String(k)
if not EFFECT_KINDS.has(kind):
errs.append({"category": "enum", "message": "%s effect_kind 出域 %s: %s" % [iid, str(EFFECT_KINDS), kind]})
if kind_seen.has(kind):
errs.append({"category": "dup_key", "message": "%s effect_kind 重复: %s" % [iid, kind]})
kind_seen[kind] = true
# 承重护栏①真视窄域:真视类活行 target 必须 area_3x3 或 cell(一次最多 3×3,禁全图看雷;
# reveal_tiles 与 cell 档纳入同护栏,线香单格=cell ⊂ 3×3 契约内)
for rv in REVEAL_KINDS:
if kind_seen.has(String(rv)) and not (target == "area_3x3" or target == "cell"):
errs.append({"category": "load_bearing", "message": "%s 真视窄域护栏:%s 行 target 必须 area_3x3 或 cell,实得 %s(秘宝 GDD 结构冻结)" % [iid, String(rv), target]})
# 承重护栏②board_wide 仅恢复推理类:全图域行禁替推理效果
if target == "board_wide" and kind_seen.has("mine_reveal"):
errs.append({"category": "load_bearing", "message": "%s board_wide 行 effect_kinds 不得含 mine_reveal(全图域仅恢复推理原料类——Alpha 重定后落位)" % iid})
# magnitude:MAGNITUDE_KINDS 行消费(≥1);其余活行恒 0(同调批 T1——消费面自 ap_grant 扩至 N 值 kind)
var mag: int = int(row.get("magnitude", 0))
if mag < 0 or mag > 999:
errs.append({"category": "domain", "message": "%s magnitude 越域 [0,999]: %d" % [iid, mag]})
var mag_consuming := false
for k in kind_seen.keys():
if MAGNITUDE_KINDS.has(String(k)):
mag_consuming = true
if mag_consuming and mag < 1:
errs.append({"category": "cross_field", "message": "%s N 值 kind 行 magnitude 应 ≥1,实得 %d" % [iid, mag]})
if not mag_consuming and mag != 0 and not row.has("triggers"):
errs.append({"category": "cross_field", "message": "%s 非 N 值 kind 活行 magnitude 应 0(trigger 行 magnitude=value_from 取数源豁免),实得 %d" % [iid, mag]})
# ---- 同调行约束(form 非空——装配面;过渡行 form="" 不校验)----
errs.append_array(_validate_row_active(row, iid, kind_seen))
var form: String = String(row.get("form", ""))
if form != "":
if int(row.get("slot_cost", 1)) != 1:
errs.append({"category": "load_bearing", "message": "%s 同调占位统一 1 槽(slot_cost 恒 1——底稿裁决 5,schema 强制)" % iid})
# 档位必填(槽位档位约束数据面:件档≤格档判定的取数源)
var tier: String = String(row.get("tier", ""))
if not TIERS.has(tier):
errs.append({"category": "domain", "message": "%s 同调行 tier 必填且 ∈ %s,实得 %s" % [iid, str(TIERS), tier]})
var charges: int = int(row.get("charges_per_adventure", 0))
if form == "passive" and charges != 0:
errs.append({"category": "cross_field", "message": "%s passive 行 charges_per_adventure 应 0,实得 %d" % [iid, charges]})
if form == "active_charged" and charges < 0:
errs.append({"category": "domain", "message": "%s active_charged 行 charges_per_adventure 越域 ≥0,实得 %d" % [iid, charges]})
if form == "active_charged" and charges == 0:
# 充能 0=积点制/滑耗制合法态(试炼镐转化获取,无固定充能上限):
# 必须带使用闸——ap_charge_safe 滑耗(安全格积点)或 treasure_used+counter_spend 受理闸,二者缺一即孤儿行
var has_gate := kind_seen.has("ap_charge_safe")
if not has_gate and row.has("triggers"):
for tr_v in (row.get("triggers", []) as Array):
if String((tr_v as Dictionary).get("do", {}).get("kind", "")) == "counter_spend":
has_gate = true
break
if not has_gate:
errs.append({"category": "cross_field", "message": "%s active_charged 行 charges=0(积点制)须带 ap_charge_safe 或 counter_spend 受理闸,实得二者皆无" % iid})
# 充能重置周期(adventure 缺省/battle 每战/level 每关;仅 active_charged 行可设)
var creset: String = String(row.get("charge_reset", "adventure"))
if not CHARGE_RESETS.has(creset):
errs.append({"category": "enum", "message": "%s charge_reset 出域 %s: %s" % [iid, str(CHARGE_RESETS), creset]})
if form != "active_charged" and row.has("charge_reset") and creset != "adventure":
errs.append({"category": "cross_field", "message": "%s 非 active_charged 行 charge_reset 仅可缺省(adventure),实得 %s" % [iid, creset]})
# 解锁混合制字段校验(契约 6:钥石购买价与条件谓词互斥——每件单解锁方式,混合不做)
var price: int = int(row.get("unlock_keystone_cost", 0))
var ureq_count: int = (row.get("unlock_requires", []) as Array).size()
if price != 0 and ureq_count > 0:
errs.append({"category": "cross_field", "message": "%s 解锁方式互斥:unlock_keystone_cost 与 unlock_requires 不得并存(每件单解锁方式,契约 6)" % iid})
if price < 0 or price > 999:
errs.append({"category": "domain", "message": "%s unlock_keystone_cost 越域 [0,999](0=非购买件缺省),实得 %d" % [iid, price]})
var eff_rounds: int = int(row.get("effective_rounds", 0))
if eff_rounds < 0 or eff_rounds > 999:
errs.append({"category": "domain", "message": "%s effective_rounds 越域 [0,999]: %d" % [iid, eff_rounds]})
if kind_seen.has("ap_rounds_front") and eff_rounds < 1:
errs.append({"category": "cross_field", "message": "%s ap_rounds_front 行 effective_rounds 应 ≥1(P4 甲表值化),实得 %d" % [iid, eff_rounds]})
if not kind_seen.has("ap_rounds_front") and eff_rounds != 0 and not row.has("triggers"):
errs.append({"category": "cross_field", "message": "%s 非 ap_rounds_front 行 effective_rounds 应 0(trigger 行=when value_from 取数源豁免),实得 %d" % [iid, eff_rounds]})
var charge_n: int = int(row.get("charge_n", 0))
if charge_n < 0 or charge_n > 999:
errs.append({"category": "domain", "message": "%s charge_n 越域 [0,999]: %d" % [iid, charge_n]})
if kind_seen.has("ap_charge_safe") and charge_n < 1:
errs.append({"category": "cross_field", "message": "%s ap_charge_safe 行 charge_n 应 ≥1(每次充能需 N 安全格),实得 %d" % [iid, charge_n]})
if not kind_seen.has("ap_charge_safe") and charge_n != 0:
errs.append({"category": "cross_field", "message": "%s 非 ap_charge_safe 行 charge_n 应 0,实得 %d" % [iid, charge_n]})
# unlock_requires 谓词行:空 = 已解锁(首批全解锁合法态,底稿 §2.2);非空键 ∈ 计数键封闭枚举
var ureqs: Array[Variant] = row.get("unlock_requires", [])
var ukeys := {}
for req in ureqs:
var ukey: String = String(req.get("key", ""))
if not UNLOCK_COUNTER_KEYS.has(ukey):
errs.append({"category": "enum", "message": "%s unlock_requires 计数键出域 %s: %s" % [iid, str(UNLOCK_COUNTER_KEYS), ukey]})
if ukeys.has(ukey):
errs.append({"category": "dup_key", "message": "%s unlock_requires 计数键重复: %s" % [iid, ukey]})
ukeys[ukey] = true
var uth: int = int(req.get("threshold", 0))
if uth < 1 or uth > 999:
errs.append({"category": "domain", "message": "%s unlock_requires 阈值越域 [1,999]: %d" % [iid, uth]})
errs.append_array(_validate_row_attunement(row, iid, kind_seen))
errs.append_array(_validate_row_unlock_costs(row, iid))
errs.append_array(_validate_row_effect_numbers(row, iid, kind_seen))
errs.append_array(_validate_row_unlock_requires(row, iid))
return errs
## 行头域:display_name/维度标签/target/use_phase/行键白名单/triggers 订阅形态/form 词表
func _validate_row_head(row: Dictionary, iid: String) -> Array[Variant]:
var errs: Array[Variant] = []
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % iid})
# dimension_tags:五维词表 + 非空(退役/失义行仍须登记维度——语义面保留)
var tags: Array[Variant] = row.get("dimension_tags", [])
if tags.is_empty():
errs.append({"category": "domain", "message": "%s dimension_tags 空" % iid})
for tag in tags:
if not DIMENSIONS.has(String(tag)):
errs.append({"category": "enum", "message": "%s dimension_tag 出域 %s: %s" % [iid, str(DIMENSIONS), str(tag)]})
var target: String = String(row.get("target", "none"))
if not TARGETS.has(target):
errs.append({"category": "enum", "message": "%s target 出域 %s: %s" % [iid, str(TARGETS), target]})
var phase: String = String(row.get("use_phase", "active_dig"))
if not USE_PHASES.has(phase):
errs.append({"category": "enum", "message": "%s use_phase 出域 %s: %s" % [iid, str(USE_PHASES), phase]})
# 🔴 零裸 flat 结构护栏:键白名单封闭(同调批 T1——机制轴禁无条件属性加项)
for k in row.keys():
if not KEY_WHITELIST.has(String(k)):
errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s(KEY_WHITELIST 外键禁入)" % [iid, String(k)]})
errs.append_array(_validate_row_triggers(row, iid))
var form: String = String(row.get("form", ""))
if not FORMS.has(form):
errs.append({"category": "enum", "message": "%s form 出域 %s: %s" % [iid, str(FORMS), form]})
return errs
## 订阅形态域:triggers 事件订阅行判据与行级触发词表校验(被动件形态;与 effect_kinds 互斥校验
## 面=passive 行(单形态——效果行为只走订阅);active_charged 行两形合法并存
## (effect_kinds=主动分发通道 + triggers=受理闸订阅,掘进蓄能先例))
func _validate_row_triggers(row: Dictionary, iid: String) -> Array[Variant]:
var errs: Array[Variant] = []
if row.has("triggers"):
if (row.get("effect_kinds", []) as Array).size() > 0 and String(row.get("form", "")) != "active_charged" and not bool(row.get("retired", false)):
errs.append({"category": "domain", "message": "%s triggers 与 effect_kinds 并存(换代单形态——被动件迁移期禁双挂)" % iid})
EffectEngine.validate_trigger_row(iid, row, row.get("triggers", []) as Array, errs)
return errs
## 活行域:effect_kinds 非空与词表/真视窄域与 board_wide 承重护栏/magnitude 语义(kind_seen 引用填充——同调域消费)
func _validate_row_active(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]:
var errs: Array[Variant] = []
var kinds: Array[Variant] = row.get("effect_kinds", [])
var target: String = String(row.get("target", "none"))
if kinds.is_empty() and not row.has("triggers"):
errs.append({"category": "domain", "message": "%s effect_kinds 空(活行;失义件应标 deferred,被动件应挂 triggers 订阅)" % iid})
for k in kinds:
var kind: String = String(k)
if not EFFECT_KINDS.has(kind):
errs.append({"category": "enum", "message": "%s effect_kind 出域 %s: %s" % [iid, str(EFFECT_KINDS), kind]})
if kind_seen.has(kind):
errs.append({"category": "dup_key", "message": "%s effect_kind 重复: %s" % [iid, kind]})
kind_seen[kind] = true
# 承重护栏①真视窄域:真视类活行 target 必须 area_3x3 或 cell(一次最多 3×3,禁全图看雷;
# reveal_tiles 与 cell 档纳入同护栏,线香单格=cell ⊂ 3×3 契约内)
for rv in REVEAL_KINDS:
if kind_seen.has(String(rv)) and not (target == "area_3x3" or target == "cell"):
errs.append({"category": "load_bearing", "message": "%s 真视窄域护栏:%s 行 target 必须 area_3x3 或 cell,实得 %s(秘宝 GDD 结构冻结)" % [iid, String(rv), target]})
# 承重护栏②board_wide 仅恢复推理类:全图域行禁替推理效果
if target == "board_wide" and kind_seen.has("mine_reveal"):
errs.append({"category": "load_bearing", "message": "%s board_wide 行 effect_kinds 不得含 mine_reveal(全图域仅恢复推理原料类——Alpha 重定后落位)" % iid})
errs.append_array(_validate_row_magnitude(row, iid, kind_seen))
return errs
## N 值语义域:magnitude 越域与 MAGNITUDE_KINDS 行消费(≥1);其余活行恒 0(同调批 T1——消费面自 ap_grant 扩至 N 值 kind)
func _validate_row_magnitude(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]:
var errs: Array[Variant] = []
var mag: int = int(row.get("magnitude", 0))
if mag < 0 or mag > 999:
errs.append({"category": "domain", "message": "%s magnitude 越域 [0,999]: %d" % [iid, mag]})
var mag_consuming := false
for k in kind_seen.keys():
if MAGNITUDE_KINDS.has(String(k)):
mag_consuming = true
if mag_consuming and mag < 1:
errs.append({"category": "cross_field", "message": "%s N 值 kind 行 magnitude 应 ≥1,实得 %d" % [iid, mag]})
if not mag_consuming and mag != 0 and not row.has("triggers"):
errs.append({"category": "cross_field", "message": "%s 非 N 值 kind 活行 magnitude 应 0(trigger 行 magnitude=value_from 取数源豁免),实得 %d" % [iid, mag]})
return errs
## 同调充能与档位域(form 非空——装配面;过渡行 form="" 不校验):槽位统一/档位必填/充能值域与积点制受理闸/充能重置周期
func _validate_row_attunement(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]:
var errs: Array[Variant] = []
if int(row.get("slot_cost", 1)) != 1:
errs.append({"category": "load_bearing", "message": "%s 同调占位统一 1 槽(slot_cost 恒 1——底稿裁决 5,schema 强制)" % iid})
# 档位必填(槽位档位约束数据面:件档≤格档判定的取数源)
var tier: String = String(row.get("tier", ""))
if not TIERS.has(tier):
errs.append({"category": "domain", "message": "%s 同调行 tier 必填且 ∈ %s,实得 %s" % [iid, str(TIERS), tier]})
var charges: int = int(row.get("charges_per_adventure", 0))
var form: String = String(row.get("form", ""))
if form == "passive" and charges != 0:
errs.append({"category": "cross_field", "message": "%s passive 行 charges_per_adventure 应 0,实得 %d" % [iid, charges]})
if form == "active_charged" and charges < 0:
errs.append({"category": "domain", "message": "%s active_charged 行 charges_per_adventure 越域 ≥0,实得 %d" % [iid, charges]})
if form == "active_charged" and charges == 0:
# 充能 0=积点制/滑耗制合法态(试炼镐转化获取,无固定充能上限):
# 必须带使用闸——ap_charge_safe 滑耗(安全格积点)或 treasure_used+counter_spend 受理闸,二者缺一即孤儿行
errs.append_array(_validate_row_charge_gate(row, iid, kind_seen))
# 充能重置周期(adventure 缺省/battle 每战/level 每关;仅 active_charged 行可设)
var creset: String = String(row.get("charge_reset", "adventure"))
if not CHARGE_RESETS.has(creset):
errs.append({"category": "enum", "message": "%s charge_reset 出域 %s: %s" % [iid, str(CHARGE_RESETS), creset]})
if form != "active_charged" and row.has("charge_reset") and creset != "adventure":
errs.append({"category": "cross_field", "message": "%s 非 active_charged 行 charge_reset 仅可缺省(adventure),实得 %s" % [iid, creset]})
return errs
## 积点制受理闸域:charges=0 的 active_charged 行必须带使用闸(ap_charge_safe 滑耗或 counter_spend 受理闸)
func _validate_row_charge_gate(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]:
var errs: Array[Variant] = []
var has_gate := kind_seen.has("ap_charge_safe")
if not has_gate and row.has("triggers"):
for tr_v in (row.get("triggers", []) as Array):
if String((tr_v as Dictionary).get("do", {}).get("kind", "")) == "counter_spend":
has_gate = true
break
if not has_gate:
errs.append({"category": "cross_field", "message": "%s active_charged 行 charges=0(积点制)须带 ap_charge_safe 或 counter_spend 受理闸,实得二者皆无" % iid})
return errs
## 解锁互斥域(form 非空行,契约 6):钥石购买价与条件谓词互斥——每件单解锁方式,混合不做
func _validate_row_unlock_costs(row: Dictionary, iid: String) -> Array[Variant]:
var errs: Array[Variant] = []
# 解锁混合制字段校验(契约 6:钥石购买价与条件谓词互斥——每件单解锁方式,混合不做)
var price: int = int(row.get("unlock_keystone_cost", 0))
var ureq_count: int = (row.get("unlock_requires", []) as Array).size()
if price != 0 and ureq_count > 0:
errs.append({"category": "cross_field", "message": "%s 解锁方式互斥:unlock_keystone_cost 与 unlock_requires 不得并存(每件单解锁方式,契约 6)" % iid})
if price < 0 or price > 999:
errs.append({"category": "domain", "message": "%s unlock_keystone_cost 越域 [0,999](0=非购买件缺省),实得 %d" % [iid, price]})
return errs
## 效果数值域(form 非空行):effective_rounds 与 charge_n 的值域及 kind 交叉
func _validate_row_effect_numbers(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]:
var errs: Array[Variant] = []
var eff_rounds: int = int(row.get("effective_rounds", 0))
if eff_rounds < 0 or eff_rounds > 999:
errs.append({"category": "domain", "message": "%s effective_rounds 越域 [0,999]: %d" % [iid, eff_rounds]})
if kind_seen.has("ap_rounds_front") and eff_rounds < 1:
errs.append({"category": "cross_field", "message": "%s ap_rounds_front 行 effective_rounds 应 ≥1(P4 甲表值化),实得 %d" % [iid, eff_rounds]})
if not kind_seen.has("ap_rounds_front") and eff_rounds != 0 and not row.has("triggers"):
errs.append({"category": "cross_field", "message": "%s 非 ap_rounds_front 行 effective_rounds 应 0(trigger 行=when value_from 取数源豁免),实得 %d" % [iid, eff_rounds]})
var charge_n: int = int(row.get("charge_n", 0))
if charge_n < 0 or charge_n > 999:
errs.append({"category": "domain", "message": "%s charge_n 越域 [0,999]: %d" % [iid, charge_n]})
if kind_seen.has("ap_charge_safe") and charge_n < 1:
errs.append({"category": "cross_field", "message": "%s ap_charge_safe 行 charge_n 应 ≥1(每次充能需 N 安全格),实得 %d" % [iid, charge_n]})
if not kind_seen.has("ap_charge_safe") and charge_n != 0:
errs.append({"category": "cross_field", "message": "%s 非 ap_charge_safe 行 charge_n 应 0,实得 %d" % [iid, charge_n]})
return errs
## 谓词行域:unlock_requires 计数键封闭枚举与阈值(空 = 已解锁——首批全解锁合法态,底稿 §2.2)
func _validate_row_unlock_requires(row: Dictionary, iid: String) -> Array[Variant]:
var errs: Array[Variant] = []
var ureqs: Array[Variant] = row.get("unlock_requires", [])
var ukeys := {}
for req in ureqs:
var ukey: String = String(req.get("key", ""))
if not UNLOCK_COUNTER_KEYS.has(ukey):
errs.append({"category": "enum", "message": "%s unlock_requires 计数键出域 %s: %s" % [iid, str(UNLOCK_COUNTER_KEYS), ukey]})
if ukeys.has(ukey):
errs.append({"category": "dup_key", "message": "%s unlock_requires 计数键重复: %s" % [iid, ukey]})
ukeys[ukey] = true
var uth: int = int(req.get("threshold", 0))
if uth < 1 or uth > 999:
errs.append({"category": "domain", "message": "%s unlock_requires 阈值越域 [1,999]: %d" % [iid, uth]})
return errs
+109 -49
View File
@@ -26,57 +26,82 @@ func validate() -> Array:
var errs: Array[Dictionary] = []
var seen := {}
for row in rows:
var vid: String = row.get("variant_id", "")
if vid == "":
errs.append({"category": "domain", "message": "variant_id 缺失/空"})
var head := _validate_row(row, seen)
errs.append_array(head["errs"])
if not head["ok"]:
continue
if seen.has(vid):
errs.append({"category": "dup_key", "message": "variant_id 重复: %s" % vid})
var vid: String = head["vid"]
errs.append_array(_validate_effect_interlocks(row, vid))
errs.append_array(_validate_global_interlocks(row, vid))
return errs
## 行域:variant_id 非空与查重/display_name/unlock_level/四词表(scope/effect/trigger/placement)
func _validate_row(row: Dictionary, seen: Dictionary) -> Dictionary:
var errs: Array[Dictionary] = []
var vid: String = row.get("variant_id", "")
if vid == "":
errs.append({"category": "domain", "message": "variant_id 缺失/空"})
return {"errs": errs, "vid": vid, "ok": false}
if seen.has(vid):
errs.append({"category": "dup_key", "message": "variant_id 重复: %s" % vid})
seen[vid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % vid})
var ul: int = int(row.get("unlock_level", 0))
if ul < 1 or ul > 999:
errs.append({"category": "domain", "message": "%s unlock_level 越域 [1,999]: %d" % [vid, ul]})
if not SCOPES.has(row.get("scope", "")):
errs.append({"category": "enum", "message": "%s scope 出域: %s" % [vid, row.get("scope")]})
if not EFFECTS.has(row.get("effect", "")):
errs.append({"category": "enum", "message": "%s effect 出域: %s" % [vid, row.get("effect")]})
if not TRIGGERS.has(row.get("trigger", "")):
errs.append({"category": "enum", "message": "%s trigger 出域: %s" % [vid, row.get("trigger")]})
if not PLACEMENTS.has(row.get("placement", "")):
errs.append({"category": "enum", "message": "%s placement 出域: %s" % [vid, row.get("placement")]})
# 跨字段互锁(加载期致命——枚举语义一致性立闸):
# 伤害型 = 挖开即伤 ⇒ 触发 on_open + 只落未开格(closed_only)+ 伤害量为加法核小整数
if row.get("effect", "") == "flat_damage":
if row.get("trigger", "") != "on_open":
errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ trigger 必为 on_open" % vid})
if row.get("placement", "") != "closed_only":
errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ placement 必为 closed_only(只出现在未打开的格子)" % vid})
var ev: int = int(row.get("effect_value", 0))
if ev < 1 or ev > 9:
errs.append({"category": "domain", "message": "%s effect_value 越域 [1,9](加法核小整数): %d" % [vid, ev]})
# 信息型 = 被打开且显示数字时起作用 ⇒ 触发 on_number_shown
if row.get("effect", "") == "hide_number" and row.get("trigger", "") != "on_number_shown":
errs.append({"category": "cross_field", "message": "%s hide_number ⇒ trigger 必为 on_number_shown" % vid})
# 信息遮蔽型只允许落安全格(遮蔽对象是数字;危险格无数字可遮)——placement=any 时由读取方
# 呈现面按 kind=safe 自然短路,此处不设布置约束
# 全局作用域型互锁(resource_face ⟺ scope=global 三条 + 新值域;
# 判定键=effect/scope 词值,既有六 region 行(flat_damage/hide_number)不入本组——零误伤)
if row.get("effect", "") == "resource_face":
if row.get("scope", "") != "global":
errs.append({"category": "cross_field", "message": "%s resource_face ⇒ scope 必为 global(整图作用域)" % vid})
# resource_face 行 effect_value = 生存族权重下调百分比(0=中性合法态——off 锚用)
var gev: int = int(row.get("effect_value", 0))
if gev < 0 or gev > 50:
errs.append({"category": "domain", "message": "%s effect_value 越域 [0,50](资源面偏斜百分比): %d" % [vid, gev]})
if row.get("scope", "") == "global":
if row.get("effect", "") != "resource_face":
errs.append({"category": "cross_field", "message": "%s scope=global ⇒ effect 必为 resource_face" % vid})
if row.get("placement", "") != "any":
errs.append({"category": "cross_field", "message": "%s scope=global ⇒ placement 必为 any(整图无格子布置域)" % vid})
if row.get("trigger", "") != "always":
errs.append({"category": "cross_field", "message": "%s scope=global ⇒ trigger 必为 always(整图恒在,无触发时机)" % vid})
return {"errs": errs, "vid": vid, "ok": false}
seen[vid] = true
if String(row.get("display_name", "")) == "":
errs.append({"category": "domain", "message": "%s display_name 空" % vid})
var ul: int = int(row.get("unlock_level", 0))
if ul < 1 or ul > 999:
errs.append({"category": "domain", "message": "%s unlock_level 越域 [1,999]: %d" % [vid, ul]})
if not SCOPES.has(row.get("scope", "")):
errs.append({"category": "enum", "message": "%s scope 出域: %s" % [vid, row.get("scope")]})
if not EFFECTS.has(row.get("effect", "")):
errs.append({"category": "enum", "message": "%s effect 出域: %s" % [vid, row.get("effect")]})
if not TRIGGERS.has(row.get("trigger", "")):
errs.append({"category": "enum", "message": "%s trigger 出域: %s" % [vid, row.get("trigger")]})
if not PLACEMENTS.has(row.get("placement", "")):
errs.append({"category": "enum", "message": "%s placement 出域: %s" % [vid, row.get("placement")]})
return {"errs": errs, "vid": vid, "ok": true}
## 效果互锁域(加载期致命——枚举语义一致性立闸):
## 伤害型 = 挖开即伤 ⇒ 触发 on_open + 只落未开格(closed_only)+ 伤害量为加法核小整数;
## 信息型 = 被打开且显示数字时起作用 ⇒ 触发 on_number_shown
## (信息遮蔽型只允许落安全格——遮蔽对象是数字,危险格无数字可遮;placement=any 时由读取方
## 呈现面按 kind=safe 自然短路,此处不设布置约束)
func _validate_effect_interlocks(row: Dictionary, vid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
if row.get("effect", "") == "flat_damage":
if row.get("trigger", "") != "on_open":
errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ trigger 必为 on_open" % vid})
if row.get("placement", "") != "closed_only":
errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ placement 必为 closed_only(只出现在未打开的格子)" % vid})
var ev: int = int(row.get("effect_value", 0))
if ev < 1 or ev > 9:
errs.append({"category": "domain", "message": "%s effect_value 越域 [1,9](加法核小整数): %d" % [vid, ev]})
if row.get("effect", "") == "hide_number" and row.get("trigger", "") != "on_number_shown":
errs.append({"category": "cross_field", "message": "%s hide_number ⇒ trigger 必为 on_number_shown" % vid})
return errs
## 全局作用域互锁域(resource_face ⟺ scope=global;判定键=effect/scope 词值,
## 既有六 region 行(flat_damage/hide_number)不入本组——零误伤);
## resource_face 行 effect_value = 生存族权重下调百分比(0=中性合法态——off 锚用)
func _validate_global_interlocks(row: Dictionary, vid: String) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
if row.get("effect", "") == "resource_face":
if row.get("scope", "") != "global":
errs.append({"category": "cross_field", "message": "%s resource_face ⇒ scope 必为 global(整图作用域)" % vid})
var gev: int = int(row.get("effect_value", 0))
if gev < 0 or gev > 50:
errs.append({"category": "domain", "message": "%s effect_value 越域 [0,50](资源面偏斜百分比): %d" % [vid, gev]})
if row.get("scope", "") == "global":
if row.get("effect", "") != "resource_face":
errs.append({"category": "cross_field", "message": "%s scope=global ⇒ effect 必为 resource_face" % vid})
if row.get("placement", "") != "any":
errs.append({"category": "cross_field", "message": "%s scope=global ⇒ placement 必为 any(整图无格子布置域)" % vid})
if row.get("trigger", "") != "always":
errs.append({"category": "cross_field", "message": "%s scope=global ⇒ trigger 必为 always(整图恒在,无触发时机)" % vid})
return errs
@@ -85,3 +110,38 @@ func row_by_id(variant_id: String) -> Dictionary:
if row.get("variant_id", "") == variant_id:
return row
return {}
# ---- 候选池访问器(自 ConfigDB 归位;消费面 db.combat.variation.<m> 直连)----
# 门控第三维 = 节点身份(全局作用域变异):scope=global 行仅 boss/elite 节点入池,
# 其余节点(battle 等)静默跳过——门控兜底 P0 口径(零报错零警告);elite 系前向词位(首发零表行)。
# node_type 缺省 "" = 旧双参调用口径(global 行跳过)——既有测试/冒烟调用零扰动。
## 候选池门控(∅ board_types = 通配;字段名对齐 board_type 词表,无 biome_tags)
func variant_pool(level_index: int, board_type: String, node_type: String = "") -> Array:
var pool: Array[Dictionary] = []
for row in rows:
if String(row.get("scope", "region")) == "global" \
and node_type != "boss" and node_type != "elite":
continue
if int(row.get("unlock_level", 999)) > level_index:
continue
var tags: Array[Variant] = row.get("board_types", [])
if tags.is_empty():
pool.append(row)
continue
if tags.has(board_type):
pool.append(row)
return pool
## 全局型单行决议(战斗编排装板期消费——访问器纪律:消费面禁直扫表):
## boss/elite 节点取候选池内首个 scope=global 行(首发批全局池恒恰一行;多行为配置越界态,
## 按表序取首行确定性兜底);空 = 无全局型(调用方回落 region 布置——兜底回落合法态)
func global_variant_row(level_index: int, board_type: String, node_type: String) -> Dictionary:
if node_type != "boss" and node_type != "elite":
return {}
for row in variant_pool(level_index, board_type, node_type):
if String(row.get("scope", "region")) == "global":
return row
return {}
+20 -24
View File
@@ -1,11 +1,12 @@
[gd_resource type="Resource" script_class="AdventureTable" load_steps=2 format=3]
[gd_resource type="Resource" script_class="AdventureTable" load_steps=5 format=3]
[ext_resource type="Script" path="res://data/schemas/adventure_table.gd" id="1"]
[ext_resource type="Script" path="res://data/schemas/adventure_quota_segment.gd" id="2"]
[ext_resource type="Script" path="res://data/schemas/adventure_value_segment.gd" id="3"]
[ext_resource type="Script" path="res://data/schemas/adventure_cap_segment.gd" id="4"]
[resource]
script = ExtResource("1")
clear_heal_pct = 20
curio_candidate_count = 3
[sub_resource type="Resource" id="AdventureTable_quota"]
script = ExtResource("2")
curio_quota_attack_common = 18
curio_quota_attack_excellent = 5
curio_quota_max_hp_common = 17
@@ -17,6 +18,9 @@ curio_quota_gp_excellent = 5
curio_quota_heal_excellent = 3
curio_quota_refresh_excellent = 2
curio_quota_ap_rare = 3
[sub_resource type="Resource" id="AdventureTable_value"]
script = ExtResource("3")
curio_value_attack_common = 1
curio_value_attack_excellent = 2
curio_value_max_hp_common = 5
@@ -28,26 +32,18 @@ curio_value_gp_excellent = 50
curio_value_heal_excellent = 6
curio_value_refresh_excellent = 1
curio_value_ap_rare = 1
[sub_resource type="Resource" id="AdventureTable_cap"]
script = ExtResource("4")
[resource]
script = ExtResource("1")
clear_heal_pct = 20
curio_candidate_count = 3
quota_segment = SubResource("AdventureTable_quota")
value_segment = SubResource("AdventureTable_value")
cap_segment = SubResource("AdventureTable_cap")
feat_ticket_chance = 20
feat_ticket_quota = 2
feat_ticket_limit = 1
feat_ticket_pity_step = 5
; 迭代 20 T2 池结构案D 换代(2026-10-11,奇物.md 池结构与抽取 v1):四级档位 70/25/7/0 = 102 枚——
; 稀有档投放三 kind=AP 件 3/储备金袋 2/时沙 2(ap_rare 显式载于本表,与 schema 缺省同值);
; 概率与上限解耦(案②令牌池+提前退役):概率位=档+令牌数、已拿上限=curio_cap_*(拿了才计);
; 曝光槽=三选一 2 格全池+1 格优秀档以上子池(子池空回退全池);凭证不进子池。
; 前史 TP0 令牌池换代(2026-10-03,node-reward v2.2 追认 b 落地):三选一池 = 固定配额令牌池——
; 洗牌不放回抽 3、选中踢池未选回池、池耗即枯竭;
; 种类次数上限退役(池消耗即上限;货架库存同账本=牌库剩余令牌数)。
; 档位构成(现行):普通〔小GP 18/小增伤 18/小护甲 17/小生命 17〕+ 优秀〔大GP/大增伤/大护甲/大生命 各 5+恢复 3+刷新件 2〕
; + 稀有〔AP 件 3/储备金袋 2/时沙 2〕。
; 数值对:小GP 20/大GP 50、小增伤 +1/大 +2、小护甲 +1/大 +2、小生命 +5/大 +10、恢复 +6(当即回血)、AP +1。
; 恢复件 = 新入池种类(优秀档)。
; 退役字段:bonus_weight_*(E-S 权重族)/ bonus_cap_*(种类次数上限族)/ BONUS_CAP_UNLIMITED 哨兵——
; 洗牌改令牌 Fisher-Yates(不放回抽 3,消费 reward_pick 封闭流)。
; 迭代 11 第②批 商店共享池注记:货架 = 令牌级抽签(同 kind 去重每 kind 至多一卡、价格公式 tier-blind
; ceil(基价×b^(关序−1)×A^visit) 维持批②裁定、购买消费在架令牌本体)。
; 末关的关卡完成不发三选一与回血(奖励只朝下一战生效)。
; 全部 provisional-pending-曲线调参批(值锚与上调约束归 难度曲线与Boss;档位配额结构锚 70/25/7/0——
; schema TIER_QUOTAS,validate 硬断言,改总量须重裁)。
+1 -1
View File
@@ -73,7 +73,7 @@ static func boss_name(runner: Variant, db: ConfigDb) -> String:
if runner != null:
var node: Dictionary[Variant, Variant] = runner.current_node()
if String(node.get("type", "")) == "boss":
var rowset: Array[Variant] = db.resolve_enemy_rowset(int(runner.level_row.level_index), int(node.get("node_depth", -1)), "boss")
var rowset: Array[Variant] = db.combat.enemy.resolve_rowset(int(runner.level_row.level_index), int(node.get("node_depth", -1)), "boss")
if not rowset.is_empty():
return String(rowset[0].get("display_name", ""))
return "遗迹守卫"
+4 -4
View File
@@ -19,8 +19,8 @@ func _init(db_ref: ConfigDb, debug_overlay_ref: DebugOverlay, settlement_getter:
# 禁表写路径(双写叠加隐患源头);
# 合成式单点在 UiKit.scaled——与调试浮窗生效值预览共用,本类不持有同式私有实现)
func debug_hero_override() -> Dictionary:
return {"atk": UiKit.scaled(int(_db.hero.atk), _debug_overlay.atk_pct()),
"max_hp": UiKit.scaled(int(_db.hero.max_hp), _debug_overlay.hp_pct())}
return {"atk": UiKit.scaled(int(_db.combat.hero.atk), _debug_overlay.atk_pct()),
"max_hp": UiKit.scaled(int(_db.combat.hero.max_hp), _debug_overlay.hp_pct())}
# 奖励徽章身份解析(组合根守卫:仅挖掘期 Active 相位内查询——地图战斗资源 Ready 域外零读、零诊断噪音;
@@ -45,7 +45,7 @@ func variant_mask_lookup(c: Vector2i) -> bool:
if b == null:
return false
for vid in b.variant_ids(c):
var vrow: Dictionary[Variant, Variant] = _db.variation.row_by_id(String(vid))
var vrow: Dictionary[Variant, Variant] = _db.combat.variation.row_by_id(String(vid))
if String(vrow.get("effect", "")) == "hide_number":
return true
return false
@@ -61,7 +61,7 @@ func variant_mark_lookup(c: Vector2i) -> String:
if b == null:
return ""
for vid in b.variant_ids(c):
var vrow: Dictionary[Variant, Variant] = _db.variation.row_by_id(String(vid))
var vrow: Dictionary[Variant, Variant] = _db.combat.variation.row_by_id(String(vid))
if String(vrow.get("effect", "")) == "flat_damage":
return "flat_damage"
return ""
+8 -8
View File
@@ -59,12 +59,12 @@ func on_camp_closed() -> void:
func camp_data() -> Dictionary:
var meta = _meta_get.call()
return {
"balance": meta.keystone_balance() if meta != null else 0,
"balance": meta.meta_state.keystone_balance() if meta != null else 0,
"workshop": meta.treasure_shop_status() if meta != null else [], # 键名沿 workshop(视图消费键不变——行内容=秘宝匣 12 件解锁投影)
"forge": meta.forge_shop_status() if meta != null else [], # 锻造工坊两区六路线投影
"attune": meta.attune_camp_status() if meta != null else {}, # 同调装配面投影
"tree": meta.tree_camp_rows() if meta != null else [], # 树面投影(含 forge_workshop/ks_workshop 钥匙门判据行)
"fork_groups": meta.tree_fork_groups() if meta != null else [],
"tree": meta.tree_camp_status()["rows"] if meta != null else [], # 树面投影(含 forge_workshop/ks_workshop 钥匙门判据行)
"fork_groups": meta.tree_camp_status()["fork_groups"] if meta != null else [],
"recent": _save_get.call().recent_summaries() if _save_get.call() != null else [],
}
@@ -107,7 +107,7 @@ func on_camp_tree_buy(node_id: String) -> void:
var meta = _meta_get.call()
if meta == null:
return
var ok: bool = meta.tree_buy_fork(node_id) if String(db.tree_node(node_id).get("kind", "")) == "fork" else meta.tree_buy(node_id)
var ok: bool = meta.tree_buy_fork(node_id) if String(db.crossrun.meta_tree.row_by_id(node_id).get("kind", "")) == "fork" else meta.tree_buy(node_id)
if not ok:
toast.show_toast("购买被拒——前置/行门槛/余额不足", "warn")
return
@@ -157,8 +157,8 @@ func on_camp_forge_select(route_id: String) -> void:
var meta = _meta_get.call()
if meta == null:
return
var ok: bool = meta.forge_select_weapon(route_id) if String(db.forge_route(route_id).get("kind", "")) == "weapon" \
else meta.forge_select_armor(route_id)
var ok: bool = meta.forge_select("weapon", route_id) if String(db.crossrun.forge.route(route_id).get("kind", "")) == "weapon" \
else meta.forge_select("armor", route_id)
if not ok:
toast.show_toast("切换被拒——路线未解锁", "warn")
return
@@ -177,7 +177,7 @@ func on_shop_buy(product_id: String) -> void:
toast.show_toast("GP不足——买不起这件商品", "warn")
return
shop_overlay.open_shelf(runner.shop_shelf(), int(runner.gp))
shop_overlay.set_visit_info(int(runner.shop_bp_visit_buys()), float(db.shop_price_a())) # T6 爬价规则行
shop_overlay.set_visit_info(int(runner.shop_bp_visit_buys()), float(db.run.shop.price_a)) # T6 爬价规则行
shop_overlay.set_refresh_display(runner.shop_refresh_free_left(), runner.shop_refresh_cost(), int(runner.gp) >= runner.shop_refresh_cost())
_refresh_presentations.call()
return
@@ -199,7 +199,7 @@ func on_shop_refresh() -> void:
toast.show_toast("GP不足——刷新失败", "warn")
return
shop_overlay.open_shelf(runner.shop_shelf(), int(runner.gp))
shop_overlay.set_visit_info(int(runner.shop_bp_visit_buys()), float(db.shop_price_a())) # T6 爬价规则行
shop_overlay.set_visit_info(int(runner.shop_bp_visit_buys()), float(db.run.shop.price_a)) # T6 爬价规则行
shop_overlay.set_refresh_display(runner.shop_refresh_free_left(), runner.shop_refresh_cost(), int(runner.gp) >= runner.shop_refresh_cost())
_refresh_presentations.call()
@@ -165,9 +165,9 @@ func _curio_card_texts(candidates: Array) -> Array:
text = "专长获取
选中或买下后立刻开一次专长三选一"
elif kind == "relic":
text = Narrator.feat_card_text(db.feat_row(rid))
text = Narrator.feat_card_text(db.crossrun.feats.row_by_id(rid))
else:
text = Narrator.bonus_card_text(kind, tier, int(db.adventure.curio_value(kind, tier)))
text = Narrator.bonus_card_text(kind, tier, int(db.run.adventure.curio_value(kind, tier)))
out.append({"id": cid, "text": text})
return out
@@ -251,6 +251,6 @@ func item_name_lookup() -> Dictionary:
var lookup := {}
var meta = _meta_get.call()
if meta != null:
for row in db.treasure_attunement_rows():
for row in db.crossrun.treasure_items.attunement_rows():
lookup[String(row.get("item_id", ""))] = String(row.get("display_name", ""))
return lookup
+15 -15
View File
@@ -79,7 +79,7 @@ static func _refresh_hero(stage: BattleStageView, info: HeroInfoPanelView, s: Di
# ---- 行动经济(AP 点阵 / RAP / 弃图刷新按钮态——RAP 读数在本条) ----
static func _refresh_economy(ap_bar: ApBarView, s: Dictionary, db: ConfigDb, refresh_remaining: int) -> void:
ap_bar.set_ap(int(s.ap), int(db.action_economy.ap_per_round))
ap_bar.set_ap(int(s.ap), int(db.run.action_economy.ap_per_round))
ap_bar.set_rap(int(s.rap))
var digging := String(s.phase) == ActionEconomy.PHASE_ACTIVE
ap_bar.set_refresh_state(refresh_remaining,
@@ -92,11 +92,11 @@ static func _refresh_economy(ap_bar: ApBarView, s: Dictionary, db: ConfigDb, ref
# 无 archetype 字段,聚合形态会空键兜底实显「流派 ×N · 档一」僵尸文案;流派档位非现行机制)
static func feat_holdings_lines(db: ConfigDb, held: Array) -> Array[String]:
var rlines: Array[String] = []
if db == null or db.feats == null or (db.feats.rows as Array).is_empty() or held.is_empty():
if db == null or db.feats == null or (db.crossrun.feats.rows as Array).is_empty() or held.is_empty():
return rlines
var counts := {}
for rid in held:
var rrow: Dictionary[Variant, Variant] = db.feat_row(String(rid))
var rrow: Dictionary[Variant, Variant] = db.crossrun.feats.row_by_id(String(rid))
var fname := String(rrow.get("display_name", String(rid)))
counts[fname] = int(counts.get(fname, 0)) + 1
for fname in counts.keys():
@@ -112,7 +112,7 @@ static func board_variant_lines(db: ConfigDb, ids: Array) -> Array[String]:
if db == null or db.variation == null:
return lines
for vid in ids:
var row: Dictionary[Variant, Variant] = db.variation.row_by_id(String(vid))
var row: Dictionary[Variant, Variant] = db.combat.variation.row_by_id(String(vid))
if not row.is_empty():
lines.append(String(row.get("display_name", vid)))
lines.append("回复与护甲资源减少")
@@ -229,7 +229,7 @@ static func _p90(a: Array) -> float:
# (两入口开层时 runner 恒在役——状态面板卡 F3 证据链)
static func status_data(db: ConfigDb, meta: MetaProgressionCore, runner: Variant, treasure_ui: Variant) -> Dictionary:
return {
"balance": meta.keystone_balance(),
"balance": meta.meta_state.keystone_balance(),
"hero_hp": int(runner.hero_hp), # LevelRunner 属性面(_ledger 直取下标——键缺失=致命)
"max_hp": runner.effective_max_hp(),
"atk": runner.effective_atk(),
@@ -257,20 +257,20 @@ static func status_curios(treasure_ui: Variant) -> Array:
static func career_data(db: ConfigDb, save_manager: SaveManagerCore, meta: MetaProgressionCore) -> Dictionary:
var clears: Array[Dictionary] = []
var boss_first := 0
for i in db.level_count():
for i in db.run.level.level_count():
var li: int = i + 1
clears.append({"display_name": String(db.level_row(li).get("display_name", "")),
"count": save_manager.clear_count(li)})
if save_manager.boss_first_kill_marked(li):
clears.append({"display_name": String(db.run.level.row_by_level_index(li).get("display_name", "")),
"count": save_manager.meta_state.career_state().clear_counts.get(str(li), 0)})
if save_manager.meta_state.career_state().boss_first_kill.has(str(li)):
boss_first += 1
return {
"total": save_manager.adventure_total(),
"cleared": save_manager.adventure_cleared(),
"total": save_manager.meta_state.career_state().adventure_total,
"cleared": save_manager.meta_state.career_state().adventure_cleared,
"clears": clears,
"boss_first_kills": boss_first,
"boss_first_total": db.level_count(),
"boss_kills": save_manager.boss_kill_count(),
"keystone": meta.keystone_balance(),
"boss_first_total": db.run.level.level_count(),
"boss_kills": save_manager.meta_state.career_state().boss_kill_count,
"keystone": meta.meta_state.keystone_balance(),
}
@@ -280,7 +280,7 @@ static func career_data(db: ConfigDb, save_manager: SaveManagerCore, meta: MetaP
static func save_select_data(save_manager: SaveManagerCore) -> Dictionary:
var slots: Array[Dictionary] = []
for i in range(1, save_manager.SLOT_COUNT + 1):
var p := save_manager.peek_slot_summary(i) # 推断 Dictionary(窥视返回合同——裸注解触 R1、参数化赋值运行期严格拒)
var p := save_manager.slots.peek_slot_summary(i) # 推断 Dictionary(窥视返回合同——裸注解触 R1、参数化赋值运行期严格拒)
slots.append({"slot": i,
"occupied": bool(p.get("has_file", false)) and bool(p.get("readable", false)),
"keystone": int(p.get("keystone", 0)),
+9 -9
View File
@@ -312,7 +312,7 @@ func _on_confirm_accepted() -> void:
_close_modal(confirm_dialog) # 模态栈单口
match _confirm_consumer:
"delete_slot":
save_manager.delete_slot(_confirm_consumer_slot)
save_manager.slots.delete_slot(_confirm_consumer_slot)
save_select_overlay.open_save_select(HudPresenter.save_select_data(save_manager)) # 就地刷新(该槽回空态)
_confirm_consumer = ""
@@ -366,9 +366,9 @@ func _wire_adventure() -> void:
static func build_adventure_params(save_manager_ref: SaveManagerCore, meta_ref: MetaProgressionCore, extra: Dictionary = {}) -> Dictionary:
var params: Dictionary[Variant, Variant] = {
"hero_override": meta_ref.hero_override() if meta_ref != null else {},
"attack_type": meta_ref.hero_attack_type() if meta_ref != null else "",
"armor_type": meta_ref.hero_armor_type() if meta_ref != null else "",
"attunement": save_manager_ref.attunement_equipped() if save_manager_ref != null else [],
"attack_type": meta_ref.hero_type_axes()["attack"] if meta_ref != null else "",
"armor_type": meta_ref.hero_type_axes()["armor"] if meta_ref != null else "",
"attunement": save_manager_ref.meta_state.attune_state().equipped if save_manager_ref != null else [],
"attune_refresh_y": meta_ref.attune_refresh_charges() if meta_ref != null else 0,
"tree_rules": meta_ref.tree_rule_keys() if meta_ref != null else [],
}
@@ -447,7 +447,7 @@ func _on_battle_finished(payload: Dictionary) -> void:
match String(decision.outcome):
"next_layer":
_inter_label.text = "第 %d 层通过!\nHP %d/%d 带入下一层" % [
int(decision.layer), int(decision.carry_hp), int(db.hero.max_hp)]
int(decision.layer), int(decision.carry_hp), int(db.combat.hero.max_hp)]
_inter_overlay.visible = true
_refresh_presentations()
return
@@ -772,11 +772,11 @@ func _on_refresh_confirmed() -> void:
logger.log_action("refresh", Vector2i(-1, -1), ok)
if ok:
logger.log_ux("ux_refresh_attempt", {"result": "accepted", "round_refresh_index": _refresh_used_snapshot()})
board_zone.toast.show_toast("RAP−%d · 旧板封印作废——新图已就位,AP 保留继续挖" % int(db.action_economy.rap_refresh_cost), "gold")
board_zone.toast.show_toast("RAP−%d · 旧板封印作废——新图已就位,AP 保留继续挖" % int(db.run.action_economy.rap_refresh_cost), "gold")
else:
# 资源面/上限拒绝(EC13 合法拒绝可见;行动经济 无载荷——客户端按上下文推断)
var rap: int = int(run_scope.settlement.presentation_snapshot().get("rap", 0)) if run_scope.settlement != null else 0
var cost: int = int(db.action_economy.rap_refresh_cost)
var cost: int = int(db.run.action_economy.rap_refresh_cost)
var result := "rejected_rap" if rap < cost else "rejected_cap"
logger.log_ux("ux_refresh_attempt", {"result": result, "round_refresh_index": _refresh_used_snapshot() + 1})
if result == "rejected_rap":
@@ -811,7 +811,7 @@ func _refresh_used_snapshot() -> int:
func _refresh_presentations() -> void:
# 每个核心信号/写调用返回后的全量重查装配(更新触发注记——无推送信号,命令式重查)
var refresh_remaining: int = maxi(0, int(db.action_economy.refresh_cap_per_round) - _refresh_used_snapshot())
var refresh_remaining: int = maxi(0, int(db.run.action_economy.refresh_cap_per_round) - _refresh_used_snapshot())
HudPresenter.refresh(stage, info_panel, ap_bar, run_scope.settlement, run_scope.runner, run_scope.level_active, db, refresh_remaining)
_treasure_ui.refresh_slots() # C 区秘宝槽重查(试炼镐积点进度随挖格刷新悬停浮窗)
_sync_settlement_visuals()
@@ -906,6 +906,6 @@ func _seed_value() -> int:
# 封闭流、禁 randomize()/自建 RNG——组合根建种子属引擎熵豁免面,不进玩法 RNG 消费)
if _seed_edit.text.is_valid_int():
return int(_seed_edit.text)
if save_manager.is_first_adventure_done():
if save_manager.meta_state.career_state().first_adventure_done:
return randi() # rng-seed-exempt(非首局空框引擎熵——组合根建种子不进玩法 RNG 消费,static_scan S1/S2 标记豁免)
return 42
+1 -1
View File
@@ -80,7 +80,7 @@ static func _on_runner_node(root: Variant, node: Dictionary, info: Dictionary) -
root._open_modal(root.shop_overlay)
if root.run_scope.runner.is_shop_pool():
root.shop_overlay.open_shelf(root.run_scope.runner.shop_shelf(), int(root.run_scope.runner.gp))
root.shop_overlay.set_visit_info(int(root.run_scope.runner.shop_bp_visit_buys()), float(root.db.shop_price_a())) # 爬价规则行
root.shop_overlay.set_visit_info(int(root.run_scope.runner.shop_bp_visit_buys()), float(root.db.run.shop.price_a)) # 爬价规则行
root.shop_overlay.set_refresh_display(root.run_scope.runner.shop_refresh_free_left(), root.run_scope.runner.shop_refresh_cost(), int(root.run_scope.runner.gp) >= root.run_scope.runner.shop_refresh_cost()) # MC-1
else:
root.shop_overlay.open_shop(root.run_scope.runner.shop_products(), int(root.run_scope.runner.gp))
+2 -2
View File
@@ -68,14 +68,14 @@ static func _on_save_select_closed(root: Variant) -> void:
# 空槽新档链(Q3 裁乙暂行落点=现役 CampOverlay;卡④ 重立卡后改挂场景图):卡① 创建=激活=白板落盘,
# 落点开层。动线序(禁先关后开):先落点开层(开始层保持隐藏),选档层被模态栈遮挡隐藏、不关闭。
static func _on_save_select_new(root: Variant, slot: int) -> void:
root.save_manager.create_slot(slot)
root.save_manager.slots.create_slot(slot)
root._camp_pres.open_camp()
# 有档槽进档链(契约 8 进档即激活):卡① 激活+读盘(兜底/拒读链按槽成立)→暂行落点 CampOverlay。
static func _on_save_select_load(root: Variant, slot: int) -> void:
root.save_manager.activate_slot(slot)
root.save_manager.slots.activate_slot(slot)
root._camp_pres.open_camp()
+3 -3
View File
@@ -81,7 +81,7 @@ static func _build_battle_wiring(root: Variant) -> void:
root.board_zone.chord_attempted.connect(root._on_chord_attempted)
root.logger = PlaytestLogger.new()
root.logger.ap_per_round = int(root.db.action_economy.ap_per_round)
root.logger.ap_per_round = int(root.db.run.action_economy.ap_per_round)
static func _build_overlays(root: Variant) -> void:
# 模态覆盖层(登记序 = 树序 = z 遮挡序:图例 < 明细回看 < 节点 < 关卡画面 < 三选一 < 终局
@@ -193,7 +193,7 @@ static func _build_debug_and_resolvers(root: Variant) -> void:
# 诊断转储入口在本浮窗,玩家画面零常驻调试件)。
# 树序在开始层与确认弹窗之后 = 浮于菜单之上的 Dialog(不隐藏开始层——与确认弹窗同款形态);
# 开窗唯一入口 = _show_debug_overlay(C6 单口——角标/探针/启动路径全收口)
root.debug_overlay = DebugOverlay.new(root.db, int(root.db.hero.atk), int(root.db.hero.max_hp))
root.debug_overlay = DebugOverlay.new(root.db, int(root.db.combat.hero.atk), int(root.db.combat.hero.max_hp))
root.add_child(root.debug_overlay)
root._seed_edit = root.debug_overlay.seed_edit # 种子读取面在浮窗(_seed_value 唯一消费点不变)
root.debug_overlay.closed.connect(root._on_modal_closed.bind(root.debug_overlay))
@@ -235,7 +235,7 @@ static func _build_modal_flow(root: Variant) -> void:
root._modals = root._modal_flow.stack # 探针 _list 登记序断言缝(ui_struct 登记序=z 序纪律断言)
# 视觉层合同装配(唯一事实源=LAYER_CONTRACT:树序+登记序同源派生——改序只改合同表)
root._apply_layer_contract()
root._tune_sim = TuneSimController.new(root.db, root.debug_overlay, int(root.db.hero.max_hp))
root._tune_sim = TuneSimController.new(root.db, root.debug_overlay, int(root.db.combat.hero.max_hp))
static func fatal_screen(root: Variant, msg: String) -> void:
var l := UiKit.label(msg,UiKit.FS_SUB, true, UiKit.RED_ALERT)
@@ -44,9 +44,9 @@ func unlocked_set() -> Dictionary:
var out := {}
if meta == null:
return out
for row in db.treasure_transitional_rows():
for row in db.crossrun.treasure_items.transitional_rows():
var iid := String(row.get("item_id", ""))
if meta.is_item_unlocked(iid):
if meta.meta_state.item_unlocked(iid):
out[iid] = true
return out
@@ -79,7 +79,7 @@ func on_slot_clicked(slot_handle: int) -> void:
break
if rec.is_empty() or bool(rec.get("used", false)):
return
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(rec.get("item_id", "")))
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(rec.get("item_id", "")))
if String(arow.get("target", "none")) == "none":
attempt_use(slot_handle) # AP 类:显式无目标即用(不再伪造哨兵坐标)
return
@@ -106,7 +106,7 @@ func attempt_use(handle: int, cell: Variant = null) -> void:
refresh_slots()
refresh_overlays()
board_view.refresh() # 移除格即时落图(无移除态的件 diff 零变化幂等)
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(v.get("item_id", "")))
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(v.get("item_id", "")))
toast.show_toast("已使用 %s" % String(arow.get("display_name", "")), "info")
@@ -144,7 +144,7 @@ func inventory_projection() -> Array:
# 库存投影行(字形=名首字;usable=整图零信息预判回调接排雷玩法只读面)+
# 积点进度浮窗行(自动兑付件 C 区悬停 tooltip 补 N/M 进度;件 id 锁定=装配行唯一)
func _projection_row(rec: Dictionary, cs: Dictionary) -> Dictionary:
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(rec.get("item_id", "")))
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(rec.get("item_id", "")))
var dn := String(arow.get("display_name", ""))
var note := ""
if bool(cs.get("assembled", false)) and String(cs.get("item_id", "")) == String(rec.get("item_id", "")):
+1 -1
View File
@@ -38,7 +38,7 @@ func run(n: int) -> void:
# 共享日志会话(模拟运行与真人试玩同日志合规——操作/轮次/战斗/关卡完成全量 JSONL;
# 一文件多局,run 行携带种子切分局次)
var logger = PlaytestLogger.new()
logger.ap_per_round = int(db.action_economy.ap_per_round)
logger.ap_per_round = int(db.run.action_economy.ap_per_round)
var log_path: String = logger.start_session(SEED_BASE)
print("[tune_sim] 模拟日志: %s" % log_path)
panel.set_sim_running(true)
+2 -2
View File
@@ -32,7 +32,7 @@ static func build_battle_ui(root: Control, db: ConfigDb) -> Dictionary:
var board_zone := BoardZoneView.new()
bottom.add_child(board_zone)
board_zone.ap_bar.set_refresh_cost(int(db.action_economy.rap_refresh_cost)) # RAP 读数表值驱动
board_zone.ap_bar.set_refresh_cost(int(db.run.action_economy.rap_refresh_cost)) # RAP 读数表值驱动
# 结算演出覆盖层(A–D 之上——z 序 = addChild 序,模态覆盖层组在 build_overlays 中更晚挂入)
var settlement_fx := SettlementFxView.new()
@@ -65,7 +65,7 @@ static func build_battle_ui(root: Control, db: ConfigDb) -> Dictionary:
# 本组不含明细回看 overlay——冒险中回看归「生涯」页;
# 本组不含营地覆盖层——在 camp_screen 屏壳构建)
static func build_overlays(host: Control, db: ConfigDb) -> Dictionary:
var legend_overlay := LegendOverlay.new(int(db.action_economy.rap_refresh_cost)) # RAP 行表值注入
var legend_overlay := LegendOverlay.new(int(db.run.action_economy.rap_refresh_cost)) # RAP 行表值注入
host.add_child(legend_overlay)
var node_overlay := NodeOverlay.new()
+2 -2
View File
@@ -133,7 +133,7 @@ func _flush_pending_agg() -> void:
func _source_label(src_key: String) -> String:
if src_key == "":
return ""
return String(_db.settlement.core.source_label_map.get(src_key, src_key))
return String(_db.combat.settlement.core.source_label_map.get(src_key, src_key))
func _gold_frame(text: String) -> String:
@@ -171,7 +171,7 @@ func _format_row(row: Dictionary, agg_count := -1) -> String:
if row.get("roll", null) != null:
roll_txt = "〔掷 %d vs 护甲 %d" % [int(row.roll), int(row.ac)]
if bool(row.get("crit", false)):
roll_txt += " · 暴击×%d" % int(_db.settlement.damage_roll.crit_multiplier)
roll_txt += " · 暴击×%d" % int(_db.combat.settlement.damage_roll.crit_multiplier)
elif int(row.tier) < 100:
roll_txt += " · ×%d%%" % int(row.tier)
roll_txt += "〕"
+5 -5
View File
@@ -104,13 +104,13 @@ static func isolate_save(main, tag: String, mark_first_done: bool = false, with_
var iso = SaveManagerScript.new(save_path)
iso.load_from_disk()
if mark_first_done:
iso.mark_first_adventure_done()
iso.meta_state.mark_first_adventure_done()
# --script 模式无裸 autoload 标识符(run_all.gd 头注)——经 main 节点路径解析
var sm = main.get_node("/root/SaveManager")
if swap_save:
sm.attach_path(save_path)
sm.slots.attach_path(save_path)
if mark_first_done:
sm.mark_first_adventure_done()
sm.meta_state.mark_first_adventure_done()
iso = sm
else:
main.get_node("/root/MetaProgression").save = iso # 只换 meta 侧(营地段语义)——SaveManager 全局不动
@@ -123,10 +123,10 @@ static func isolate_save(main, tag: String, mark_first_done: bool = false, with_
# 驱动后写盘目标=隔离槽文件(持久化断言读 user://<tag>_slot_1.json)。返回 false=新档钮缺失(驱动中断)。
static func open_camp_via_savesel(main, tag: String) -> bool:
var sm = main.get_node("/root/SaveManager")
sm.attach_slot_template("user://%s_slot_%%d.json" % tag)
sm.slots.attach_slot_template("user://%s_slot_%%d.json" % tag)
for i in range(1, 4): # 夹具清场——空槽起(陈旧槽文件会翻有档态、新档钮缺席)
for suf in ["", ".tmp", ".corrupt.bak"]:
var sp: String = sm.slot_path(i) + suf
var sp: String = sm.slots.slot_path(i) + suf
if FileAccess.file_exists(sp):
DirAccess.remove_absolute(sp)
main._open_save_select()
+1 -1
View File
@@ -132,7 +132,7 @@ func _step_camp_back_to_menu() -> void:
# 返回钮真实信号路径关闭→ESC 语境不响应(卡⑤ 契约 2 范围外——零实现即断言)。----
func _step_save_select_open_back() -> void:
ProbeUiUtil.isolate_save(main, "smoke_screen_flow_savesel", false, false, true)
main.save_manager.attach_slot_template("user://smoke_screen_flow_savesel_slot_%d.json")
main.save_manager.slots.attach_slot_template("user://smoke_screen_flow_savesel_slot_%d.json")
var esb: Button = ProbeUiUtil.find_button(main._start_overlay, "进入存档", "prefix")
if esb == null:
_problems.append("[savesel] mn3 进入存档钮缺失(卡② 交付物)")
+14 -14
View File
@@ -71,7 +71,7 @@ func _ready() -> void:
_problems.append("树淬炼柜台购买钮缺失(点击路径——四柜台重排后树=主界面)")
else:
# 树钮句柄用后即取(refresh_camp 随刷重建树行板——旧钮 queue_free,跨刷新句柄必失效)
iso_c.add_keystone(9999)
iso_c.meta_state._add_keystone(9999)
main.camp_overlay.refresh_camp(main._camp_data())
await get_tree().process_frame
var buy_btn: Button = null
@@ -81,15 +81,15 @@ func _ready() -> void:
if buy_btn == null:
_problems.append("脊柱生命行缺失(点击路径)")
else:
var bal_before: int = iso_c.keystone_balance()
var price: int = int(main.db.tree_node("sp_hp").get("price", 0))
var bal_before: int = iso_c.meta_state.keystone_balance()
var price: int = int(main.db.crossrun.meta_tree.row_by_id("sp_hp").get("price", 0))
buy_btn.pressed.emit() # 合成购买点击 → _on_camp_tree_buy → meta.tree_buy(写调用唯一归组合根)
await get_tree().process_frame
if int(iso_c.keystone_balance()) != bal_before - price:
_problems.append("营地树购买未出账(%d→%d 应 −%d)" % [bal_before, iso_c.keystone_balance(), price])
if int(iso_c.meta_state.keystone_balance()) != bal_before - price:
_problems.append("营地树购买未出账(%d→%d 应 −%d)" % [bal_before, iso_c.meta_state.keystone_balance(), price])
var iso_chk = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp_slot_1.json")
iso_chk.load_from_disk()
if int(iso_chk.tree_level("sp_hp")) != 1:
if int(iso_chk.meta_state.tree_state().nodes.get("sp_hp", 0)) != 1:
_problems.append("营地树购买未持久(reload 后级 != 1)")
# Fork 双态切换点击路径(V20——购=解锁默认 A,钮切 B;前置=同调位 1,先点亮)
var socket_btn: Button = null
@@ -123,7 +123,7 @@ func _ready() -> void:
await get_tree().process_frame
var iso_fk = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp_slot_1.json")
iso_fk.load_from_disk()
if String(iso_fk.fork_side("fg_posture")) != "b":
if String(iso_fk.meta_state.tree_state().fork_sides.get("fg_posture", "")) != "b":
_problems.append("Fork 切态未持久(reload 后激活态 != b)")
# ---- 营地六处灰字取色断言(迭代36 wc4——真实入口开营地后按文本锚取 label,
# font_color 逐处 == STONE_DEEP;六源位 :167 图鉴锁文/:417+:476 行状态字/:509 同调形态/:513 档位标签/:525 刷新行)----
@@ -189,7 +189,7 @@ func _ready() -> void:
if FileAccess.file_exists(cp):
DirAccess.remove_absolute(cp)
ProbeUiUtil.isolate_save(main, "smoke_click_probe_savesel", true, false, true)
main.save_manager.attach_slot_template(csel_tpl)
main.save_manager.slots.attach_slot_template(csel_tpl)
var f_c2 := FileAccess.open(csel_tpl % 2, FileAccess.WRITE)
f_c2.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 100,
"adventure_total": 3, "adventure_cleared": 1, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}}))
@@ -215,8 +215,8 @@ func _ready() -> void:
_problems.append("[savesel] sl12 动线序违约:选档隐藏帧营地层未可见(先关后开=回闪形态)")
elif samples[0][2]:
_problems.append("[savesel] sl12 动线序违约:开始层在落点帧恢复(回闪一帧)")
if main.save_manager.active_slot() != 1 or not FileAccess.file_exists(csel_tpl % 1):
_problems.append("[savesel] sl5 新档链失守(active=%d 应=1 且槽1 白板应落盘)" % main.save_manager.active_slot())
if main.save_manager.slots.active_slot() != 1 or not FileAccess.file_exists(csel_tpl % 1):
_problems.append("[savesel] sl5 新档链失守(active=%d 应=1 且槽1 白板应落盘)" % main.save_manager.slots.active_slot())
var occluded: Array = main._modals._occluded.get(main.camp_overlay, [])
if not occluded.has(main.save_select_overlay):
_problems.append("[savesel] sl12 选档层被关闭而非栈记账挂起(先关后开形态——遮挡挂起中间态钉死)")
@@ -233,8 +233,8 @@ func _ready() -> void:
else:
load_btn.pressed.emit()
await get_tree().process_frame
if main.save_manager.active_slot() != 2:
_problems.append("[savesel] sl6 进档链失守(active=%d 应=2——激活该槽)" % main.save_manager.active_slot())
if main.save_manager.slots.active_slot() != 2:
_problems.append("[savesel] sl6 进档链失守(active=%d 应=2——激活该槽)" % main.save_manager.slots.active_slot())
if not main.camp_overlay.visible or main.save_select_overlay.visible:
_problems.append("[savesel] sl6 落点未开营地层或选档未隐藏")
main._on_camp_closed()
@@ -834,7 +834,7 @@ func _run_modal_probes_once() -> void:
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", "")),
oracle.append({"name": String(main.db.crossrun.treasure_items.row_by_id(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()
@@ -896,7 +896,7 @@ func _run_modal_probes_once() -> void:
var used_now: int = int(main.run_scope.settlement.presentation_snapshot().get("refresh_used", 0))
if used_now != 1:
_problems.append("刷新轮内计数错误(%d)" % used_now)
var remain_expect: int = maxi(0, int(main.db.action_economy.refresh_cap_per_round) - used_now)
var remain_expect: int = maxi(0, int(main.db.run.action_economy.refresh_cap_per_round) - used_now)
if not main.ap_bar.refresh_remaining_text().contains(str(remain_expect)):
_problems.append("HUD 刷新余量与快照推导失符(HUD='%s' 应含 %d)" % [main.ap_bar.refresh_remaining_text(), remain_expect])
+2 -2
View File
@@ -475,7 +475,7 @@ func _ready() -> void:
# attune_param_sum 只读出口→set_fog_glow_bonus〕→视野 +4 生效与卸装复原。
# settlement.attunement 手动注入=模拟 begin_battle enc 注入后的运行态〔注入面本体归
# 冒烟+单测覆盖〕;不重启冒险——case⑯ 起既有板面继续用)
main.save_manager.set_attunement_equipped(["wide_scout_band"])
main.save_manager.meta_state.set_attunement_equipped(["wide_scout_band"])
var p_lantern: Dictionary = main.build_adventure_params(main.save_manager, main.meta)
if not (p_lantern.get("attunement", []) as Array).has("wide_scout_band"):
_problems.append("case⑮ 装配未进开局参数面(attunement=%s 应含 wide_scout_band)" % str(p_lantern.get("attunement", [])))
@@ -491,7 +491,7 @@ func _ready() -> void:
_problems.append("case⑮ 卸装后视野未复原(lantern 应=0——契约复原语义显式重算)")
if int(bv.cells_probe()[ring_cell].fog_state) == 0:
_problems.append("case⑮ 卸装后 dist==R+1 格未复原遮蔽")
main.save_manager.set_attunement_equipped([])
main.save_manager.meta_state.set_attunement_equipped([])
# ---- case⑯ 状态面板输入锁语义(R3——开层锁棋盘、关层恢复无残留;真实 push_input
# 路径:入口角标本体 press+release 真点击 → 状态面板开;锁期挖掘点击零穿透;「继续」关层后
# 同格挖掘重新到达——锁语义与既有模态同款,无状态残留。置于全 case 之尾:取格若需前沿移位
+1 -1
View File
@@ -99,7 +99,7 @@ func _ready() -> void:
problems.append("B 区轮次·阶段指示缺失(%s)" % main.stage.round_label.text)
if main.stage.enemy_box.get_child_count() < 1:
problems.append("B 区敌方站桩未构建(%d 个)" % main.stage.enemy_box.get_child_count())
if main.ap_bar._dots.get_child_count() != int(main.db.action_economy.ap_per_round):
if main.ap_bar._dots.get_child_count() != int(main.db.run.action_economy.ap_per_round):
problems.append("D 区 AP 点阵数量错误(%d)" % main.ap_bar._dots.get_child_count())
if main.ap_bar.refresh_remaining_text() == "":
problems.append("D 区弃图刷新按钮文案缺失")
+4 -4
View File
@@ -81,7 +81,7 @@ func _ready() -> void:
if FileAccess.file_exists(spp):
DirAccess.remove_absolute(spp)
ProbeUiUtil.isolate_save(main, "smoke_ui_struct_probe_savesel", true, false, true)
main.save_manager.attach_slot_template(ssel_tpl) # 注入口(激活态回零——sl9 重启链同构态)
main.save_manager.slots.attach_slot_template(ssel_tpl) # 注入口(激活态回零——sl9 重启链同构态)
var f_s1 := FileAccess.open(ssel_tpl % 1, FileAccess.WRITE)
f_s1.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 555,
"adventure_total": 7, "adventure_cleared": 2, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}}))
@@ -89,7 +89,7 @@ func _ready() -> void:
var f_s3 := FileAccess.open(ssel_tpl % 3, FileAccess.WRITE)
f_s3.store_string("broken json not a save{{{") # 槽3 坏档(窥视 readable=false→按空呈现)
f_s3.close()
if main.save_manager.active_slot() != 0:
if main.save_manager.slots.active_slot() != 0:
problems.append("[savesel] 开层前激活态非零(sl9 重启链——boot/注入面后应无激活)")
main._open_save_select()
_settle()
@@ -201,8 +201,8 @@ func _ready() -> void:
], 20)
_settle()
var ticket_row: Dictionary = main.shop_overlay._shelf_rows[0]
if String(main.db.shop_pool_row("feat_ticket").get("display_name", "")) != "专长获取":
problems.append("[shop_term] shop.tres 专长凭证件名未换代(现=%s)" % String(main.db.shop_pool_row("feat_ticket").get("display_name", "")))
if String(main.db.run.shop.pool_row("feat_ticket").get("display_name", "")) != "专长获取":
problems.append("[shop_term] shop.tres 专长凭证件名未换代(现=%s)" % String(main.db.run.shop.pool_row("feat_ticket").get("display_name", "")))
if ticket_row.card.name_l.text != "专长获取":
problems.append("[shop_term] 专长凭证件名未用物品名「专长获取」(现=%s)" % ticket_row.card.name_l.text)
if ticket_row.btn.get_parent() != ticket_row.card.body or ticket_row.btn.get_index() >= ticket_row.card.foot_l.get_index():
+6 -6
View File
@@ -36,8 +36,8 @@ func _ready() -> void:
await get_tree().process_frame
var problems: Array = []
# 基线读表(表值驱动——不写字面量)
var base_atk := int(main.db.hero.atk)
var base_hp := int(main.db.hero.max_hp)
var base_atk := int(main.db.combat.hero.atk)
var base_hp := int(main.db.combat.hero.max_hp)
var boosted_atk := base_atk * 2 # 攻击滑条 100 → ×2
var boosted_hp := int(round(base_hp * 1.5)) # 生命滑条 50 → +50%
# ① 全局浮层角标开窗(调试入口=UI 最顶层浮层,任何 UI 不遮挡——
@@ -86,8 +86,8 @@ func _ready() -> void:
if main.debug_overlay.visible or not main._start_overlay.visible:
problems.append("关闭后层级状态错误")
# ⑦ hero 表运行期不可变(E4 收口回归断言:任何代码 reintroduce 表写在此被抓获——原"表值残留"断言语义升级)
if int(main.db.hero.atk) != base_atk or int(main.db.hero.max_hp) != base_hp:
problems.append("hero 表被运行时改写(E4 违例:atk=%s hp=%s,期望恒基线 %d/%d)" % [str(main.db.hero.atk), str(main.db.hero.max_hp), base_atk, base_hp])
if int(main.db.combat.hero.atk) != base_atk or int(main.db.combat.hero.max_hp) != base_hp:
problems.append("hero 表被运行时改写(E4 违例:atk=%s hp=%s,期望恒基线 %d/%d)" % [str(main.db.combat.hero.atk), str(main.db.combat.hero.max_hp), base_atk, base_hp])
# ⑧ 单次加成生效回归断言:应用加成 + 启动单场战斗 → 呈现快照 hero 必须是"基线 × 单次加成"
# (攻击 ×2 / 生命 +50%)——旧缺陷(残留值被当基线再乘)在 encounter 参数链上同样被抓获
# 存档隔离(停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律;
@@ -165,11 +165,11 @@ func _ready() -> void:
# SaveManager 是 autoload 单例——换注走原地 attach(首局态挂载+恢复隔离档);
# --script 模式无裸 autoload 标识符——经 main 节点路径解析
var sm = main.get_node("/root/SaveManager")
sm.attach_path("user://smoke_tune_probe_seed.json") # 全新空档——first_adventure_done=false(首局态;新档缺省面)
sm.slots.attach_path("user://smoke_tune_probe_seed.json") # 全新空档——first_adventure_done=false(首局态;新档缺省面)
main.debug_overlay.seed_edit.text = ""
if main._seed_value() != 42:
problems.append("首局空框种子 != 42(P5 甲失守——教学态可复现破约)")
sm.attach_path("user://smoke_tune_probe_save.json") # 恢复隔离档(非首局态——isolate_save 已建档置位)
sm.slots.attach_path("user://smoke_tune_probe_save.json") # 恢复隔离档(非首局态——isolate_save 已建档置位)
var draws: Array[int] = []
for i in range(4):
draws.append(main._seed_value())
+2 -2
View File
@@ -78,7 +78,7 @@ func _initialize() -> void:
var slots: int = 3
var passives: Array = []
var actives: Array = []
for r in db.treasure_attunement_rows():
for r in db.crossrun.treasure_items.attunement_rows():
if String(r.get("form", "")) == "passive":
passives.append(String(r.get("item_id", "")))
elif String(r.get("form", "")) == "active_charged":
@@ -102,7 +102,7 @@ func _initialize() -> void:
for policy in policies:
# 每策略一个共享日志会话(全量 JSONL 体量随局数增长,分文件便于定位)
var logger = load("res://presentation/playtest_logger.gd").new()
logger.ap_per_round = int(db.action_economy.ap_per_round)
logger.ap_per_round = int(db.run.action_economy.ap_per_round)
var log_path: String = logger.start_session(seed0)
var tally := {}
for i in range(n):
+1 -1
View File
@@ -24,7 +24,7 @@ func _initialize() -> void:
var gen = load("res://core/board_generator.gd").new()
print("=== 板形统计:N=%d/行 · 种子基 %d ===" % [n, seed0])
print("%-4s %-4s %-8s %-8s %-10s %-10s %-10s %-10s %-10s %-10s" % ["L", "D", "宽x高", "类型", "|M|均", "|M|min", "|M|max", "|S|均", "雷密度实测", "达标率"])
for row in db.board_gen.rows:
for row in db.combat.board_gen.rows:
var L: int = int(row.get("level_index", 0))
var D: int = int(row.get("node_depth", -1))
var w: int = int(row.get("board_width", 0))
+8 -4
View File
@@ -9,9 +9,13 @@ extends SceneTree
class FakeDB:
var row: Dictionary
func resolve_board_gen(_L: int, _D: int) -> Dictionary:
return row
class _FakeBoardGen:
var row: Dictionary
func resolve_row(_L: int, _D: int) -> Dictionary:
return row
class _FakeCombat:
var board_gen := _FakeBoardGen.new()
var combat := _FakeCombat.new()
func status_effect_row(_id: String) -> Dictionary:
return {}
@@ -28,7 +32,7 @@ func _initialize() -> void:
return
var gen = load("res://core/board_generator.gd").new()
print("level,depth,w,h,n,mines_mean,mines_med,safe_mean,safe_med,r0_mean,r0_sd,r0_p90,r0_max,inf_mean,cap_rej_mean")
for row in db.board_gen.rows:
for row in db.combat.board_gen.rows:
var rd := {
"level_index": int(row.level_index), "node_depth": int(row.node_depth),
"board_width": int(row.board_width), "board_height": int(row.board_height),
+16 -16
View File
@@ -53,17 +53,17 @@ func _initialize() -> void:
func _probe_boss(db, seed_val: int) -> void:
var math = load("res://core/damage_math.gd")
var clamp_lo: int = int(db.settlement.core.pool_clamp_lo)
var clamp_hi: int = int(db.settlement.core.pool_clamp_hi)
var clamp_lo: int = int(db.combat.settlement.core.pool_clamp_lo)
var clamp_hi: int = int(db.combat.settlement.core.pool_clamp_hi)
var hero_def_negative := 0
for eff_id in db.settlement.core.effect_pool_map.keys():
if String(db.settlement.core.effect_pool_map[eff_id]) != "hero_defense":
for eff_id in db.combat.settlement.core.effect_pool_map.keys():
if String(db.combat.settlement.core.effect_pool_map[eff_id]) != "hero_defense":
continue
var row: Dictionary = db.status_effect_row(eff_id)
var row: Dictionary = db.combat.status_effects.row_by_id(eff_id)
if not row.is_empty() and String(row.get("unit", "")) == "percent":
hero_def_negative = mini(hero_def_negative, int(row.get("magnitude", 0)))
print("表值面:hero_defense 池最负 percent 行 %d · pool_clamp [%d, %d] · damage_floor %d · AC 基准 %d(+ac_up×%d 层)" % [
hero_def_negative, clamp_lo, clamp_hi, int(db.settlement.damage_roll.damage_floor), int(db.hero.armor_class), int(db.status_effect_row("ac_up").get("max_stack", 0))])
hero_def_negative, clamp_lo, clamp_hi, int(db.combat.settlement.damage_roll.damage_floor), int(db.combat.hero.armor_class), int(db.combat.status_effects.row_by_id("ac_up").get("max_stack", 0))])
print("--- Run A:fresh 池(零挖掘停手轮——无授予,攻击链裸验)---")
var run_a := _run_boss(db, seed_val, false)
@@ -92,7 +92,7 @@ func _run_boss(db, seed_val: int, dig: bool) -> Dictionary:
settlement.step_row.connect(func(row): cap["rows"].append(row))
settlement.settlement_snapshot.connect(func(s): cap["snaps"].append(s))
settlement.battle_finished.connect(func(p): cap["result"] = String(p.get("result", "")))
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(db.hero.max_hp), "node_type": "boss"}):
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(db.combat.hero.max_hp), "node_type": "boss"}):
cap["result"] = "begin_failed"
return cap
var rounds := 0
@@ -175,11 +175,11 @@ func _analyze_round(db, math, rows: Array, beats: Dictionary) -> Dictionary:
# 池归属表驱动(settlement.effect_pool_map),percent 池加算后 clamp(战斗结算 F2 同口径)
var pools := {"hero_defense": 0, "enemy_output": 0, "hero_offense": 0, "flat_attach": 0}
for eid in last_pool:
var pool_name: String = String(db.settlement.core.effect_pool_map.get(eid, ""))
var pool_name: String = String(db.combat.settlement.core.effect_pool_map.get(eid, ""))
if pools.has(pool_name):
pools[pool_name] += int(last_pool[eid])
var clamp_lo: int = int(db.settlement.core.pool_clamp_lo)
var clamp_hi: int = int(db.settlement.core.pool_clamp_hi)
var clamp_lo: int = int(db.combat.settlement.core.pool_clamp_lo)
var clamp_hi: int = int(db.combat.settlement.core.pool_clamp_hi)
var pd: int = clampi(int(pools["hero_defense"]), clamp_lo, clamp_hi)
var pe: int = clampi(int(pools["enemy_output"]), clamp_lo, clamp_hi)
var lines: Array = []
@@ -194,11 +194,11 @@ func _analyze_round(db, math, rows: Array, beats: Dictionary) -> Dictionary:
# 掷点口径重建:基线按行留档档位打折(暴击 ×倍率),附加必中
var tier: int = int(row.get("tier", 100))
var crit: bool = bool(row.get("crit", false))
var base_factor: float = float(tier) * (float(db.settlement.damage_roll.crit_multiplier) / 100.0 if crit else 1.0)
var base_factor: float = float(tier) * (float(db.combat.settlement.damage_roll.crit_multiplier) / 100.0 if crit else 1.0)
var rolled_base := int(floor(float(baseline) * base_factor / 100.0))
var raw: int = rolled_base + attach
var tick: int = int(row.get("value", 0))
var expected: int = maxi(int(db.settlement.damage_roll.damage_floor), math.pool_damage(raw, pd, pe))
var expected: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), math.pool_damage(raw, pd, pe))
var roll_txt := "掷%s" % str(int(row.get("roll", -1)))
var zeroed := "✓" if tick == expected else "✗ 算术不符!"
lines.append("拍=%s %s/档%d%s 基线%d→掷后%d 附加%d hero_def=%d enemy_out=%d d_tick=%d 期望=%d %s" % [
@@ -266,7 +266,7 @@ func _encounter_prediction(db, seed_val: int) -> Dictionary:
if t != "battle" and t != "boss":
continue
var depth := int(node.get("node_depth", -1))
var row: Dictionary = db.resolve_enemy_rowset(1, depth, t)[0] # 行集首行(row_kind 随节点类型)
var row: Dictionary = db.combat.enemy.resolve_rowset(1, depth, t)[0] # 行集首行(row_kind 随节点类型)
var lo := int(row.get("unit_count", 1))
var hi := int(row.get("unit_count_max", lo))
var rolled := lo
@@ -280,7 +280,7 @@ func _encounter_prediction(db, seed_val: int) -> Dictionary:
func _level_row(db, level_index: int) -> Dictionary:
for row in db.level.rows:
for row in db.run.level.rows:
if int(row.level_index) == level_index:
return row
return {}
@@ -415,11 +415,11 @@ func _scan_spawns(db, seed0: int, n_seeds: int) -> Dictionary:
var kind: String = String(dep.get("kind", "battle"))
total += 1
var settlement = load("res://core/battle_orchestrator.gd").new(db, rng)
if not settlement.begin_battle({"level_index": 1, "node_depth": depth, "node_type": kind, "hero_hp": int(db.hero.max_hp)}):
if not settlement.begin_battle({"level_index": 1, "node_depth": depth, "node_type": kind, "hero_hp": int(db.combat.hero.max_hp)}):
bad += 1
continue
var n: int = settlement.presentation_snapshot().enemies.size()
var row: Dictionary = db.resolve_enemy_rowset(1, depth, kind)[0] # 行集首行(同上,row_kind 随节点类型)
var row: Dictionary = db.combat.enemy.resolve_rowset(1, depth, kind)[0] # 行集首行(同上,row_kind 随节点类型)
var lo := int(row.get("unit_count", 1))
var hi := int(row.get("unit_count_max", lo))
if n < 1 or n < lo or n > hi:
+1 -1
View File
@@ -19,7 +19,7 @@ func _initialize() -> void:
level = int(OS.get_cmdline_user_args()[0])
if OS.get_cmdline_user_args().size() > 1:
rng.set_master_seed(int(OS.get_cmdline_user_args()[1]))
settlement.begin_battle({"level_index": level, "node_depth": 0, "hero_hp": db.hero.max_hp})
settlement.begin_battle({"level_index": level, "node_depth": 0, "hero_hp": db.combat.hero.max_hp})
print("=== 棋盘摘要 L%d ===" % level)
var bd = settlement.minesweeper.current_board()
print("尺寸 %dx%d · 雷 %d · 安全格 %d · R₀ %d" % [bd.width, bd.height, bd.mines.size(), bd.safe_cells().size(), bd.metrics.r0_count])
+8 -4
View File
@@ -56,13 +56,17 @@ func _params_from(row: Dictionary) -> Dictionary:
}
class FakeDB:
var row: Dictionary
func resolve_board_gen(_L: int, _D: int) -> Dictionary:
return row
class _FakeBoardGen:
var row: Dictionary
func resolve_row(_L: int, _D: int) -> Dictionary:
return row
class _FakeCombat:
var board_gen := _FakeBoardGen.new()
var combat := _FakeCombat.new()
func status_effect_row(_id: String) -> Dictionary:
return {}
func _db(_real, row: Dictionary):
var f := FakeDB.new()
f.row = row
f.combat.board_gen.row = row
return f
+2 -2
View File
@@ -39,7 +39,7 @@ static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct:
if own_session:
logger = load("res://presentation/playtest_logger.gd").new()
logger.start_session(seed_val)
logger.ap_per_round = int(db.action_economy.ap_per_round)
logger.ap_per_round = int(db.run.action_economy.ap_per_round)
logger.log_run({"seed": seed_val, "level_index": level_index, "atk_pct": atk_pct, "hp_pct": hp_pct, "policy": policy, "route": route})
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
@@ -117,7 +117,7 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_
if own_session:
logger = load("res://presentation/playtest_logger.gd").new()
logger.start_session(seed_val)
logger.ap_per_round = int(db.action_economy.ap_per_round)
logger.ap_per_round = int(db.run.action_economy.ap_per_round)
logger.log_run({"seed": seed_val, "adventure": true, "atk_pct": atk_pct, "hp_pct": hp_pct,
"policy": policy, "route": route, "hero_override": hero_override})
var rng = load("res://core/rng_service.gd").new()
+2 -2
View File
@@ -44,7 +44,7 @@ func _run_encounter(db, L: int) -> void:
print("=== L%d · %d 种子(败 %d / 超时 %d)===" % [L, N, losses, timeouts])
print(" 胜利轮数 均值 %.2f 中位 %d p10 %.1f p90 %.1f 分布 %s" % [_mean(rounds_v), _median(rounds_v), _pct_f(rounds_v, 0.1), _pct_f(rounds_v, 0.9), _hist(rounds_v)])
print(" 每轮承伤 均值 %.2f 中位 %.1f p90 %.1f" % [_mean(dmg_per_round), _median_f(dmg_per_round), _pct_f(dmg_per_round, 0.9)])
print(" 整场承伤 均值 %.1f 中位 %.0f(主角 max_hp=%d)" % [_mean(dmg_total), _median_f(dmg_total), int(db.hero.max_hp)])
print(" 整场承伤 均值 %.1f 中位 %.0f(主角 max_hp=%d)" % [_mean(dmg_total), _median_f(dmg_total), int(db.combat.hero.max_hp)])
print(" 开安全格 均值 %.1f 中位 %d" % [_mean(safe_opened), _median(safe_opened)])
print(" 清图(换板) 均值 %.2f 中位 %d" % [_mean(clears), _median(clears)])
print(" 攻防buff授予 均值 %.2f" % [_mean(buff_grants)])
@@ -65,7 +65,7 @@ func _run_battle(db, seed_val: int, L: int) -> Dictionary:
settlement.minesweeper.cell_revealed.connect(func(evt):
if evt.kind == "safe" and evt.channel != "initial":
out.safe_opened += 1)
if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": int(db.hero.max_hp)}):
if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp)}):
return out
var guard_rounds := 40
while out.result == "" and guard_rounds > 0:
+1 -1
View File
@@ -19,7 +19,7 @@ func _initialize() -> void:
quit(1)
return
var logger = load("res://presentation/playtest_logger.gd").new()
logger.ap_per_round = int(db.action_economy.ap_per_round)
logger.ap_per_round = int(db.run.action_economy.ap_per_round)
var log_path: String = logger.start_session(seed0)
print("节奏批日志: %s" % log_path)
var autoplay = load("res://sim/level_autoplay.gd")
+2 -2
View File
@@ -14,7 +14,7 @@ func _initialize() -> void:
db.load_tables("res://data/tables", false)
# ① 表侧(行集数组取首行,战斗节点查 battle 键域)
var row = db.resolve_enemy_rowset(1, 1, "battle")[0]
var row = db.combat.enemy.resolve_rowset(1, 1, "battle")[0]
print("[1] row(1,1) display=", row.get("display_name"), " unit_count=", row.get("unit_count"),
" pool=", row.get("pool"))
@@ -50,7 +50,7 @@ func _run_battle(db, seed_val: int, L: int, depth: int, casts: Dictionary) -> Di
if row.type == "damage" and row.source_class == "enemy_action":
var sid: String = str(row.get("skill_id", "?"))
casts[sid] = casts.get(sid, 0) + 1)
if not settlement.begin_battle({"level_index": L, "node_depth": depth, "hero_hp": int(db.hero.max_hp)}):
if not settlement.begin_battle({"level_index": L, "node_depth": depth, "hero_hp": int(db.combat.hero.max_hp)}):
return out
var guard_rounds := 40
while out.result == "" and guard_rounds > 0:
+9 -9
View File
@@ -39,7 +39,7 @@ func _initialize() -> void:
quit(1)
return
var logger = load("res://presentation/playtest_logger.gd").new()
logger.ap_per_round = int(db.action_economy.ap_per_round)
logger.ap_per_round = int(db.run.action_economy.ap_per_round)
var log_path: String = logger.start_session(opt.seed0)
print("模拟日志: %s" % log_path)
var autoplay = load("res://sim/level_autoplay.gd")
@@ -154,7 +154,7 @@ func _seg_campaign(db, autoplay, logger, opt: Dictionary, counts: Dictionary) ->
for r in range(int(opt.k)):
var seed_val := _next_seed(int(opt.seed0))
var ov: Dictionary = meta.hero_override()
var keystone_before: int = meta.keystone_balance()
var keystone_before: int = meta.meta_state.keystone_balance()
var st: Dictionary = autoplay.run_adventure(db, seed_val, 0, 0, false, logger, policy, "min", ov)
var row := _adventure_row(st, policy, seed_val)
row["seg"] = "campaign"
@@ -171,15 +171,15 @@ func _seg_campaign(db, autoplay, logger, opt: Dictionary, counts: Dictionary) ->
var settle: Dictionary = meta.settle_adventure(st.summary)
row["keystone_income"] = int(settle.total)
row["purchases"] = _spend(meta, String(opt.spend))
row["keystone_after"] = meta.keystone_balance()
row["tree"] = {"sp_hp": meta.save.tree_level("sp_hp"), "sp_hit": meta.save.tree_level("sp_hit")}
row["keystone_after"] = meta.meta_state.keystone_balance()
row["tree"] = {"sp_hp": meta.save.meta_state.tree_state().nodes.get("sp_hp", 0), "sp_hit": meta.save.meta_state.tree_state().nodes.get("sp_hit", 0)}
row["new_unlocks"] = settle.new_unlocks
_emit(row)
counts.campaign += 1
if (r + 1) % 10 == 0:
print("进度 campaign %s %d/%d(树 血%d/命中%d · 钥石 %d)" % [policy, r + 1, int(opt.k),
meta.save.tree_level("sp_hp"), meta.save.tree_level("sp_hit"),
meta.keystone_balance()])
meta.save.meta_state.tree_state().nodes.get("sp_hp", 0), meta.save.meta_state.tree_state().nodes.get("sp_hit", 0),
meta.meta_state.keystone_balance()])
_remove_save(save_path) # 段尾清理(与 smoke 存档纪律同源——不遗留隔离档)
@@ -193,9 +193,9 @@ func _spend(meta, policy_name: String) -> Array:
guard -= 1
var bought := false
for nid in order:
var price: int = int(meta.db.tree_node(nid).get("price", 0))
if price > 0 and meta.keystone_balance() >= price and meta.tree_buy(nid):
out.append({"node_id": nid, "level": meta.save.tree_level(nid), "price": price})
var price: int = int(meta.db.crossrun.meta_tree.row_by_id(nid).get("price", 0))
if price > 0 and meta.meta_state.keystone_balance() >= price and meta.tree_buy(nid):
out.append({"node_id": nid, "level": meta.save.meta_state.tree_state().nodes.get(nid, 0), "price": price})
bought = true
break
if not bought:
+12 -68
View File
@@ -43,72 +43,17 @@ tests/
> **分层判据**:能不能在 `--script` 模式下不加载 autoload 就验完?能 = 单元测试;
> 需要两个以上真实系统协同并走落盘/落库 = 集成测试;需要完整项目引导与场景树 = 冒烟/系统/UI 探针。
**能力清单 v1**(2026-10-28 立——验证与清单体系整改阶段 1 的覆盖索引:一行=一个玩家或系统可观测的能力,代表用例只给指针、不复制断言细节;原「集成旅程清单」五条跨系统旅程已并入本表。首期覆盖核心四域(29 行)+跨域条件闸 1 行,共 30 行;其余域按同一形态扩面):
**集成旅程清单**(2026-10-28 立——跨域旅程用例的登记处:一个行为穿过多个领域的对外方法=旅程,验收落本层;缺口旅程由属主批/工程债批按批落用例后回填本表):
> **与验证集清单的关系**:`tests/验证集清单.md` 已于阶段 2「存量断言精简」完成后删除——冻结对账基准(基点 `b9260138`/1587 条)与精简对账结果(33 站点并为 7、1587→1561,合回 `87d241c1`)留 git 历史。本表是该清单退役后的唯一覆盖索引(维护锚=test-conventions §8.3)。本表不是闸,不替代改动—验收对照表、体验验证清单与静态纪律;每行的唯一事实源仍是 `game/tests/` 下的测试代码。
>
> **代表用例口径**:写 `文件:函数`,每行 2–3 个;函数内可含 `Kit.check_quiet` 静默通道(逐断言提取器口径外,test-conventions §5.2 已备案)——本表以函数为锚,不受该工具局限影响。
>
> **维护规则(每批收口核对本批受影响行)**:本批改到哪个能力,收口时核对该行代表用例可 grep、在本批真实跑批中执行、真实入口未绕过改动点,并把「最后验证批次」刷新为本批回执;新增能力加行,能力退役删行。全表核对频率与抽样规则归阶段 3。首期未覆盖:排雷循环(棋盘生成/排雷玩法/行动经济)、表现与支撑(HUD/主题/字体/地形瓦片/模态)、数据配置与跨系统契约——按同一形态后续扩面。
>
> **旅程补录方法**:新旅程=加一行,「系统」列写涉及边界;源=architecture「API 边界」表+「数据流」存档/读档路径+各 GDD 登记的 deferral 集成用例。
>
> 「最后验证批次」列记回执 commit;多数行初值 `20c13c3a`=存量断言精简 A+B 首批的全量域闸回执,RNG 行随 C 档批刷新为 `fd6cf968`。
| 旅程 | 涉及边界 | 现行覆盖 | 缺口 | 登记批 |
|---|---|---|---|---|
| 存档⇄RNG 快照往返保真 | SaveManager×AdventureRunner×RngService | `save_rng_roundtrip_test.gd` 用例一(闸 02) | — | 迭代 29 T3 |
| 旧档迁移后断点续玩零跳号 | SaveManager 迁移链×AdventureRunner×BoardGenerator | `save_rng_roundtrip_test.gd` 用例二(闸 02) | — | 迭代 29 T3 |
| 断点附随件与事件日志会话 | AdventureRunner×SaveManager×日志面 | `cross_system_flow_test.gd`(闸 02) | 盘点候选 | 断言迁移批(2026-10-23) |
| 关卡图断点重生成同图/生成图确定性 | LevelRunner×节点图生成器×AdventureRunner | `node_map_determinism_test.gd`(闸 02) | — | 断言迁移批(2026-10-23,迭代 35 卡②随动换代) |
| 回放确定性(同种子逐位) | 全玩法链 | `parity_anchor.py`(闸 11 条件闸) | — | 迭代 29 T3 收编为条件闸件 |
**旅程段一:开机与存档**
| 能力 | 系统 | 真实入口 | 代表用例(文件:函数) | 最后验证批次 | 缺口 |
|---|---|---|---|---|---|
| 存档写盘⇄读回往返保真 | 存档序列化 | `SaveManagerCore.write_to_disk()`/`load_from_disk()` | `save_manager_domain_test.gd:_smoke_save` | `20c13c3a` | — |
| 三槽位隔离与激活失败回滚 | 存档序列化 | `SaveManagerCore.activate_slot()`/`create_slot()`/`delete_slot()` | `save_slot_isolation_test.gd:_test_three_slot_roundtrip_and_isolation`;`save_slot_isolation_test.gd:_test_activate_failure_restores_previous_slot` | `20c13c3a` | — |
| 槽位摘要窥视与坏槽三态 | 存档序列化 | `SaveManagerCore.peek_slot_summary()` | `save_slot_summary_test.gd:_test_peek_three_slots_distinct_values`;`save_slot_summary_test.gd:_test_peek_empty_and_unreadable_states`;`save_slot_summary_test.gd:_test_peek_zero_side_effect` | `20c13c3a` | — |
| 启动态无激活不写盘 | 存档序列化 | `SaveManagerCore.has_write_target()`/`write_to_disk()` | `save_boot_no_activate_test.gd:_test_write_rejected_without_target`;`save_boot_no_activate_test.gd:_test_out_of_range_slots_rejected` | `20c13c3a` | — |
| 旧档迁移链与断点续玩零跳号(集成) | 存档序列化×冒险结构 | `SaveManagerCore.load_from_disk()`(内置迁移链)× `AdventureRunner.resume_adventure()` | `save_rng_roundtrip_test.gd:_test_old_version_save_migrates_and_resumes` | `20c13c3a` | — |
| 存档⇄RNG 快照往返保真(集成) | 存档序列化×RNG 服务×冒险结构 | `AdventureRunner.start_adventure()` → `SaveManagerCore.write_to_disk()`/`load_from_disk()` | `save_rng_roundtrip_test.gd:_test_disk_roundtrip_preserves_breakpoint` | `20c13c3a` | — |
| RNG 统一流派生与取号账本 | RNG 服务 | `RngServiceCore.derive_at()`/`open_stream()`/`counter()` | `rng_service_test.gd:_test_derive_determinism`;`rng_service_test.gd:_test_stream_draw_determinism`;`rng_stream_boot_test.gd:_smoke_initialize` | `fd6cf968` | — |
| 断点片段往返与续玩幂等 | 冒险结构×关卡节点地图 | `LevelRunner.breakpoint_fragment()`/`restore_fragment()`;`AdventureRunner.resume_adventure()` | `breakpoint_resume_test.gd:_init` | `20c13c3a` | — |
| 跨系统附随面:事件日志落盘与断点附随件(集成) | RNG 服务×存档序列化×冒险结构 | `RngServiceCore.new_adventure()`(开事件日志会话);`SaveManagerCore.breakpoint_snapshot()`(断点附随键) | `cross_system_flow_test.gd:_rng_event_log_session`;`cross_system_flow_test.gd:_feats_breakpoint_roundtrip` | `20c13c3a` | 日志面仅此一条会话覆盖 |
**旅程段二:冒险开局与选路**
| 能力 | 系统 | 真实入口 | 代表用例(文件:函数) | 最后验证批次 | 缺口 |
|---|---|---|---|---|---|
| 冒险生命周期:开局受理/三关序列/败北线 | 冒险结构 | `AdventureRunner.start_adventure()`/`continue_to_next_level()` | `adventure_feats_test.gd:_smoke_adventure_api`;`adventure_feats_test.gd:_smoke_adventure` | `20c13c3a` | — |
| 专长(奇物)授予与凭证保底 | 奇物 | `AdventureRunner.present_node_curio()`/`feats_held()` × 效果引擎兑付 | `adventure_feats_test.gd:_smoke_feats`;`feat_vouchers_test.gd:_init` | `20c13c3a` | — |
| 节点图生成合法性与同种子复现 | 关卡节点图生成 | `NodeMapGenerator.generate()`/`validate_output()` | `node_map_generator_test.gd:_test_output_ok`;`node_map_generator_test.gd:_test_reproducible`;`node_map_generator_test.gd:_test_exit_structure` | `20c13c3a` | — |
| 节点图生长递增与无解 fail-fast | 关卡节点图生成 | `NodeMapGenerator.generate()`/`is_fatal()` | `node_map_growth_test.gd:_test_growth`;`node_map_growth_test.gd:_test_fatal_subprocess` | `20c13c3a` | — |
| 起关/选路/断点重生成同图(集成) | 关卡节点地图×关卡节点图生成×冒险结构 | `LevelRunner.start_level()`/`start_level_external()`/`pick_next_node()`;`AdventureRunner.resume_adventure()` | `node_map_determinism_test.gd:_smoke_generated_map_determinism` | `20c13c3a` | — |
| 信息迷雾类型可见性 | 关卡节点地图 | `LevelRunner.type_visible_ids()` | `level_map_type_vis_test.gd:_test_layer_distance`;`level_map_type_vis_test.gd:_test_boss_exempt`;`level_map_type_vis_test.gd:_test_resume_rebuild` | `20c13c3a` | — |
| 关卡表消费侧校验与关卡推进 | 关卡节点地图×数据配置 | `LevelTable.validate_graph()`;`LevelRunner.start_level()` | `level_table_validate_test.gd:_test_positive`;`level_table_validate_test.gd:_test_negative`;`level_shop_economy_test.gd:_smoke_level` | `20c13c3a` | — |
**旅程段三:战斗结算**
| 能力 | 系统 | 真实入口 | 代表用例(文件:函数) | 最后验证批次 | 缺口 |
|---|---|---|---|---|---|
| 伤害公式与掷点乘区 | 战斗结算 | `DamageMath.pool_damage()`/`roll_discount_percent()`/`rolled_component()` | `damage_math_test.gd:_test_pool_damage`;`damage_math_test.gd:_test_roll_discount_percent`;`roll_multiplier_test.gd:_smoke_armor_roll` | `20c13c3a` | — |
| 伤害单据编排与回放 | 战斗结算 | `DamageController.submit()`/`open_ticket()`/`flush()` | `damage_controller_test.gd:_run_recipe_sequence`;`damage_controller_test.gd:_test_five_recipes_and_replay` | `20c13c3a` | — |
| 轮内流与结算五段全序 | 战斗结算 | `BattleOrchestrator.begin_battle()` | `round_flow_skill_pool_test.gd:_smoke_full_battle`;`round_flow_skill_pool_test.gd:_smoke_settlement_segments` | `20c13c3a` | — |
| 技能池数据面与回放独立 | 敌人系统 | `BattleOrchestrator.begin_battle()`(技能池抽号/校验) | `round_flow_skill_pool_test.gd:_smoke_skill_pool_data`;`round_flow_skill_pool_test.gd:_smoke_skill_pool_replay_independence` | `20c13c3a` | — |
| Boss 半血解锁技门控 | 敌人系统 | `BattleOrchestrator.begin_battle()`(Boss 行) | `skill_unlock_test.gd:_test_gate_predicate`;`skill_unlock_test.gd:_test_latch_irreversible` | `20c13c3a` | — |
| 封印与极性路由/不可逆 | 地图战斗资源×战斗结算 | 效果引擎 × `BattleOrchestrator.begin_battle()` | `seal_polarity_test.gd:_smoke_seal_ap_exhausted`;`seal_polarity_test.gd:_smoke_seal_irreversible_quadrants`;`seal_polarity_test.gd:_smoke_polarity_routing` | `20c13c3a` | — |
| 变异布置门控 | 地形变异 | `BattleOrchestrator.begin_battle()`(变异布置段) | `variant_placement_test.gd:_smoke_variant_placement`;`variant_placement_test.gd:_smoke_global_variant` | `20c13c3a` | — |
**旅程段四:奖励、经济与局外成长**
| 能力 | 系统 | 真实入口 | 代表用例(文件:函数) | 最后验证批次 | 缺口 |
|---|---|---|---|---|---|
| 清图奖励双份与零路径 | 地图战斗资源 | `BattleOrchestrator.begin_battle()` → 步骤流行(`source_class=clear_reward`) | `seal_polarity_test.gd:_smoke_clear_reward_chain`;`seal_polarity_test.gd:_smoke_clear_reward_zero_paths` | `20c13c3a` | — |
| 奖励三选一候选/刷新付费/呈现 | 奇物×节点奖励屏 | `AdventureRunner.present_node_curio()`(数据面);奖励节点覆盖层场景(呈现面) | `curio_refresh_paid_test.gd:_init`;`reward_overlay_test.gd:_run` | `20c13c3a` | — |
| GP 钱包/escrow/商店收口/共享池 | 地图战斗资源×商店 | `LevelRunner.buy_product()`/`player_request_refresh()`/`close_wallet()`/`set_shop_pool()` | `level_shop_economy_test.gd:_smoke_gp_wallet`;`level_shop_economy_test.gd:_smoke_shop_room`;`level_shop_economy_test.gd:_smoke_shop_pool` | `20c13c3a` | — |
| 元进度入账与锻造两轴 | 元进度×锻造工坊 | `SaveManagerCore.add_keystone()`/`spend_keystone()`/`set_forge_route_level()` | `meta_progression_forge_test.gd:_meta_pricing_and_buy`;`meta_progression_forge_test.gd:_smoke_forge_axes` | `20c13c3a` | — |
| 天赋树购买与六键合成 | 天赋树 | `SaveManagerCore.tree_nodes()`/`set_tree_level()` | `meta_progression_forge_test.gd:_smoke_tree`;`meta_progression_forge_test.gd:_smoke_tree_rules`;`tree_rules_v11_test.gd:_init` | `20c13c3a` | — |
| 秘宝结构冻结与 12 件效果 | 秘宝 | `TreasureItems.add_item()`/`query_inventory()`/`validate_use()` | `treasure_items_test.gd:_init`;`treasure_effects_test.gd:_init` | `20c13c3a` | — |
**跨域条件闸**
| 能力 | 系统 | 真实入口 | 代表用例 | 最后验证批次 | 缺口 |
|---|---|---|---|---|---|
| 回放确定性(同种子三变体逐位) | 全玩法链 | `tools/parity_anchor.py`(闸 11 条件闸,按批发起) | `tools/parity_anchor.py`(整件为条件闸) | —(条件闸) | 条件闸未随本批执行 |
候选旅程盘点方法:源=architecture「API 边界」表逐行+「数据流」存档/读档路径+各 GDD 登记的 deferral 集成用例;新旅程先登记「缺口」列,用例按批落地后回填「现行覆盖」。
## 断言计数双口径(2026-10-08 迭代 19 卡① T4 订正)
@@ -125,7 +70,7 @@ tests/
|---|---|---|---|
| `tools/run_smoke.py`(冒烟测试) | 这版能否启动、能否加载表、能否进主屏——零逻辑判定 | 退出码+脚本错误扫描 | 开机路径/表加载改动后的快速自检;收口由闸编排 |
| `tools/run_system_test.py`(系统测试) | 真实玩法路径的行为断言面(推理→战斗→结算→进度推进) | 退出码+脚本错误扫描 | 玩法链路改动的调试期确认 |
| `tests/run_all.gd`(单元与集成) | 公式/状态机/契约/跨系统闭环(64 套件 manifest) | 退出码+三硬闸(见下);每套件子进程带截止时间守护——超时先 taskkill /T 整树回收再判该套件 FAIL、批继续(默认 120s,环境变量 `RUN_ALL_SUITE_TIMEOUT_SEC` 可覆盖) | 新套件单跑调试;逻辑改动的最小单元复跑;开发期选跑——追加 `-- --only=<词[,词…]>` 只跑路径命中词的套件(零命中=用法错 exit 2;选跑态仅供开发期最窄确认,不产闸回执、不作验收凭据) |
| `tests/run_all.gd`(单元与集成) | 公式/状态机/契约/跨系统闭环(64 套件 manifest) | 退出码+三硬闸(见下) | 新套件单跑调试;逻辑改动的最小单元复跑;开发期选跑——追加 `-- --only=<词[,词…]>` 只跑路径命中词的套件(零命中=用法错 exit 2;选跑态仅供开发期最窄确认,不产闸回执、不作验收凭据) |
| `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 行) | 改产品码前后的快速自检;新增纪律先补扫描规则再修码 |
@@ -151,9 +96,8 @@ python tools/run_system_test.py
# 场景探针(UI 探针层)——以探针场景为运行目标,走完整项目引导(autoload 在位)
godot --headless --path . res://probe/<probe>.tscn
# 挂死防护内建于 run_all.gd(引擎无死锁探测,超时靠外部判):每套件子进程带截止时间——
# 默认 120s,环境变量 RUN_ALL_SUITE_TIMEOUT_SEC 可覆盖;超时先 taskkill /T 整树回收
#(cmd wrapper 连引擎孙进程一起杀,不留孤儿),该套件判 FAIL、批继续——单套件挂死不拖死整闸
# 本地闸必须配外部 wall-clock 超时(引擎无死锁探测;run_all_gates.sh 已内置)
timeout 120s godot --headless --script tests/run_all.gd
```
> 套件 manifest 现况:`_suites()` 登记 **64 条**(61 单元+3 集成,2026-10-28 实数;口径=manifest 条目数,
@@ -112,24 +112,24 @@ func _save_summary_integration() -> void:
# ⑨ 摘要集成(冒险结构批产物驱动):run_adventure 真局摘要 → apply 原语 → 域读数 → 写盘读回保持
var st9: Dictionary = LevelAutoplay.run_adventure(_db, 42, 900, 400)
var f9 = sm_script.new(path9)
f9.apply_adventure_summary(st9.get("summary", {}))
f9.meta_state.apply_adventure_summary(st9.get("summary", {}))
var ks_run := 0
for li in range(1, int(_db.level.rows.size()) + 1):
ks_run += int(_db.meta_boss_keystone_row(li).get("first_kill", 0))
for li in range(1, int(_db.run.level.rows.size()) + 1):
ks_run += int(_db.crossrun.meta.boss_keystone_row(li).get("first_kill", 0))
Kit.check("摘要集成:冒险驱动满配冒险成功(seed 42)", String(st9.get("result", "")) == "completed")
Kit.check("集成钥石余额 = ΣBoss 首杀行值(%d——逐杀掉落全源)" % f9.keystone_balance(), f9.keystone_balance() == ks_run)
Kit.check("集成关卡完成次数表全 1", f9.clear_count(1) == 1 and f9.clear_count(2) == 1 and f9.clear_count(3) == 1)
Kit.check("集成三关首杀标记齐", f9.boss_first_kill_marked(1) and f9.boss_first_kill_marked(2) and f9.boss_first_kill_marked(3))
Kit.check("集成 recent 1 条且 seed 42", f9.recent_summaries().size() == 1 and int(f9.recent_summaries()[0].get("master_seed", -1)) == 42)
Kit.check("集成钥石余额 = ΣBoss 首杀行值(%d——逐杀掉落全源)" % f9.meta_state.keystone_balance(), f9.meta_state.keystone_balance() == ks_run)
Kit.check("集成关卡完成次数表全 1", f9.meta_state.career_state().clear_counts.get(str(1), 0) == 1 and f9.meta_state.career_state().clear_counts.get(str(2), 0) == 1 and f9.meta_state.career_state().clear_counts.get(str(3), 0) == 1)
Kit.check("集成三关首杀标记齐", f9.meta_state.career_state().boss_first_kill.has(str(1)) and f9.meta_state.career_state().boss_first_kill.has(str(2)) and f9.meta_state.career_state().boss_first_kill.has(str(3)))
Kit.check("集成 recent 1 条且 seed 42", f9.meta_state.career_state().recent_summaries.size() == 1 and int(f9.meta_state.career_state().recent_summaries[0].get("master_seed", -1)) == 42)
var st9b: Dictionary = LevelAutoplay.run_adventure(_db, 42, 900, 400)
f9.apply_adventure_summary(st9b.get("summary", {}))
Kit.check("第二局后关卡完成次数全 2", f9.clear_count(1) == 2 and f9.clear_count(2) == 2 and f9.clear_count(3) == 2)
Kit.check("第二局后余额翻倍(2 × Σ首杀——模拟驱动恒首杀口径)", f9.keystone_balance() == 2 * ks_run)
Kit.check("集成 recent 长度 2", f9.recent_summaries().size() == 2)
f9.meta_state.apply_adventure_summary(st9b.get("summary", {}))
Kit.check("第二局后关卡完成次数全 2", f9.meta_state.career_state().clear_counts.get(str(1), 0) == 2 and f9.meta_state.career_state().clear_counts.get(str(2), 0) == 2 and f9.meta_state.career_state().clear_counts.get(str(3), 0) == 2)
Kit.check("第二局后余额翻倍(2 × Σ首杀——模拟驱动恒首杀口径)", f9.meta_state.keystone_balance() == 2 * ks_run)
Kit.check("集成 recent 长度 2", f9.meta_state.career_state().recent_summaries.size() == 2)
Kit.check("集成写盘成功", f9.write_to_disk())
var r9 = sm_script.new(path9)
Kit.check("集成写盘读回全量保持", r9.load_from_disk() and r9.keystone_balance() == 2 * ks_run and r9.clear_count(3) == 2
and r9.recent_summaries().size() == 2 and r9.boss_first_kill_marked(3))
Kit.check("集成写盘读回全量保持", r9.load_from_disk() and r9.meta_state.keystone_balance() == 2 * ks_run and r9.meta_state.career_state().clear_counts.get(str(3), 0) == 2
and r9.meta_state.career_state().recent_summaries.size() == 2 and r9.meta_state.career_state().boss_first_kill.has(str(3)))
if FileAccess.file_exists(path9): # 段尾清理(与段首清场对称——源 _smoke_save 同款)
DirAccess.remove_absolute(path9)
@@ -147,7 +147,7 @@ func _smoke_playtest_logger() -> void:
var snaps: Array = []
settlement.settlement_snapshot.connect(func(snap): snaps.append(snap))
Kit.check("会话文件开启(%s)" % path, path.contains("playtest_20260912"))
Kit.check("快照战斗启动", settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}))
Kit.check("快照战斗启动", settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)}))
var minesweeper = settlement.minesweeper
for i in range(2):
minesweeper.settle_batch([{"action": "stop"}])
@@ -159,7 +159,7 @@ func _smoke_playtest_logger() -> void:
if not s1.has(k):
missing.append(k)
Kit.check("快照字段齐备(缺 %s)" % str(missing), missing.is_empty())
Kit.check("快照含英雄/经济/板序号(板 seq=%d)" % int(s1.board.seq), int(s1.hero.max_hp) == int(_db.hero.max_hp) and s1.economy.has("phase") and s1.board.has("seq"))
Kit.check("快照含英雄/经济/板序号(板 seq=%d)" % int(s1.board.seq), int(s1.hero.max_hp) == int(_db.combat.hero.max_hp) and s1.economy.has("phase") and s1.board.has("seq"))
# spawn_count 回归(playtest-2026-09-12 报告 Bug#1):停手两轮零击杀(主角攻击 6×2 < 24 HP 确定)→
# unit_count 必须 == spawn_count(全灭才允许 <);快照级区分「首轮全灭」vs「空场」的数据面在位
Kit.check("快照 spawn_count 在位且 == unit_count(无击杀轮:%d/%d——全灭/空场可判别)" % [int(s1.get("spawn_count", -1)), int(s1.get("unit_count", -1))],
@@ -35,7 +35,7 @@ func _init() -> void:
func _smoke_generated_map_determinism() -> void:
# ① 纯函数复现:图 = f(参数档, 种子)——同档同种子两次产图逐位同图,换种子必变图
var gen = NodeMapGenerator.new(_fresh_rng(910001))
var params2: Dictionary = _db.level_gen_params_row(2)
var params2: Dictionary = _db.run.level_gen_params.row_by_level_index(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)
@@ -60,7 +60,7 @@ func _smoke_generated_map_determinism() -> void:
Kit.check("冒险模式起关受理(入口停点)", runner2.start_level_external(3,
Callable(self, "_smoke_rest_stub"), true))
Kit.check("起关产图零计数器消费(同图不依赖取数号位)", rng_c.counter() == counter_before)
var params3: Dictionary = _db.level_gen_params_row(3)
var params3: Dictionary = _db.run.level_gen_params.row_by_level_index(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)
@@ -81,7 +81,7 @@ func _smoke_generated_map_determinism() -> void:
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", []))
adr_x.runner().nodes() == (gen.generate(_db.run.level_gen_params.row_by_level_index(1), 42) as Dictionary).get("nodes", []))
# ⑤ 断点写→恢复→再产图同图(续玩按主种子+参数档重生成,断点不存图快照)
Kit.check("停点断点落盘在档", sv_w.has_breakpoint())
var bp: Dictionary = sv_w.breakpoint_snapshot()
@@ -96,21 +96,21 @@ func _smoke_generated_map_determinism() -> void:
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"):
for li in range(1, _db.run.level.level_count() + 1):
if (_db.run.level.row_by_level_index(li) as Dictionary).has("templates"):
tpl_free = false
Kit.check("level 表三行均无 templates 键(模板数据已清出)〔v9〕",
tpl_free and _db.level_count() == 3)
tpl_free and _db.run.level.level_count() == 3)
# ⑦ 收束置位:新档 false → settle 置 true 恰一次(幂等)
var sv = SaveManagerScript.new(_bp_path + ".flag.json")
sv.load_from_disk()
Kit.check("新档首次冒险布尔位 = false(默认域——首局恒走固定种子 42)",
sv.is_first_adventure_done() == false)
sv.meta_state.career_state().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)
Kit.check("冒险收束点置位(三结局同写——写入只在收束挂点)", sv.meta_state.career_state().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)
Kit.check("置位幂等(重复收束不翻转)", sv.meta_state.career_state().first_adventure_done == true)
DirAccess.remove_absolute(_bp_path)
DirAccess.remove_absolute(_bp_path + ".flag.json")
@@ -103,7 +103,7 @@ func _test_old_version_save_migrates_and_resumes() -> void:
var sm_old := SaveManagerScript.new(SAVE_PATH)
Kit.check_quiet(sm_old.load_from_disk(), "旧档用例:旧档经迁移链可载")
Kit.check_quiet(sm_old.has_breakpoint(), "旧档用例:迁移后断点未被迁移链吃掉")
var forge: Dictionary = sm_old.forge_routes_snapshot()
var forge: Dictionary = sm_old.meta_state.forge_state()
Kit.check_quiet(str(forge.get("weapon", "")) == "sword",
"旧档用例:迁移补齐当前版本新增域(缺 forge_routes 的旧档按初始持有态可玩)")
+5 -69
View File
@@ -1,16 +1,12 @@
# tests/run_all.gd — 单元测试统一入口(双载体的逻辑测试调用点,套件 manifest 协议 D2 AC-2b)
# 用法:godot --headless --path game --script res://tests/run_all.gd
# 用法:godot --headless --path game --script res://tests/run_all.gd (本地闸 02 闸包装外部超时:timeout 120s …)
# 选跑态(开发期直跑专用):追加 `-- --only=<词[,词…]>` ——对套件 res:// 路径做子串匹配,只跑命中套件;
# 零命中/空值=用法错 quit(2)。选跑态仅供开发期最窄确认,不产闸回执、不作验收凭据;
# 批末全量闸不带参数不经选跑分支。
# 批末全量闸不带参数不经选跑分支——默认(无参数)行为逐字节不变。
# 协议:套件清单逐条"套件 → 期望退出码";致命类独立进程(expect=1)。
# 执行形态:逐套件起独立 Godot 子进程实跑
#(OS.get_executable_path() 解析本引擎二进制——--script 模式无 autoload/全局标识符,
# 每个测试文件自带显式实例化夹具,见 tests/README 协议要点)。
# 挂死守护:每套件子进程带截止时间(默认 120s,环境变量 RUN_ALL_SUITE_TIMEOUT_SEC 可覆盖)。
# 套件挂死(_init 在 quit() 前中断,子进程永不退出)=超时先 taskkill /T 整树回收
#(连 cmd wrapper 带引擎孙进程一起杀,只杀 wrapper 会留孤儿殃及后续全部 Godot 启动)
# 再判该套件 FAIL 继续下一套件——单套件挂死不拖死整批;正常退出路径判定形态不变。
# 场景探针(.tscn 载体)由本地闸(probe_gate.sh)单独步骤调用,不经本入口。
extends SceneTree
@@ -18,11 +14,6 @@ extends SceneTree
# 错误不改进程退出码、不计失败数,只能由调用方扫输出——三硬闸之二)。
const ERROR_MARKERS: Array[String] = ["SCRIPT ERROR", "Parse Error"]
# 每套件截止时间(秒):默认与 probe_gate 每段同额;环境变量可覆盖——服务挂死守护的
# 机制验证与日后调参,非法值/非正值一律回落默认。
const SUITE_TIMEOUT_ENV := "RUN_ALL_SUITE_TIMEOUT_SEC"
const SUITE_TIMEOUT_DEFAULT_SEC := 120
func _init() -> void:
# 选跑态解析:--only=<词[,词…]>(经 -- 分隔的 user args)——对套件 res:// 路径做子串匹配。
@@ -163,69 +154,21 @@ func _run_suite(suite: Dictionary, idx: int, filtered: bool = false) -> int:
+ "非 console 版是 GUI 子系统,子进程 stdout/stderr 句柄无效,脚本错误扫描无从落地")
return 1
# 子进程输出只能落盘再读:OS.execute 的 output 管道在本机 Godot/Windows 组合下恒为空
#(实测 0 行),故整条命令交给 cmd 并重定向到日志。退出码同样走文件通道——
# 阻塞式 execute 无超时能力(套件挂死时整批陪葬),换 create_process 非阻塞起进程
# 后又没有退出码读取 API,故由 cmd /V:ON 在套件退出后把 !ERRORLEVEL! 回显落盘,
# 值与阻塞式 execute 经 cmd 回传的同一。
#(实测 0 行),故整条命令交给 cmd /c 并重定向到日志,退出码由 cmd 原样回传。
var proj := ProjectSettings.globalize_path("res://")
var log := proj + "/tests/.gates/run_all%s_%02d.log" % ["_filter" if filtered else "", idx]
var codefile := log + ".code"
if FileAccess.file_exists(codefile):
DirAccess.remove_absolute(codefile) # 陈值防护:上轮临界残件不许冒充本轮退出码
DirAccess.make_dir_recursive_absolute(proj + "/tests/.gates")
# /V:ON=延迟展开:!ERRORLEVEL! 到 echo 执行时(套件已退出)才取值;
# 延迟展开会吞字面 !,命令串各段(引擎路径/工程路径/套件路径/日志路径)不得含 !。
var cmd := '"%s" --headless --path "%s" --script "%s" > "%s" 2>&1 & echo !ERRORLEVEL! > "%s"' % [bin, proj, path, log, codefile]
var cmd := '"%s" --headless --path "%s" --script "%s" > "%s" 2>&1' % [bin, proj, path, log]
var out := PackedStringArray()
var pid: int = OS.create_process("cmd.exe", PackedStringArray(["/V:ON", "/C", cmd]))
if pid < 0:
push_error("[run_all] FAIL %s — cmd 子进程启动失败(pid=%d)" % [path, pid])
return 1
# 轮询先查存活再查截止:进程在轮询间隙自然退出时按正常路径判,截止临界不误杀。
var timed_out := false
var deadline_ms := Time.get_ticks_msec() + _suite_timeout_sec() * 1000
while true:
if not OS.is_process_running(pid):
break
if Time.get_ticks_msec() >= deadline_ms:
timed_out = true
break
OS.delay_msec(200)
var code: int = OS.execute("cmd.exe", PackedStringArray(["/c", cmd]), out, true, false)
var text := FileAccess.get_file_as_string(log) if FileAccess.file_exists(log) else ""
var negative := bool(suite.get("negative", false))
if timed_out:
var kill_out := PackedStringArray()
OS.execute("taskkill", PackedStringArray(["/F", "/T", "/PID", str(pid)]), kill_out, true)
# taskkill 落刀到进程对象消失有间隔:等死确认再放行下一套件(10s 宽限),
# 确认不了也如实报红留 PID 供手工清杀,不静默放行。
var reap_deadline := Time.get_ticks_msec() + 10000
while OS.is_process_running(pid) and Time.get_ticks_msec() < reap_deadline:
OS.delay_msec(200)
var reaped := not OS.is_process_running(pid)
# 临界窗口:截止到落刀之间套件可能恰好跑完 echo——同删退出码文件,.gates 不积残件。
if FileAccess.file_exists(codefile):
DirAccess.remove_absolute(codefile)
if negative:
print("[run_all] %s:负面套件——全文输出已折叠(输出中的错误为其测试目标,非缺陷)" % path)
else:
print(text)
if reaped:
push_error("[run_all] FAIL %s — 超时挂死(%ds),进程树已整树回收(pid=%d)" % [path, _suite_timeout_sec(), pid])
else:
push_error("[run_all] FAIL %s — 超时挂死(%ds),进程树回收未确认(pid=%d,需手工清杀残留)" % [path, _suite_timeout_sec(), pid])
return 1
# 负面套件(坏表/违规输入是它的测试目标)全文折叠,只回显结论行——
# 它输出里的 Parse Error 是预期产物,回显出去会让编排层的日志扫描误判成真实脚本错误。
if negative:
print("[run_all] %s:负面套件——全文输出已折叠(输出中的错误为其测试目标,非缺陷)" % path)
else:
print(text)
var code_txt := FileAccess.get_file_as_string(codefile).strip_edges() if FileAccess.file_exists(codefile) else ""
if code_txt == "" or not code_txt.is_valid_int():
push_error("[run_all] FAIL %s — 退出码文件缺失或非数字(%s)——子进程未走到退出码落盘即死,判红" % [path, codefile])
return 1
var code: int = int(code_txt)
DirAccess.remove_absolute(codefile) # 用毕即删:退出码经文件通道但不落 residuum(.gates 只留 *.log)
if not negative and _has_script_error(text):
push_error("[run_all] FAIL %s — 输出含脚本错误标记:%s" % [path, _script_error_line(text)])
return 1
@@ -262,13 +205,6 @@ func _engine_bin() -> String:
return ""
func _suite_timeout_sec() -> int:
var env := OS.get_environment(SUITE_TIMEOUT_ENV)
if env != "" and env.is_valid_int() and int(env) > 0:
return int(env)
return SUITE_TIMEOUT_DEFAULT_SEC
func _has_script_error(text: String) -> bool:
return _script_error_line(text) != ""
+2
View File
@@ -66,8 +66,10 @@ func _check_main_scene(main: Node) -> void:
if main == null:
return
var run_scope: Node = main.get("run_scope")
check(run_scope != null, "闲时会话 run_scope 非空(永非 null 契约)")
check(run_scope != null and run_scope.is_inside_tree(), "闲时会话 run_scope 在树")
var start_overlay: Control = main.get("_start_overlay")
check(start_overlay != null, "开始菜单层已装配(_start_overlay)")
check(start_overlay != null and start_overlay.visible, "启动态菜单可见(boot 后玩家见菜单)")
check(main.get("settings_store") != null, "系统设置存储已装配(settings_store)")
check(main.get("debug_badge") != null, "调试入口角标已装配(debug_badge)")
+13 -11
View File
@@ -173,10 +173,12 @@ func _smoke_boot_chain() -> void:
var sm = main.get_node("/root/SaveManager")
# ④-1 boot 路径:启动态钉死(卡① sv2)——boot 无激活态、断点在档恒否,续玩钮恒隐
#(原「按钮态==真实档断点态」关系判据随启动态换代改写:test-conventions §9.5 申报在案,卡② mn8 再承接)
check(not sm.has_breakpoint(),
"V2d boot 无断点在档(启动态不隐式激活——卡① sv2 钉死)〔装配生命批 V2·卡② mn8 改写〕")
# ④-1b 启动态三断言(卡① sv2——真实 autoload boot 链)
check(sm.active_slot() == 0 and not sm.has_write_target(),
check(sm.slots.active_slot() == 0 and not sm.slots.has_write_target(),
"V2-s1 boot 后无激活态(active_slot=0、写盘目标空)〔卡① sv2〕")
check(sm.keystone_balance() == 0 and not sm.has_breakpoint() and sm.adventure_total() == 0,
check(sm.meta_state.keystone_balance() == 0 and not sm.has_breakpoint() and sm.meta_state.career_state().adventure_total == 0,
"V2-s2 boot 内存=默认合法空态(钥石/断点/总局数全零值)〔卡① sv2〕")
check(not sm.write_to_disk(),
"V2-s3 boot 无激活写盘拒绝(write_to_disk=false 生产入口)〔卡① sv3〕")
@@ -185,7 +187,7 @@ func _smoke_boot_chain() -> void:
for p in ["user://smoke_boot_chain.json", "user://smoke_boot_chain.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
sm.attach_path("user://smoke_boot_chain.json")
sm.slots.attach_path("user://smoke_boot_chain.json")
check(main._menu_screen.entersavebtn != null and main._menu_screen.settingsbtn != null
and main._menu_screen.quitbtn != null,
"V2d 三钮在位·进入存档/设置/退出游戏(续玩钮族退役后主菜单操作组承接)〔卡② mn1/mn8〕")
@@ -220,8 +222,8 @@ func _smoke_run_lifecycle() -> void:
for p in ["user://smoke_run_lifecycle.json", "user://smoke_run_lifecycle.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
sm.attach_path("user://smoke_run_lifecycle.json")
sm.mark_first_adventure_done()
sm.slots.attach_path("user://smoke_run_lifecycle.json")
sm.meta_state.mark_first_adventure_done()
# ---- 第一局:开局→注脏→放弃→终局出口→会话收束 ----
main.debug_overlay.seed_edit.text = "42"
main._start_adventure_from_menu()
@@ -276,8 +278,8 @@ func _smoke_sim_guards() -> void:
for p in ["user://smoke_sim_guards.json", "user://smoke_sim_guards.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
sm.attach_path("user://smoke_sim_guards.json")
sm.mark_first_adventure_done()
sm.slots.attach_path("user://smoke_sim_guards.json")
sm.meta_state.mark_first_adventure_done()
# wd6:闲时态 + 模拟运行态 → 调试两层入口守卫早退(开始层不收起、无结算对象)
check(main.run_scope.settlement == null and not main.run_scope.is_adventure,
"前置:闲时会话(settlement 空 + is_adventure=false)")
@@ -418,7 +420,7 @@ func _smoke_lifecycle_matrix() -> void:
for p in ["user://smoke_lifecycle.json", "user://smoke_lifecycle.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
sm.attach_path("user://smoke_lifecycle.json")
sm.slots.attach_path("user://smoke_lifecycle.json")
main.debug_overlay.seed_edit.text = "42"
# 阶段①闲时
await _stage_faces(main, "V3a 闲时", "null", "null", "null",
@@ -511,7 +513,7 @@ func _smoke_lifecycle_matrix() -> void:
# 营地经进档开层(卡② mn9 新路径——campbtn 已撤);槽模板注入隔离命名空间
#(防碰真实槽 user://save_slot_N.json——真实档红线),营地闭合后恢复生命周期 attach 写盘目标
#(卡① sv3 无激活拒写——后续阶段断点写盘依赖)
sm.attach_slot_template("user://smoke_lifecycle_slot_%d.json")
sm.slots.attach_slot_template("user://smoke_lifecycle_slot_%d.json")
main._open_save_select()
for i in range(2):
await process_frame
@@ -544,7 +546,7 @@ func _smoke_lifecycle_matrix() -> void:
main._on_save_select_closed() # 关闭被栈还原的选档层(动线序中间态——卡④ 换代点)
for i in range(2):
await process_frame
sm.attach_path("user://smoke_lifecycle.json") # 恢复生命周期写盘目标
sm.slots.attach_path("user://smoke_lifecycle.json") # 恢复生命周期写盘目标
for suf in ["", ".tmp"]:
var slot_p: String = "user://smoke_lifecycle_slot_1.json" + suf
if FileAccess.file_exists(slot_p):
@@ -576,7 +578,7 @@ func _smoke_lifecycle_matrix() -> void:
for p in ["user://smoke_lifecycle_resume.json", "user://smoke_lifecycle_resume.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
sm.attach_path("user://smoke_lifecycle_resume.json")
sm.slots.attach_path("user://smoke_lifecycle_resume.json")
sm.set_breakpoint({
"master_seed": 42, "rng_counter": 0, "hero_hp": 20, "gp": 24, "keystone": 0,
"level_index": 1, "bonus": {}, "shop_purchased": {}, "relics": [],
@@ -53,7 +53,7 @@ func _harness(db):
var ctrl = DamageControllerScript.new(db, rng, units, effects)
ctrl.emit_row_cb = Callable(self, "_on_row")
effects.notify_battle_start()
var spawn_ok: bool = units.spawn(1, 0, int(db.hero.max_hp))
var spawn_ok: bool = units.spawn({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp)})
return {"db": db, "rng": rng, "units": units, "effects": effects, "ctrl": ctrl, "spawn_ok": spawn_ok}
@@ -79,15 +79,15 @@ func _run_recipe_sequence(db) -> Array:
for e in units.all_enemies():
enemy_hp_before.append(int(e.hp))
var front = units.default_target()
var threshold_expected: int = ctrl.defender_ac(front) + int(h.db.settlement.damage_roll.attack_bonus)
var threshold_expected: int = ctrl.defender_ac(front) + int(h.db.combat.settlement.damage_roll.attack_bonus)
var atk_type := ""
var eff_keys: Array = h.db.settlement.damage_roll.armor_effectiveness.keys()
var eff_keys: Array = h.db.combat.settlement.damage_roll.armor_effectiveness.keys()
if eff_keys.size() > 0:
atk_type = String(eff_keys[0])
var floor_v: int = int(h.db.settlement.damage_roll.damage_floor)
var floor_v: int = int(h.db.combat.settlement.damage_roll.damage_floor)
var t1: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack", "T1主角攻击",
{"channel": "战斗段", "base_value": int(h.db.hero.atk),
{"channel": "战斗段", "base_value": int(h.db.combat.hero.atk),
"target_rule": DamageControllerScript.TARGET_FRONT_ALIVE, "ri": 1}))
# 敌方技来源取后位敌(前位是攻击/直伤/群伤的靶位——来源与靶位分离,免死置干扰配方 redeemed 断言)
var alive_list: Array = units.query_alive("enemy")
@@ -124,9 +124,9 @@ func _run_recipe_sequence(db) -> Array:
var r1: Dictionary = _row_by_display_prefix("T1主角攻击")
var ex1: Dictionary = r1.get("extra", {})
Kit.check("主角攻击行 AC = 防守方 AC + 攻击加成(开单前快照一致)", int(ex1.get("ac", -1)) == threshold_expected)
Kit.check("掷点 ∈ [1, 骰面]", int(ex1.get("roll", -1)) >= 1 and int(ex1.get("roll", -1)) <= int(h.db.settlement.damage_roll.roll_faces))
Kit.check("掷点 ∈ [1, 骰面]", int(ex1.get("roll", -1)) >= 1 and int(ex1.get("roll", -1)) <= int(h.db.combat.settlement.damage_roll.roll_faces))
var tier_ok := false
for dv in h.db.settlement.damage_roll.roll_tier_discounts:
for dv in h.db.combat.settlement.damage_roll.roll_tier_discounts:
if int(dv) == int(ex1.get("tier", -1)):
tier_ok = true
if int(ex1.get("tier", -1)) == 100:
@@ -159,7 +159,7 @@ func _run_recipe_sequence(db) -> Array:
continue
if String(r.source_class).is_empty() or String(r.display).is_empty():
protocol_ok = false
if int(r.value) < int(h.db.settlement.damage_roll.damage_floor):
if int(r.value) < int(h.db.combat.settlement.damage_roll.damage_floor):
protocol_ok = false
if not (r.extra is Dictionary) or not r.extra.has("target"):
protocol_ok = false
@@ -187,11 +187,11 @@ func _test_policy_immediate_mode(db) -> void:
if not h.spawn_ok:
return
h.ctrl.policy = DamageControllerScript.POLICY_IMMEDIATE
var floor_v: int = int(h.db.settlement.damage_roll.damage_floor)
var floor_v: int = int(h.db.combat.settlement.damage_roll.damage_floor)
var t: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "A模式反噬",
{"channel": "棋盘兑付", "base_value": floor_v, "target_rule": DamageControllerScript.TARGET_HERO, "ri": 1}))
Kit.check("A 模式开单即结(无 flush 即 redeemed)", h.ctrl.outcome(t) == "redeemed")
Kit.check("A 模式主角即时扣血", int(h.units.hero_unit().hp) == int(h.db.hero.max_hp) - floor_v)
Kit.check("A 模式主角即时扣血", int(h.units.hero_unit().hp) == int(h.db.combat.hero.max_hp) - floor_v)
# ---- 来源敌死亡:队列中未结单据作废(死亡即不动手) ----
@@ -267,7 +267,7 @@ func _test_target_id_payload(db: ConfigDb) -> void:
var expect_id := String(target.get("enemy_id", ""))
Kit.check("前置:目标敌携非空 enemy_id", expect_id != "")
var ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack", "T6目标id",
{"channel": "战斗段", "base_value": int(db.hero.atk),
{"channel": "战斗段", "base_value": int(db.combat.hero.atk),
"target_rule": DamageControllerScript.TARGET_FRONT_ALIVE, "ri": 1})
var fx_payload: Dictionary = ticket.context.get("fx_attack_payload", {})
Kit.check("attack_commit fx 载荷携 target_id〔wd12〕",
@@ -284,7 +284,7 @@ func _test_requires_gate_and_zero(db) -> void:
var h = _harness(db)
if not h.spawn_ok:
return
var floor_v: int = int(h.db.settlement.damage_roll.damage_floor)
var floor_v: int = int(h.db.combat.settlement.damage_roll.damage_floor)
# 先清场(flush_submit 同步开单——清图批专用通道的契约面同时被覆盖)
var front_hp: int = int(h.units.query_alive("enemy")[0].max_hp)
var big: int = front_hp * 10
@@ -294,7 +294,7 @@ func _test_requires_gate_and_zero(db) -> void:
var t_zero: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward", "空场群伤",
{"channel": "棋盘兑付", "base_value": floor_v, "target_rule": DamageControllerScript.TARGET_ALL_ALIVE, "ri": 1}))
var gated_ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack", "前置门攻击",
{"channel": "战斗段", "base_value": int(h.db.hero.atk),
{"channel": "战斗段", "base_value": int(h.db.combat.hero.atk),
"target_rule": DamageControllerScript.TARGET_FRONT_ALIVE, "ri": 1})
gated_ticket["requires"] = t_zero
var t_gated: int = h.ctrl.submit(gated_ticket)
@@ -310,7 +310,7 @@ func _test_hero_death_halt(db) -> void:
var h = _harness(db)
if not h.spawn_ok:
return
var max_hp: int = int(h.db.hero.max_hp)
var max_hp: int = int(h.db.combat.hero.max_hp)
var t_kill: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "致死反噬",
{"channel": "棋盘兑付", "base_value": max_hp, "target_rule": DamageControllerScript.TARGET_HERO, "ri": 1}))
var t_after: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "余单",
@@ -327,14 +327,14 @@ func _test_ac_merge(db) -> void:
var h = _harness(db)
if not h.spawn_ok:
return
var base_ac: int = int(h.db.hero.armor_class)
var base_ac: int = int(h.db.combat.hero.armor_class)
Kit.check("缺省兜底:defender_ac(hero) = hero 表值", h.ctrl.defender_ac(h.units.hero_unit()) == base_ac)
var override_v: int = base_ac + int(h.db.settlement.damage_roll.damage_floor)
var override_v: int = base_ac + int(h.db.combat.settlement.damage_roll.damage_floor)
h.ctrl.set_hero_ac_override(override_v)
Kit.check("override 合流入参生效", h.ctrl.defender_ac(h.units.hero_unit()) == override_v)
# ac 型状态效果叠其上(找 hero 侧 unit=ac 行)
var ac_eff := {}
for row in h.db.status_effects.rows:
for row in h.db.combat.status_effects.rows:
if String(row.get("unit", "")) == "ac" and String(row.get("target_side", "")) == "hero":
ac_eff = row
break
@@ -354,15 +354,15 @@ func _test_pool_clamp(db) -> void:
var h = _harness(db)
if not h.spawn_ok:
return
var lo: int = int(h.db.settlement.core.pool_clamp_lo)
var hi: int = int(h.db.settlement.core.pool_clamp_hi)
var lo: int = int(h.db.combat.settlement.core.pool_clamp_lo)
var hi: int = int(h.db.combat.settlement.core.pool_clamp_hi)
Kit.check("空效果池读 0(表 clamp 域含 0 前提)", h.ctrl.clamped_pool("hero_offense") == 0 and lo <= 0 and hi >= 0)
# 找 hero_offense 池的 percent 型 hero 侧效果行
var pool_eff := {}
for row in h.db.status_effects.rows:
for row in h.db.combat.status_effects.rows:
var eid := String(row.get("effect_id", ""))
if String(row.get("unit", "")) == "percent" and String(row.get("target_side", "")) == "hero" \
and String(h.db.settlement.core.effect_pool_map.get(eid, "")) == "hero_offense":
and String(h.db.combat.settlement.core.effect_pool_map.get(eid, "")) == "hero_offense":
pool_eff = row
break
if pool_eff.is_empty():
@@ -377,7 +377,7 @@ func _test_pool_clamp(db) -> void:
second_ok and h.ctrl.clamped_pool("hero_offense") == expected1)
# ac 型效果不入 percent 池
var ac_eff2 := {}
for row2 in h.db.status_effects.rows:
for row2 in h.db.combat.status_effects.rows:
if String(row2.get("unit", "")) == "ac" and String(row2.get("target_side", "")) == "hero":
ac_eff2 = row2
break
@@ -387,7 +387,7 @@ func _test_pool_clamp(db) -> void:
Kit.check("ac 型效果不入池(percent 池隔离)", h.ctrl.clamped_pool("hero_offense") == before)
# stack 政策效果:全新容器授予至满层 → 再授拒绝(满层拒绝原子——容器零写入)
var stack_eff := {}
for row3 in h.db.status_effects.rows:
for row3 in h.db.combat.status_effects.rows:
if String(row3.get("stack_policy", "")) == "stack":
stack_eff = row3
break
@@ -24,7 +24,7 @@ func _init() -> void:
func _floor_val() -> int:
return int(db.settlement.damage_roll.damage_floor)
return int(db.combat.settlement.damage_roll.damage_floor)
# ---- F2 乘区:池内加算 → 池间乘算 → floor(单一取整规则)----
@@ -32,11 +32,11 @@ func _floor_val() -> int:
func _test_pool_damage() -> void:
var d := _floor_val() + 9 # 表值推导的任意正探针量(非玩法行值)
Kit.check("中性双池 (0,0) 原样透传", DamageMathScript.pool_damage(d, 0, 0) == d)
Kit.check("负敌方池压低不增高", DamageMathScript.pool_damage(d, 0, int(db.settlement.core.pool_clamp_lo)) <= d)
Kit.check("正·主角池抬升不低于原值", DamageMathScript.pool_damage(d, int(db.settlement.core.pool_clamp_hi), 0) >= d)
Kit.check("负敌方池压低不增高", DamageMathScript.pool_damage(d, 0, int(db.combat.settlement.core.pool_clamp_lo)) <= d)
Kit.check("正·主角池抬升不低于原值", DamageMathScript.pool_damage(d, int(db.combat.settlement.core.pool_clamp_hi), 0) >= d)
Kit.check("主角池单调不减(+50% vs +49% 探针档)", DamageMathScript.pool_damage(d, 50, 0) >= DamageMathScript.pool_damage(d, 49, 0))
Kit.check("对称性:池值互换结果不变(乘算交换)", DamageMathScript.pool_damage(d, 30, -20) == DamageMathScript.pool_damage(d, -20, 30))
Kit.check("输出非负(maxi(0,·) 封底)", DamageMathScript.pool_damage(d, int(db.settlement.core.pool_clamp_lo), int(db.settlement.core.pool_clamp_lo)) >= 0)
Kit.check("输出非负(maxi(0,·) 封底)", DamageMathScript.pool_damage(d, int(db.combat.settlement.core.pool_clamp_lo), int(db.combat.settlement.core.pool_clamp_lo)) >= 0)
# ---- 克制分量:空桶不吃修正恒 0;落地下限保底([1,+∞) 全来源)----
@@ -54,8 +54,8 @@ func _test_counter_component() -> void:
func _test_roll_discount_percent() -> void:
var threshold := 10 # 查档探针阈值(夹具值——档位参数本身全部来自表)
var bounds: Array = db.settlement.damage_roll.roll_tier_bounds
var discounts: Array = db.settlement.damage_roll.roll_tier_discounts
var bounds: Array = db.combat.settlement.damage_roll.roll_tier_bounds
var discounts: Array = db.combat.settlement.damage_roll.roll_tier_discounts
Kit.check("前置:档位数 = 边界数 + 1(查档契约——档数 = 边界数 + 1)", discounts.size() == bounds.size() + 1 and discounts.size() > 0)
Kit.check("掷点 ≥ 阈值恒全额 100", DamageMathScript.roll_discount_percent(threshold, threshold, bounds, discounts) == 100
and DamageMathScript.roll_discount_percent(threshold + 5, threshold, bounds, discounts) == 100)
@@ -78,7 +78,7 @@ func _test_roll_discount_percent() -> void:
func _test_rolled_component() -> void:
var base := float(_floor_val() + 7) # 表值推导探针基线
var crit_mult: int = int(db.settlement.damage_roll.crit_multiplier)
var crit_mult: int = int(db.combat.settlement.damage_roll.crit_multiplier)
Kit.check("满额折扣无暴击 = floor(基线) + 修正 0", DamageMathScript.rolled_component(base, 100, false, crit_mult, 0) == int(floor(base)))
Kit.check("满额折扣暴击按表倍率放大", DamageMathScript.rolled_component(base, 100, true, crit_mult, 0) == int(floor(base * float(crit_mult) / 100.0)))
Kit.check("折扣降低不高于满额", DamageMathScript.rolled_component(base, 50, false, crit_mult, 0) <= DamageMathScript.rolled_component(base, 100, false, crit_mult, 0))
@@ -47,13 +47,13 @@ func _smoke_enemy_attack_type() -> void:
# ① 表列:20 行全填 normal(N3 全填不做差异化——Q9)
var at_ok := true
var at_detail := ""
for row in _db.enemy.rows:
for row in _db.combat.enemy.rows:
var at := String(row.get("attack_type", ""))
if at != "normal":
at_ok = false
at_detail += " %s:%s" % [String(row.get("enemy_id", "?")), at]
Kit.check("敌方 %d 行 attack_type 全填 normal(迭代 24 N3 轴开列,越轨行%s)" % [
_db.enemy.rows.size(), at_detail if at_detail != "" else "无"], at_ok)
_db.combat.enemy.rows.size(), at_detail if at_detail != "" else "无"], at_ok)
# ②③④ 矩阵命中证词扫描:(3,0) 灰矮人 atk 区间 [2,4] 基线足够((1,2) 熊地精 atk 1 会塌地板);
# 同 seed 三甲型各开一场、各取首个敌方伤害行,对拍 ±1。
var matrix_found := false
@@ -70,7 +70,7 @@ func _smoke_enemy_attack_type() -> void:
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 3, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "armor_type": armor}):
if not settlement.begin_battle({"level_index": 3, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp), "armor_type": armor}):
totals[armor] = -1
continue
if armor == "light":
@@ -109,7 +109,7 @@ func _smoke_armor_roll() -> void:
var dist := {100: 0, 75: 0, 50: 0, 25: 0}
var tier_ok := true
for roll in range(1, 21):
var d: int = DamageMath.roll_discount_percent(roll, cfg.ac, _db.settlement.damage_roll.roll_tier_bounds, _db.settlement.damage_roll.roll_tier_discounts)
var d: int = DamageMath.roll_discount_percent(roll, cfg.ac, _db.combat.settlement.damage_roll.roll_tier_bounds, _db.combat.settlement.damage_roll.roll_tier_discounts)
if not dist.has(d):
tier_ok = false
continue
@@ -117,7 +117,7 @@ func _smoke_armor_roll() -> void:
Kit.check("查档 AC%d 全掷点域分布(100/75/50/25 = %s)" % [cfg.ac, str([dist[100], dist[75], dist[50], dist[25]])], tier_ok and dist == cfg.expect)
var ev_sum := 0
for roll in range(1, 21):
ev_sum += DamageMath.roll_discount_percent(roll, 10, _db.settlement.damage_roll.roll_tier_bounds, _db.settlement.damage_roll.roll_tier_discounts)
ev_sum += DamageMath.roll_discount_percent(roll, 10, _db.combat.settlement.damage_roll.roll_tier_bounds, _db.combat.settlement.damage_roll.roll_tier_discounts)
Kit.check("AC10 EV 锚:期望系数合计 %d/2000 = 期望减免 21.25%%(nat20 恒全额含内)" % ev_sum, ev_sum == 1575)
# ② nat20 暴击(行为面):探针按行为面扫描
# 「主角先手首掷 = 20」的种子,不假设 armor_roll scope0 位
@@ -125,20 +125,21 @@ func _smoke_armor_roll() -> void:
var crit_rows := {}
for s in range(1, 800):
crit_rows = _first_hero_attack_rows(s)
if int(crit_rows.get("roll", -1)) == int(_db.settlement.damage_roll.crit_roll):
if int(crit_rows.get("roll", -1)) == int(_db.combat.settlement.damage_roll.crit_roll):
crit_seed = s
break
Kit.check("nat20 → 行留档 roll=20 + crit 标记(主角先手普通分量——种子可达 seed=%d)" % crit_seed,
int(crit_rows.get("roll", -1)) == int(_db.settlement.damage_roll.crit_roll) and bool(crit_rows.get("crit", false)))
Kit.check("nat20 → 普通分量 ×2(%d = 2×%d,暴击倍率只乘该分量,穿刺/魔法不吃)" % [int(crit_rows.get("normal", -1)), int(_db.hero.atk)],
int(crit_rows.get("normal", -1)) == 2 * int(_db.hero.atk) and int(crit_rows.get("value", -1)) == 2 * int(_db.hero.atk))
Kit.check("nat20 种子可达(seed=%d——主角先手首掷命中 %d)" % [crit_seed, int(_db.combat.settlement.damage_roll.crit_roll)], crit_seed > 0)
Kit.check("nat20 → 行留档 roll=20 + crit 标记(主角先手普通分量)",
int(crit_rows.get("roll", -1)) == int(_db.combat.settlement.damage_roll.crit_roll) and bool(crit_rows.get("crit", false)))
Kit.check("nat20 → 普通分量 ×2(%d = 2×%d,暴击倍率只乘该分量,穿刺/魔法不吃)" % [int(crit_rows.get("normal", -1)), int(_db.combat.hero.atk)],
int(crit_rows.get("normal", -1)) == 2 * int(_db.combat.hero.atk) and int(crit_rows.get("value", -1)) == 2 * int(_db.combat.hero.atk))
# ③ 削弱续时(行为面):授予→衰减一轮→再授予 → 层数保持 1、剩余结算数重置
# (削弱为续时语义,无叠层通道)
var weaken_probe := _weaken_refresh_probe()
Kit.check("削弱重复授予 → 层数保持 1、剩余结算数重置 2(续时语义——结构性消饱和)",
int(weaken_probe.get("layers", -1)) == 1 and int(weaken_probe.get("remaining", -1)) == 2)
Kit.check("削弱单层池读数 = 行值(enemy_output 池单层贡献——×2 叠满触底 ×0 不可达,免伤轮根治)",
int(weaken_probe.get("pool", -999)) == int(_db.status_effect_row("weaken").magnitude))
int(weaken_probe.get("pool", -999)) == int(_db.combat.status_effects.row_by_id("weaken").magnitude))
# ④ AC11 折扣档分布(行为面):ac_up 逐轮授给(时效 1)→ Boss 行攻击拍逐拍判定
# (ac_up 层数恒 1——局内可达 AC 上限 10+1=11,探针随行取值)
var tiers := _ac11_tier_samples()
@@ -171,7 +172,7 @@ func _smoke_armor_roll() -> void:
Kit.check("棋盘来源伤害行零掷点字段(爆裂/直伤/清障炸药/奖励即时必中全额)", board_ok)
# ⑥ 全来源伤害下限(任何伤害落地取值域 [1,+∞) 无上限——棋盘克制直伤/反噬/地形变异/爆裂
# 全部收在 damage_floor 闸内)
var df: int = int(_db.settlement.damage_roll.damage_floor)
var df: int = int(_db.combat.settlement.damage_roll.damage_floor)
Kit.check("棋盘克制直伤贴底 → 下限 1(点数 2 vs 不利修正 −1)", DamageMath.counter_component(2, -1, df) == 1)
Kit.check("克制修正触底穿透 → 下限 1(点数 1 vs 修正 −3)", DamageMath.counter_component(1, -3, df) == 1)
Kit.check("空桶不吃修正恒 0(零点数分量无伤害可落地——边界不变不受下限影响)", DamageMath.counter_component(0, 5, df) == 0)
@@ -184,7 +185,7 @@ func _first_hero_attack_rows(seed_val: int) -> Dictionary:
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}):
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)}):
return {}
settlement.minesweeper.settle_batch([{"action": "stop"}])
for row in rows:
@@ -200,7 +201,7 @@ func _weaken_refresh_probe() -> Dictionary:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260916)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}):
if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.combat.hero.max_hp)}):
return {}
var src: Dictionary = {"source_class": "mine_hit", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index}
settlement._status_effects.apply_effect("weaken", src)
@@ -223,7 +224,7 @@ func _ac11_tier_samples() -> Array:
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}):
if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.combat.hero.max_hp)}):
continue
var guard := 12
while settlement.battle_result == "" and guard > 0:
@@ -258,7 +259,7 @@ func _run_battle(L: int, D: int, axes: Dictionary = {}) -> Dictionary:
settlement.minesweeper.seal_judged.connect(func(evt): out.events.append(evt))
settlement.minesweeper.round_end_board.connect(func(p): out.events.append({"ev": "round_end_board", "path_tag": p.path_tag}))
settlement.action_economy.round_end_stopped.connect(func(p): out.events.append({"ev": "round_end_stopped", "path_tag": p.path_tag}))
var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.hero.max_hp}
var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.combat.hero.max_hp}
enc.merge(axes, true) # 两根类型轴注入尾参(缺省零注入=回退面)
if not settlement.begin_battle(enc):
out.stream.append({"type": "verdict", "value": "begin_failed"})
@@ -324,7 +325,7 @@ func _hero_attack_row_with(opts: Dictionary, hero_atk_val: int, seed_val: int) -
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_atk": hero_atk_val}):
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp), "hero_atk": hero_atk_val}):
return {}
for eff in opts.get("effects", []):
settlement._status_effects.apply_effect(String(eff), {"source_class": "mine_hit", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index})
@@ -342,15 +343,15 @@ func _smoke_damage_types() -> void:
# ① 护甲类型快照传播(spawn 行值 → 单位字典)
var enemy_system = load("res://core/unit_container.gd").new(_db)
Kit.check("goblin 护甲 = light(轻甲——教学位)",
enemy_system.spawn(1, 0, int(_db.hero.max_hp)) and String(enemy_system.all_enemies()[0].armor_type) == "light")
enemy_system.spawn({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)}) and String(enemy_system.all_enemies()[0].armor_type) == "light")
var enemy_system_b = load("res://core/unit_container.gd").new(_db)
Kit.check("bugbear 护甲 = light(护甲类型重配批 2026-09-18:关卡 1 全轻甲)",
enemy_system_b.spawn(1, 2, int(_db.hero.max_hp)) and String(enemy_system_b.all_enemies()[0].armor_type) == "light")
enemy_system_b.spawn({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.combat.hero.max_hp)}) and String(enemy_system_b.all_enemies()[0].armor_type) == "light")
var enemy_system_c = load("res://core/unit_container.gd").new(_db)
Kit.check("ogre_chief 护甲 = light(Boss 行集——护甲类型重配批 2026-09-18:关卡 1 全轻甲)",
enemy_system_c.spawn(1, 4, int(_db.hero.max_hp), [], -1, -1, [], "", "boss") and String(enemy_system_c.all_enemies()[0].armor_type) == "light")
enemy_system_c.spawn({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "hero_armor_type": "boss"}) and String(enemy_system_c.all_enemies()[0].armor_type) == "light")
# ② 克制矩阵表值在位(三档点数 克制 +1 / 中性 0 / 被克 −1,加法核非 percent)
var am: Dictionary = _db.settlement.damage_roll.armor_effectiveness
var am: Dictionary = _db.combat.settlement.damage_roll.armor_effectiveness
Kit.check("普通行:轻甲 0 / 魔法护甲 +1 / 重甲 −1(加法核)",
int(am.normal.light) == 0 and int(am.normal.ward) == 1 and int(am.normal.heavy) == -1)
Kit.check("穿刺行:轻甲 −1 / 魔法护甲 +1 / 重甲 0 中性(用户拍板)",
@@ -386,7 +387,7 @@ func _run_seal_backlash_round(seed_val: int) -> Dictionary:
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}):
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"}):
return {"rows": rows, "safe_cell": Vector2i(-1, -1), "safe_type": ""}
var minesweeper = settlement.minesweeper
var safe_t := _find_seal_target(minesweeper, false)
@@ -442,7 +443,7 @@ func _smoke_attack_chain_fresh_pool() -> void:
ticks.append({"baseline": int(row.baseline), "attach": int(row.get("rider", 0)), "roll": int(row.roll),
"tier": int(row.tier), "crit": bool(row.get("crit", false)), "tick": int(row.value)}))
Kit.check("fresh 池回归战斗启动(Boss 行)",
settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}))
settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"}))
# 停手轮:零挖掘零封印 → 零授予 → 池恒空;Boss 拍 = 开轮摇号结果(伤害技/普攻兑付伤害行、硬化施放行不计)
for i in range(6):
if settlement.battle_result != "":
@@ -451,9 +452,9 @@ func _smoke_attack_chain_fresh_pool() -> void:
Kit.check("非施放攻击拍采样充足(%d 拍——增益施放行零兑付为设计语义不计)" % ticks.size(), ticks.size() >= 2)
var all_full := true
for t in ticks:
var base_factor: float = float(t.tier) * (float(_db.settlement.damage_roll.crit_multiplier) / 100.0 if t.crit else 1.0)
var base_factor: float = float(t.tier) * (float(_db.combat.settlement.damage_roll.crit_multiplier) / 100.0 if t.crit else 1.0)
var rolled_base := int(floor(float(t.baseline) * base_factor / 100.0))
var expected: int = maxi(int(_db.settlement.damage_roll.damage_floor), rolled_base + int(t.get("attach", 0)))
var expected: int = maxi(int(_db.combat.settlement.damage_roll.damage_floor), rolled_base + int(t.get("attach", 0)))
if int(t.tick) != expected or int(t.tick) <= 0:
all_full = false
Kit.check("fresh 池非蓄力拍 d_tick = max(1, 掷点档位基线)(Boss 攻击链健康——Bug#2 回归断言)", all_full)
@@ -58,6 +58,7 @@ func _smoke_full_battle(L: int, D: int, label: String) -> void:
break
Kit.check("%s 回放断言:结算步骤流逐行相同" % label, JSON.stringify(run1.stream) == JSON.stringify(run2.stream))
Kit.check("%s 回放断言:事件流逐位相同" % label, JSON.stringify(run1.events) == JSON.stringify(run2.events))
Kit.check("%s 结算步骤流非空(%d 行)" % [label, run1.stream.size()], run1.stream.size() > 0)
var has_verdict := false
var hero_attacks := 0
var hero_breakdown_ok := true
@@ -68,7 +69,7 @@ func _smoke_full_battle(L: int, D: int, label: String) -> void:
hero_attacks += 1
# 单分量伤害构成行:hero_attack 行必须携带实际攻击类型键 + 掷点读数(P2 归因)
# 类型键主语 = 锻造工坊配置默认值(hero.tres 无两轴字段;本夹具零注入)
var atk_t: String = String(_db.forge_default_attack_type())
var atk_t: String = String(_db.crossrun.forge.default_attack_type)
if not (row.has(atk_t) and row.has("roll") and row.has("ac") and row.has("tier")):
hero_breakdown_ok = false
Kit.check("%s 步骤流含判定行" % label, has_verdict)
@@ -92,7 +93,7 @@ func _run_battle(L: int, D: int, axes: Dictionary = {}) -> Dictionary:
settlement.action_economy.round_end_stopped.connect(func(p): out.events.append({"ev": "round_end_stopped", "path_tag": p.path_tag}))
var minesweeper = settlement.minesweeper
var action_economy = settlement.action_economy
var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.hero.max_hp}
var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.combat.hero.max_hp}
enc.merge(axes, true) # 两根类型轴注入尾参(缺省零注入=回退面)
if not settlement.begin_battle(enc):
out.stream.append({"type": "verdict", "value": "begin_failed"})
@@ -156,12 +157,12 @@ func _smoke_active_refresh() -> void:
if String(row.get("type", "")) == "decay" and not bool(ct["waiting_probe_hit"]):
ct["waiting_probe_hit"] = true
ct["waiting_refresh_rejected"] = not settlement.player_request_refresh())
Kit.check("轮内刷新战斗启动(Boss 行 150HP——承载多轮样本)", settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}))
Kit.check("轮内刷新战斗启动(Boss 行 150HP——承载多轮样本)", settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"}))
var minesweeper = settlement.minesweeper
# 轮 1–3:零挖掘停手攒 RAP = 3×AP(每轮全转存;表驱动——AP 从表读不写死)
for i in range(3):
minesweeper.settle_batch([{"action": "stop"}])
var rap_banked: int = 3 * int(_db.action_economy.ap_per_round)
var rap_banked: int = 3 * int(_db.run.action_economy.ap_per_round)
Kit.check("三轮停手攒 RAP=%d(round=%d rap=%d)" % [rap_banked, settlement.round_index, settlement.action_economy.rap], settlement.round_index == 4 and settlement.action_economy.rap == rap_banked)
Kit.check("第 4 轮 Active 相位", settlement.action_economy.phase == "Active")
# 挖 1 格 → 刷 → 挖 1 格 → 刷 → 挖 1 格 → 第 3 刷拒(上限)
@@ -175,7 +176,7 @@ func _smoke_active_refresh() -> void:
var ok1: bool = settlement.player_request_refresh()
Kit.check("轮内第 1 次刷新受理", ok1)
Kit.check("相位保持 Active、AP 保留不重置(ap=%d)" % settlement.action_economy.ap, settlement.action_economy.phase == "Active" and settlement.action_economy.ap == ap_before)
Kit.check("RAP -刷新价(%d)" % settlement.action_economy.rap, settlement.action_economy.rap == rap_banked - int(_db.action_economy.rap_refresh_cost))
Kit.check("RAP -刷新价(%d)" % settlement.action_economy.rap, settlement.action_economy.rap == rap_banked - int(_db.run.action_economy.rap_refresh_cost))
Kit.check("当场换新图(board_changed +1)", int(ct["board_swaps"]) == swaps_before + 1 and minesweeper.current_board() != board_before)
var plan2 = AutoPlayer.choose_dig(minesweeper)
if plan2.hero_cell != minesweeper.hero_pos():
@@ -220,7 +221,7 @@ func _active_refresh_replay() -> Array:
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}):
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"}):
return rows
var minesweeper = settlement.minesweeper
for i in range(3):
@@ -250,7 +251,7 @@ func _smoke_encounter_roll() -> void:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(30000 + i)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
Kit.check("遭遇扫描战斗启动(quiet 计数档——计数入总账,成败由后续 spawn 断言归因)", settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})) # 源 quiet=true 计数档——Kit 通道无此参(只影响 PASS 行打印,计数/红绿同口径)
Kit.check("遭遇扫描战斗启动(quiet 计数档——计数入总账,成败由后续 spawn 断言归因)", settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)})) # 源 quiet=true 计数档——Kit 通道无此参(只影响 PASS 行打印,计数/红绿同口径)
counts.append(settlement.presentation_snapshot().enemies.size())
# 区间生效:20 个种子应出现 1 和 2(D0 [1,2])
Kit.check("D0 区间 [1,2] 两值均出现(分布 %s)" % str(counts), counts.has(1) and counts.has(2))
@@ -268,13 +269,13 @@ func _smoke_encounter_roll() -> void:
var rng3 = load("res://core/rng_service.gd").new()
rng3.set_master_seed(31000)
var settlement_d1 = load("res://core/battle_orchestrator.gd").new(_db, rng3)
Kit.check("D1 战斗启动", settlement_d1.begin_battle({"level_index": 1, "node_depth": 1, "hero_hp": int(_db.hero.max_hp)}))
Kit.check("D1 战斗启动", settlement_d1.begin_battle({"level_index": 1, "node_depth": 1, "hero_hp": int(_db.combat.hero.max_hp)}))
var n1: int = settlement_d1.presentation_snapshot().enemies.size()
Kit.check("D1 区间 [2,3] 域内(%d 只)" % n1, n1 >= 2 and n1 <= 3)
# 熊地精首遇带固定(教学位不加骰)——首遇带数据驱动定位(契约 8,禁写死坐标)
var bugbear_lead_band := _first_encounter_band(1, "bugbear")
var bugbear_lead_row: Dictionary = {}
for row in _db.enemy.rows:
for row in _db.combat.enemy.rows:
if int(row.level_index) == 1 and String(row.row_kind) == "battle" \
and int(row.depth_band) == bugbear_lead_band and String(row.enemy_id) == "bugbear":
bugbear_lead_row = row
@@ -282,7 +283,7 @@ func _smoke_encounter_roll() -> void:
var rng4 = load("res://core/rng_service.gd").new()
rng4.set_master_seed(31000)
var settlement_d2 = load("res://core/battle_orchestrator.gd").new(_db, rng4)
settlement_d2.begin_battle({"level_index": 1, "node_depth": bugbear_lead_band, "hero_hp": int(_db.hero.max_hp)})
settlement_d2.begin_battle({"level_index": 1, "node_depth": bugbear_lead_band, "hero_hp": int(_db.combat.hero.max_hp)})
Kit.check("熊地精首遇带(1,battle,%d——扫表取最低带)固定 1 不加骰〔fv23〕" % bugbear_lead_band,
settlement_d2.presentation_snapshot().enemies.size() == 1 \
and int(bugbear_lead_row.get("unit_count", -1)) == 1 and not bugbear_lead_row.has("unit_count_max"))
@@ -299,11 +300,11 @@ func _smoke_encounter_roll() -> void:
for depth_v in depths:
scan_total += 1
var settlement_scan = load("res://core/battle_orchestrator.gd").new(_db, rngs)
if not settlement_scan.begin_battle({"level_index": 1, "node_depth": int(depth_v), "hero_hp": int(_db.hero.max_hp)}):
if not settlement_scan.begin_battle({"level_index": 1, "node_depth": int(depth_v), "hero_hp": int(_db.combat.hero.max_hp)}):
scan_bad += 1
continue
var n: int = settlement_scan.presentation_snapshot().enemies.size()
var erow2: Dictionary = _db.resolve_enemy_rowset(1, int(depth_v), "battle")[0] # 行集首行(扫描面 = 战斗节点)
var erow2: Dictionary = _db.combat.enemy.resolve_rowset(1, int(depth_v), "battle")[0] # 行集首行(扫描面 = 战斗节点)
var lo2: int = int(erow2.get("unit_count", 1))
var hi2: int = int(erow2.get("unit_count_max", lo2))
if n < 1 or n < lo2 or n > hi2:
@@ -328,7 +329,7 @@ func _row_kind_of(level_index: int, depth: int) -> String:
# 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字
func _first_encounter_band(level_index: int, enemy_id: String) -> int:
var best := -1
for row in _db.enemy.rows:
for row in _db.combat.enemy.rows:
if int(row.get("level_index", -1)) != level_index:
continue
if String(row.get("row_kind", "")) != "battle" or String(row.get("enemy_id", "")) != enemy_id:
@@ -344,12 +345,12 @@ func _level_active_nodes(level_index: int) -> Array:
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", [])
return (gen.generate(_db.run.level_gen_params.row_by_level_index(level_index), 42) as Dictionary).get("nodes", [])
func _level_battle_nodes(level_index: int) -> Array:
var out: Array = []
for row in _db.level.rows:
for row in _db.run.level.rows:
if int(row.level_index) == level_index:
for node in _level_active_nodes(level_index):
var t := String(node.get("type", ""))
@@ -363,7 +364,7 @@ func _encounter_replay(seed_val: int) -> Array:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)})
out.append(settlement.presentation_snapshot().enemies.size())
return out
@@ -377,7 +378,7 @@ func _encounter_replay(seed_val: int) -> Array:
func _smoke_settlement_segments() -> void:
print("--- 五段全序 + 行型封闭 ---")
Kit.check("segment_order 五段冻结在位(表值 = 拍板序恒等)",
Array(_db.settlement.segment_order) == SettlementTable.FROZEN_SEGMENT_ORDER
Array(_db.combat.settlement.segment_order) == SettlementTable.FROZEN_SEGMENT_ORDER
and SettlementTable.FROZEN_SEGMENT_ORDER == ["grant", "buff_debuff", "combat", "decay", "verdict"])
var run = _run_battle(1, 0)
var legacy := {"item": 0, "consumed_ineffective": 0, "use_rejected": 0, "window_open": 0, "window_skip": 0}
@@ -492,53 +493,53 @@ func _smoke_skill_pool() -> void:
# ① 数据面:技能表行集 + 普攻锁死 + 建议池行值 + 空池不可能(结构断言)
func _smoke_skill_pool_data() -> void:
Kit.check("技能表十六行(普攻/突刺/蓄力/重击/硬化 + 撕咬/毒爆/蛛网束缚 + 大地脉动 + 震地怒击/遏毒/岩崩 + 毒吐/盾击——前三批 + Boss 半血解锁技三行〔迭代14④ T5〕+ 普通敌错峰两行〔迭代 24 N2〕+ 触发条件批示范两行〔孤注守势/趁隙偷袭〕)", _db.skill.rows.size() == 16)
var basic: Dictionary = _db.skill.row_by_id("attack")
Kit.check("技能表十六行(普攻/突刺/蓄力/重击/硬化 + 撕咬/毒爆/蛛网束缚 + 大地脉动 + 震地怒击/遏毒/岩崩 + 毒吐/盾击——前三批 + Boss 半血解锁技三行〔迭代14④ T5〕+ 普通敌错峰两行〔迭代 24 N2〕+ 触发条件批示范两行〔孤注守势/趁隙偷袭〕)", _db.combat.skill.rows.size() == 16)
var basic: Dictionary = _db.combat.skill.row_by_id("attack")
Kit.check("普攻行:倍率恒 1 · 冷却恒 0(域校验锁死——池恒非空的结构前提)",
not basic.is_empty() and String(basic.type) == "basic" and int(basic.cooldown_beats) == 0
and (not basic.has("damage_mult") or is_equal_approx(float(basic.damage_mult), 1.0)))
var lunge: Dictionary = _db.skill.row_by_id("lunge")
var lunge: Dictionary = _db.combat.skill.row_by_id("lunge")
Kit.check("突刺行(伤害 ×1.5 · 冷却 2——提案 §2.5 建议池新行)", not lunge.is_empty() and is_equal_approx(float(lunge.damage_mult), 1.5) and int(lunge.cooldown_beats) == 2)
var charge_row: Dictionary = _db.skill.row_by_id("charge")
var charge_row: Dictionary = _db.combat.skill.row_by_id("charge")
Kit.check("蓄力行 = 纯攻击增益技(0 伤害、buff_ref → empower——与重击无组合语义)", not charge_row.is_empty() and String(charge_row.buff_ref) == "empower")
var bite: Dictionary = _db.skill.row_by_id("bite")
var bite: Dictionary = _db.combat.skill.row_by_id("bite")
Kit.check("撕咬行(伤害 ×1.5 · 冷却 1——巨蜘蛛池载体,与突刺同倍率错开冷却轴)", not bite.is_empty() and is_equal_approx(float(bite.damage_mult), 1.5) and int(bite.cooldown_beats) == 1)
var venom: Dictionary = _db.skill.row_by_id("venom_burst")
var venom: Dictionary = _db.combat.skill.row_by_id("venom_burst")
Kit.check("毒爆行(伤害 ×2.5 · 冷却 4 · 必中附加 ×0.75——蛛化卓尔精灵专属池独占技,新效应词汇批)",
not venom.is_empty() and is_equal_approx(float(venom.damage_mult), 2.5) and int(venom.cooldown_beats) == 4
and is_equal_approx(float(venom.rider_mult), 0.75))
var venom_sole_ok := true
for row in _db.enemy.rows:
for row in _db.combat.enemy.rows:
for entry in row.pool:
if String(entry.skill_id) == "venom_burst" and String(row.enemy_id) != "drider":
venom_sole_ok = false
Kit.check("毒爆独占性(仅蛛化卓尔精灵池引用——Boss 专属池独占技纪律)", venom_sole_ok)
# 妨害技首发行 + off_guard 落点 + 实装面收窄
var snare: Dictionary = _db.skill.row_by_id("web_snare")
var snare: Dictionary = _db.combat.skill.row_by_id("web_snare")
Kit.check("蛛网束缚行(妨害型 · buff_ref → off_guard 失衡 · duration 2 = 1 有效拍 · 冷却 4——新效应词汇批首发)",
not snare.is_empty() and String(snare.type) == "debuff" and String(snare.buff_ref) == "off_guard"
and int(snare.buff_duration) == 2 and int(snare.cooldown_beats) == 4)
var snare_sole_ok := true
for row in _db.enemy.rows:
for row in _db.combat.enemy.rows:
for entry in row.pool:
if String(entry.skill_id) == "web_snare" and String(row.enemy_id) != "drider":
snare_sole_ok = false
Kit.check("蛛网束缚独占性(仅蛛化卓尔精灵池引用——实装面收窄,其余敌种不动)", snare_sole_ok)
var off_guard_row: Dictionary = _db.status_effect_row("off_guard")
var off_guard_row: Dictionary = _db.combat.status_effects.row_by_id("off_guard")
Kit.check("失衡状态行(hero · ac 轴——妨害技落点,ConfigDB side 守卫正例)", not off_guard_row.is_empty() and String(off_guard_row.target_side) == "hero" and String(off_guard_row.unit) == "ac")
# 普通敌错峰两新行——毒吐/盾击(V3)
var spit: Dictionary = _db.skill.row_by_id("venom_spit")
var spit: Dictionary = _db.combat.skill.row_by_id("venom_spit")
Kit.check("毒吐行(伤害 ×1.6 · 冷却 2 · 无 rider——低 atk 下 rider 恒被 damage_floor 兜成 1 白占字段,迭代 24 N2)",
not spit.is_empty() and String(spit.display_name) == "毒吐" and String(spit.type) == "damage" \
and is_equal_approx(float(spit.damage_mult), 1.6) and int(spit.cooldown_beats) == 2 and not spit.has("rider_mult"))
var bash: Dictionary = _db.skill.row_by_id("shield_bash")
var bash: Dictionary = _db.combat.skill.row_by_id("shield_bash")
Kit.check("盾击行(伤害 ×1.7 · 冷却 3 · 无 rider——迭代 24 N2)",
not bash.is_empty() and String(bash.display_name) == "盾击" and String(bash.type) == "damage" \
and is_equal_approx(float(bash.damage_mult), 1.7) and int(bash.cooldown_beats) == 3 and not bash.has("rider_mult"))
var spider_lead_band := _first_encounter_band(2, "giant_spider") # 首遇带数据驱动定位(契约 8)
var spit_sole_ok := true # 毒吐只挂巨蜘蛛族且首遇带不投(Q6——教学位池零新招)
var bash_sole_ok := true # 盾击只挂灰矮人族三行
for row in _db.enemy.rows:
for row in _db.combat.enemy.rows:
for entry in row.pool:
var sid := String(entry.skill_id)
if sid == "venom_spit" and not (String(row.enemy_id) == "giant_spider" and int(row.level_index) == 2 \
@@ -550,7 +551,7 @@ func _smoke_skill_pool_data() -> void:
Kit.check("盾击投放面(仅 (3,·) 灰矮人族——钩爪恐魔零触碰,迭代 24 N2)", bash_sole_ok)
# 首遇带行池恒 {attack:6, bite:2, charge:1}(去写死深度判据——带键口径)
var spider_lead_pool := {}
for row in _db.enemy.rows:
for row in _db.combat.enemy.rows:
if int(row.level_index) == 2 and String(row.row_kind) == "battle" \
and int(row.depth_band) == spider_lead_band and String(row.enemy_id) == "giant_spider":
for entry in row.pool:
@@ -591,7 +592,7 @@ func _smoke_skill_pool_data() -> void:
"3_boss_13_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
}
var pools_ok := true
for row in _db.enemy.rows:
for row in _db.combat.enemy.rows:
var rk := "%d_%s_%d_%s" % [int(row.level_index), String(row.row_kind), int(row.depth_band), String(row.enemy_id)]
if not expect_pools.has(rk):
pools_ok = false
@@ -603,10 +604,10 @@ func _smoke_skill_pool_data() -> void:
pools_ok = false
Kit.check("二十八行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1/1〔触发条件批+孤注守势 w1〕 · 熊地精 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/2〔触发条件批+趁隙偷袭 w2〕 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2——半血解锁收编技能行 trigger 闩型——迭代14④ T5/触发条件批迁移〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕;迭代 35 覆盖批八行为顶档池克隆,逐行同检)", pools_ok)
var basic_count_ok := true
for row in _db.enemy.rows:
for row in _db.combat.enemy.rows:
var n := 0
for entry in row.pool:
var srow: Dictionary = _db.skill_row(String(entry.skill_id))
var srow: Dictionary = _db.combat.skill.row_by_id(String(entry.skill_id))
if not srow.is_empty() and String(srow.type) == "basic":
n += 1
if n != 1:
@@ -615,11 +616,11 @@ func _smoke_skill_pool_data() -> void:
# 池构成纪律复查(V4):零违反——增益 ≤1 / 妨害 ≤1(敌人系统 v0.13 结构冻结;载入期
# validate_skill_pool_links 已硬闸,此处冒烟侧显式复核新投放后仍绿)
var comp_ok := true
for row in _db.enemy.rows:
for row in _db.combat.enemy.rows:
var n_buff := 0
var n_debuff := 0
for entry in row.pool:
var srow: Dictionary = _db.skill_row(String(entry.skill_id))
var srow: Dictionary = _db.combat.skill.row_by_id(String(entry.skill_id))
if srow.is_empty():
continue
if String(srow.type) == "buff":
@@ -631,10 +632,10 @@ func _smoke_skill_pool_data() -> void:
Kit.check("逐池增益 ≤1 · 妨害 ≤1(池构成纪律——迭代 24 N2 毒吐/盾击投放后复查)", comp_ok)
# 池构成纪律复查(触发条件批 V7 冒烟侧):每池至少一行无条件特殊技(普攻不计;trigger 缺省行计数)
var uncond_ok := true
for row in _db.enemy.rows:
for row in _db.combat.enemy.rows:
var n_uncond := 0
for entry in row.pool:
var srow_u: Dictionary = _db.skill_row(String(entry.skill_id))
var srow_u: Dictionary = _db.combat.skill.row_by_id(String(entry.skill_id))
if not srow_u.is_empty() and String(srow_u.type) != "basic" and not srow_u.has("trigger"):
n_uncond += 1
if n_uncond < 1:
@@ -644,7 +645,7 @@ func _smoke_skill_pool_data() -> void:
var st_unlock: Dictionary = {"rows": []}
var sett_u = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(4242))
sett_u.step_row.connect(func(row): st_unlock["rows"].append(row))
Kit.check("前置:卓尔 Boss 战受理(2,9——解锁技 venom_curb 门槛 50)", sett_u.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}))
Kit.check("前置:卓尔 Boss 战受理(2,9——解锁技 venom_curb 门槛 50)", sett_u.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"}))
var boss_u = sett_u._enemy_system.all_enemies()[0] # 白盒夹具(smoke 惯例——autoplay 同族私有面)
sett_u._enemy_system.apply_damage(boss_u, (int(boss_u.max_hp) - 65))
Kit.check("前置:Boss 压到阈值 65(=round(130×0.5))", int(boss_u.hp) == 65)
@@ -660,25 +661,25 @@ func _smoke_skill_pool_data() -> void:
var rollable_ok := true
for depth in [0, 1, 2, 3, 4]:
var es = load("res://core/unit_container.gd").new(_db)
es.spawn(1, int(depth), int(_db.hero.max_hp), [], -1, -1, [], "", _row_kind_of(1, int(depth)))
es.spawn({"level_index": 1, "node_depth": int(depth), "hero_hp": int(_db.combat.hero.max_hp), "hero_armor_type": _row_kind_of(1, int(depth))})
for u in es.all_enemies():
if es.rollable_skills(u).is_empty():
rollable_ok = false
for depth in [0, 1, 4, 5, 6, 7, 8]:
var es2 = load("res://core/unit_container.gd").new(_db)
es2.spawn(2, int(depth), int(_db.hero.max_hp), [], -1, -1, [], "", _row_kind_of(2, int(depth)))
es2.spawn({"level_index": 2, "node_depth": int(depth), "hero_hp": int(_db.combat.hero.max_hp), "hero_armor_type": _row_kind_of(2, int(depth))})
for u in es2.all_enemies():
if es2.rollable_skills(u).is_empty():
rollable_ok = false
Kit.check("全深度实例 spawn 后可摇集合恒非空(普攻恒冷却 0——容器级断言,L1/L2 双关卡)", rollable_ok)
# 状态表新行 + 敌方防御池映射
var empower: Dictionary = _db.status_effect_row("empower")
var fortify: Dictionary = _db.status_effect_row("fortify")
var empower: Dictionary = _db.combat.status_effects.row_by_id("empower")
var fortify: Dictionary = _db.combat.status_effects.row_by_id("fortify")
Kit.check("蓄力状态行 empower(enemy +100 percent——敌方输出 ×2 走既有 enemy_output 池)", not empower.is_empty() and String(empower.target_side) == "enemy" and int(empower.magnitude) == 100)
Kit.check("硬化状态行 fortify(enemy −50 percent——敌方防御池负值 = 主角攻击折半;+100 符号 bug 2026-09-18 纠正,provisional 归 状态层重设计批)", not fortify.is_empty() and String(fortify.target_side) == "enemy" and int(fortify.magnitude) == -50)
Kit.check("effect_pool_map 收编 empower/fortify(敌方防御池 enemy_defense 入表)",
String(_db.settlement.core.effect_pool_map.get("empower", "")) == "enemy_output" \
and String(_db.settlement.core.effect_pool_map.get("fortify", "")) == "enemy_defense")
String(_db.combat.settlement.core.effect_pool_map.get("empower", "")) == "enemy_output" \
and String(_db.combat.settlement.core.effect_pool_map.get("fortify", "")) == "enemy_defense")
# ② 同种子回放逐位复现(D3 大地精,多实例同种)
@@ -763,11 +764,11 @@ func _smoke_skill_pool_ev_anchor() -> void:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260921)
var n_beats := 5000
var pity_m: int = int(_db.skill.pity_threshold)
var pity_m: int = int(_db.combat.skill.pity_threshold)
for si in species.size():
var eid: String = species[si]
var template: Array = []
for row in _db.enemy.rows:
for row in _db.combat.enemy.rows:
if String(row.enemy_id) == eid:
template = row.pool
break
@@ -784,13 +785,13 @@ func _smoke_skill_pool_ev_anchor() -> void:
if streak >= pity_m:
var specials: Array = []
for e in pool:
var sr: Dictionary = _db.skill_row(String(e.skill_id))
var sr: Dictionary = _db.combat.skill.row_by_id(String(e.skill_id))
if not sr.is_empty() and String(sr.type) != "basic":
specials.append({"skill_id": e.skill_id, "weight": e.weight})
if not specials.is_empty():
entries = specials
var sid := SkillLottery.draw(entries, rng.open_stream("behavior_roll"))
var srow: Dictionary = _db.skill_row(sid)
var srow: Dictionary = _db.combat.skill.row_by_id(sid)
var mult := 0.0
if String(srow.type) == "basic":
mult = 1.0
@@ -817,12 +818,12 @@ func _smoke_skill_pool_death_void() -> void:
for s in range(1, 400):
var rg = load("res://core/rng_service.gd").new()
rg.set_master_seed(s)
var erow: Dictionary = _db.resolve_enemy_rowset(1, 2, "battle")[0] # 行集首行(战斗节点 battle 键域)
var erow: Dictionary = _db.combat.enemy.resolve_rowset(1, 2, "battle")[0] # 行集首行(战斗节点 battle 键域)
var entries: Array = []
for entry in erow.pool:
entries.append({"skill_id": String(entry.skill_id), "weight": int(entry.weight)})
var sid := SkillLottery.draw(entries, rg.open_stream("behavior_roll"))
if String(_db.skill_row(sid).type) != "basic":
if String(_db.combat.skill.row_by_id(sid).type) != "basic":
void_seed = s
break
Kit.check("死亡打断种子可达(seed=%d——D2 首拍摇中特殊技)" % void_seed, void_seed > 0)
@@ -831,7 +832,7 @@ func _smoke_skill_pool_death_void() -> void:
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
Kit.check("死亡打断战斗启动(hero_atk 999——先手必杀 40HP 熊地精)", settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}))
Kit.check("死亡打断战斗启动(hero_atk 999——先手必杀 40HP 熊地精)", settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.combat.hero.max_hp), "hero_atk": 999}))
var u: Dictionary = settlement._enemy_system.all_enemies()[0] # 句柄预捕获(胜利 despawn 后容器已清,字典引用仍可读)
var pending: String = settlement._enemy_system.pending_action(u)
Kit.check("开轮已摇定本拍动作(待发 = %s)" % pending, pending != "")
@@ -855,14 +856,14 @@ func _smoke_skill_pool_validation_reject() -> void:
var skill_table = load("res://data/schemas/skill_table.gd").new()
var enemy_table = load("res://data/schemas/enemy_table.gd").new()
enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "ghost", "weight": 1}]}])
Kit.check("加载校验拒绝:池条目 skill_id 悬空致命", _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "dangling_skill"))
Kit.check("加载校验拒绝:池条目 skill_id 悬空致命", _has_err_category(_db._validate_skill_pool_links(skill_table, enemy_table), "dangling_skill"))
skill_table.rows.assign([{"skill_id": "a", "display_name": "a", "type": "damage", "damage_mult": 1.5, "cooldown_beats": 2}])
Kit.check("加载校验拒绝:池普攻行数 ≠ 1(空池不可能前提)", _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"))
Kit.check("加载校验拒绝:池普攻行数 ≠ 1(空池不可能前提)", _has_err_category(_db._validate_skill_pool_links(skill_table, enemy_table), "pool_composition"))
skill_table.rows.assign([
{"skill_id": "b1", "display_name": "b1", "type": "buff", "buff_ref": "empower", "buff_duration": 2, "cooldown_beats": 3},
{"skill_id": "b2", "display_name": "b2", "type": "buff", "buff_ref": "fortify", "buff_duration": 2, "cooldown_beats": 4}])
enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "b1", "weight": 1}, {"skill_id": "b2", "weight": 1}]}])
Kit.check("加载校验拒绝:池增益技 > 1(每池增益 ≤1 纪律)", _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"))
Kit.check("加载校验拒绝:池增益技 > 1(每池增益 ≤1 纪律)", _has_err_category(_db._validate_skill_pool_links(skill_table, enemy_table), "pool_composition"))
skill_table.rows.assign([{"skill_id": "atk", "display_name": "a", "type": "basic", "damage_mult": 2.0, "cooldown_beats": 0}])
Kit.check("加载校验拒绝:普攻配倍率 ≠ 1(域校验锁死)", not skill_table.validate().is_empty())
skill_table.rows.assign([{"skill_id": "atk", "display_name": "a", "type": "basic", "cooldown_beats": 2}])
@@ -879,7 +880,7 @@ func _smoke_skill_pool_validation_reject() -> void:
{"skill_id": "w1", "display_name": "w1", "type": "debuff", "buff_ref": "off_guard", "buff_duration": 2, "cooldown_beats": 4},
{"skill_id": "w2", "display_name": "w2", "type": "debuff", "buff_ref": "off_guard", "buff_duration": 2, "cooldown_beats": 4}])
enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "w1", "weight": 1}, {"skill_id": "w2", "weight": 1}]}])
Kit.check("加载校验拒绝:池妨害技 > 1(每池妨害 ≤1 纪律)", _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"))
Kit.check("加载校验拒绝:池妨害技 > 1(每池妨害 ≤1 纪律)", _has_err_category(_db._validate_skill_pool_links(skill_table, enemy_table), "pool_composition"))
skill_table.rows.assign([{"skill_id": "w1", "display_name": "w1", "type": "debuff", "cooldown_beats": 4}])
Kit.check("加载校验拒绝:妨害技缺 buff_ref(效果引用)", not skill_table.validate().is_empty())
skill_table.rows.assign([{"skill_id": "d", "display_name": "d", "type": "damage", "damage_mult": 1.5, "rider_mult": 1.5, "cooldown_beats": 1}])
@@ -891,7 +892,7 @@ func _smoke_skill_pool_validation_reject() -> void:
{"skill_id": "atk", "display_name": "a", "type": "basic", "cooldown_beats": 0},
{"skill_id": "w1", "display_name": "w1", "type": "debuff", "buff_ref": "empower", "buff_duration": 2, "cooldown_beats": 4}])
enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "atk", "weight": 1}, {"skill_id": "w1", "weight": 1}]}])
Kit.check("加载校验拒绝:妨害技引用敌方侧状态(施放类 side 守卫)", _has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table, _db.status_effects), "cast_side"))
Kit.check("加载校验拒绝:妨害技引用敌方侧状态(施放类 side 守卫)", _has_err_category(_db._validate_skill_pool_links(skill_table, enemy_table, _db.status_effects), "cast_side"))
func _has_err_category(errs: Array, category: String) -> bool:
@@ -914,9 +915,9 @@ func _smoke_debuff_rider() -> void:
var venom_check_ok := true
var venom_rows_seen := 0
var snare_seed := -1
var base_ac: int = int(_db.hero.armor_class)
var off_m: int = int(_db.status_effect_row("off_guard").magnitude)
var floor_v: int = int(_db.settlement.damage_roll.damage_floor)
var base_ac: int = int(_db.combat.hero.armor_class)
var off_m: int = int(_db.combat.status_effects.row_by_id("off_guard").magnitude)
var floor_v: int = int(_db.combat.settlement.damage_roll.damage_floor)
for s in range(1, 401):
if venom_seed > 0 and snare_seed > 0:
break
@@ -925,7 +926,7 @@ func _smoke_debuff_rider() -> void:
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rg)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}):
if not settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"}):
continue
var boss_atk: int = int(settlement._enemy_system.all_enemies()[0].atk) # 开战掷定攻击基线(毒分量随其缩放)
for i in range(6):
@@ -948,7 +949,7 @@ func _smoke_debuff_rider() -> void:
if rider_v != maxi(floor_v, int(floor(float(boss_atk) * 0.75))):
venom_check_ok = false
# 终值精确复算(停手轮池恒空):主分量链结果 + 必中附加后置
var base_factor: float = float(int(row.tier)) * (float(_db.settlement.damage_roll.crit_multiplier) / 100.0 if bool(row.get("crit", false)) else 1.0)
var base_factor: float = float(int(row.tier)) * (float(_db.combat.settlement.damage_roll.crit_multiplier) / 100.0 if bool(row.get("crit", false)) else 1.0)
var rolled_main: int = int(floor(float(int(row.baseline)) * base_factor / 100.0))
var expected: int = maxi(floor_v, rolled_main + int(row.get("attach", 0))) + rider_v
if int(row.value) != expected:
@@ -52,10 +52,10 @@ func _smoke_seal_ap_exhausted() -> void:
var rounds: Array = []
settlement.round_started.connect(func(ri): rounds.append(ri))
Kit.check("回归战斗启动(Boss 行——D0 20HP 级会被盲挖粉尘秒杀,无法承载封印回归场景)",
settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}))
settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"}))
var minesweeper = settlement.minesweeper
# 恰花 (AP-1) 点挖掘,留末点给封印(表驱动——AP 从表读不写死)
var ap_total: int = int(_db.action_economy.ap_per_round)
var ap_total: int = int(_db.run.action_economy.ap_per_round)
for i in range(ap_total - 1):
if settlement.action_economy.phase != "Active" or settlement.action_economy.ap == 0:
break
@@ -151,7 +151,7 @@ func _run_battle(L: int, D: int, axes: Dictionary = {}) -> Dictionary:
settlement.minesweeper.seal_judged.connect(func(evt): out.events.append(evt))
settlement.minesweeper.round_end_board.connect(func(p): out.events.append({"ev": "round_end_board", "path_tag": p.path_tag}))
settlement.action_economy.round_end_stopped.connect(func(p): out.events.append({"ev": "round_end_stopped", "path_tag": p.path_tag}))
var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.hero.max_hp}
var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.combat.hero.max_hp}
enc.merge(axes, true) # 两根类型轴注入尾参(缺省零注入=回退面)
if not settlement.begin_battle(enc):
out.stream.append({"type": "verdict", "value": "begin_failed"})
@@ -169,7 +169,7 @@ func _run_battle(L: int, D: int, axes: Dictionary = {}) -> Dictionary:
# 清图驱动(真实流):标一面真雷封印(完美前置)→ 逐轮 AutoPlayer 挖到轮末;board_seq 递增 = 清图换图。
# from_refresh=true 时先三轮停手攒 RAP、第 4 轮轮内刷新换图,再清刷新图。
func _run_full_clear(seed_val: int, from_refresh: bool, rows: Array) -> Dictionary:
_db.difficulty.rows.append({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hp_mult": 10.0, "atk_mult": 1.0})
_db.run.difficulty.rows.append({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hp_mult": 10.0, "atk_mult": 1.0})
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
@@ -177,7 +177,7 @@ func _run_full_clear(seed_val: int, from_refresh: bool, rows: Array) -> Dictiona
var res := {"cleared": false, "refreshed": false, "rounds": 0}
if not settlement.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "node_type": "boss",
"hero_hp": CLEAR_SMOKE_HERO_HP, "hero_max_hp": CLEAR_SMOKE_HERO_HP}):
_db.difficulty.rows.clear()
_db.run.difficulty.rows.clear()
return res
if from_refresh:
for i in range(3):
@@ -186,7 +186,7 @@ func _run_full_clear(seed_val: int, from_refresh: bool, rows: Array) -> Dictiona
var drive := _drive_clear(settlement)
res["cleared"] = drive.cleared
res["rounds"] = drive.rounds
_db.difficulty.rows.clear() # 测试台账还原(后续冒烟基线)
_db.run.difficulty.rows.clear() # 测试台账还原(后续冒烟基线)
return res
@@ -266,7 +266,7 @@ func _polarity_probe_accepts(probe: Dictionary) -> bool:
# 极性路由探针驱动:标 1 面真雷封印(标中)+ 1 面安全格封印(标空)→ AutoPlayer 挖到战斗终局(多轮样本)。
# 收集全部 cell_revealed / seal_judged 事件与结算行;返回格子集合与封印格坐标。
func _run_polarity_probe(seed_val: int) -> Dictionary:
_db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 10.0, "atk_mult": 0.5}) # 抬敌血压低敌攻——多轮挖掘样本不被终局截断
_db.run.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 10.0, "atk_mult": 0.5}) # 抬敌血压低敌攻——多轮挖掘样本不被终局截断
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
@@ -305,7 +305,7 @@ func _run_polarity_probe(seed_val: int) -> Dictionary:
while settlement.battle_result == "" and guard > 0:
guard -= 1
_auto_play_round(settlement, out)
_db.difficulty.rows.clear() # 测试台账还原
_db.run.difficulty.rows.clear() # 测试台账还原
return probe
@@ -439,16 +439,10 @@ func _smoke_polarity_routing() -> void:
if _polarity_probe_accepts(probe):
polarity_seed = s
break
# 触达三腿(标中标空入队/挖危险格/挖安全格)是 _polarity_probe_accepts 的合取项,
# 可达即三腿同真——缺腿明细进标签承载诊断,不再各立一条(2026-10-28 断言精简批删并)
var legs := ""
if not (bool(probe["seal_hit_done"]) and bool(probe["seal_miss_done"])):
legs += "标中标空未入队 "
if int(probe["mine_events"]) < 1:
legs += "挖危险格零 "
if int(probe["safe_events"]) < 1:
legs += "挖安全格零 "
Kit.check("极性路由构造种子可达(seed=%d——并流后行为面重锚;缺腿:%s)" % [polarity_seed, legs], polarity_seed > 0)
Kit.check("极性路由构造种子可达(seed=%d——并流后行为面重锚)" % polarity_seed, polarity_seed > 0)
Kit.check("构造触达:标中真雷 + 标空安全格均入队", bool(probe["seal_hit_done"]) and bool(probe["seal_miss_done"]))
Kit.check("构造触达:挖到危险格 ≥1(%d)" % int(probe["mine_events"]), int(probe["mine_events"]) >= 1)
Kit.check("构造触达:挖到安全格 ≥1(%d)" % int(probe["safe_events"]), int(probe["safe_events"]) >= 1)
# 挖到的安全格:只出正向——其全部来源行必存在且 effect 行 ∈ 正向集
# (action 型排队行 effect_id=ap_delta 正负两极共用路由键——按行值符号辨极性:正向格恒 +power)
var safe_all_pos := true
@@ -500,7 +494,7 @@ func _smoke_polarity_routing() -> void:
# δ 读 settlement.seal_hit_premium(125 provisional);九型正向行值全固定(唯一区间行 clear_blast
# 系清图槽不入格池)⇒ 逐型期望全确定。整数量级四舍五入域注记:ac_up/action 1 点量纲取整后不变
# =整数粒度地板——等号压线判读在当量聚合面(audit 工具/registry clause3_inequality),非断言面。
var prem_pct: int = int(_db.settlement.core.seal_hit_premium)
var prem_pct: int = int(_db.combat.settlement.core.seal_hit_premium)
Kit.check("封印溢价配置在位(seal_hit_premium=125 provisional——形态 a 兑付溢价)", prem_pct == 125)
var prem_rows := 0
var prem_ok := true
@@ -517,7 +511,7 @@ func _smoke_polarity_routing() -> void:
elif eff == "ap_delta":
prem_ok = prem_ok and v == 1 # +1AP:1 点量纲(round(1.25)=1——整数粒度地板)
else:
var mag: int = int(_db.status_effect_row(eff).get("magnitude", -999))
var mag: int = int(_db.combat.status_effects.row_by_id(eff).get("magnitude", -999))
prem_ok = prem_ok and v == int(round(mag * prem_pct / 100.0)) # 增伤50→63/削弱65→81/护甲1→1
elif rtype == "heal":
# 急救 2→3(clamp 封顶只降不升:满血走 reward_ineffective 不入本行——区间断言)
@@ -574,7 +568,7 @@ func _smoke_seal_irreversible_quadrants() -> void:
var settle_a = load("res://core/battle_orchestrator.gd").new(_db, rng_a)
var rows_a: Array = []
settle_a.step_row.connect(func(row): rows_a.append(row))
Kit.check("四情况 A:开局受理", settle_a.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}))
Kit.check("四情况 A:开局受理", settle_a.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"}))
var ms_a = settle_a.minesweeper
var t_a := _find_seal_target(ms_a, false)
Kit.check("四情况 A:找到安全格封印目标", not t_a.is_empty())
@@ -612,7 +606,7 @@ func _smoke_seal_irreversible_quadrants() -> void:
var settle_b = load("res://core/battle_orchestrator.gd").new(_db, rng_b)
var rows_b: Array = []
settle_b.step_row.connect(func(row): rows_b.append(row))
Kit.check("四情况 B:开局受理", settle_b.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}))
Kit.check("四情况 B:开局受理", settle_b.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"}))
var ms_b = settle_b.minesweeper
var t_b := _find_seal_target(ms_b, true)
Kit.check("四情况 B:找到危险格封印目标", not t_b.is_empty())
@@ -642,7 +636,7 @@ func _smoke_seal_irreversible_quadrants() -> void:
var settle_c = load("res://core/battle_orchestrator.gd").new(_db, rng_c)
var rows_c: Array = []
settle_c.step_row.connect(func(row): rows_c.append(row))
Kit.check("四情况 C:开局受理", settle_c.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}))
Kit.check("四情况 C:开局受理", settle_c.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"}))
var ms_c = settle_c.minesweeper
var t_safe := _find_seal_target(ms_c, false)
var t_mine := _find_seal_target(ms_c, true)
@@ -677,12 +671,12 @@ func _smoke_seal_irreversible_quadrants() -> void:
func _smoke_negative_dictionary() -> void:
print("--- 负向字典逐型直验(sapped 在位 / heal 直伤 / blast 警觉 / action 行动点)---")
# 数据面:sapped 行在位(状态表七行 + effect_pool_map 映射在位)
var sapped: Dictionary = _db.status_effect_row("sapped")
var sapped: Dictionary = _db.combat.status_effects.row_by_id("sapped")
Kit.check("sapped 行在位(hero · −25 percent · dur 1 · max 16 · stack——weaken 型负向)",
not sapped.is_empty() and String(sapped.get("target_side", "")) == "hero" \
and int(sapped.get("magnitude", 0)) == -25 and int(sapped.get("duration_settlements", 0)) == 1 \
and int(sapped.get("max_stack", 0)) == 16 and String(sapped.get("stack_policy", "")) == "stack")
Kit.check("effect_pool_map 收编 sapped → hero_offense(attack_up 镜像池)", String(_db.settlement.core.effect_pool_map.get("sapped", "")) == "hero_offense")
Kit.check("effect_pool_map 收编 sapped → hero_offense(attack_up 镜像池)", String(_db.combat.settlement.core.effect_pool_map.get("sapped", "")) == "hero_offense")
# 行为面:逐型负向兑付形态(真实板 + 真实路由路径)
# heal 权重低(无 heal 板概率 ≈1/3)——单板五型齐备不再必然:
# 本断言多板扫描,取第一块五型齐备的板做行为断言。
@@ -698,7 +692,7 @@ func _smoke_negative_dictionary() -> void:
settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
rows = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_max_hp": int(_db.hero.max_hp)}):
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp), "hero_max_hp": int(_db.combat.hero.max_hp)}):
continue
board = settlement.minesweeper.current_board()
var r0 := {}
@@ -738,7 +732,7 @@ func _smoke_negative_dictionary() -> void:
settlement.reward_grant().grant_routed(snap_h, "negative", {"source_class": "mine_hit", "cell": by_type["heal"], "ri": 1})
var hp_after: int = int(settlement.presentation_snapshot().hero.hp)
var backlash_power: int = 0
for row in _db.reward.rows:
for row in _db.run.reward.rows:
if String(row.get("reward_id", "")) == "heal_backlash":
backlash_power = int(row.power)
Kit.check("heal 型负向 → 主角直伤 %d(%d → %d,不经增益状态)" % [backlash_power, hp_before, hp_after], hp_after == hp_before - backlash_power)
@@ -795,7 +789,7 @@ func _smoke_resource_surface() -> void:
var rng3 = load("res://core/rng_service.gd").new()
rng3.set_master_seed(20260920)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng3)
settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)})
var board = settlement.minesweeper.current_board()
var r0_first: Variant = null
for c in board.compute_r0():
@@ -26,7 +26,7 @@ func _init() -> void:
# 门谓词:卓尔 (2,9) hp 130(×mult 1.0)→ venom_curb unlock 50 → 阈值 round(130×0.5)=65
func _boss(db, L: int, D: int, kind := "boss"):
var ec = UnitContainerScript.new(db)
var ok: bool = ec.spawn(L, D, int(db.hero.max_hp), [], -1, -1, [], "", "", kind)
var ok: bool = ec.spawn({"level_index": L, "node_depth": D, "hero_hp": int(db.combat.hero.max_hp), "row_kind": kind})
Kit.check("前置:spawn (%d,%d) 受理" % [L, D], ok)
return ec
@@ -83,7 +83,7 @@ func _test_latch_irreversible(db) -> void:
# 普通行无字段逐位零扰动:地精 (1,0) 池快照零 unlock 键、过滤恒空转、可摇=全池
func _test_plain_rows_zero_drift(db) -> void:
var ec = load("res://core/unit_container.gd").new(db)
Kit.check("零扰动前置:巨蜘蛛首遇带 spawn(池三行全无 trigger——快照键策略钉死载体)", ec.spawn(2, 1, int(db.hero.max_hp), [], -1, -1, [], "", "", "battle"))
Kit.check("零扰动前置:巨蜘蛛首遇带 spawn(池三行全无 trigger——快照键策略钉死载体)", ec.spawn({"level_index": 2, "node_depth": 1, "hero_hp": int(db.combat.hero.max_hp)}))
var u: Dictionary = ec.all_enemies()[0]
var any_unlock_key := false
for e in u.pool:
@@ -96,7 +96,7 @@ func _test_plain_rows_zero_drift(db) -> void:
Kit.check("零扰动:可摇集合 = 全池(普攻冷却恒 0,过滤恒空转)", rollable.size() == pool_count)
# 同种子确定性:容器零随机——两实例同输入查询逐位同(同款零扰动载体)
var ec2 = load("res://core/unit_container.gd").new(db)
Kit.check("确定性前置:第二实例蜘蛛 spawn", ec2.spawn(2, 1, int(db.hero.max_hp), [], -1, -1, [], "", "", "battle"))
Kit.check("确定性前置:第二实例蜘蛛 spawn", ec2.spawn({"level_index": 2, "node_depth": 1, "hero_hp": int(db.combat.hero.max_hp)}))
var u2: Dictionary = ec2.all_enemies()[0]
ec.apply_damage(u, 3)
ec2.apply_damage(u2, 3)
@@ -52,7 +52,7 @@ func _init() -> void:
# ---- V2 门槛过滤双面(spawn 真实表→直调 rollable/special)----
var ec = load("res://core/unit_container.gd").new(db)
Kit.check_quiet(ec.spawn(2, 9, 30, [], 8, 30, [], "", "", "boss"), "V2 前置:卓尔 Boss spawn")
Kit.check_quiet(ec.spawn({"level_index": 2, "node_depth": 9, "hero_hp": 30, "hero_atk_override": 8, "hero_max_hp_override": 30, "row_kind": "boss"}), "V2 前置:卓尔 Boss spawn")
var u = ec.all_enemies()[0]
Kit.check_quiet(int(u.hp) == int(u.max_hp), "V2 前置:满血(hp=max_hp)")
var r_full: Array = ec.rollable_skills(u)
@@ -67,7 +67,7 @@ func _init() -> void:
# ---- V3 保底不逼条件不满足技(保底集合取 special_skills 已含排除——编排级:
# 无条件特殊技 A w1+条件假技 B w9999+普攻;保底命中摇出的是 A,撤销过滤态同种子必中 B)----
var ec_v3 = load("res://core/unit_container.gd").new(db)
Kit.check_quiet(ec_v3.spawn(1, 0, 8, [], 6, 8, [], "", "", "battle"), "V3 前置:地精 spawn")
Kit.check_quiet(ec_v3.spawn({"level_index": 1, "node_depth": 0, "hero_hp": 8, "hero_atk_override": 6, "hero_max_hp_override": 8}), "V3 前置:地精 spawn")
var g3 = ec_v3.all_enemies()[0]
g3["pool"] = [
{"skill_id": "attack", "weight": 9, "cooldown_left": 0},
@@ -75,7 +75,7 @@ func _init() -> void:
{"skill_id": "desperate_guard", "weight": 9999, "cooldown_left": 0,
"trigger": {"fact": "self_hp_pct", "op": "lte", "value": 35, "latch": false}}, # B=条件假技 w9999
]
g3["basic_streak"] = int(db.skill.pity_threshold) # 保底已触发位(夹具内存改行——round_flow 白盒先例)
g3["basic_streak"] = int(db.combat.skill.pity_threshold) # 保底已触发位(夹具内存改行——round_flow 白盒先例)
var specials_v3: Array = ec_v3.special_skills(g3)
Kit.check_quiet(_has(specials_v3, "heavy") and not _has(specials_v3, "desperate_guard"),
"V3 保底集合含 A 排除 B(special_skills 真实入口——条件假技不逼出)〔触发器批 V3〕")
@@ -96,7 +96,7 @@ func _init() -> void:
Kit.check_quiet(ec.check_trigger_unlocks(u).is_empty() and _has(ec.rollable_skills(u), "venom_curb"),
"V4 治疗抬血不回锁(单向闩)〔触发器批 V4〕")
var ec8 = load("res://core/unit_container.gd").new(db)
Kit.check_quiet(ec8.spawn(1, 0, 8, [], 6, 8, [], "", "", "battle"), "V4 前置:地精 spawn(hp 基线 8)")
Kit.check_quiet(ec8.spawn({"level_index": 1, "node_depth": 0, "hero_hp": 8, "hero_atk_override": 6, "hero_max_hp_override": 8}), "V4 前置:地精 spawn(hp 基线 8)")
var g = ec8.all_enemies()[0]
g["pool"] = [{"skill_id": "attack", "weight": 9, "cooldown_left": 0},
{"skill_id": "desperate_guard", "weight": 1, "cooldown_left": 0,
@@ -139,28 +139,28 @@ func _init() -> void:
if String((e as Dictionary).get("category", "")) == "pool_composition" and String((e as Dictionary).get("message", "")).contains("无条件特殊技"):
comp_violations += 1
Kit.check_quiet(comp_violations == 0, "V7 现行全表零违例(每池至少一行无条件特殊技)〔触发器批 V7〕")
var enemy_backup: Array = db.enemy.rows.duplicate(true)
var cond_only_row: Dictionary = (db.enemy.rows[0] as Dictionary).duplicate(true)
var enemy_backup: Array = db.combat.enemy.rows.duplicate(true)
var cond_only_row: Dictionary = (db.combat.enemy.rows[0] as Dictionary).duplicate(true)
var cond_pool: Array[Variant] = []
for entry in (cond_only_row.get("pool", []) as Array):
var sid := String((entry as Dictionary).get("skill_id", ""))
var srow_c: Dictionary[Variant, Variant] = db.skill_row(sid)
var srow_c: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(sid)
if not srow_c.is_empty() and String(srow_c.get("type", "")) == "basic":
cond_pool.append(entry) # 普攻保留(普攻不计入无条件特殊技)
cond_pool.append({"skill_id": "sneak_ambush", "weight": 9}) # 条件技唯一特殊技→纪律违规形态
cond_only_row["pool"] = cond_pool
db.enemy.rows[0] = cond_only_row
db.combat.enemy.rows[0] = cond_only_row
var errs2: Array = cv.validate_skill_pool_links(db.skill, db.enemy, db.status_effects)
var hit2 := false
for e in errs2:
if String((e as Dictionary).get("message", "")).contains("无条件特殊技"):
hit2 = true
Kit.check_quiet(hit2, "V7 构造全条件池报 pool_composition 错〔触发器批 V7〕")
db.enemy.rows = enemy_backup
db.combat.enemy.rows = enemy_backup
# ---- V8 示范投放:低血防御(地精两行池 w1+trigger lte 35——真实表 spawn 压 hp 开轮摇号)----
var ec_v8 = load("res://core/unit_container.gd").new(db)
Kit.check_quiet(ec_v8.spawn(1, 0, 8, [], 6, 8, [], "", "", "battle"), "V8 前置:地精 spawn")
Kit.check_quiet(ec_v8.spawn({"level_index": 1, "node_depth": 0, "hero_hp": 8, "hero_atk_override": 6, "hero_max_hp_override": 8}), "V8 前置:地精 spawn")
var g8 = ec_v8.all_enemies()[0]
var has_dg := false
for e in (g8.get("pool", []) as Array):
@@ -176,7 +176,7 @@ func _init() -> void:
# ---- V9 示范投放:停手偷袭(钩爪恐魔池 w2+last_round_end eq stopped——facts 缺键=拦 fail-safe)----
var ec_v9 = load("res://core/unit_container.gd").new(db)
Kit.check_quiet(ec_v9.spawn(3, 1, 30, [], 8, 30, [], "", "", "battle"), "V9 前置:钩爪恐魔 spawn")
Kit.check_quiet(ec_v9.spawn({"level_index": 3, "node_depth": 1, "hero_hp": 30, "hero_atk_override": 8, "hero_max_hp_override": 30}), "V9 前置:钩爪恐魔 spawn")
var h9 = ec_v9.all_enemies()[0]
Kit.check_quiet(_not_has(ec_v9.rollable_skills(h9), "sneak_ambush"),
"V9 首轮(facts 缺 last_round_end 键=fail-safe 拦)不入集合〔触发器批 V9〕")
@@ -189,7 +189,7 @@ func _init() -> void:
# ---- V10 缺省零扰动(无 trigger 行的池快照逐键无新增+摇号同种子不变——快照键策略钉死)----
var ec_v10 = load("res://core/unit_container.gd").new(db)
Kit.check_quiet(ec_v10.spawn(1, 0, 8, [], 6, 8, [], "", "", "battle"), "V10 前置:地精 spawn")
Kit.check_quiet(ec_v10.spawn({"level_index": 1, "node_depth": 0, "hero_hp": 8, "hero_atk_override": 6, "hero_max_hp_override": 8}), "V10 前置:地精 spawn")
var g10 = ec_v10.all_enemies()[0]
var stray := 0
for e in (g10.get("pool", []) as Array):
@@ -56,7 +56,7 @@ func _smoke_variant_placement(L: int) -> void:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(42)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.hero.max_hp}):
if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.combat.hero.max_hp}):
Kit.check("L%d 布置段战斗启动失败" % L, false)
return
var b = settlement.minesweeper.current_board()
@@ -64,7 +64,7 @@ func _smoke_variant_placement(L: int) -> void:
if b == null:
return
var pool_ids: Array = []
for r in _db.variant_pool(L, String(b.board_type)):
for r in _db.combat.variation.variant_pool(L, String(b.board_type)):
pool_ids.append(String(r.variant_id))
var vcells: Array = b.variants.keys()
var vid0 := ""
@@ -95,13 +95,13 @@ func _smoke_variant_placement(L: int) -> void:
for c in b.safe_cells():
if not r0.has(c):
cand_count += 1
var expect_cap: int = int(round(float(cand_count) * float(_db.variation.percent_of_cells) / 100.0)) \
+ (L - 1) * int(_db.variation.per_level_bonus)
var expect_cap: int = int(round(float(cand_count) * float(_db.combat.variation.percent_of_cells) / 100.0)) \
+ (L - 1) * int(_db.combat.variation.per_level_bonus)
Kit.check("L%d 变异格数 %d ≤ 公式上限 %d(可挖池百分比 + 关卡递增)" % [L, vcells.size(), expect_cap], vcells.size() <= expect_cap)
# 数量公式量随候选池缩放:L1 无关卡加成,极小池(<10 格)可 round 至 0——
# 零布置为合法态(同池空口径);非零断言为条件式,公式量>0 时才要求非零(L2/L3 关卡加成保底恒>0)
Kit.check("L%d 公式量>0 时非零布置(极小池 round 至 0 = 合法零布置——2026-09-21 基数修正批)" % L, expect_cap == 0 or vcells.size() > 0)
var pack_max: int = int(_db.variation.pack_max)
var pack_max: int = int(_db.combat.variation.pack_max)
var vset := {}
for c in vcells:
vset[c] = true
@@ -127,7 +127,7 @@ func _smoke_variant_placement(L: int) -> void:
var rng2 = load("res://core/rng_service.gd").new()
rng2.set_master_seed(42)
var s2 = load("res://core/battle_orchestrator.gd").new(_db, rng2)
s2.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.hero.max_hp})
s2.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.combat.hero.max_hp})
var b2 = s2.minesweeper.current_board()
Kit.check("L%d 同种子变异布置逐格相同(variant_place 流确定性)" % L, b2 != null and _variant_dump(b2) == _variant_dump(b))
@@ -175,24 +175,24 @@ func _smoke_global_variant() -> void:
Kit.check("互锁负样本⑦:resource_face effect_value=51 越域 [0,50] 被拒(不适用 flat_damage [1,9] 加法核)", domain_hit)
# ② 门控第三维(节点身份——global 行仅 boss/elite 入池;缺省/普通节点静默跳过零报错)
var pool_boss_ids: Array = []
for r in _db.variant_pool(2, "underdark", "boss"):
for r in _db.combat.variation.variant_pool(2, "underdark", "boss"):
pool_boss_ids.append(String(r.variant_id))
Kit.check("门控三维:L2 underdark boss 池 = region 两行(贫矿脉 2026-10-16 屏蔽删行后无 global 行)", pool_boss_ids == ["magma", "ash"])
var pool_battle_ids: Array = []
for r in _db.variant_pool(2, "underdark", "battle"):
for r in _db.combat.variation.variant_pool(2, "underdark", "battle"):
pool_battle_ids.append(String(r.variant_id))
Kit.check("门控三维:battle 节点静默跳过 global 行(普通图零报错零警告)", pool_battle_ids == ["magma", "ash"])
Kit.check("门控三维:缺省双参调用 = global 行跳过(既有调用口径零扰动)", _db.variant_pool(2, "underdark").is_empty() == false and _db.variant_pool(2, "underdark").size() == 2)
Kit.check("L1 Boss 免疫:unlock_level=2 门控在 boss 节点同样生效(教学保护带 B)", _db.variant_pool(1, "underdark", "boss").is_empty())
Kit.check("全局决议:battle 节点恒无全局型", _db.global_variant_row(2, "underdark", "battle").is_empty())
Kit.check("全局决议:L2 boss 全局池空 → 空行(贫矿脉屏蔽后兜底回落承载)", _db.global_variant_row(2, "underdark", "boss").is_empty())
Kit.check("全局决议:L1 boss 全局池空 → 空行(兜底回落承载)", _db.global_variant_row(1, "underdark", "boss").is_empty() and _db.global_variant_row(1, "dungeon", "boss").is_empty())
Kit.check("elite 前向词位:boss 同池恒空(贫矿脉屏蔽后——词表位不造行)", _db.global_variant_row(2, "underdark", "elite").is_empty())
Kit.check("门控三维:缺省双参调用 = global 行跳过(既有调用口径零扰动)", _db.combat.variation.variant_pool(2, "underdark").is_empty() == false and _db.combat.variation.variant_pool(2, "underdark").size() == 2)
Kit.check("L1 Boss 免疫:unlock_level=2 门控在 boss 节点同样生效(教学保护带 B)", _db.combat.variation.variant_pool(1, "underdark", "boss").is_empty())
Kit.check("全局决议:battle 节点恒无全局型", _db.combat.variation.global_variant_row(2, "underdark", "battle").is_empty())
Kit.check("全局决议:L2 boss 全局池空 → 空行(贫矿脉屏蔽后兜底回落承载)", _db.combat.variation.global_variant_row(2, "underdark", "boss").is_empty())
Kit.check("全局决议:L1 boss 全局池空 → 空行(兜底回落承载)", _db.combat.variation.global_variant_row(1, "underdark", "boss").is_empty() and _db.combat.variation.global_variant_row(1, "dungeon", "boss").is_empty())
Kit.check("elite 前向词位:boss 同池恒空(贫矿脉屏蔽后——词表位不造行)", _db.combat.variation.global_variant_row(2, "underdark", "elite").is_empty())
# ③ 兜底回落:全局池空 → Boss 图改走 region 候选池(恰一行 region 变异 + 格子级布置 + 正常取号)
var crng = _CountVariantRng.new()
crng.set_master_seed(42)
var boss_settlement = load("res://core/battle_orchestrator.gd").new(_db, crng)
var boss_ok: bool = boss_settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})
var boss_ok: bool = boss_settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"})
Kit.check("L2 Boss 战斗启动(node_depth=9 Boss 终点——兜底回落断言载体)", boss_ok)
var boss1_ids: Array = []
var boss1_variants: Dictionary = {}
@@ -216,7 +216,7 @@ func _smoke_global_variant() -> void:
var crng2 = _CountVariantRng.new()
crng2.set_master_seed(42)
var boss2 = load("res://core/battle_orchestrator.gd").new(_db, crng2)
if boss2.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}):
if boss2.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.combat.hero.max_hp), "node_type": "boss"}):
var bb2 = boss2.minesweeper.current_board()
Kit.check("确定性:同种子 Boss 图两遍 board_variants/变异格/取号逐位相同(region 布置路径)", _arr_str_eq(bb2.board_variant_ids(), boss1_ids) and bb2.variants == boss1_variants and crng2.variant_calls == boss1_calls)
# ④ 资源面偏斜投影(RewardEconomy 纯函数面——守恒/白名单/构造例/合成序)
@@ -267,12 +267,12 @@ func _smoke_global_variant() -> void:
var nrng = load("res://core/rng_service.gd").new()
nrng.set_master_seed(42)
var normal1 = load("res://core/battle_orchestrator.gd").new(_db, nrng)
normal1.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
normal1.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)})
var n1: Dictionary = _board_resource_dump(normal1)
var nrng2 = load("res://core/rng_service.gd").new()
nrng2.set_master_seed(42)
var normal2 = load("res://core/battle_orchestrator.gd").new(_db, nrng2)
normal2.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
normal2.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)})
Kit.check("确定性:同种子普通图布置/摇号两遍逐位相同(off 全链路)", n1 == _board_resource_dump(normal2) and not n1.is_empty())
@@ -60,7 +60,7 @@ func _test_damage_controller_vocabularies() -> void:
# ---- D2↔B1:掷点用途标签 ∈ RngService 封闭词表(EC12——跨系统字面量契约的成员性断言) ----
func _test_rng_tag_closure(db) -> void:
var stream_label: String = String(db.settlement.damage_roll.armor_roll_stream)
var stream_label: String = String(db.combat.settlement.damage_roll.armor_roll_stream)
Kit.check("settlement.armor_roll_stream ∈ RngService 封闭标签词表(EC12)",
RngServiceScript.PURPOSE_TAGS.has(stream_label))
Kit.check("RngService 用途标签词表非空且互异(封闭语义由流改标签)",
@@ -102,8 +102,8 @@ func _test_status_boolean_acceptance(db) -> void:
var effects = StatusContainerScript.new(db)
# EC1 域闭:notify_battle_start 前授予 = false(受理面封闭)
var any_row: Dictionary = {}
if db.status_effects.rows.size() > 0:
any_row = db.status_effects.rows[0]
if db.combat.status_effects.rows.size() > 0:
any_row = db.combat.status_effects.rows[0]
Kit.check("前置:状态表非空", not any_row.is_empty())
if any_row.is_empty():
return
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more