diff --git a/game/core/action_economy.gd b/game/core/action_economy.gd index 17a48750..5a49f05b 100644 --- a/game/core/action_economy.gd +++ b/game/core/action_economy.gd @@ -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: @@ -66,7 +62,7 @@ func _phase_text(p: int) -> String: func _cost(kind: String) -> int: - var ae = db.action_economy + var ae = db.run.action_economy match kind: "dig": return ae.dig_ap_cost # 基础挖格成本恒表值(钻头对价挂付费开掘侧) @@ -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: diff --git a/game/core/adventure_runner.gd b/game/core/adventure_runner.gd index 80a1afec..44ccda53 100644 --- a/game/core/adventure_runner.gd +++ b/game/core/adventure_runner.gd @@ -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}) # 逐杀明细+累计单写原语 diff --git a/game/core/attune_loadout.gd b/game/core/attune_loadout.gd index bd6fcf4a..55fabc8e 100644 --- a/game/core/attune_loadout.gd +++ b/game/core/attune_loadout.gd @@ -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} diff --git a/game/core/attunement.gd b/game/core/attunement.gd index f426f60c..b3a66a56 100644 --- a/game/core/attunement.gd +++ b/game/core/attunement.gd @@ -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 diff --git a/game/core/battle_fx_host.gd b/game/core/battle_fx_host.gd index 5ffa6955..a4a3bc42 100644 --- a/game/core/battle_fx_host.gd +++ b/game/core/battle_fx_host.gd @@ -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)) diff --git a/game/core/battle_orchestrator.gd b/game/core/battle_orchestrator.gd index fbaed186..3136eaaa 100644 --- a/game/core/battle_orchestrator.gd +++ b/game/core/battle_orchestrator.gd @@ -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,12 +675,12 @@ 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 := { "round_index": round_index, - "last_round_end": "none" if (action_economy.last_round_end as Dictionary).is_empty() else String((action_economy.last_round_end as Dictionary).get("path_tag", "none")), + "last_round_end": "none" if action_economy.last_round_end().is_empty() else String(action_economy.last_round_end().get("path_tag", "none")), "mines_hit_last": int(_mines_hits["last"]), "mines_hit_total": int(_mines_hits["total"]), } @@ -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), diff --git a/game/core/board_generator.gd b/game/core/board_generator.gd index 9e338fe6..accfd631 100644 --- a/game/core/board_generator.gd +++ b/game/core/board_generator.gd @@ -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) diff --git a/game/core/config_db.gd b/game/core/config_db.gd index e6370402..bd535c90 100644 --- a/game/core/config_db.gd +++ b/game/core/config_db.gd @@ -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: diff --git a/game/core/config_db_cross_validator.gd b/game/core/config_db_cross_validator.gd index 8bcc0fa7..0ca4c90d 100644 --- a/game/core/config_db_cross_validator.gd +++ b/game/core/config_db_cross_validator.gd @@ -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 diff --git a/game/core/config_db_resolver.gd b/game/core/config_db_resolver.gd index 34852bdd..37668e44 100644 --- a/game/core/config_db_resolver.gd +++ b/game/core/config_db_resolver.gd @@ -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 diff --git a/game/core/config_domain_combat.gd b/game/core/config_domain_combat.gd new file mode 100644 index 00000000..3ea1e5f1 --- /dev/null +++ b/game/core/config_domain_combat.gd @@ -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 diff --git a/game/core/config_domain_combat.gd.uid b/game/core/config_domain_combat.gd.uid new file mode 100644 index 00000000..517c2070 --- /dev/null +++ b/game/core/config_domain_combat.gd.uid @@ -0,0 +1 @@ +uid://dpy0a6fcggbwe diff --git a/game/core/config_domain_crossrun.gd b/game/core/config_domain_crossrun.gd new file mode 100644 index 00000000..e001d1ba --- /dev/null +++ b/game/core/config_domain_crossrun.gd @@ -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 diff --git a/game/core/config_domain_crossrun.gd.uid b/game/core/config_domain_crossrun.gd.uid new file mode 100644 index 00000000..a44745fc --- /dev/null +++ b/game/core/config_domain_crossrun.gd.uid @@ -0,0 +1 @@ +uid://du0gxv0lnrxnp diff --git a/game/core/config_domain_run.gd b/game/core/config_domain_run.gd new file mode 100644 index 00000000..0bdaa330 --- /dev/null +++ b/game/core/config_domain_run.gd @@ -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 diff --git a/game/core/config_domain_run.gd.uid b/game/core/config_domain_run.gd.uid new file mode 100644 index 00000000..3e07d6eb --- /dev/null +++ b/game/core/config_domain_run.gd.uid @@ -0,0 +1 @@ +uid://jaxlqd8ml3co diff --git a/game/core/curio_deck.gd b/game/core/curio_deck.gd index c69c4092..4df30645 100644 --- a/game/core/curio_deck.gd +++ b/game/core/curio_deck.gd @@ -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 diff --git a/game/core/curio_stop.gd b/game/core/curio_stop.gd index 76dec168..fdaaa76a 100644 --- a/game/core/curio_stop.gd +++ b/game/core/curio_stop.gd @@ -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) diff --git a/game/core/damage_controller.gd b/game/core/damage_controller.gd index 6afbef7b..8d72cb9b 100644 --- a/game/core/damage_controller.gd +++ b/game/core/damage_controller.gd @@ -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)) # 攻击行显示名(掷点档位随行文本可读;重档折扣 = "被压制") diff --git a/game/core/effect_engine.gd b/game/core/effect_engine.gd index db949132..4033eb64 100644 --- a/game/core/effect_engine.gd +++ b/game/core/effect_engine.gd @@ -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() - if db != null and db.feats != null: - for row in db.feats.rows: + _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.crossrun.feats != null: + 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)、 diff --git a/game/core/hero_stats.gd b/game/core/hero_stats.gd index aad9753d..dc0390ac 100644 --- a/game/core/hero_stats.gd +++ b/game/core/hero_stats.gd @@ -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)) diff --git a/game/core/level_runner.gd b/game/core/level_runner.gd index cb860f5f..9469a2f0 100644 --- a/game/core/level_runner.gd +++ b/game/core/level_runner.gd @@ -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" \ diff --git a/game/core/meta_progression.gd b/game/core/meta_progression.gd index 25749848..e762d613 100644 --- a/game/core/meta_progression.gd +++ b/game/core/meta_progression.gd @@ -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: int = 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: int = 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: int = 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: bool = (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: String = 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: int = 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() diff --git a/game/core/meta_state_store.gd b/game/core/meta_state_store.gd new file mode 100644 index 00000000..67eb114d --- /dev/null +++ b/game/core/meta_state_store.gd @@ -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 diff --git a/game/core/meta_state_store.gd.uid b/game/core/meta_state_store.gd.uid new file mode 100644 index 00000000..43f69277 --- /dev/null +++ b/game/core/meta_state_store.gd.uid @@ -0,0 +1 @@ +uid://bc5perm32w2yp diff --git a/game/core/minesweeper_rules.gd b/game/core/minesweeper_rules.gd index 4d31d522..24163cf8 100644 --- a/game/core/minesweeper_rules.gd +++ b/game/core/minesweeper_rules.gd @@ -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", "")}) diff --git a/game/core/node_map_generator.gd b/game/core/node_map_generator.gd index d3f43d2d..1de0c1cd 100644 --- a/game/core/node_map_generator.gd +++ b/game/core/node_map_generator.gd @@ -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] diff --git a/game/core/refresh_bank.gd b/game/core/refresh_bank.gd index 4502be5b..1259006d 100644 --- a/game/core/refresh_bank.gd +++ b/game/core/refresh_bank.gd @@ -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 调用——刷新三源与付费计数全零) diff --git a/game/core/reward_economy.gd b/game/core/reward_economy.gd index e02515f2..907a7ec1 100644 --- a/game/core/reward_economy.gd +++ b/game/core/reward_economy.gd @@ -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 {} diff --git a/game/core/reward_grant.gd b/game/core/reward_grant.gd index 5b0dfe17..3267c2ac 100644 --- a/game/core/reward_grant.gd +++ b/game/core/reward_grant.gd @@ -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)) diff --git a/game/core/save_manager.gd b/game/core/save_manager.gd index ed4a56fa..a4fabc6a 100644 --- a/game/core/save_manager.gd +++ b/game/core/save_manager.gd @@ -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 = 高版本拒读(默认数据运行、禁写盘)。 +# 损坏兜底:原文件改名 .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 = 高版本拒读(默认数据运行、禁写盘)。 -# 损坏兜底:原文件改名 .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: diff --git a/game/core/settlement/combat_segment.gd b/game/core/settlement/combat_segment.gd index ccb739cd..db8bae28 100644 --- a/game/core/settlement/combat_segment.gd +++ b/game/core/settlement/combat_segment.gd @@ -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, diff --git a/game/core/settlement/grant_segment.gd b/game/core/settlement/grant_segment.gd index 162a1e3e..2de0123c 100644 --- a/game/core/settlement/grant_segment.gd +++ b/game/core/settlement/grant_segment.gd @@ -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 外组合 = 数据行新词表未接线(显式忽略面) # 地形变异伤害单据——无类型必中,下限保底在控制器管线内 diff --git a/game/core/shop_session.gd b/game/core/shop_session.gd index 028a98c6..531610d6 100644 --- a/game/core/shop_session.gd +++ b/game/core/shop_session.gd @@ -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: diff --git a/game/core/slot_store.gd b/game/core/slot_store.gd new file mode 100644 index 00000000..3b7a8746 --- /dev/null +++ b/game/core/slot_store.gd @@ -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 diff --git a/game/core/slot_store.gd.uid b/game/core/slot_store.gd.uid new file mode 100644 index 00000000..04116c73 --- /dev/null +++ b/game/core/slot_store.gd.uid @@ -0,0 +1 @@ +uid://dylsba7whx5ql diff --git a/game/core/status_container.gd b/game/core/status_container.gd index e1433e10..52def7f8 100644 --- a/game/core/status_container.gd +++ b/game/core/status_container.gd @@ -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: diff --git a/game/core/treasure_items.gd b/game/core/treasure_items.gd index fb0ab1b9..e3015e22 100644 --- a/game/core/treasure_items.gd +++ b/game/core/treasure_items.gd @@ -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: diff --git a/game/core/unit_container.gd b/game/core/unit_container.gd index 066f5ca0..2eff1088 100644 --- a/game/core/unit_container.gd +++ b/game/core/unit_container.gd @@ -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: diff --git a/game/data/schemas/adventure_cap_segment.gd b/game/data/schemas/adventure_cap_segment.gd new file mode 100644 index 00000000..4d09d676 --- /dev/null +++ b/game/data/schemas/adventure_cap_segment.gd @@ -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] diff --git a/game/data/schemas/adventure_cap_segment.gd.uid b/game/data/schemas/adventure_cap_segment.gd.uid new file mode 100644 index 00000000..9437e666 --- /dev/null +++ b/game/data/schemas/adventure_cap_segment.gd.uid @@ -0,0 +1 @@ +uid://b88nnsq53mium diff --git a/game/data/schemas/adventure_quota_segment.gd b/game/data/schemas/adventure_quota_segment.gd new file mode 100644 index 00000000..7ddc3870 --- /dev/null +++ b/game/data/schemas/adventure_quota_segment.gd @@ -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 归调参) diff --git a/game/data/schemas/adventure_quota_segment.gd.uid b/game/data/schemas/adventure_quota_segment.gd.uid new file mode 100644 index 00000000..1af7b113 --- /dev/null +++ b/game/data/schemas/adventure_quota_segment.gd.uid @@ -0,0 +1 @@ +uid://bi52mqsld3mpc diff --git a/game/data/schemas/adventure_table.gd b/game/data/schemas/adventure_table.gd index ef87d79b..bd3512bd 100644 --- a/game/data/schemas/adventure_table.gd +++ b/game/data/schemas/adventure_table.gd @@ -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 diff --git a/game/data/schemas/adventure_value_segment.gd b/game/data/schemas/adventure_value_segment.gd new file mode 100644 index 00000000..e64fb15c --- /dev/null +++ b/game/data/schemas/adventure_value_segment.gd @@ -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 diff --git a/game/data/schemas/adventure_value_segment.gd.uid b/game/data/schemas/adventure_value_segment.gd.uid new file mode 100644 index 00000000..b611d35f --- /dev/null +++ b/game/data/schemas/adventure_value_segment.gd.uid @@ -0,0 +1 @@ +uid://c0y3hcpqyv1li diff --git a/game/data/schemas/board_gen_table.gd b/game/data/schemas/board_gen_table.gd index 664c4ab2..a8322475 100644 --- a/game/data/schemas/board_gen_table.gd +++ b/game/data/schemas/board_gen_table.gd @@ -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.resolve_board_gen(L, D) diff --git a/game/data/schemas/difficulty_table.gd b/game/data/schemas/difficulty_table.gd index 964eabdd..f7ff7cf5 100644 --- a/game/data/schemas/difficulty_table.gd +++ b/game/data/schemas/difficulty_table.gd @@ -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.resolve_difficulty_modifiers(L, D) diff --git a/game/data/schemas/enemy_table.gd b/game/data/schemas/enemy_table.gd index 633fa734..bb13f710 100644 --- a/game/data/schemas/enemy_table.gd +++ b/game/data/schemas/enemy_table.gd @@ -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.resolve_enemy_rowset(L, D, row_kind) diff --git a/game/data/schemas/forge_table.gd b/game/data/schemas/forge_table.gd index a9ffb6ec..57447c6a 100644 --- a/game/data/schemas/forge_table.gd +++ b/game/data/schemas/forge_table.gd @@ -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. 直连)—— +# 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 {} diff --git a/game/data/schemas/level_gen_params.gd b/game/data/schemas/level_gen_params.gd index 20df9628..53284bcd 100644 --- a/game/data/schemas/level_gen_params.gd +++ b/game/data/schemas/level_gen_params.gd @@ -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 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: diff --git a/game/data/schemas/level_table.gd b/game/data/schemas/level_table.gd index 1d7fabea..b52a3dec 100644 --- a/game/data/schemas/level_table.gd +++ b/game/data/schemas/level_table.gd @@ -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() diff --git a/game/data/schemas/meta_table.gd b/game/data/schemas/meta_table.gd index 7f68bd4c..3150b389 100644 --- a/game/data/schemas/meta_table.gd +++ b/game/data/schemas/meta_table.gd @@ -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. 直连)---- + +## 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 {} diff --git a/game/data/schemas/meta_tree_table.gd b/game/data/schemas/meta_tree_table.gd index 8e75a3a5..e88ceae2 100644 --- a/game/data/schemas/meta_tree_table.gd +++ b/game/data/schemas/meta_tree_table.gd @@ -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 diff --git a/game/data/schemas/reward_table.gd b/game/data/schemas/reward_table.gd index 65ef00f1..610e1845 100644 --- a/game/data/schemas/reward_table.gd +++ b/game/data/schemas/reward_table.gd @@ -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_ 命名空间预留拒绝: %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_ 命名空间预留拒绝/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_ 命名空间预留拒绝: %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 diff --git a/game/data/schemas/shop_table.gd b/game/data/schemas/shop_table.gd index 8028e36e..768493e2 100644 --- a/game/data/schemas/shop_table.gd +++ b/game/data/schemas/shop_table.gd @@ -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. 直连)---- + +## 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 {} diff --git a/game/data/schemas/skill_table.gd b/game/data/schemas/skill_table.gd index 335d9664..aa12eda8 100644 --- a/game/data/schemas/skill_table.gd +++ b/game/data/schemas/skill_table.gd @@ -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 diff --git a/game/data/schemas/status_effect_table.gd b/game/data/schemas/status_effect_table.gd index 6811a235..4e1568cb 100644 --- a/game/data/schemas/status_effect_table.gd +++ b/game/data/schemas/status_effect_table.gd @@ -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: diff --git a/game/data/schemas/treasure_item_table.gd b/game/data/schemas/treasure_item_table.gd index 20acd403..ceaeccf0 100644 --- a/game/data/schemas/treasure_item_table.gd +++ b/game/data/schemas/treasure_item_table.gd @@ -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 diff --git a/game/data/schemas/variation_table.gd b/game/data/schemas/variation_table.gd index 65c4c7b6..1b4af099 100644 --- a/game/data/schemas/variation_table.gd +++ b/game/data/schemas/variation_table.gd @@ -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. 直连)---- +# 门控第三维 = 节点身份(全局作用域变异):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 {} diff --git a/game/data/tables/adventure.tres b/game/data/tables/adventure.tres index 17a5c641..c60c6d66 100644 --- a/game/data/tables/adventure.tres +++ b/game/data/tables/adventure.tres @@ -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 硬断言,改总量须重裁)。 diff --git a/game/presentation/battle_narrator.gd b/game/presentation/battle_narrator.gd index 066aa8d9..ee8671ca 100644 --- a/game/presentation/battle_narrator.gd +++ b/game/presentation/battle_narrator.gd @@ -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 "遗迹守卫" @@ -98,31 +98,36 @@ static func curio_pick_header(payload: Dictionary) -> String: # 对齐 v2.2 追认 b 口径:小/大前缀(小GP/大GP 族)、恢复/AP 不带档位词;道具件掷定前置时 # treasure_line = 具体道具「名 · 效果摘要」行——常空走通用分支) static func bonus_card_text(id: String, tier: String, value: int, treasure_line: String = "") -> String: - var ts := "" - if tier == "common": - ts = " · 小" - elif tier == "rare": - ts = " · 大" - match id: - "attack": - return "攻击力提升%s\n攻击 +%d" % [ts, value] - "max_hp": - return "生命上限提升%s\n生命上限 +%d" % [ts, value] - "armor": - return "护甲等级提升%s\n护甲 +%d" % [ts, value] - "gp": - return "GP 袋%s\nGP +%d" % [ts, value] - "heal": - return "恢复药剂\n生命 +%d(当即回复)" % value - "treasure_item": - if treasure_line != "": - return "秘宝\n%s" % treasure_line - return "秘宝\n随机获得一件" - "ap": - return "行动点提升\n每战首轮 AP +%d" % value + if id == "treasure_item": + return _bonus_treasure_line(treasure_line) + var ts := _tier_suffix(tier) + var templates := {"attack": "攻击力提升%s\n攻击 +%d", "max_hp": "生命上限提升%s\n生命上限 +%d", + "armor": "护甲等级提升%s\n护甲 +%d", "gp": "GP 袋%s\nGP +%d"} + if templates.has(id): + return templates[id] % [ts, value] + if id == "heal": + return "恢复药剂\n生命 +%d(当即回复)" % value + if id == "ap": + return "行动点提升\n每战首轮 AP +%d" % value return id +## 档位尾注域(common=小/rare=大——旧载荷防御口径) +static func _tier_suffix(tier: String) -> String: + if tier == "common": + return " · 小" + if tier == "rare": + return " · 大" + return "" + + +## 秘宝卡文案域(treasure_line 空串=随机获得一件) +static func _bonus_treasure_line(treasure_line: String) -> String: + if treasure_line != "": + return "秘宝\n%s" % treasure_line + return "秘宝\n随机获得一件" + + # 三选一兑付反馈行(选中件效果 + 丢弃提示;tier 缺省普通档——旧载荷防御口径) static func bonus_result_text(result: Dictionary) -> String: var name := "" diff --git a/game/presentation/board_resolvers.gd b/game/presentation/board_resolvers.gd index 554d8c5e..0856ee2f 100644 --- a/game/presentation/board_resolvers.gd +++ b/game/presentation/board_resolvers.gd @@ -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 "" diff --git a/game/presentation/camp_flow_context.gd b/game/presentation/camp_flow_context.gd index e1f384f8..e8dd0b47 100644 --- a/game/presentation/camp_flow_context.gd +++ b/game/presentation/camp_flow_context.gd @@ -4,33 +4,16 @@ extends RefCounted # 原 13 参长签名收一处命名面——单测可直接 new() 本 context + 控制器获得独立实例化缝)。 # 字段即原构造参数逐一同名迁移;依赖仅视图句柄 + 查询/编排 Callable(组合根注入)。 +## 三束按域收拢(u21——同 u20 形;views=视图句柄/getters=查询 Callable 活引用/flow=编排 Callable): +## 原 12 参长签名收 4 参——字段即原构造参数逐一同名迁移,键面=消费方改址合同。 var db -var meta_get: Callable # () -> MetaProgressionCore(现值回调——组合根 lambda) -var save_get: Callable # () -> SaveManagerCore -var camp_overlay: CampOverlay -var shop_overlay: ShopOverlay -var toast: ToastStack -var start_overlay: Control -var logger # PlaytestLogger(试玩日志验收取数面) -var runner_get: Callable # () -> LevelRunner -var open_modal: Callable -var close_modal: Callable -var refresh_presentations: Callable +var views: Dictionary = {} # {camp_overlay, shop_overlay, toast, start_overlay, logger} +var getters: Dictionary = {} # {meta_get, save_get, runner_get} +var flow: Dictionary = {} # {open_modal, close_modal, refresh_presentations} -func _init(db_ref, meta_get_cb: Callable, save_get_cb: Callable, camp_overlay_ref: CampOverlay, - shop_overlay_ref: ShopOverlay, toast_ref: ToastStack, start_overlay_ref: Control, - logger_ref, runner_get_cb: Callable, open_modal_cb: Callable, close_modal_cb: Callable, - refresh_presentations_cb: Callable) -> void: +func _init(db_ref, views_bundle: Dictionary, getters_bundle: Dictionary, flow_bundle: Dictionary) -> void: db = db_ref - meta_get = meta_get_cb - save_get = save_get_cb - camp_overlay = camp_overlay_ref - shop_overlay = shop_overlay_ref - toast = toast_ref - start_overlay = start_overlay_ref - logger = logger_ref - runner_get = runner_get_cb - open_modal = open_modal_cb - close_modal = close_modal_cb - refresh_presentations = refresh_presentations_cb + views = views_bundle + getters = getters_bundle + flow = flow_bundle diff --git a/game/presentation/camp_shop_presenter.gd b/game/presentation/camp_shop_presenter.gd index 86fcc5ec..a17d329c 100644 --- a/game/presentation/camp_shop_presenter.gd +++ b/game/presentation/camp_shop_presenter.gd @@ -21,17 +21,17 @@ var _refresh_presentations: Callable func _init(ctx: CampFlowContext) -> void: # 构造参数收拢 context 对象(13 参长签名收一处命名面) db = ctx.db - _meta_get = ctx.meta_get - _save_get = ctx.save_get - camp_overlay = ctx.camp_overlay - shop_overlay = ctx.shop_overlay - toast = ctx.toast - start_overlay = ctx.start_overlay - logger = ctx.logger - _runner_get = ctx.runner_get - _open_modal = ctx.open_modal - _close_modal = ctx.close_modal - _refresh_presentations = ctx.refresh_presentations + _meta_get = ctx.getters.meta_get + _save_get = ctx.getters.save_get + camp_overlay = ctx.views.camp_overlay + shop_overlay = ctx.views.shop_overlay + toast = ctx.views.toast + start_overlay = ctx.views.start_overlay + logger = ctx.views.logger + _runner_get = ctx.getters.runner_get + _open_modal = ctx.flow.open_modal + _close_modal = ctx.flow.close_modal + _refresh_presentations = ctx.flow.refresh_presentations # 打开营地(主菜单入口——开合与开始层互斥仿设置面先例:开始覆盖层非模态且树序在后, @@ -59,13 +59,13 @@ 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 [], - "recent": _save_get.call().recent_summaries() if _save_get.call() != 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().meta_state.career_state()["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() diff --git a/game/presentation/curio_flow_context.gd b/game/presentation/curio_flow_context.gd index 480fb587..1e9ac5bf 100644 --- a/game/presentation/curio_flow_context.gd +++ b/game/presentation/curio_flow_context.gd @@ -4,42 +4,16 @@ extends RefCounted # 原 16 参长签名收一处命名面——单测可直接 new() 本 context + 控制器获得独立实例化缝)。 # 字段即原构造参数逐一同名迁移;依赖仅视图句柄 + 查询/编排 Callable(组合根注入)。 +## 三束按域收拢(u20——views=视图句柄/getters=查询 Callable 活引用〔禁快照〕/flow=编排 Callable): +## 原 16 参长签名收 4 参——字段即原构造参数逐一同名迁移,键面=消费方改址合同。 var db -var meta_get: Callable # () -> MetaProgressionCore(现值回调——组合根 lambda) -var curio_overlay: CurioPickOverlay -var end_overlay: BattleEndOverlay -var board_view: BoardView -var toast: ToastStack -var save_get: Callable # () -> SaveManagerCore -var adventure_get: Callable # () -> AdventureRunner(会话现值) -var runner_get: Callable # () -> LevelRunner -var adventure_mode_get: Callable # () -> bool -var open_modal: Callable # (Control) -> void -var open_modal_after_playback: Callable -var close_modal: Callable -var refresh_presentations: Callable -var fatal_screen: Callable -var gp_summary: Callable # (Dictionary) -> String +var views: Dictionary = {} # {curio_overlay, end_overlay, board_view, toast} +var getters: Dictionary = {} # {meta_get, save_get, adventure_get, runner_get, adventure_mode_get} +var flow: Dictionary = {} # {open_modal, open_modal_after_playback, close_modal, refresh_presentations, fatal_screen, gp_summary} -func _init(db_ref, meta_get_cb: Callable, curio_overlay_ref: CurioPickOverlay, end_overlay_ref: BattleEndOverlay, - board_view_ref: BoardView, toast_ref: ToastStack, save_get_cb: Callable, - adventure_get_cb: Callable, runner_get_cb: Callable, adventure_mode_get_cb: Callable, - open_modal_cb: Callable, open_modal_after_playback_cb: Callable, close_modal_cb: Callable, - refresh_presentations_cb: Callable, fatal_screen_cb: Callable, gp_summary_cb: Callable) -> void: +func _init(db_ref, views_bundle: Dictionary, getters_bundle: Dictionary, flow_bundle: Dictionary) -> void: db = db_ref - meta_get = meta_get_cb - curio_overlay = curio_overlay_ref - end_overlay = end_overlay_ref - board_view = board_view_ref - toast = toast_ref - save_get = save_get_cb - adventure_get = adventure_get_cb - runner_get = runner_get_cb - adventure_mode_get = adventure_mode_get_cb - open_modal = open_modal_cb - open_modal_after_playback = open_modal_after_playback_cb - close_modal = close_modal_cb - refresh_presentations = refresh_presentations_cb - fatal_screen = fatal_screen_cb - gp_summary = gp_summary_cb + views = views_bundle + getters = getters_bundle + flow = flow_bundle diff --git a/game/presentation/curio_interact_controller.gd b/game/presentation/curio_interact_controller.gd index afa4bece..df3f20fc 100644 --- a/game/presentation/curio_interact_controller.gd +++ b/game/presentation/curio_interact_controller.gd @@ -8,44 +8,16 @@ extends RefCounted const Narrator := preload("res://presentation/battle_narrator.gd") var db -var _meta_get: Callable # () → 元进度账本现值(组合根 lambda——探针 isolate_save 换注面) -var curio_overlay: CurioPickOverlay -var end_overlay: BattleEndOverlay -var board_view: BoardView -var toast: ToastStack -var _save_get: Callable # () → 存档承载现值(同上——Attunement.slots 读数面) -var _adventure_get: Callable # () → 冒险结构现值(组合根 lambda——随局更替) -var _runner_get: Callable # () → 关卡推进器现值(组合根 lambda——reward_node 继续推进) -var _adventure_mode_get: Callable # () → 冒险模式标记现值(组合根 lambda) -var _open_modal: Callable # 模态栈单口(组合根) -var _open_modal_after_playback: Callable # 演播后排窗单口(组合根) -var _close_modal: Callable -var _refresh_presentations: Callable -var _fatal_screen: Callable -var _gp_summary: Callable # () → 战斗总结 GP 行文案(组合根 lambda——_last_gain_amount 取数面在根) +var ctx: CurioFlowContext # u13 引用束注入:构造注入 context 活引用(views/getters/flow 三束键名) +# _pending_curio/_curio_context 纯内部态保持宿主 var _pending_curio := {} # 三选一停点载荷暂存(战斗总结结算确认后开三选一面;续玩停点复用) var _curio_context := "level_clear" # 三选一面上下文(level_clear=关卡完成停点→推进下一关 / reward_node=奖励节点→回图选路) -func _init(ctx: CurioFlowContext) -> void: - # 构造参数收拢 context 对象(16 参长签名收一处命名面) +func _init(ctx_ref: CurioFlowContext) -> void: + ctx = ctx_ref db = ctx.db - _meta_get = ctx.meta_get - curio_overlay = ctx.curio_overlay - end_overlay = ctx.end_overlay - board_view = ctx.board_view - toast = ctx.toast - _save_get = ctx.save_get - _adventure_get = ctx.adventure_get - _runner_get = ctx.runner_get - _adventure_mode_get = ctx.adventure_mode_get - _open_modal = ctx.open_modal - _open_modal_after_playback = ctx.open_modal_after_playback - _close_modal = ctx.close_modal - _refresh_presentations = ctx.refresh_presentations - _fatal_screen = ctx.fatal_screen - _gp_summary = ctx.gp_summary # 停点在途判据(组合根 _on_battle_end_continue 分派面——战斗总结确认后开三选一) @@ -66,17 +38,17 @@ func resume_if_pending() -> bool: # 差异仅零面可收——curio_overlay 未开不 close。core 停点经 skip_empty_curio 消化后推进 #(continue_to_next_level 的「停点未兑付拒推进」守卫因此放行)。 func _skip_empty_stop() -> bool: - toast.show_toast("可用战利品为空——本次三选一跳过", "info") - var adventure = _adventure_get.call() + ctx.views.toast.show_toast("可用战利品为空——本次三选一跳过", "info") + var adventure = ctx.getters.adventure_get.call() if adventure != null: adventure.stop().skip_empty_curio() # core 停点消化(其守卫=仅空候选放行) if _curio_context == "reward_node": - var runner = _runner_get.call() + var runner = ctx.getters.runner_get.call() if runner != null: runner.continue_after_node() - elif _curio_context == "level_clear" and bool(_adventure_mode_get.call()): + elif _curio_context == "level_clear" and bool(ctx.getters.adventure_mode_get.call()): if not adventure.continue_to_next_level(): - _fatal_screen.call("关卡推进失败——见输出日志") + ctx.flow.fatal_screen.call("关卡推进失败——见输出日志") # feat_ticket = 只消化停点回店(零推进——与 on_continue 同构) _pending_curio = {} return true @@ -93,41 +65,41 @@ func on_presented(payload: Dictionary) -> void: # 空候选防御(零卡不开死面;core _redeem_feat_ticket 守卫外的末道) _skip_empty_stop() return - _open_modal.call(curio_overlay) - curio_overlay.open_pick(Narrator.node_curio_header(), _curio_card_texts(payload.get("candidates", []) as Array)) + ctx.flow.open_modal.call(ctx.views.curio_overlay) + ctx.views.curio_overlay.open_pick(Narrator.node_curio_header(), _curio_card_texts(payload.get("candidates", []) as Array)) return if int(payload.get("aux_cleared", 0)) > 0: - toast.show_toast("上关剩余 %d 件秘宝已随关卡结束清空" % int(payload.get("aux_cleared", 0)), "info") # 清空反馈 + ctx.views.toast.show_toast("上关剩余 %d 件秘宝已随关卡结束清空" % int(payload.get("aux_cleared", 0)), "info") # 清空反馈 _pending_curio = payload.duplicate(true) _curio_context = "level_clear" if bool(payload.get("refreshed", false)): # 刷新重发:refreshed 标直落选择面——不再重开胜利总结面 show_pick() return - end_overlay.open_victory(Narrator.battle_end_text("victory", false), "继续", - board_view.get_global_rect(), - "%s\n%s" % [String(_gp_summary.call()), Narrator.bonus_keystone_line(payload)]) - _open_modal_after_playback.call(end_overlay) - _refresh_presentations.call() + ctx.views.end_overlay.open_victory(Narrator.battle_end_text("victory", false), "继续", + ctx.views.board_view.get_global_rect(), + "%s\n%s" % [String(ctx.flow.gp_summary.call()), Narrator.bonus_keystone_line(payload)]) + ctx.flow.open_modal_after_playback.call(ctx.views.end_overlay) + ctx.flow.refresh_presentations.call() # 奖励节点三选一(与关卡完成三选一共享同池同权重同上限;CurioPickOverlay 共用面)。 # 无替换子交互(同调装配不可变,见类头)。 func present_node_curio() -> void: - var adventure = _adventure_get.call() + var adventure = ctx.getters.adventure_get.call() _curio_context = "reward_node" var node_draw: Dictionary[Variant, Variant] = adventure.stop().node_curio_candidates() var node_cands: Array[Variant] = node_draw.get("candidates", []) as Array if node_cands.is_empty(): # 空池降级——不开面、不挂节点停点(present_node_curio 未调即未挂), # 兑付确认直落回图选路(auto-sim「node_entered 回调内立即 continue_after_node」先例) - toast.show_toast("可用战利品为空——本次三选一跳过", "info") - var runner = _runner_get.call() + ctx.views.toast.show_toast("可用战利品为空——本次三选一跳过", "info") + var runner = ctx.getters.runner_get.call() if runner != null: runner.continue_after_node() return - _open_modal.call(curio_overlay) - curio_overlay.open_pick(Narrator.node_curio_header(), _curio_card_texts(node_cands)) + ctx.flow.open_modal.call(ctx.views.curio_overlay) + ctx.views.curio_overlay.open_pick(Narrator.node_curio_header(), _curio_card_texts(node_cands)) adventure.stop().present_node_curio(node_cands) _feed_refresh_display(adventure) # 刷新面投喂单点 @@ -135,20 +107,20 @@ func present_node_curio() -> void: # 三选一面开出(战斗总结结算确认 / 续玩停点恢复) func show_pick() -> void: var payload: Dictionary[Variant, Variant] = _pending_curio - _open_modal.call(curio_overlay) - curio_overlay.open_pick(Narrator.curio_pick_header(payload), _curio_card_texts(payload.get("candidates", []) as Array)) - var adventure = _adventure_get.call() + ctx.flow.open_modal.call(ctx.views.curio_overlay) + ctx.views.curio_overlay.open_pick(Narrator.curio_pick_header(payload), _curio_card_texts(payload.get("candidates", []) as Array)) + var adventure = ctx.getters.adventure_get.call() if adventure != null: _feed_refresh_display(adventure) # 刷新面投喂单点 - _refresh_presentations.call() + ctx.flow.refresh_presentations.call() # 刷新呈现投喂单点(免费余量 X/Y + GP 付费价 + 冒险钱包可付——读数与商店 set_refresh_display 同构) func _feed_refresh_display(adventure: AdventureRunner) -> void: - var runner = _runner_get.call() + var runner = ctx.getters.runner_get.call() var gp := int(runner.gp) if runner != null else 0 var cost: int = adventure.refresh_bank().gp_cost() - curio_overlay.set_refresh_display(adventure.refresh_bank().free_left(), cost, gp >= cost) + ctx.views.curio_overlay.set_refresh_display(adventure.refresh_bank().free_left(), cost, gp >= cost) func _curio_card_texts(candidates: Array) -> Array: @@ -165,9 +137,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 @@ -180,29 +152,29 @@ func _curio_card_texts(candidates: Array) -> Array: # 同 shop_refresh_cost_base/curve 旋钮、同冒险钱包 runner.gp;关卡完成停点经重发 curio_choice_presented 直落 # 选择面,节点停点在此读在途态重 present——零自动推进契约不破,停点仍在途) func on_refresh() -> void: - var adventure = _adventure_get.call() + var adventure = ctx.getters.adventure_get.call() if adventure == null or not adventure.stop().curio_stop_pending(): return - var runner = _runner_get.call() + var runner = ctx.getters.runner_get.call() var refreshed: bool = adventure.stop().refresh_curio() if not refreshed: # 免费位(X/Y)已穷——GP 付费档(先扣钱包后重抽,余额不足拒绝零副作用) var cost: int = adventure.refresh_bank().gp_cost() if runner == null or not runner.spend_gp(cost): - toast.show_toast("GP不足——刷新失败", "warn") + ctx.views.toast.show_toast("GP不足——刷新失败", "warn") return refreshed = adventure.stop().refresh_curio_paid() - _refresh_presentations.call() # 钱包变化上屏(对齐商店购买后刷新面) + ctx.flow.refresh_presentations.call() # 钱包变化上屏(对齐商店购买后刷新面) if not refreshed: return if _curio_context == "reward_node": var st: Dictionary[Variant, Variant] = adventure.stop().node_curio_pending_state() - curio_overlay.open_pick(Narrator.node_curio_header(), _curio_card_texts(st.get("candidates", []) as Array)) + ctx.views.curio_overlay.open_pick(Narrator.node_curio_header(), _curio_card_texts(st.get("candidates", []) as Array)) _feed_refresh_display(adventure) func on_pick_confirmed(item_id: String) -> void: - var adventure = _adventure_get.call() + var adventure = ctx.getters.adventure_get.call() if adventure == null: return # TP0 令牌池——卡片 id "kind|tier|feat_id" 拆键(缺 | = 旧载荷防御归普通档;relic 三段) @@ -218,39 +190,39 @@ func on_pick_confirmed(item_id: String) -> void: # 后续兑付/确认仍走节点路径回图选路) if _curio_context == "reward_node" and kind == "feat_ticket": var st2: Dictionary[Variant, Variant] = adventure.stop().node_curio_pending_state() - curio_overlay.open_pick(Narrator.node_curio_header(), _curio_card_texts(st2.get("candidates", []) as Array)) + ctx.views.curio_overlay.open_pick(Narrator.node_curio_header(), _curio_card_texts(st2.get("candidates", []) as Array)) # 兑付反馈(curio_resolved 到达——结果行上屏,唯一出口 = 继续) func on_resolved(result: Dictionary) -> void: _pending_curio = {} - curio_overlay.show_result(Narrator.bonus_result_text(result)) - _refresh_presentations.call() + ctx.views.curio_overlay.show_result(Narrator.bonus_result_text(result)) + ctx.flow.refresh_presentations.call() # 继续钮:收三选一面 → 上下文分派(关卡完成停点=推进下一关 / 奖励节点=兑付确认回图选路——零自动推进) func on_continue() -> void: - _close_modal.call(curio_overlay) - curio_overlay.close_pick() - var adventure = _adventure_get.call() + ctx.flow.close_modal.call(ctx.views.curio_overlay) + ctx.views.curio_overlay.close_pick() + var adventure = ctx.getters.adventure_get.call() if _curio_context == "feat_ticket": return # T5 店内专长凭证面——专长兑付完成后关面回店(无推进/无回图) if _curio_context == "reward_node": - var runner = _runner_get.call() + var runner = ctx.getters.runner_get.call() if runner != null: runner.continue_after_node() return - if bool(_adventure_mode_get.call()) and adventure != null: + if bool(ctx.getters.adventure_mode_get.call()) and adventure != null: if not adventure.continue_to_next_level(): - _fatal_screen.call("关卡推进失败——见输出日志") + ctx.flow.fatal_screen.call("关卡推进失败——见输出日志") # 秘宝 item_id → 显示名投影(keystone_settle_text/替换子命名文案数据源——拼句归 Narrator); # 数据源=treasure 装配词表 12 件 func item_name_lookup() -> Dictionary: var lookup := {} - var meta = _meta_get.call() + var meta = ctx.getters.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 diff --git a/game/presentation/hud_presenter.gd b/game/presentation/hud_presenter.gd index 408bc94f..e6ae0aa6 100644 --- a/game/presentation/hud_presenter.gd +++ b/game/presentation/hud_presenter.gd @@ -11,8 +11,13 @@ const Narrator := preload("res://presentation/battle_narrator.gd") # 每次结算行/轮次/节点变化/动作返回后调用(组合根全量重查装配) # 钱包 GP/钥石不在本装配面投喂(中/大循环资源迁出战斗屏——信息归属单一展示面原则); # HP/状态效果单投喂 B 区站桩(B/C 双投喂属重复展示——单一展示面原则) -static func refresh(stage: BattleStageView, info: HeroInfoPanelView, ap_bar: ApBarView, - settlement, runner, level_active: bool, db, refresh_remaining: int) -> void: +# 运行态尾参收拢 ctx 束(u19——键 {settlement, runner, level_active, db, refresh_remaining}) +static func refresh(stage: BattleStageView, info: HeroInfoPanelView, ap_bar: ApBarView, ctx: Dictionary) -> void: + var settlement = ctx.get("settlement") + var runner = ctx.get("runner") + var level_active: bool = ctx.get("level_active", false) + var db = ctx.get("db") + var refresh_remaining: int = ctx.get("refresh_remaining", 0) # 特性列表(runner 统一加算区投影:商店购买与关卡大奖励 # 共账本〔level_runner bonus{atk,max_hp,armor_class}〕;空加算=整节隐藏〔视图内空态纪律〕。 # 呈现只读投影零核心触碰;行文格式=白名单项) @@ -36,7 +41,7 @@ static func refresh(stage: BattleStageView, info: HeroInfoPanelView, ap_bar: ApB info.set_feat_holdings([]) if settlement == null: info.set_board_variants([]) # 战斗域角标出战斗即隐(空态纪律) - _refresh_out_of_battle(stage, info, ap_bar, runner, level_active, db) + _refresh_out_of_battle(stage, info, ap_bar, ctx) return var s: Dictionary[Variant, Variant] = settlement.presentation_snapshot() # 地形变异常驻角标投影(整图变异快照 → 文案行; @@ -44,7 +49,7 @@ static func refresh(stage: BattleStageView, info: HeroInfoPanelView, ap_bar: ApB info.set_board_variants(board_variant_lines(db, s.get("board_variants", []))) var hero: Dictionary[Variant, Variant] = s.hero if hero.is_empty() or not bool(hero.get("alive", false)): - _refresh_out_of_battle(stage, info, ap_bar, runner, level_active, db) + _refresh_out_of_battle(stage, info, ap_bar, ctx) return _refresh_hero(stage, info, s) _refresh_economy(ap_bar, s, db, refresh_remaining) @@ -79,7 +84,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 +97,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.crossrun.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(): @@ -109,10 +114,10 @@ static func feat_holdings_lines(db: ConfigDb, held: Array) -> Array[String]: # 文案用常规表述,不用俚语 static func board_variant_lines(db: ConfigDb, ids: Array) -> Array[String]: var lines: Array[String] = [] - if db == null or db.variation == null: + if db == null or db.combat.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("回复与护甲资源减少") @@ -145,7 +150,10 @@ static func sync_settlement_visuals(stage: BattleStageView, board_view: BoardVie # 战斗外(节点间隙/关卡结束)——HP 读 runner 携带值 static func _refresh_out_of_battle(stage: BattleStageView, info: HeroInfoPanelView, - ap_bar: ApBarView, runner, level_active: bool, db) -> void: + ap_bar: ApBarView, ctx: Dictionary) -> void: + var runner = ctx.get("runner") + var level_active: bool = ctx.get("level_active", false) + var db = ctx.get("db") if not level_active: return if runner != null: @@ -185,7 +193,14 @@ static func outcome_text(outcome: Dictionary) -> String: # ---- 批量模拟报告装配(文案归 presenter——视图只 set_text) ---- -static func tune_sim_report(n: int, atk_pct: int, hp_pct: int, wins: int, dmg_all: Array, hp_left_win: Array, hp_boss: Array, rounds_all: Array, death_nodes: Dictionary, base_hp: int) -> String: +## 统计束收拢(u19——stats 六键 {wins, dmg_all, hp_left_win, hp_boss, rounds_all, death_nodes}) +static func tune_sim_report(n: int, atk_pct: int, hp_pct: int, stats: Dictionary, base_hp: int) -> String: + var wins: int = stats.get("wins", 0) + var dmg_all: Array = stats.get("dmg_all", []) + var hp_left_win: Array = stats.get("hp_left_win", []) + var hp_boss: Array = stats.get("hp_boss", []) + var rounds_all: Array = stats.get("rounds_all", []) + var death_nodes: Dictionary = stats.get("death_nodes", {}) var lines: Array[String] = [] lines.append("[b]模拟 N=%d(atk+%d%% / hp+%d%%)[/b]" % [n, atk_pct, hp_pct]) lines.append("关卡完成率 [color=#%s]%.0f%%[/color](%d/%d)" % [UiKit.HEX_LOG_WIN, 100.0 * float(wins) / float(maxi(1, n)), wins, n]) @@ -229,7 +244,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 +272,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 +295,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)), diff --git a/game/presentation/main.gd b/game/presentation/main.gd index e4adf790..50ba97ef 100644 --- a/game/presentation/main.gd +++ b/game/presentation/main.gd @@ -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 = "" @@ -360,15 +360,15 @@ func _wire_adventure() -> void: # meta.attune_refresh_charges(当前等级行表值)、树 rule 键集=meta.tree_rule_keys(Fork 激活侧 effects); # hero_override 沿用既有 meta 现算口径。static 形态=冒烟直呼单测本函数,免实例化整菜单场景)。 # 两根类型轴随开局下传(attack_type/armor_type——契约 2 所有权移交+契约 8 冒险开始快照; -# 取值=meta.hero_attack_type/hero_armor_type=存档选中路线类型轴,未解锁/异常选中态回退锻造配置默认值)。 +# 取值=meta.hero_type_axes()=存档选中路线类型轴(attack/armor 两键),未解锁/异常选中态回退锻造配置默认值)。 # 注:runner 侧 resume_adventure 受理面收 attunement/attune_refresh_y/tree_rules 三键 # (传参/受理两侧对齐)。 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,21 +447,17 @@ 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 "level_end": _end_overlay.open_victory(Narrator.battle_end_text("victory", true), - "返回", board_view.get_global_rect(), _gp_summary_text(payload), - "钥石 · 累计 %d" % run_scope.runner.keystone, HudPresenter.bonus_summary_text(run_scope.runner), - int(payload.get("gp_dropped", 0)), int(run_scope.runner.gp)) + "返回", board_view.get_global_rect(), _end_victory_opts(payload)) "standalone_end": if result == "victory": _end_overlay.open_victory(Narrator.battle_end_text("victory", false), - "回到遭遇选择", board_view.get_global_rect(), _gp_summary_text(payload), - "钥石 · 累计 %d" % run_scope.runner.keystone, HudPresenter.bonus_summary_text(run_scope.runner), - int(payload.get("gp_dropped", 0)), int(run_scope.runner.gp)) + "回到遭遇选择", board_view.get_global_rect(), _end_victory_opts(payload)) else: _end_overlay.open_defeat(Narrator.battle_end_text("defeat", false), "返回", board_view.get_global_rect()) @@ -482,6 +478,14 @@ func _gp_summary_text(payload: Dictionary) -> String: # ==================== core↔view 接线(战斗信号统一走这里)==================== + +## 战末账行束(open_victory opts 键装配单点——GP/钥石/加成三行与增量合计) +func _end_victory_opts(payload: Dictionary) -> Dictionary: + return {"gp_summary": _gp_summary_text(payload), + "ks_summary": "钥石 · 累计 %d" % run_scope.runner.keystone, + "bonus_summary": HudPresenter.bonus_summary_text(run_scope.runner), + "gp_gain": int(payload.get("gp_dropped", 0)), "gp_total": int(run_scope.runner.gp)} + func _wire_battle(settlement_ref: BattleOrchestrator) -> void: settlement_ref.step_row.connect(_on_step_row) settlement_ref.battle_finished.connect(_on_battle_finished) @@ -772,11 +776,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,8 +815,8 @@ 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()) - HudPresenter.refresh(stage, info_panel, ap_bar, run_scope.settlement, run_scope.runner, run_scope.level_active, db, refresh_remaining) + 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, {"settlement": run_scope.settlement, "runner": run_scope.runner, "level_active": run_scope.level_active, "db": db, "refresh_remaining": refresh_remaining}) _treasure_ui.refresh_slots() # C 区秘宝槽重查(试炼镐积点进度随挖格刷新悬停浮窗) _sync_settlement_visuals() @@ -906,6 +910,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 diff --git a/game/presentation/main_adventure_flow.gd b/game/presentation/main_adventure_flow.gd index f0c2a4ea..66cbe3c2 100644 --- a/game/presentation/main_adventure_flow.gd +++ b/game/presentation/main_adventure_flow.gd @@ -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)) @@ -111,7 +111,7 @@ static func _on_adventure_finished(root: Variant, summary: Dictionary) -> void: text += "\n" + root.Narrator.keystone_settle_text(detail, root._bonus.item_name_lookup()) if result == "completed": root._end_overlay.open_victory(text, "返回冒险", root.board_view.get_global_rect(), - "本局 GP 入账 · 合计 %d" % int(summary.get("gp_final", 0))) # GP 行(取数=summary gp_final 清账前快照);钥石明细已在 text(keystone_settle_text)——三行账 + {"gp_summary": "本局 GP 入账 · 合计 %d" % int(summary.get("gp_final", 0))}) # GP 行(取数=summary gp_final 清账前快照);钥石明细已在 text(keystone_settle_text)——三行账 else: root._end_overlay.open_defeat(text, "返回", root.board_view.get_global_rect()) root.logger.log_level_end(summary) # 试玩时间线:冒险终局(摘要随载荷) @@ -178,7 +178,7 @@ static func _on_choices_presented(root: Variant, candidates: Array) -> void: root.run_scope.pending_candidates = candidates.duplicate() root._end_overlay.open_victory(root.Narrator.battle_end_text("victory", false), "下一节点", root.board_view.get_global_rect(), - "战利品 GP +%d · 合计 %d" % [root.run_scope.last_gain_amount, int(root.run_scope.runner.gp)]) + {"gp_summary": "战利品 GP +%d · 合计 %d" % [root.run_scope.last_gain_amount, int(root.run_scope.runner.gp)]}) root._open_modal_after_playback(root._end_overlay) root._refresh_presentations() return diff --git a/game/presentation/main_overlay_flow.gd b/game/presentation/main_overlay_flow.gd index 3b0337d6..ff74ed03 100644 --- a/game/presentation/main_overlay_flow.gd +++ b/game/presentation/main_overlay_flow.gd @@ -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() diff --git a/game/presentation/main_ui_build.gd b/game/presentation/main_ui_build.gd index 862c51f7..52f206c5 100644 --- a/game/presentation/main_ui_build.gd +++ b/game/presentation/main_ui_build.gd @@ -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 遮挡序:图例 < 明细回看 < 节点 < 关卡画面 < 三选一 < 终局 @@ -134,11 +134,17 @@ static func _build_curio(root: Variant) -> void: root.add_child(root.curio_overlay) # 提升三选一与替换子交互(本体在 curio_interact_controller.gd;本类只接线与回调注入, # 模态栈/演播后排窗/呈现刷新/fatal 经回调注入,adventure/runner 现值回调) - root._bonus = CurioInteractController.new(CurioFlowContext.new(root.db, func() -> Variant: return root.meta, root.curio_overlay, root._end_overlay, root.board_view, - root.board_zone.toast, func() -> Variant: return root.save_manager, func() -> Variant: return root.run_scope.adventure, - func() -> Variant: return root.run_scope.runner, func() -> Variant: return root.run_scope.is_adventure, - root._open_modal, root._open_modal_after_playback, root._close_modal, root._refresh_presentations, - root._fatal_screen, func() -> String: return root._gp_summary_text({"gp_dropped": root.run_scope.last_gain_amount}))) # 构造参数收拢 context + root._bonus = CurioInteractController.new(CurioFlowContext.new(root.db, + {"curio_overlay": root.curio_overlay, "end_overlay": root._end_overlay, + "board_view": root.board_view, "toast": root.board_zone.toast}, + {"meta_get": func() -> Variant: return root.meta, "save_get": func() -> Variant: return root.save_manager, + "adventure_get": func() -> Variant: return root.run_scope.adventure, + "runner_get": func() -> Variant: return root.run_scope.runner, + "adventure_mode_get": func() -> Variant: return root.run_scope.is_adventure}, + {"open_modal": root._open_modal, "open_modal_after_playback": root._open_modal_after_playback, + "close_modal": root._close_modal, "refresh_presentations": root._refresh_presentations, + "fatal_screen": root._fatal_screen, + "gp_summary": _gp_summary_cb(root)})) # 构造参数收拢 context(u20 三束) root.curio_overlay.pick_confirmed.connect(root._bonus.on_pick_confirmed) root.curio_overlay.continue_pressed.connect(root._bonus.on_continue) root.curio_overlay.refresh_requested.connect(root._bonus.on_refresh) @@ -154,9 +160,13 @@ static func _build_menu_wiring(root: Variant) -> void: root.debug_badge.pressed.connect(root._on_debug_badge_pressed) # 营地/商店投影(本体在 camp_shop_presenter.gd;本类只接线、探针缝保缝转发; # 购买写调用唯一经 MetaProgression/LevelRunner core 单口——组合根 lambda 现值注入) - root._camp_pres = CampShopPresenter.new(CampFlowContext.new(root.db, func() -> Variant: return root.meta, func() -> Variant: return root.save_manager, root.camp_overlay, root.shop_overlay, - root.board_zone.toast, root._start_overlay, root.logger, func() -> Variant: return root.run_scope.runner, - root._open_modal, root._close_modal, root._refresh_presentations)) # 构造参数收拢 context + root._camp_pres = CampShopPresenter.new(CampFlowContext.new(root.db, + {"camp_overlay": root.camp_overlay, "shop_overlay": root.shop_overlay, + "toast": root.board_zone.toast, "start_overlay": root._start_overlay, "logger": root.logger}, + {"meta_get": func() -> Variant: return root.meta, "save_get": func() -> Variant: return root.save_manager, + "runner_get": func() -> Variant: return root.run_scope.runner}, + {"open_modal": root._open_modal, "close_modal": root._close_modal, + "refresh_presentations": root._refresh_presentations})) # 构造参数收拢 context(u21 三束) root.shop_overlay.buy_requested.connect(root._camp_pres.on_shop_buy) root.shop_overlay.refresh_requested.connect(root._camp_pres.on_shop_refresh) # MC-1 root.shop_overlay.leave_pressed.connect(root._camp_pres.on_shop_leave) @@ -193,7 +203,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,9 +245,14 @@ 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) l.set_anchors_preset(Control.PRESET_CENTER) root.add_child(l) + + +# GP 总结行回调(gp_summary 束键——单行 lambda 内嵌字典实参收尾歧义,提函数承载) +static func _gp_summary_cb(root: Variant) -> Callable: + return func() -> String: return root._gp_summary_text({"gp_dropped": root.run_scope.last_gain_amount}) diff --git a/game/presentation/playtest_logger.gd b/game/presentation/playtest_logger.gd index 1d8dce84..4182051f 100644 --- a/game/presentation/playtest_logger.gd +++ b/game/presentation/playtest_logger.gd @@ -157,8 +157,6 @@ func dump_diagnosis(context: Dictionary) -> String: if f != null: f.store_string(JSON.stringify(dump, " ")) f.close() - print("[playtest_logger] 诊断转储已写入: %s(项目日志目录见输出)" % path) - print(" [diag] %s" % JSON.stringify(context)) return path diff --git a/game/presentation/treasure_interact_controller.gd b/game/presentation/treasure_interact_controller.gd index 7deab97d..de978289 100644 --- a/game/presentation/treasure_interact_controller.gd +++ b/game/presentation/treasure_interact_controller.gd @@ -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", "")): diff --git a/game/presentation/tune_sim_controller.gd b/game/presentation/tune_sim_controller.gd index 4f681dab..5c998019 100644 --- a/game/presentation/tune_sim_controller.gd +++ b/game/presentation/tune_sim_controller.gd @@ -38,33 +38,31 @@ func run(n: int) -> void: # 共享日志会话(模拟运行与真人试玩同日志合规——操作/轮次/战斗/关卡完成全量 JSONL; # 一文件多局,run 行携带种子切分局次) var logger = PlaytestLogger.new() - logger.ap_per_round = int(db.action_economy.ap_per_round) - var log_path: String = logger.start_session(SEED_BASE) - print("[tune_sim] 模拟日志: %s" % log_path) + logger.ap_per_round = int(db.run.action_economy.ap_per_round) + logger.start_session(SEED_BASE) panel.set_sim_running(true) panel.set_sim_progress("模拟中:0/%d …" % n) - var wins := 0 - var dmg_all: Array[float] = [] - var hp_left_win: Array[float] = [] - var hp_boss: Array[float] = [] - var rounds_all: Array[int] = [] - var death_nodes := {} + var stats := {"wins": 0, "dmg_all": [], "hp_left_win": [], "hp_boss": [], "rounds_all": [], "death_nodes": {}} var autoplay = _autoplay() for i in range(n): - var st = autoplay.run_level(db, SEED_BASE + i, 1, panel.atk_pct(), panel.hp_pct(), false, logger) - dmg_all.append(st.hero_dmg) - rounds_all.append(st.rounds) - if st.result == "victory": - wins += 1 - hp_left_win.append(st.hp_left) - hp_boss.append(st.hp_at_boss) - elif st.result == "defeat": - var key := "%d.%s" % [st.defeat_node_index, st.defeat_node_type] - death_nodes[key] = int(death_nodes.get(key, 0)) + 1 + _run_one_level(autoplay, stats, i, panel, logger) panel.set_sim_progress("模拟中:%d/%d …" % [i + 1, n]) await panel.get_tree().process_frame logger.close_session() - panel.set_sim_report(HudPresenter.tune_sim_report(n, panel.atk_pct(), panel.hp_pct(), - wins, dmg_all, hp_left_win, hp_boss, rounds_all, death_nodes, _base_hp)) + panel.set_sim_report(HudPresenter.tune_sim_report(n, panel.atk_pct(), panel.hp_pct(), stats, _base_hp)) + + +## 单局模拟域(循环体提函数——结果四桶记账;u19 stats 六键同构承载) +func _run_one_level(autoplay, stats: Dictionary, i: int, panel, logger) -> void: + var st = autoplay.run_level(db, SEED_BASE + i, 1, panel.atk_pct(), panel.hp_pct(), false, logger) + (stats["dmg_all"] as Array).append(st.hero_dmg) + (stats["rounds_all"] as Array).append(st.rounds) + if st.result == "victory": + stats["wins"] = int(stats["wins"]) + 1 + (stats["hp_left_win"] as Array).append(st.hp_left) + (stats["hp_boss"] as Array).append(st.hp_at_boss) + elif st.result == "defeat": + var key := "%d.%s" % [st.defeat_node_index, st.defeat_node_type] + stats["death_nodes"][key] = int(stats["death_nodes"].get(key, 0)) + 1 panel.set_sim_running(false) _running = false diff --git a/game/presentation/ui_assembler.gd b/game/presentation/ui_assembler.gd index d1b2706e..e0d6909d 100644 --- a/game/presentation/ui_assembler.gd +++ b/game/presentation/ui_assembler.gd @@ -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() @@ -101,31 +101,11 @@ static func build_start_overlay(host: Control, db: ConfigDb = null) -> Dictionar var bd := UiKit.backdrop_tile() if bd != null: overlay.add_child(bd) - overlay.add_child(UiKit.beams_rect()) + overlay.add_child(UiKit._beams_rect()) overlay.add_child(UiKit.vignette_rect()) # 双列骨架:左=操作列 56% / 右=舞台(立绘看台) - var cols := HBoxContainer.new() - cols.set_anchors_preset(Control.PRESET_FULL_RECT) - cols.add_theme_constant_override("separation", 32) - var pad := MarginContainer.new() - pad.set_anchors_preset(Control.PRESET_FULL_RECT) - for side in ["margin_left", "margin_right", "margin_top", "margin_bottom"]: - pad.add_theme_constant_override(side, 56) - cols.add_child(pad) - overlay.add_child(cols) - var box := VBoxContainer.new() - box.name = "start_box" - box.size_flags_horizontal = Control.SIZE_EXPAND_FILL - box.size_flags_stretch_ratio = 1.4 - box.add_theme_constant_override("separation", 10) - pad.add_child(box) - - var title := UiKit.label("Dungeon Sweeper",UiKit.FS_H1, true, UiKit.INK_ON_DEEP) - box.add_child(title) - box.add_child(HSeparator.new()) - - # 三入口(主菜单三入口化——营地交互重做卡②,用户裁决 1:进入存档/设置/退出游戏; - # 组帽双撤——冒险组帽组内零钮退化态不留、「营地与系统」帽指称失义,追认单留痕) + var cols := _start_columns(overlay) + var box := _start_left_column(cols) var entersavebtn := UiKit.primary_button("进入存档") # 主操作钮(唯一进度入口) entersavebtn.tooltip_text = "选择一个存档位进入(新档 / 进档 / 删档)" box.add_child(entersavebtn) @@ -135,12 +115,45 @@ static func build_start_overlay(host: Control, db: ConfigDb = null) -> Dictionar # 退出游戏与设置同层并行——只在一级菜单出现(行为零变化——契约 2) var quitbtn := UiKit.button("退出游戏") box.add_child(quitbtn) - # 调试入口收浮层角标;boot 菜单优先不开 # 浮窗,调试入口=UI 最顶层全局浮层角标(build_debug_badge——LAYER_CONTRACT 末位、 # ModalStack 登记外,任何 UI 不遮挡;本层不设屏内调试角标,全局浮层全场景承载) + _start_right_column(cols) + return {"overlay": overlay, "box": box, + "entersavebtn": entersavebtn, + "settingsbtn": settingsbtn, "quitbtn": quitbtn} - # 右列:立绘看台(三张立绘 + 标题底座;界面禁玩法文字墙,故无速览牌) + +## 双列骨架域:左=操作列 56% / 右=舞台(立绘看台)——全屏锚+56px 四边衬距 +static func _start_columns(overlay: Control) -> HBoxContainer: + var cols := HBoxContainer.new() + cols.set_anchors_preset(Control.PRESET_FULL_RECT) + cols.add_theme_constant_override("separation", 32) + var pad := MarginContainer.new() + pad.set_anchors_preset(Control.PRESET_FULL_RECT) + for side in ["margin_left", "margin_right", "margin_top", "margin_bottom"]: + pad.add_theme_constant_override(side, 56) + cols.add_child(pad) + overlay.add_child(cols) + return cols + + +## 左操作列域:标题+三入口(主菜单三入口化——营地交互重做卡②,用户裁决 1: +## 进入存档/设置/退出游戏;组帽双撤——冒险组帽组内零钮退化态不留、「营地与系统」帽指称失义,追认单留痕) +static func _start_left_column(cols: HBoxContainer) -> VBoxContainer: + var box := VBoxContainer.new() + box.name = "start_box" + box.size_flags_horizontal = Control.SIZE_EXPAND_FILL + box.size_flags_stretch_ratio = 1.4 + box.add_theme_constant_override("separation", 10) + (cols.get_child(0) as MarginContainer).add_child(box) + box.add_child(UiKit.label("Dungeon Sweeper",UiKit.FS_H1, true, UiKit.INK_ON_DEEP)) + box.add_child(HSeparator.new()) + return box + + +## 右列域:立绘看台(三张立绘 + 标题底座;界面禁玩法文字墙,故无速览牌) +static func _start_right_column(cols: HBoxContainer) -> void: var right := VBoxContainer.new() right.name = "start_right" right.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -153,17 +166,7 @@ static func build_start_overlay(host: Control, db: ConfigDb = null) -> Dictionar foe_row.alignment = BoxContainer.ALIGNMENT_CENTER foe_row.add_theme_constant_override("separation", 36) for cfg in [[UiKit.TEX_ENEMY_GOBLIN, 46, 96], [UiKit.TEX_HERO_PORTRAIT, 138, 144], [UiKit.TEX_ENEMY_OGRE_CHIEF, 80, 96]]: - var tr := TextureRect.new() - var tex := UiKit.load_tex(cfg[0]) - if tex != null: - tr.texture = tex - tr.custom_minimum_size = Vector2(cfg[1], cfg[2]) - tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - tr.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST - if cfg[0] != UiKit.TEX_HERO_PORTRAIT: - tr.modulate = Color(0.7, 0.7, 0.7, 0.4) # 敌影剪影退后(§6.1 立绘>背景) - foe_row.add_child(tr) + foe_row.add_child(_start_portrait(cfg)) stage.add_child(foe_row) var pedestal := Panel.new() var ped_sb := UiKit.panel_flat(UiKit.TITLE_PEDESTAL, UiKit.BG_ROOT, 17, 2) @@ -173,9 +176,20 @@ static func build_start_overlay(host: Control, db: ConfigDb = null) -> Dictionar stage.add_child(pedestal) right.add_child(stage) - return {"overlay": overlay, "box": box, - "entersavebtn": entersavebtn, - "settingsbtn": settingsbtn, "quitbtn": quitbtn} + +## 单立绘域(循环体提函数——像素过滤居中;敌影剪影退后 §6.1 立绘>背景) +static func _start_portrait(cfg: Array) -> TextureRect: + var tr := TextureRect.new() + var tex := UiKit.load_tex(cfg[0]) + if tex != null: + tr.texture = tex + tr.custom_minimum_size = Vector2(cfg[1], cfg[2]) + tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + tr.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + if cfg[0] != UiKit.TEX_HERO_PORTRAIT: + tr.modulate = Color(0.7, 0.7, 0.7, 0.4) + return tr # 调试浮层入口角标(UI 最顶层浮层,任何 UI 不遮挡)——main 直接子层、 diff --git a/game/presentation/views/ap_bar_view.gd b/game/presentation/views/ap_bar_view.gd index 254d88ce..f80a4d6d 100644 --- a/game/presentation/views/ap_bar_view.gd +++ b/game/presentation/views/ap_bar_view.gd @@ -41,6 +41,16 @@ func _init() -> void: row.add_theme_constant_override("separation", 8) add_child(row) + _build_ap_face(row) + _build_rap_face(row) + _build_refresh_confirm(row) + _build_legend_refresh(row) + + +# ---- 构建私有族(AP 计量/RAP 读数/破坏性确认/图例刷新四域) ---- + +# AP 计量域:点阵+读数+停手钮+弹性撑 +func _build_ap_face(row: HBoxContainer) -> void: _dots = HBoxContainer.new() _dots.add_theme_constant_override("separation", 3) _dots.size_flags_vertical = Control.SIZE_SHRINK_CENTER @@ -48,19 +58,19 @@ func _init() -> void: _ap_label = UiKit.label("AP 0/0",UiKit.FS_BODY, true) _ap_label.size_flags_vertical = Control.SIZE_SHRINK_CENTER row.add_child(_ap_label) - _btn_stop = UiKit.button("停手") _btn_stop.custom_minimum_size = Vector2(64, 0) _btn_stop.size_flags_vertical = Control.SIZE_SHRINK_CENTER _btn_stop.pressed.connect(_emit_stop_pressed) row.add_child(_btn_stop) - var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(spacer) - # RAP 读数(自 C 区迁入):RAP 唯一消费口 = 弃图刷新——资源数字贴消费按钮 - # (就近原则);扣费文案「RAP−%d」已在刷新按钮副行,此处不重复代价提示(单一展示面原则) + +# RAP 读数域(自 C 区迁入):RAP 唯一消费口 = 弃图刷新——资源数字贴消费按钮 +# (就近原则);扣费文案「RAP−%d」已在刷新按钮副行,此处不重复代价提示(单一展示面原则) +func _build_rap_face(row: HBoxContainer) -> void: _rap_dots = HBoxContainer.new() _rap_dots.add_theme_constant_override("separation", 2) _rap_dots.size_flags_vertical = Control.SIZE_SHRINK_CENTER @@ -72,7 +82,9 @@ func _init() -> void: _rap_feedback.size_flags_vertical = Control.SIZE_SHRINK_CENTER row.add_child(_rap_feedback) - # 破坏性确认异位(pattern 7):确认 chips 出现在刷新按钮左方——与常规"按钮自身确认"不同位 + +# 破坏性确认异位(pattern 7):确认 chips 出现在刷新按钮左方——与常规"按钮自身确认"不同位 +func _build_refresh_confirm(row: HBoxContainer) -> void: _confirm_row = HBoxContainer.new() _confirm_row.add_theme_constant_override("separation", 4) _confirm_row.visible = false @@ -83,12 +95,14 @@ func _init() -> void: confirm_btn.pressed.connect(_disarm_and_fire) _confirm_row.add_child(confirm_btn) + +# 图例与弃图刷新域:图例开关钮+刷新钮组(副行=剩余次数读数) +func _build_legend_refresh(row: HBoxContainer) -> void: _btn_legend = UiKit.button("图例") _btn_legend.custom_minimum_size = Vector2(56, 0) _btn_legend.size_flags_vertical = Control.SIZE_SHRINK_CENTER _btn_legend.pressed.connect(_emit_legend_toggled) row.add_child(_btn_legend) - var refresh_box := VBoxContainer.new() refresh_box.add_theme_constant_override("separation", 0) refresh_box.size_flags_vertical = Control.SIZE_SHRINK_CENTER diff --git a/game/presentation/views/battle_end_overlay.gd b/game/presentation/views/battle_end_overlay.gd index bfb40e2e..d5340755 100644 --- a/game/presentation/views/battle_end_overlay.gd +++ b/game/presentation/views/battle_end_overlay.gd @@ -10,22 +10,30 @@ const COL_GOLD := UiKit.GOLD const COL_DUSK := UiKit.DUSK # 暮蓝(全作唯一暮蓝档) const COL_WARM := UiKit.WARM -var _dim: ColorRect -var _seal: Panel -var _verdict: Label -var _rows_box: VBoxContainer # 三行账(GP/钥石/本关加成——demo ⑧两列对齐;失败侧整行隐藏) -var _gp_row: HBoxContainer -var _gp_name: Label -var _gp_val: Label -var _ks_row: HBoxContainer -var _ks_name: Label -var _ks_val: Label -var _bonus_row_l: HBoxContainer -var _bonus_name: Label -var _bonus_val: Label -var _cele: TextureRect # 庆祝立绘(idle 占位浮动) -var _btn: Button -var _board_rect := Rect2() +## 内部状态件(u6 子域拆件——契约 9 职责语句「战末呈现与演出态」):单一变化原因=终局画面呈现与 +## 演出计数。探针直读冻结成员(_btn/_gp_name/_gp_row/_gp_val)住宿主不进状态件。 +class _EndOverlayState extends RefCounted: + var _dim: ColorRect + var _seal: Panel + var _verdict: Label + var _rows_box: VBoxContainer # 三行账(GP/钥石/本关加成——demo ⑧两列对齐;失败侧整行隐藏) + var _ks_row: HBoxContainer + var _ks_name: Label + var _ks_val: Label + var _bonus_row_l: HBoxContainer + var _bonus_name: Label + var _bonus_val: Label + var _cele: TextureRect # 庆祝立绘(idle 占位浮动) + var _board_rect := Rect2() + var _crest_bricks: Array[ColorRect] = [] # 纹章碎砖驻留面(close 时回收) + var _fx_counts := {"crest": 0, "dust": 0, "seal": 0} # 演出计数(fx_probe 断言面——crest/dust/seal 三键) + + +var st := _EndOverlayState.new() +var _gp_row: HBoxContainer # 探针直读冻结(S17⑤) +var _gp_name: Label # 探针直读冻结 +var _gp_val: Label # 探针直读冻结 +var _btn: Button # 探针直读冻结 func _init() -> void: @@ -33,21 +41,45 @@ func _init() -> void: #(与营地/设置/图例同族)。半透明氛围 dim 保留(胜利暖纱/失败暮蓝——open_* 就地换色),信息与 # 按钮全部上板;金蜡封 = 面板头部静态徽记(坠落演出落点被面板遮挡)。 # 骨架经 UiKit.modal_shell;_dim 引用摘出供 open_* 换氛围色。 - var shell := UiKit.modal_shell(self, Color(COL_WARM, 0.0), Vector2(520, 0), 12, true, + var shell := UiKit.modal_shell(self, Color(COL_WARM, 0.0), Vector2(520, 0), 12, UiKit.panel_skinned(Vector4(16, 14, 16, 14))) - _dim = shell.dim + st._dim = shell.dim var box: VBoxContainer = shell.box + _build_header(box) + _build_ledger(box) + # 无明细回看钮——冒险中回看归「生涯」 + _btn = UiKit.primary_button("继续") + _btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER # 紧凑钮 + _btn.custom_minimum_size.x = 150 + _btn.pressed.connect(_emit_continue_pressed) + box.add_child(_btn) + visible = false + + +## 头部域:金蜡封徽记+判定行+庆祝立绘(demo ⑧——idle 帧浮动占位;减动效=静帧) +func _build_header(box: VBoxContainer) -> void: var header := HBoxContainer.new() header.add_theme_constant_override("separation", 12) header.alignment = BoxContainer.ALIGNMENT_CENTER box.add_child(header) - _seal = Panel.new() - _seal.mouse_filter = Control.MOUSE_FILTER_IGNORE + st._seal = Panel.new() + st._seal.mouse_filter = Control.MOUSE_FILTER_IGNORE var sb := UiKit.panel_flat(COL_GOLD, UiKit.GOLD_DEEP, 200, 4) - _seal.add_theme_stylebox_override("panel", sb) - _seal.custom_minimum_size = Vector2(52, 52) - _seal.size_flags_vertical = Control.SIZE_SHRINK_CENTER - header.add_child(_seal) + st._seal.add_theme_stylebox_override("panel", sb) + st._seal.custom_minimum_size = Vector2(52, 52) + st._seal.size_flags_vertical = Control.SIZE_SHRINK_CENTER + header.add_child(st._seal) + st._seal.add_child(_seal_img()) + st._verdict = UiKit.label("",UiKit.FS_TITLE, true, UiKit.INK_TITLE) + st._verdict.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + st._verdict.size_flags_vertical = Control.SIZE_SHRINK_CENTER + header.add_child(st._verdict) + st._cele = _cele_rect() + header.add_child(st._cele) + + +## 蜡封图域(32px 像素过滤居中) +func _seal_img() -> TextureRect: var seal_img := TextureRect.new() var seal_tex := UiKit.load_tex("res://assets/art/board/seal_mark.png") if seal_tex != null: @@ -58,76 +90,76 @@ func _init() -> void: seal_img.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST seal_img.size_flags_vertical = Control.SIZE_SHRINK_CENTER seal_img.mouse_filter = Control.MOUSE_FILTER_IGNORE - _seal.add_child(seal_img) - _verdict = UiKit.label("",UiKit.FS_TITLE, true, UiKit.INK_TITLE) - _verdict.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _verdict.size_flags_vertical = Control.SIZE_SHRINK_CENTER - header.add_child(_verdict) - # 庆祝立绘(demo ⑧——idle 帧浮动占位;减动效=静帧) - _cele = TextureRect.new() + return seal_img + + +## 庆祝立绘域(64px idle 帧) +func _cele_rect() -> TextureRect: + var cele := TextureRect.new() var cele_tex := UiKit.load_tex(UiKit.TEX_HERO_IDLE) if cele_tex != null: - _cele.texture = cele_tex - _cele.custom_minimum_size = Vector2(64, 64) - _cele.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - _cele.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - _cele.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST - _cele.size_flags_vertical = Control.SIZE_SHRINK_CENTER - _cele.visible = false - header.add_child(_cele) - _rows_box = VBoxContainer.new() - _rows_box.add_theme_constant_override("separation", 6) - _rows_box.visible = false - box.add_child(_rows_box) - # 两列对齐账行(审计差距④对齐 demo ⑧——项目名左/数值右,定宽 360) + cele.texture = cele_tex + cele.custom_minimum_size = Vector2(64, 64) + cele.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + cele.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + cele.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + cele.size_flags_vertical = Control.SIZE_SHRINK_CENTER + cele.visible = false + return cele + + +## 三行账域(两列对齐账行——审计差距④对齐 demo ⑧:项目名左/数值右,定宽 360) +func _build_ledger(box: VBoxContainer) -> void: + st._rows_box = VBoxContainer.new() + st._rows_box.add_theme_constant_override("separation", 6) + st._rows_box.visible = false + box.add_child(st._rows_box) _gp_row = _ledger_row() _gp_name = _ledger_name(UiKit.FS_SUB, UiKit.GOLD_INK, true) # GOLD_DEEP 对结算板 2.67:1 越带,换深金墨 5.21:1 _gp_val = _ledger_value(UiKit.FS_SUB, UiKit.GOLD_INK, true) # 同上 _gp_row.add_child(_gp_name) _gp_row.add_child(_gp_val) - _rows_box.add_child(_gp_row) - _ks_row = _ledger_row() - _ks_name = _ledger_name(UiKit.FS_BODY, UiKit.INK_LABEL, false) - _ks_val = _ledger_value(UiKit.FS_BODY, UiKit.INK_LABEL, true) - _ks_row.add_child(_ks_name) - _ks_row.add_child(_ks_val) - _ks_row.visible = false - _rows_box.add_child(_ks_row) - _bonus_row_l = _ledger_row() - _bonus_name = _ledger_name(UiKit.FS_HINT, UiKit.INK_LABEL, false) - _bonus_val = _ledger_value(UiKit.FS_HINT, UiKit.INK_LABEL, true) - _bonus_row_l.add_child(_bonus_name) - _bonus_row_l.add_child(_bonus_val) - _bonus_row_l.visible = false - _rows_box.add_child(_bonus_row_l) + st._rows_box.add_child(_gp_row) + st._ks_row = _ledger_row() + st._ks_name = _ledger_name(UiKit.FS_BODY, UiKit.INK_LABEL, false) + st._ks_val = _ledger_value(UiKit.FS_BODY, UiKit.INK_LABEL, true) + st._ks_row.add_child(st._ks_name) + st._ks_row.add_child(st._ks_val) + st._ks_row.visible = false + st._rows_box.add_child(st._ks_row) + st._bonus_row_l = _ledger_row() + st._bonus_name = _ledger_name(UiKit.FS_HINT, UiKit.INK_LABEL, false) + st._bonus_val = _ledger_value(UiKit.FS_HINT, UiKit.INK_LABEL, true) + st._bonus_row_l.add_child(st._bonus_name) + st._bonus_row_l.add_child(st._bonus_val) + st._bonus_row_l.visible = false + st._rows_box.add_child(st._bonus_row_l) box.add_child(HSeparator.new()) - # 无明细回看钮——冒险中回看归「生涯」 - _btn = UiKit.primary_button("继续") - _btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER # 紧凑钮 - _btn.custom_minimum_size.x = 150 - _btn.pressed.connect(_emit_continue_pressed) - box.add_child(_btn) - visible = false # 终局画面数据锚 = 战斗结算 判定行(verdict)——判定行文案由组合根装配传入。 # gp_summary(胜利总结结算展示 GP 总量+增量):组合根装配「战利品 +增量 · 合计总量」 # 传入(入账次序 = LevelRunner 入账先于组合根 handler,runner.gp 即战后总量);空串 = 不显示(失败侧恒空)。 -func open_victory(verdict_line: String, continue_label: String, board_rect: Rect2, gp_summary: String = "", - ks_summary: String = "", bonus_summary: String = "", gp_gain := -1, gp_total := -1) -> void: - _dim.color = Color(UiKit.DIM_NIGHT, 0.62) # 全屏压暗对齐 demo - _verdict.text = verdict_line - _seal.visible = true # 胜利徽记(面板头部静态) - _seal.modulate = Color.WHITE +# 可选尾参收拢 opts 束(u6——键 {gp_summary, ks_summary, bonus_summary, gp_gain, gp_total},缺省逐字同值) +func open_victory(verdict_line: String, continue_label: String, board_rect: Rect2, opts := {}) -> void: + var gp_summary: String = opts.get("gp_summary", "") + var ks_summary: String = opts.get("ks_summary", "") + var bonus_summary: String = opts.get("bonus_summary", "") + var gp_gain: int = opts.get("gp_gain", -1) + var gp_total: int = opts.get("gp_total", -1) + st._dim.color = Color(UiKit.DIM_NIGHT, 0.62) # 全屏压暗对齐 demo + st._verdict.text = verdict_line + st._seal.visible = true # 胜利徽记(面板头部静态) + st._seal.modulate = Color.WHITE _set_ledger_row(_gp_name, _gp_val, gp_summary, true) # GP 行=固定名「战利品 GP」(滚动/整串均只入值列) - _set_ledger_row(_ks_name, _ks_val, ks_summary) - _ks_row.visible = ks_summary != "" - _set_ledger_row(_bonus_name, _bonus_val, bonus_summary) - _bonus_row_l.visible = bonus_summary != "" - _rows_box.visible = gp_summary != "" or ks_summary != "" or bonus_summary != "" + _set_ledger_row(st._ks_name, st._ks_val, ks_summary) + st._ks_row.visible = ks_summary != "" + _set_ledger_row(st._bonus_name, st._bonus_val, bonus_summary) + st._bonus_row_l.visible = bonus_summary != "" + st._rows_box.visible = gp_summary != "" or ks_summary != "" or bonus_summary != "" _btn.text = continue_label - _board_rect = board_rect - _cele.visible = true + st._board_rect = board_rect + st._cele.visible = true _play_celebrate() visible = true if UiKit.victory_fx_style == "dust": @@ -141,23 +173,23 @@ func open_victory(verdict_line: String, continue_label: String, board_rect: Rect func open_defeat(verdict_line: String, continue_label: String, board_rect: Rect2) -> void: # 明度不变的暮蓝色相氛围(全屏唯一暮蓝档——§2.5 最静一拍) - _dim.color = Color(COL_DUSK, 0.6) # 同族压暗 - _verdict.text = verdict_line - _board_rect = board_rect + st._dim.color = Color(COL_DUSK, 0.6) # 同族压暗 + st._verdict.text = verdict_line + st._board_rect = board_rect visible = true - _seal.visible = true - _seal.modulate = Color(0.4, 0.4, 0.4) # 失败徽记灰化(demo ⑧) - _cele.visible = false - _rows_box.visible = false # 失败侧不呈现账行 + st._seal.visible = true + st._seal.modulate = Color(0.4, 0.4, 0.4) # 失败徽记灰化(demo ⑧) + st._cele.visible = false + st._rows_box.visible = false # 失败侧不呈现账行 _play_dust_sadle() # 坐砖掸灰(§2.5——终稿) _play_seal_recall(8) func close() -> void: visible = false - _seal.visible = false - _seal.modulate = Color.WHITE - _cele.visible = false + st._seal.visible = false + st._seal.modulate = Color.WHITE + st._cele.visible = false var tw: Tween = get_meta(&"celebrate_tw", null) # 循环句柄随节点存(跨 open/close,close 必杀) if tw != null and tw.is_valid(): tw.kill() @@ -170,7 +202,7 @@ func is_open() -> bool: func verdict_text() -> String: - return _verdict.text + return st._verdict.text # 两列账行工厂(审计差距④——名左/值右,定宽 360) @@ -217,68 +249,78 @@ func _fx_center() -> Vector2: # 封印化光点收回(失败——插过的封印逐面化光点收回占位,§2.5) func _play_seal_recall(count: int) -> void: - _fx_seal_dots = count + st._fx_counts["seal"] = count + var base := st._board_rect.get_center() if st._board_rect.size != Vector2.ZERO else get_viewport_rect().size * 0.5 for i in count: - var dot := ColorRect.new() - dot.color = COL_GOLD - dot.size = Vector2(6, 6) - var base := _board_rect.get_center() if _board_rect.size != Vector2.ZERO else get_viewport_rect().size * 0.5 - dot.position = base + Vector2((float(i % 5) - 2.0) * _board_rect.size.x * 0.14 if _board_rect.size.x > 0 else 0.0, - (float(i % 3) - 1.0) * _board_rect.size.y * 0.18) # 确定性散布(S2 禁全局随机——占位演出同守) - dot.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(dot) - var tw := dot.create_tween() - tw.tween_interval(UiKit.motion(0.1 * i)) - tw.tween_property(dot, "position:y", dot.position.y - 120.0, UiKit.motion(0.8)) - tw.parallel().tween_property(dot, "modulate:a", 0.0, UiKit.motion(0.8)) - tw.tween_callback(dot.queue_free) + _spawn_seal_dot(i, base) -# ---- 演出密度(程序化 tween,确定性散布,零新帧资产) ---- +## 单光点域(循环体提函数——延迟升空渐隐回收;确定性散布 S2 禁全局随机) +func _spawn_seal_dot(i: int, base: Vector2) -> void: + var dot := ColorRect.new() + dot.color = COL_GOLD + dot.size = Vector2(6, 6) + dot.position = base + Vector2((float(i % 5) - 2.0) * st._board_rect.size.x * 0.14 if st._board_rect.size.x > 0 else 0.0, + (float(i % 3) - 1.0) * st._board_rect.size.y * 0.18) + dot.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(dot) + var tw := dot.create_tween() + tw.tween_interval(UiKit.motion(0.1 * i)) + tw.tween_property(dot, "position:y", dot.position.y - 120.0, UiKit.motion(0.8)) + tw.parallel().tween_property(dot, "modulate:a", 0.0, UiKit.motion(0.8)) + tw.tween_callback(dot.queue_free) -var _crest_bricks: Array[ColorRect] = [] # 纹章碎砖驻留面(close 时回收) -var _fx_crest_shards := 0 -var _fx_dust_motes := 0 -var _fx_seal_dots := 0 + +# ---- 演出密度(程序化 tween,确定性散布,零新帧资产;计数与碎砖驻留面在 _EndOverlayState) ---- # 碎砖拼纹章(胜利 §2.4——蜡封落定后十枚碎砖自四周向蜡封外环收拢拼成纹章环,驻留至 close) func _play_crest_assemble() -> void: _clear_crest_bricks() - _fx_crest_shards = 10 + st._fx_counts["crest"] = 10 var c := _fx_center() for i in 10: - var brick := ColorRect.new() - brick.color = UiKit.BRICK if i % 2 == 0 else UiKit.FLOOR # 碎砖=砖面族色(掩尘/裸土——图底同源) - brick.size = Vector2(12, 7) - brick.mouse_filter = Control.MOUSE_FILTER_IGNORE - var ang := TAU * float(i) / 10.0 - var ring := c + Vector2(cos(ang), sin(ang)) * 64.0 - brick.position = c + Vector2(cos(ang), sin(ang)) * 200.0 # 自四周场外入场 - add_child(brick) - _crest_bricks.append(brick) - var btw := brick.create_tween() - btw.tween_interval(UiKit.motion(0.52)) # 待蜡封落定(seal 0.42+0.22 弹跳中段) - btw.tween_property(brick, "position", ring - brick.size * 0.5, UiKit.motion(0.4)).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) - btw.parallel().tween_property(brick, "rotation", sin(ang) * 0.35, UiKit.motion(0.4)) + _spawn_crest_brick(i, c) + + +## 单碎砖域(循环体提函数——待蜡封落定后自场外收拢入环;碎砖=砖面族色〔掩尘/裸土——图底同源〕) +func _spawn_crest_brick(i: int, c: Vector2) -> void: + var brick := ColorRect.new() + brick.color = UiKit.BRICK if i % 2 == 0 else UiKit.FLOOR + brick.size = Vector2(12, 7) + brick.mouse_filter = Control.MOUSE_FILTER_IGNORE + var ang := TAU * float(i) / 10.0 + var ring := c + Vector2(cos(ang), sin(ang)) * 64.0 + brick.position = c + Vector2(cos(ang), sin(ang)) * 200.0 # 自四周场外入场 + add_child(brick) + st._crest_bricks.append(brick) + var btw := brick.create_tween() + btw.tween_interval(UiKit.motion(0.52)) # 待蜡封落定(seal 0.42+0.22 弹跳中段) + btw.tween_property(brick, "position", ring - brick.size * 0.5, UiKit.motion(0.4)).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + btw.parallel().tween_property(brick, "rotation", sin(ang) * 0.35, UiKit.motion(0.4)) # 坐砖掸灰(失败 §2.5——暖灰尘絮自棋盘中带缓缓飘落,最静一拍的运动承载) func _play_dust_sadle() -> void: - _fx_dust_motes = 8 + st._fx_counts["dust"] = 8 var base := _fx_center() for i in 8: - var mote := ColorRect.new() - mote.color = Color(UiKit.WARM_FAINT, 0.85) - mote.size = Vector2(4, 4) - mote.mouse_filter = Control.MOUSE_FILTER_IGNORE - mote.position = base + Vector2((float(i % 4) - 1.5) * 30.0, -24.0 - 8.0 * float(i / 4)) - add_child(mote) - var mtw := mote.create_tween() - mtw.tween_interval(UiKit.motion(0.08 * i)) - mtw.tween_property(mote, "position:y", mote.position.y + 64.0, UiKit.motion(0.9)) - mtw.parallel().tween_property(mote, "modulate:a", 0.0, UiKit.motion(0.9)) - mtw.tween_callback(mote.queue_free) + _spawn_dust_mote(i, base) + + +## 单尘絮域(循环体提函数——中带散布缓降渐隐) +func _spawn_dust_mote(i: int, base: Vector2) -> void: + var mote := ColorRect.new() + mote.color = Color(UiKit.WARM_FAINT, 0.85) + mote.size = Vector2(4, 4) + mote.mouse_filter = Control.MOUSE_FILTER_IGNORE + mote.position = base + Vector2((float(i % 4) - 1.5) * 30.0, -24.0 - 8.0 * float(i / 4)) + add_child(mote) + var mtw := mote.create_tween() + mtw.tween_interval(UiKit.motion(0.08 * i)) + mtw.tween_property(mote, "position:y", mote.position.y + 64.0, UiKit.motion(0.9)) + mtw.parallel().tween_property(mote, "modulate:a", 0.0, UiKit.motion(0.9)) + mtw.tween_callback(mote.queue_free) # 庆祝立绘浮动(idle 占位——UiKit.motion 守减动效;motion_scale<1 静帧) @@ -288,29 +330,34 @@ func _play_celebrate() -> void: var prev: Tween = get_meta(&"celebrate_tw", null) if prev != null and prev.is_valid(): prev.kill() - var tw := _cele.create_tween() + var tw := st._cele.create_tween() set_meta(&"celebrate_tw", tw) tw.set_loops() - tw.tween_property(_cele, "position:y", _cele.position.y - 4.0, UiKit.motion(0.6)).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) - tw.tween_property(_cele, "position:y", _cele.position.y, UiKit.motion(0.6)).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + tw.tween_property(st._cele, "position:y", st._cele.position.y - 4.0, UiKit.motion(0.6)).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + tw.tween_property(st._cele, "position:y", st._cele.position.y, UiKit.motion(0.6)).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) # 尘金粒子(备选演出——金屑自面板上带飘落闪灭;确定性散布守 S2) func _play_victory_dust() -> void: - _fx_dust_motes += 10 + st._fx_counts["dust"] += 10 var c := _fx_center() for i in 10: - var mote := ColorRect.new() - mote.color = UiKit.CANDLE - mote.size = Vector2(5, 5) - mote.mouse_filter = Control.MOUSE_FILTER_IGNORE - mote.position = c + Vector2((float(i % 5) - 2.0) * 44.0, -40.0 - 10.0 * float(i / 5)) - add_child(mote) - var tw := mote.create_tween() - tw.tween_interval(UiKit.motion(0.12 * i)) - tw.tween_property(mote, "position:y", mote.position.y + 90.0, UiKit.motion(1.1)) - tw.parallel().tween_property(mote, "modulate:a", 0.0, UiKit.motion(1.1)) - tw.tween_callback(mote.queue_free) + _spawn_victory_mote(i, c) + + +## 单金屑域(循环体提函数——上带散布飘落闪灭) +func _spawn_victory_mote(i: int, c: Vector2) -> void: + var mote := ColorRect.new() + mote.color = UiKit.CANDLE + mote.size = Vector2(5, 5) + mote.mouse_filter = Control.MOUSE_FILTER_IGNORE + mote.position = c + Vector2((float(i % 5) - 2.0) * 44.0, -40.0 - 10.0 * float(i / 5)) + add_child(mote) + var tw := mote.create_tween() + tw.tween_interval(UiKit.motion(0.12 * i)) + tw.tween_property(mote, "position:y", mote.position.y + 90.0, UiKit.motion(1.1)) + tw.parallel().tween_property(mote, "modulate:a", 0.0, UiKit.motion(1.1)) + tw.tween_callback(mote.queue_free) # GP 行数字滚动(demo ⑧——0.8s 三次缓出;减动效直出终值) @@ -325,15 +372,15 @@ func _roll_gp(gp_gain: int, gp_total: int) -> void: func _clear_crest_bricks() -> void: - for b in _crest_bricks: + for b in st._crest_bricks: if is_instance_valid(b): b.queue_free() - _crest_bricks.clear() + st._crest_bricks.clear() # 演出探针出口(fx_probe——胜利纹章/失败掸灰/封印光收回三断言面) func fx_probe() -> Dictionary: - return {"crest_shards": _fx_crest_shards, "dust_motes": _fx_dust_motes, "seal_dots": _fx_seal_dots} + return {"crest_shards": st._fx_counts["crest"], "dust_motes": st._fx_counts["dust"], "seal_dots": st._fx_counts["seal"]} func _emit_continue_pressed() -> void: diff --git a/game/presentation/views/battle_log_view.gd b/game/presentation/views/battle_log_view.gd index 3fe1ca55..30dd7e2c 100644 --- a/game/presentation/views/battle_log_view.gd +++ b/game/presentation/views/battle_log_view.gd @@ -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: @@ -145,71 +145,122 @@ func _format_row(row: Dictionary, agg_count := -1) -> String: var src := _source_label(String(row.get("source_class", ""))) var round_no: int = int(row.get("round_index", 0)) var prefix := " [color=#%s]R%d%s[/color] " % [UiKit.HEX_LOG_ROUND, round_no, ("·" + src) if src != "" else ""] - var hl := String(row.get("highlight", "")) - var v: int = int(row.get("value", 0)) - var name_: String = String(row.get("display_name", "")) - var target := String(row.get("target", "")) - var tail := " → %s" % target if target != "" else "" var suffix := "(%s)" % src if src != "" else "" - match String(row.get("type", "")): + var kind := String(row.get("type", "")) + var body := "" + match kind: "effect": - # GP 型效果行:「GP 袋 +3」兑付反馈——value = 本行 ±power, - # escrow = 战斗内缓冲现值(入钱包在战斗末一次上抛——呈现语义与增益池分账) - if String(row.get("effect_id", "")) in ["gp_gain", "gp_loss"]: - var gp_col := UiKit.HEX_LOG_GAIN if v > 0 else UiKit.HEX_LOG_LOSS - var gp_body := "%s %+d(战斗暂存 %+d)%s" % [name_, v, int(row.get("escrow", 0)), suffix] - return prefix + (_gold_frame(gp_body) if hl != "" else "[color=%s]%s[/color]" % [gp_col, gp_body]) - # 效果行 value = 单次贡献值,pool_value = 池合成值——禁相加呈现(战斗结算 呈现细则) - var pool: int = int(row.get("pool_value", 0)) - var col := UiKit.HEX_LOG_HEAL if v > 0 else UiKit.HEX_LOG_LOSS - var body := "%s 单次%+d(池 %+d)%s" % [name_, v, pool, suffix] - return prefix + (_gold_frame(body) if hl != "" else "[color=%s]%s[/color]" % [col, body]) + body = _fmt_effect(row, prefix, suffix) "damage": - # 伤害行无行上拆行文案——行上归因读数只剩掷点档位随行 - # 护甲等级掷点留档(掷点读数与档位随行可读;吃掷点分量恒带,棋盘来源行不带) - var roll_txt := "" - 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) - elif int(row.tier) < 100: - roll_txt += " · ×%d%%" % int(row.tier) - roll_txt += "〕" - var kill := " ⚔击杀!" if row.get("killed", false) else "" - # 伤害数字不明显——伤害值放大加色独立于行文(朱红大一号——数字即信息主角,非同字号色温微差) - var body := "%s [color=#%s][size=17]-%d[/size][/color]%s%s%s%s" % [name_, UiKit.HEX_LOG_DMG_NUM, v, roll_txt, tail, kill, suffix] - var body_col := UiKit.HEX_LOG_DMG_KILL if row.get("killed", false) else (UiKit.HEX_LOG_ROUND if v == 0 else UiKit.HEX_LOG_DMG_BODY) - if bool(row.get("crit", false)): - return prefix + _gold_frame(body) # nat 最大面暴击 = 金框高亮行 - return prefix + "[color=%s]%s[/color]" % [body_col, body] - "heal": - return prefix + "[color=#%s]%s +%d%s%s[/color]" % [UiKit.HEX_LOG_HEAL, name_, v, tail, suffix] + body = _fmt_damage(row, prefix, suffix) + "heal", "cast", "decay": + body = _fmt_simple(kind, row, prefix, suffix) "reward_ineffective": - # 未生效类行:⚠ icon + 划线形状冗余(非色相独载)+ 聚合计数 - var n := (" ×%d" % agg_count) if agg_count > 1 else "" - return prefix + "[s][color=#%s]⚠ %s%s[/color][/s]%s" % [UiKit.HEX_LOG_VOIDED, name_, n, suffix] + body = _fmt_ineffective(row, prefix, suffix, agg_count) "seal_miss": - var miss_note := "" - if hl == "perfect_broken": - miss_note = "(本图完美封印已破——清图奖励不再加倍)" - elif hl == "": - miss_note = "(首次封印落空已豁免——不计入完美封印判定)" - var body := "✗ %s%s" % [name_, miss_note] - return prefix + (_gold_frame(body) if hl == "perfect_broken" else "[color=#%s]%s[/color]" % [UiKit.HEX_LOG_ROUND, body]) + body = _fmt_seal_miss(row, prefix) + "verdict": + body = _fmt_verdict(row) + _: + body = _fmt_default(row, prefix, suffix) + return body + + +## 简单行域(heal/cast/decay 三型——单行单墨直排) +func _fmt_simple(kind: String, row: Dictionary, prefix: String, suffix: String) -> String: + var name_: String = String(row.get("display_name", "")) + match kind: + "heal": + return prefix + "[color=#%s]%s +%d%s%s[/color]" % [UiKit.HEX_LOG_HEAL, name_, int(row.get("value", 0)), _tail_of(row), suffix] "cast": # 施放行:增益技 0 伤害施放,具名技能 return prefix + "[color=#%s]✦ %s%s[/color]" % [UiKit.HEX_LOG_CAST, name_, suffix] "decay": - return prefix + "[color=#%s]状态衰减 ×%d[/color]" % [UiKit.HEX_LOG_DECAY, v] - "verdict": - if int(row.value) == 1: - return "[color=#%s][b]◆ %s[/b][/color]" % [UiKit.HEX_LOG_WIN, name_] - return "[color=#%s][b]◆ %s[/b][/color]" % [UiKit.HEX_LOG_GAIN, name_] - "highlight": - return prefix + _gold_frame("★ %s" % name_) + return prefix + "[color=#%s]状态衰减 ×%d[/color]" % [UiKit.HEX_LOG_DECAY, int(row.get("value", 0))] + return "" + + +## 默认行域(未知型/高亮星标——未知型=名行直排) +func _fmt_default(row: Dictionary, prefix: String, suffix: String) -> String: + var name_: String = String(row.get("display_name", "")) + if String(row.get("type", "")) == "highlight": + return prefix + _gold_frame("★ %s" % name_) return prefix + name_ + suffix +## 尾注域:目标格指向(行文附加) +func _tail_of(row: Dictionary) -> String: + var target := String(row.get("target", "")) + return " → %s" % target if target != "" else "" + + +## GP 型效果行域:「GP 袋 +3」兑付反馈——value = 本行 ±power, +## escrow = 战斗内缓冲现值(入钱包在战斗末一次上抛——呈现语义与增益池分账) +func _fmt_effect(row: Dictionary, prefix: String, suffix: String) -> String: + var v: int = int(row.get("value", 0)) + var hl := String(row.get("highlight", "")) + var name_: String = String(row.get("display_name", "")) + if String(row.get("effect_id", "")) in ["gp_gain", "gp_loss"]: + var gp_col := UiKit.HEX_LOG_GAIN if v > 0 else UiKit.HEX_LOG_LOSS + var gp_body := "%s %+d(战斗暂存 %+d)%s" % [name_, v, int(row.get("escrow", 0)), suffix] + return prefix + (_gold_frame(gp_body) if hl != "" else "[color=%s]%s[/color]" % [gp_col, gp_body]) + # 效果行 value = 单次贡献值,pool_value = 池合成值——禁相加呈现(战斗结算 呈现细则) + var pool: int = int(row.get("pool_value", 0)) + var col := UiKit.HEX_LOG_HEAL if v > 0 else UiKit.HEX_LOG_LOSS + var body := "%s 单次%+d(池 %+d)%s" % [name_, v, pool, suffix] + return prefix + (_gold_frame(body) if hl != "" else "[color=%s]%s[/color]" % [col, body]) + + +## 伤害行域:行上归因读数只剩掷点档位随行(护甲等级掷点留档——掷点读数与档位随行可读; +## 吃掷点分量恒带,棋盘来源行不带)。伤害数字不明显——伤害值放大加色独立于行文(朱红大一号) +func _fmt_damage(row: Dictionary, prefix: String, suffix: String) -> String: + var v: int = int(row.get("value", 0)) + var name_: String = String(row.get("display_name", "")) + var tail := _tail_of(row) + var roll_txt := "" + 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.combat.settlement.damage_roll.crit_multiplier) + elif int(row.tier) < 100: + roll_txt += " · ×%d%%" % int(row.tier) + roll_txt += "〕" + var kill := " ⚔击杀!" if row.get("killed", false) else "" + var body := "%s [color=#%s][size=17]-%d[/size][/color]%s%s%s%s" % [name_, UiKit.HEX_LOG_DMG_NUM, v, roll_txt, tail, kill, suffix] + if bool(row.get("crit", false)): + return prefix + _gold_frame(body) # nat 最大面暴击 = 金框高亮行 + var body_col := UiKit.HEX_LOG_DMG_KILL if row.get("killed", false) else (UiKit.HEX_LOG_ROUND if v == 0 else UiKit.HEX_LOG_DMG_BODY) + return prefix + "[color=%s]%s[/color]" % [body_col, body] + + +## 未生效行域:⚠ icon + 划线形状冗余(非色相独载)+ 聚合计数 +func _fmt_ineffective(row: Dictionary, prefix: String, suffix: String, agg_count: int) -> String: + var name_: String = String(row.get("display_name", "")) + var n := (" ×%d" % agg_count) if agg_count > 1 else "" + return prefix + "[s][color=#%s]⚠ %s%s[/color][/s]%s" % [UiKit.HEX_LOG_VOIDED, name_, n, suffix] + + +## 封印落空行域:完美破/首失豁免两注记分档(金框=破完美/普通墨=豁免) +func _fmt_seal_miss(row: Dictionary, prefix: String) -> String: + var hl := String(row.get("highlight", "")) + var name_: String = String(row.get("display_name", "")) + var miss_note := "" + if hl == "perfect_broken": + miss_note = "(本图完美封印已破——清图奖励不再加倍)" + elif hl == "": + miss_note = "(首次封印落空已豁免——不计入完美封印判定)" + var body := "✗ %s%s" % [name_, miss_note] + return prefix + (_gold_frame(body) if hl == "perfect_broken" else "[color=#%s]%s[/color]" % [UiKit.HEX_LOG_ROUND, body]) + + +## 裁定行域:胜金/负绿双墨(粗体菱形头) +func _fmt_verdict(row: Dictionary) -> String: + var name_: String = String(row.get("display_name", "")) + if int(row.value) == 1: + return "[color=#%s][b]◆ %s[/b][/color]" % [UiKit.HEX_LOG_WIN, name_] + return "[color=#%s][b]◆ %s[/b][/color]" % [UiKit.HEX_LOG_GAIN, name_] + + func _strip_bb(bb: String) -> String: var out := "" var in_tag := false diff --git a/game/presentation/views/battle_stage_view.gd b/game/presentation/views/battle_stage_view.gd index 78ab8b5e..9ab85302 100644 --- a/game/presentation/views/battle_stage_view.gd +++ b/game/presentation/views/battle_stage_view.gd @@ -26,18 +26,28 @@ var world: Node2D # StageWorld(preload 脚本实例——Node2D 世 func _init(db_ref) -> void: custom_minimum_size = Vector2(0, 252) - # 面板底(圣经 v3.5 §6.1 舞台正面目标:B 区=地窟看台,世界底层暗背景 - # =方向稿 render_battle_screen 取值(值单点收 UiKit.STAGE_BG);描边=PANEL_BORDER 同方向稿) + _build_backdrop() + _build_world_stage() + _build_text_half(db_ref) + _build_companion_layer(db_ref) + + +## 背景域(圣经 v3.5 §6.1 舞台正面目标:B 区=地窟看台,世界底层暗背景 +## =方向稿 render_battle_screen 取值(值单点收 UiKit.STAGE_BG);描边=PANEL_BORDER 同方向稿) +func _build_backdrop() -> void: var bg := Panel.new() var sb := UiKit.panel_flat(UiKit.STAGE_BG, UiKit.PANEL_BORDER, 0, 1) bg.add_theme_stylebox_override("panel", sb) bg.set_anchors_preset(Control.PRESET_FULL_RECT) bg.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(bg) + _build_beams_layer() - # 顶光柱氛围层(§6.1 光源氛围——方向稿四柱:x 基准 110/900/1055/1210@1280,暖光(值单点收 - # UiKit.BEAM_LIGHT——T4 裸色收口)自顶向下 60 段渐宽窄渐隐〔顶 α26→0〕;绘制在暗底之上/ - # 世界站桩之下——层序=bg < 光柱 < 世界) + +## 顶光柱氛围层域(§6.1 光源氛围——方向稿四柱:x 基准 110/900/1055/1210@1280,暖光(值单点收 +## UiKit.BEAM_LIGHT——T4 裸色收口)自顶向下 60 段渐宽窄渐隐〔顶 α26→0〕;绘制在暗底之上/ +## 世界站桩之下——层序=bg < 光柱 < 世界) +func _build_beams_layer() -> void: var beams := Control.new() beams.set_anchors_preset(Control.PRESET_FULL_RECT) beams.mouse_filter = Control.MOUSE_FILTER_IGNORE @@ -45,7 +55,9 @@ func _init(db_ref) -> void: beams.resized.connect(beams.queue_redraw) add_child(beams) - # ── Node2D 舞台半区(战斗舞台迁移批 Phase B 同构;stretch 1:1 无缩放系数——C2/C5)── + +## Node2D 舞台半区域(战斗舞台迁移批 Phase B 同构;stretch 1:1 无缩放系数——C2/C5) +func _build_world_stage() -> void: _svp_container = SubViewportContainer.new() _svp_container.stretch = true # 容器尺寸 = 视口逻辑分辨率,恒 1:1 _svp_container.mouse_filter = Control.MOUSE_FILTER_IGNORE # 世界零输入(tooltip 归壳层伴随件) @@ -61,7 +73,9 @@ func _init(db_ref) -> void: world = _STAGE_WORLD.new() _viewport.add_child(world) - # ── Control 文本半区(round_label / stage_center / log_view 原样保留——C1 永久 Control)── + +## Control 文本半区域(round_label / stage_center / log_view 原样保留——C1 永久 Control) +func _build_text_half(db_ref) -> void: var vb := VBoxContainer.new() vb.add_theme_constant_override("separation", 4) vb.set_anchors_preset(Control.PRESET_FULL_RECT) @@ -70,8 +84,14 @@ func _init(db_ref) -> void: vb.offset_right = -_CONTENT_MARGINS.size.x vb.offset_bottom = -_CONTENT_MARGINS.size.y add_child(vb) + _build_narr_row(vb) + _build_center_row(vb) + log_view = BattleLogView.new(db_ref) + vb.add_child(log_view) - # M8(UI 落码批):轮次牌 plate9 素牌化(demo ⑦——墨字上亮牌 12.3:1,暗底暖字退役) + +## 轮次牌域(M8 UI 落码批:plate9 素牌化——demo ⑦,墨字上亮牌 12.3:1,暗底暖字退役) +func _build_narr_row(vb: VBoxContainer) -> void: var narr_plate := PanelContainer.new() narr_plate.add_theme_stylebox_override("panel", UiKit.panel_skinned(Vector4(12, 4, 12, 4))) narr_plate.size_flags_horizontal = Control.SIZE_SHRINK_CENTER @@ -80,12 +100,14 @@ func _init(db_ref) -> void: round_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER narr_plate.add_child(round_label) + +## 中段行域:提示语居中+敌站桩壳层伴随件容器(明细带展开/收起改行高——世界布局随动) +func _build_center_row(vb: VBoxContainer) -> void: _row = HBoxContainer.new() _row.add_theme_constant_override("separation", 10) _row.size_flags_vertical = Control.SIZE_EXPAND_FILL vb.add_child(_row) - _row.resized.connect(_on_row_resized) # 明细带展开/收起改行高——世界布局随动 - + _row.resized.connect(_on_row_resized) var center := CenterContainer.new() center.size_flags_horizontal = Control.SIZE_EXPAND_FILL center.mouse_filter = Control.MOUSE_FILTER_IGNORE # 行内只剩文本——鼠标全放行给壳层伴随件 @@ -96,21 +118,17 @@ func _init(db_ref) -> void: stage_center.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER center_col.add_child(stage_center) _row.add_child(center) - enemy_box = Control.new() # 站桩壳层伴随件容器(T3 语义改写) enemy_box.mouse_filter = Control.MOUSE_FILTER_IGNORE _row.add_child(enemy_box) - log_view = BattleLogView.new(db_ref) - vb.add_child(log_view) - # ── 壳层伴随件层(最顶——世界站桩之上的 Control 触达面)── +## 壳层伴随件层域(最顶——世界站桩之上的 Control 触达面)+主角站桩注册(世界)+ 状态行伴随件(壳层) +func _build_companion_layer(db_ref) -> void: _companion_layer = Control.new() _companion_layer.set_anchors_preset(Control.PRESET_FULL_RECT) _companion_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_companion_layer) - - # 主角站桩本体(世界)+ 状态行伴随件(壳层) hero_standee = _STANDEE_BODY.new("hero") var hero_tex := UiKit.load_tex(UiKit.TEX_HERO_PORTRAIT) hero_standee.set_portrait_texture(hero_tex, true) # B 区立绘 48px 档·normal 态朝右(素材缺位 null 回退字形「人」) @@ -230,26 +248,24 @@ func enemy_body_at(i: int) -> Node2D: # 素材装载属组合点职责(主角侧同款先例 _init TEX_HERO_PORTRAIT),StandeeBody 保持纯收 tex 面。 # 素材缺位 load_tex→null→set_portrait_texture 字形回退(表现与素材批前逐位一致);未录键返回 null # 走默认字形「敌」(与 StandeeBody._portrait_glyph 默认档同口径)——数据表扩键先补此处映射。 +## 敌像 id→纹理键映射表(查表+回退——映射语义零变化;未知 id=null 回退字形) func _enemy_portrait_tex(enemy_id: String) -> Texture2D: - match enemy_id: - "goblin": - return UiKit.load_tex(UiKit.TEX_ENEMY_GOBLIN) - "bugbear": - return UiKit.load_tex(UiKit.TEX_ENEMY_BUGBEAR) - "ogre_chief": - return UiKit.load_tex(UiKit.TEX_ENEMY_OGRE_CHIEF) - "giant_spider": - return UiKit.load_tex(UiKit.TEX_ENEMY_GIANT_SPIDER) - "drider": - return UiKit.load_tex(UiKit.TEX_ENEMY_DRIDER) - "duergar": - return UiKit.load_tex(UiKit.TEX_ENEMY_DUERGAR) - "hook_horror": - return UiKit.load_tex(UiKit.TEX_ENEMY_HOOK_HORROR) - "duergar_king": - return UiKit.load_tex(UiKit.TEX_ENEMY_DUERGAR_KING) - _: - return null + var table := {"goblin": UiKit.TEX_ENEMY_GOBLIN, "bugbear": UiKit.TEX_ENEMY_BUGBEAR, + "ogre_chief": UiKit.TEX_ENEMY_OGRE_CHIEF, "giant_spider": UiKit.TEX_ENEMY_GIANT_SPIDER, + "drider": UiKit.TEX_ENEMY_DRIDER, "duergar": UiKit.TEX_ENEMY_DUERGAR, + "hook_horror": UiKit.TEX_ENEMY_HOOK_HORROR, "duergar_king": UiKit.TEX_ENEMY_DUERGAR_KING} + return UiKit.load_tex(String(table.get(enemy_id, ""))) if table.has(enemy_id) else null + + +## 单敌站桩构建域(循环体提函数——世界本体+壳层伴随件+敌种立绘接线〔仅重建拍装载一次; +## flip=false——八敌像素源核证朝左/正面,呈现即朝主角无误;反向朝向源换代时翻转义务归素材批〕) +func _spawn_enemy_standee(d: Dictionary) -> Node2D: + var body: Node2D = _STANDEE_BODY.new("enemy") + world.add_child(body) + body.slot_row = _make_slot_companion(enemy_box) + body.set_data(d) + body.set_portrait_texture(_enemy_portrait_tex(String(d.get("enemy_id", ""))), false) + return body # 敌方站桩投影更新:数量不变 = 就地更新(死亡灰化/受击闪烁等站桩上动画不被逐行刷新重置——禁瞬消纪律); @@ -263,14 +279,7 @@ func set_enemies(cards_data: Array) -> void: c.queue_free() _cards.clear() for d in cards_data: - var body: Node2D = _STANDEE_BODY.new("enemy") - world.add_child(body) - body.slot_row = _make_slot_companion(enemy_box) - body.set_data(d) - # 敌种立绘接线(仅重建拍装载一次;flip=false——八敌像素源核证朝左/正面, - # 呈现即朝主角无误;反向朝向源换代时翻转义务归素材批) - body.set_portrait_texture(_enemy_portrait_tex(String(d.get("enemy_id", ""))), false) - _cards.append(body) + _cards.append(_spawn_enemy_standee(d)) _sync_layout.call_deferred() return for i in cards_data.size(): diff --git a/game/presentation/views/board_cell_unit.gd b/game/presentation/views/board_cell_unit.gd index 7feda330..38ceac79 100644 --- a/game/presentation/views/board_cell_unit.gd +++ b/game/presentation/views/board_cell_unit.gd @@ -1,68 +1,13 @@ ## VERTICAL SLICE - NOT FOR PRODUCTION ## CellUnit——BoardView SubViewport 世界内逐格渲染件(Node2D),全部程序化 _draw 占位视觉(纹理槽位已留未装载)。 ## 无 class_name(类名缓存面纪律)——使用方经 preload 引用。 +## 内部状态件(u3 子域拆件——契约 9 职责语句「单格绘制输入集」):本件为被动绘制壳, +## 状态件 _CellPaintState 承载全部绘制入参(单一变化原因=格面呈现),setter 触发宿主重绘语义 +## 经 _owner 回引保持逐字;探针直读冻结成员(glyph/fog_state/plate/glyph_color)与 _glow_layer 住宿主。 extends Node2D const _GLOW_LAYER := preload("res://presentation/views/cell_type_glow_layer.gd") -var cell := Vector2i.ZERO # 板坐标(诊断观测面) -var edge := 46.0: # 格边长(BoardView.set_cell_area 单点驱动) - set(v): - edge = v - if _glow_layer != null and is_instance_valid(_glow_layer): - _glow_layer.setup(edge, glow_group) - queue_redraw() -var bg_sb: StyleBoxFlat = null # 格底 StyleBox(BoardView 每次 paint 重建;呼吸微光就地改边框) -# ── 接触带微光绘制面(可挖域微光画在英雄格周界空间侧——art-bible §3.2 v2.8; -# 仅英雄格单元承载,BoardView.refresh 按可挖域写边/角码、_process 逐拍更新呼吸色)── -var contact_bands: Array[int] = [] # 正交接触边码 0=N/1=S/2=W/3=E(该侧整条边) -var contact_corners: Array[int] = [] # 斜向共享角码 0=NW/1=NE/2=SW/3=SE(角点+两侧各半条边) -var contact_col := Color(0, 0, 0, 0) # 当前呼吸色(alpha 0 = 不画) -var plate := false # 数字色牌开关(BoardView paint 按已开格 glyph 写入——原则二) -var outline_sides: Array[int] = [] # 墙格地面朝向描边码 0=N/1=S/2=W/3=E(程序侧结构件) -# ── 高墙感 D 投影档(研究件四变体定 D=C+地面投影,ITB 同款高度线索)── -# 南立面 = 墙格南缘内侧「口沿高光线 + 双段暗带」(邻地面格才画,墙群内聚);地面投影 = 北邻墙格 -# 在本已开格北缘投渐隐暗带。参数按研究件 64px 拼版实测比例换算(立面双段 0.16/0.25 格边、投影带 -# 0.22 格边;坐标推导留档 commit)。素材缺位程序化回退同样服役(结构归程序——art-bible §8.5)。 -var facade_south := false: # 南立面开关(BoardView paint 按「南邻已开格」写入) - set(v): - facade_south = v - queue_redraw() -var proj_band := 0.0: # 地面投影带宽(像素,0 = 不画——BoardView 按北邻墙判定写入) - set(v): - proj_band = v - queue_redraw() -var glyph := "": # 文字位——数字/雷/封印/人/?(现行视觉封印与数字互斥,同位承载) - set(v): - glyph = v - queue_redraw() -var glyph_color := UiKit.INK: # 文字色(墨族/雷朱/封印黑/小人蓝) - set(v): - glyph_color = v - queue_redraw() -var badge_glyph := "": # 奖励徽章文字(右下角金——封印之下、变异纹之上、不进数字区) - set(v): - badge_glyph = v - if _glow_layer != null and is_instance_valid(_glow_layer): - _glow_layer.set_badge_mask(v != "") - queue_redraw() -var dot_visible := false: # BFS 路径预览点迹(中心 6×6 小人蓝) - set(v): - dot_visible = v - queue_redraw() -var hl_visible := false: # 焦点高亮(悬停态边框覆盖层) - set(v): - hl_visible = v - queue_redraw() -var hl_color := UiKit.GOLD: # 高亮色(金目标/封印朱/禁入灰禁红) - set(v): - hl_color = v - queue_redraw() -var font: Font = null # 字体槽位(null = ThemeDB.fallback_font——BoardView.set_world_font 注入) -var bevel := 0.0: # 顶面倒角强度(0=关;档位 0/0.35/0.6/1.0) - set(v): - bevel = v - queue_redraw() # ── D 投影档绘制常量(研究件实测取色——立面高光/双段暗带/地面投影;黑罩 alpha 叠加实测等效)── const FACADE_HIGHLIGHT_PX := 2.0 # 口沿高光线厚(像素) const FACADE_NEAR_FRAC := 0.16 # 立面近段高(格边比——研究件 10/64) @@ -76,235 +21,355 @@ const PROJECTION_EDGE_COL := Color(0, 0, 0, 0.06) # 投影远段(渐隐收 # 直贴零暗档零阴影;墙脚阴线只画地面/墙面交界;(b)-(c) 交界 # 无边框由 BoardView 侧跳过墙格北向描边承载)── const E_FACADE_FRAC := 0.30 # 墙面承接高(格边比 30%) -# ── 素材槽位(现全 null = 程序化占位服役;art-bible §8.2 格面 32×32 暂定档)── -var tex_base: Texture2D = null # ① 砖面底 -var tex_mutation: Texture2D = null # ② 变异纹带 -var tex_glyph: Texture2D = null # ③ 数字 -var tex_badge: Texture2D = null # ③' 徽章 -var tex_seal: Texture2D = null: # ④ 封印(槽位可运行时换装〔封印〕——同族 facade/treasure_mark - set(v): # 均 setter 触发重绘(普通 var 赋值不重绘=封印插上不显示)) - if tex_seal != v: - tex_seal = v - queue_redraw() -var tex_facade: Texture2D = null # ⑥ E 格墙面材质(v3.5——南邻墙顶 center 瓦片同源,BoardView 注入) -var facade_strip := false: # E 格开关(BoardView paint 按「已开格且南邻未开墙」写入——瓦片态专用) + + +## 单格绘制输入集(被动绘制壳的全部绘制入参——BoardView 直写面;setter 触发宿主重绘语义逐字) +class _CellPaintState extends RefCounted: + var _owner: Node2D = null + var cell := Vector2i.ZERO # 板坐标(诊断观测面) + var edge := 46.0: # 格边长(BoardView.set_cell_area 单点驱动) + set(v): + edge = v + if _owner._glow_layer != null and is_instance_valid(_owner._glow_layer): + _owner._glow_layer.setup(edge, glow_group) + _owner.queue_redraw() + var bg_sb: StyleBoxFlat = null # 格底 StyleBox(BoardView 每次 paint 重建;呼吸微光就地改边框) + # ── 接触带微光绘制面(可挖域微光画在英雄格周界空间侧——art-bible §3.2 v2.8; + # 仅英雄格单元承载,BoardView.refresh 按可挖域写边/角码、_process 逐拍更新呼吸色)── + var contact_bands: Array[int] = [] # 正交接触边码 0=N/1=S/2=W/3=E(该侧整条边) + var contact_corners: Array[int] = [] # 斜向共享角码 0=NW/1=NE/2=SW/3=SE(角点+两侧各半条边) + var contact_col := Color(0, 0, 0, 0) # 当前呼吸色(alpha 0 = 不画) + var outline_sides: Array[int] = [] # 墙格地面朝向描边码 0=N/1=S/2=W/3=E(程序侧结构件) + # ── 高墙感 D 投影档(研究件四变体定 D=C+地面投影,ITB 同款高度线索)── + # 南立面 = 墙格南缘内侧「口沿高光线 + 双段暗带」(邻地面格才画,墙群内聚);地面投影 = 北邻墙格 + # 在本已开格北缘投渐隐暗带。参数按研究件 64px 拼版实测比例换算(立面双段 0.16/0.25 格边、投影带 + # 0.22 格边;坐标推导留档 commit)。素材缺位程序化回退同样服役(结构归程序——art-bible §8.5)。 + var facade_south := false: # 南立面开关(BoardView paint 按「南邻已开格」写入) + set(v): + facade_south = v + _owner.queue_redraw() + var proj_band := 0.0: # 地面投影带宽(像素,0 = 不画——BoardView 按北邻墙判定写入) + set(v): + proj_band = v + _owner.queue_redraw() + var badge_glyph := "": # 奖励徽章文字(右下角金——封印之下、变异纹之上、不进数字区) + set(v): + badge_glyph = v + if _owner._glow_layer != null and is_instance_valid(_owner._glow_layer): + _owner._glow_layer.set_badge_mask(v != "") + _owner.queue_redraw() + var dot_visible := false: # BFS 路径预览点迹(中心 6×6 小人蓝) + set(v): + dot_visible = v + _owner.queue_redraw() + var hl_visible := false: # 焦点高亮(悬停态边框覆盖层) + set(v): + hl_visible = v + _owner.queue_redraw() + var hl_color := UiKit.GOLD: # 高亮色(金目标/封印朱/禁入灰禁红) + set(v): + hl_color = v + _owner.queue_redraw() + var font: Font = null # 字体槽位(null = ThemeDB.fallback_font——BoardView.set_world_font 注入) + var bevel := 0.0: # 顶面倒角强度(0=关;档位 0/0.35/0.6/1.0) + set(v): + bevel = v + _owner.queue_redraw() + # ── 素材槽位(现全 null = 程序化占位服役;art-bible §8.2 格面 32×32 暂定档)── + var tex_base: Texture2D = null # ① 砖面底 + var tex_mutation: Texture2D = null # ② 变异纹带 + var tex_glyph: Texture2D = null # ③ 数字 + var tex_badge: Texture2D = null # ③' 徽章 + var tex_seal: Texture2D = null: # ④ 封印(槽位可运行时换装〔封印〕——同族 facade/treasure_mark + set(v): # 均 setter 触发重绘(普通 var 赋值不重绘=封印插上不显示)) + if tex_seal != v: + tex_seal = v + _owner.queue_redraw() + var tex_facade: Texture2D = null # ⑥ E 格墙面材质(v3.5——南邻墙顶 center 瓦片同源,BoardView 注入) + var facade_strip := false: # E 格开关(BoardView paint 按「已开格且南邻未开墙」写入——瓦片态专用) + set(v): + facade_strip = v + _owner.queue_redraw() + + # 变异角标(程序化,tex_mutation 纹带槽暂留): + # "flat_damage" = 未开格伤害型变异,左下角危险红三角(开格有代价的策略纹理标记) + # 叠加层标记(秘宝整图叠加层的格面呈现;派生信息层零回写): + # "mine_reveal" = 真视格(危险红内缩框——「这格是雷」的确定决策标记,封印照常计费); + # "reward_preview" 格不复用本字段——预读徽章走 badge_glyph 注入(board_view refresh 注入 REWARD_BADGE 字形) + var treasure_mark := "": + set(v): + if treasure_mark != v: + treasure_mark = v + _owner.queue_redraw() + var mutation_mark := "": + set(v): + if mutation_mark != v: + mutation_mark = v + _owner.queue_redraw() + + # ── 未开格类型微光(英雄 8 向相邻未开格的组粒度类型提示) + # 作用集 = 可挖域 ∩ 身份可查格,由 BoardView 侧派生下发;本件只承接绘制与子件生命周期。 + # 静态:不呼吸、不入动效预算;相位门控(结算/道具窗口态隐藏)在 BoardView 侧。 + var glow_group := 0: + set(v): + if glow_group != v: + glow_group = v + _owner._sync_glow_layer() + + var removed_dim := false: # 中立移除退色层(BoardView paint 按排雷玩法 is_removed 写入) + set(v): + removed_dim = v + _owner.queue_redraw() + + +var st := _CellPaintState.new() +var glyph := "": # 文字位——数字/雷/封印/人/?(现行视觉封印与数字互斥,同位承载;探针直读冻结) set(v): - facade_strip = v + glyph = v queue_redraw() - -# 变异角标(程序化,tex_mutation 纹带槽暂留): -# "flat_damage" = 未开格伤害型变异,左下角危险红三角(开格有代价的策略纹理标记) -# 叠加层标记(秘宝整图叠加层的格面呈现;派生信息层零回写): -# "mine_reveal" = 真视格(危险红内缩框——「这格是雷」的确定决策标记,封印照常计费); -# "reward_preview" 格不复用本字段——预读徽章走 badge_glyph 注入(board_view refresh 注入 REWARD_BADGE 字形) -var treasure_mark := "": +var glyph_color := UiKit.INK: # 文字色(墨族/雷朱/封印黑/小人蓝;探针直读冻结) set(v): - if treasure_mark != v: - treasure_mark = v - queue_redraw() -var mutation_mark := "": - set(v): - if mutation_mark != v: - mutation_mark = v - queue_redraw() - + glyph_color = v + queue_redraw() +var plate := false # 数字色牌开关(BoardView paint 按已开格 glyph 写入——原则二;探针直读冻结) # ── 视野迷雾呈现态(纯呈现零逻辑;规格=art-bible §6.5 v3.5,现行事实源=HUD框架「视野迷雾」节)── # 0=正常(灯光区内)/ 1=全黑(区外未开格——现行事实源「区外未开格=全黑不渲染」不透明档)/ 2=记忆态(区外已开格灰显可读) -var fog_state := 0: +var fog_state := 0: # 探针直读冻结 set(v): if fog_state != v: fog_state = v queue_redraw() -var removed_dim := false: # 中立移除退色层(BoardView paint 按排雷玩法 is_removed 写入) - set(v): - removed_dim = v - queue_redraw() -# ── 未开格类型微光(英雄 8 向相邻未开格的组粒度类型提示) -# 作用集 = 可挖域 ∩ 身份可查格,由 BoardView 侧派生下发;本件只承接绘制与子件生命周期。 -# 静态:不呼吸、不入动效预算;相位门控(结算/道具窗口态隐藏)在 BoardView 侧。 -var glow_group := 0: - set(v): - if glow_group != v: - glow_group = v - _sync_glow_layer() - var _glow_layer: Node2D = null +func _init() -> void: + st._owner = self + + func _draw() -> void: - var rect := Rect2(Vector2.ZERO, Vector2(edge, edge)) - # ① 砖面底(占位 = StyleBoxFlat:格底色 + 暗化描边 + 3px 圆角; - # 呼吸微光/主角蓝框由 BoardView 改 bg_sb 后触发重绘) - if tex_base != null: - draw_texture_rect(tex_base, rect, false) + var rect := Rect2(Vector2.ZERO, Vector2(st.edge, st.edge)) + _draw_base_layer(rect) + _draw_mutation_marks() + _draw_glyph_layer(rect) + _draw_overlay_layers(rect) + + +## ① 砖面底层域(占位 = StyleBoxFlat:格底色 + 暗化描边 + 3px 圆角;瓦片态描边覆盖层; +## 呼吸微光/主角蓝框由 BoardView 改 bg_sb 后触发重绘。素材缺位回退走 elif 程序化原样,零观感变化) +func _draw_base_layer(rect: Rect2) -> void: + if st.tex_base != null: + draw_texture_rect(st.tex_base, rect, false) # 轮廓加强(轮廓线不明显):墙格地面朝向边补 2px 深墨线(§8.5 结构归程序—— # 素材 1px 墨线在 45/36px 非整数缩放下视觉衰减,程序线保底;墙-墙内界不画,保墙群内聚; # 坐标推导:各边内贴 2px 矩形,相邻边矩形全长相交=周界闭合) - if not outline_sides.is_empty(): - var ow := 2.0 - var oc := UiKit.FOG_OUTLINE - for b in outline_sides: - var or_ := Rect2() - match int(b): - 0: or_ = Rect2(0, 0, edge, ow) - 1: or_ = Rect2(0, edge - ow, edge, ow) - 2: or_ = Rect2(0, 0, ow, edge) - 3: or_ = Rect2(edge - ow, 0, ow, edge) - draw_rect(or_, oc, true) + if not st.outline_sides.is_empty(): + _draw_outline_sides() _draw_south_facade() _draw_north_projection() _draw_e_facade() # 瓦片态描边覆盖层:tex_base 在位时 BoardView 传入的 bg_sb # 已降级为「透明底 + 描边」载体——呼吸微光/主角蓝框画在瓦片之上(art-bible §1.2 叠层序 # 不变:底 < 纹带 < 数字 < 封印 < 焦点高亮);素材缺位回退走 elif 程序化原样,零观感变化 - if bg_sb != null: - draw_style_box(bg_sb, rect) + if st.bg_sb != null: + draw_style_box(st.bg_sb, rect) + _draw_contact_or_fallback(rect) - # 接触带微光(英雄格周界空间侧——正交邻墙=整条边;斜向=共享角点+两侧各半条边;光不进墙格): - # 带宽 4px 贴格边内侧(坐标推导:N=[0,0,edge,4] S=[0,edge-4,edge,4] W=[0,0,4,edge] E=[edge-4,0,4,edge]; - # 半条边=沿邻边自角点起 edge/2;角点圆 r=6 贴角点圆心,近白芯 r=2.5 提亮) - if contact_col.a > 0.0: + +## 接触带微光或平色回退域(英雄格周界空间侧——正交邻墙=整条边;斜向=共享角点+两侧各半条边;光不进墙格): +## 带宽 4px 贴格边内侧(坐标推导:N=[0,0,edge,4] S=[0,edge-4,edge,4] W=[0,0,4,edge] E=[edge-4,0,4,edge]; +## 半条边=沿邻边自角点起 edge/2;角点圆 r=6 贴角点圆心,近白芯 r=2.5 提亮) +func _draw_contact_or_fallback(rect: Rect2) -> void: + if st.contact_col.a > 0.0: _draw_north_projection() # 平色回退+微光并发的英雄格补画(瓦片路径已画——同像素重复无观感差) _draw_e_facade() # E 格墙面补画(同上——英雄格兼 E 格时瓦片路径已画,重复无害) - var band := 4.0 - var half := edge * 0.5 - for b in contact_bands: - var r := Rect2() - match int(b): - 0: r = Rect2(0, 0, edge, band) - 1: r = Rect2(0, edge - band, edge, band) - 2: r = Rect2(0, 0, band, edge) - 3: r = Rect2(edge - band, 0, band, edge) - draw_rect(r, contact_col, true) - for cc in contact_corners: - var pt := Vector2.ZERO - match int(cc): - 0: pt = Vector2(0, 0) - 1: pt = Vector2(edge, 0) - 2: pt = Vector2(0, edge) - 3: pt = Vector2(edge, edge) - draw_circle(pt, 6.0, contact_col) - draw_circle(pt, 2.5, Color(1, 1, 1, minf(1.0, contact_col.a * 0.95))) - # 半条边:角点沿两条邻边各延伸 edge/2(坐标推导:横带沿 N/S 自角点向板心侧 half; - # NW/SE 半带 x=y=角点坐标,NE/SE 半带自 edge-half 收进——正交整边时重叠同色无害) - if int(cc) == 0 or int(cc) == 1: - # N 侧横带:NW 自 x=0、NE 自 x=edge-half - draw_rect(Rect2(pt.x if int(cc) == 0 else pt.x - half, 0.0, half, band), contact_col, true) - if int(cc) == 2 or int(cc) == 3: - # S 侧横带:SW 自 x=0、SE 自 x=edge-half - draw_rect(Rect2(pt.x if int(cc) == 2 else pt.x - half, edge - band, half, band), contact_col, true) - if int(cc) == 0 or int(cc) == 2: - # W 侧竖带:NW 自 y=0、SW 自 y=edge-half - draw_rect(Rect2(0.0, pt.y if int(cc) == 0 else pt.y - half, band, half), contact_col, true) - if int(cc) == 1 or int(cc) == 3: - # E 侧竖带:NE 自 y=0、SE 自 y=edge-half - draw_rect(Rect2(edge - band, pt.y if int(cc) == 1 else pt.y - half, band, half), contact_col, true) - elif bg_sb != null: - draw_style_box(bg_sb, rect) + _draw_contact_bands() + elif st.bg_sb != null: + draw_style_box(st.bg_sb, rect) _draw_south_facade() # 平色回退路径——D 投影档两件同样服役(结构归程序) _draw_north_projection() - # 顶面倒角(光源左上——上/左内沿受光 2px、下/右内沿背光 2px;内缩 2px 让 - # 呼吸微光/主角蓝边框保持最外沿可见;强度随档位) - if bevel > 0.0: - var base: Color = bg_sb.bg_color - var lit: Color = base.lightened(0.22 * bevel) - var dark: Color = base.darkened(0.22 * bevel) - var t := 2.0 - draw_rect(Rect2(t, t, edge - t * 2.0, t), lit, true) - draw_rect(Rect2(t, t, t, edge - t * 2.0), lit, true) - draw_rect(Rect2(t, edge - t * 2.0, edge - t * 2.0, t), dark, true) - draw_rect(Rect2(edge - t * 2.0, t, t, edge - t * 2.0), dark, true) + _draw_bevel() + + +## ②③④ 中层域:变异纹带与标记/数字色牌与字形/徽章/封印 +func _draw_glyph_layer(rect: Rect2) -> void: # ② 变异纹带(无程序化纹带——槽位先行,贴图落位即用) - if tex_mutation != null: - draw_texture_rect(tex_mutation, rect, false) - # ②' 变异角标:左下角危险红三角(危险红=「开此格有代价」语义位,与雷族同源; - # 尺寸随格边比例——灰色可辨辅助=红对砖面明度差,禁喧宾) - # ②" 秘宝叠加层:真视格=危险红内缩框(策略纹理——确定决策输入不喧宾) - if treasure_mark == "mine_reveal": - var inset := edge * 0.14 - draw_rect(Rect2(inset, inset, edge - inset * 2.0, edge - inset * 2.0), - Color(UiKit.RED_MINE, 0.9), false, 2.0) - if mutation_mark == "flat_damage": - var mw := edge * 0.28 - var mpts := PackedVector2Array([Vector2(0, edge - mw), Vector2(0, edge), Vector2(mw, edge)]) - draw_colored_polygon(mpts, Color(UiKit.RED_MINE, 0.85)) + if st.tex_mutation != null: + draw_texture_rect(st.tex_mutation, rect, false) # ③ 数字/徽章(数字 ≥ 格高 50% 墨族对格底 ≥4.5:1;徽章右下角金,遮蔽态 = "?") if glyph != "": - # 数字色牌后备路径(圣经 v3.5 冻结④:数字亮墨直画已开格底须垫牌; - # 牌=后备件——雷/「?」维持牌载保读性(牌上深墨族 12:1); - # 牌尺寸 0.60/0.62 格边居中;封印=未开格不垫牌) - if plate: - var pw := edge * 0.60 - var ph := edge * 0.62 - var pr := Rect2((edge - pw) * 0.5, (edge - ph) * 0.5, pw, ph) - draw_rect(pr, UiKit.DIGIT_PLATE, true) - draw_rect(pr, UiKit.DIGIT_PLATE_EDGE, false, 1.0) - if tex_glyph != null: - draw_texture_rect(tex_glyph, rect, false) + _draw_digit_plate() + if st.tex_glyph != null: + draw_texture_rect(st.tex_glyph, rect, false) else: - _draw_glyph(glyph, glyph_color, maxi(18, int(ceil(edge * 0.5)))) # 字号下限 18(§7.4 冻结公式) - if badge_glyph != "": - if tex_badge != null: - draw_texture_rect(tex_badge, rect, false) + _draw_glyph(glyph, glyph_color, maxi(18, int(ceil(st.edge * 0.5)))) # 字号下限 18(§7.4 冻结公式) + if st.badge_glyph != "": + if st.tex_badge != null: + draw_texture_rect(st.tex_badge, rect, false) else: - _draw_badge(badge_glyph) + _draw_badge(st.badge_glyph) # ④ 封印(现行视觉封印文字在 ③ 文字位承载——独立槽位留给封印图) - if tex_seal != null: - draw_texture_rect(tex_seal, rect, false) + if st.tex_seal != null: + draw_texture_rect(st.tex_seal, rect, false) + + +## 数字色牌后备域(圣经 v3.5 冻结④:数字亮墨直画已开格底须垫牌; +## 牌=后备件——雷/「?」维持牌载保读性(牌上深墨族 12:1); +## 牌尺寸 0.60/0.62 格边居中;封印=未开格不垫牌) +func _draw_digit_plate() -> void: + var pw := st.edge * 0.60 + var ph := st.edge * 0.62 + var pr := Rect2((st.edge - pw) * 0.5, (st.edge - ph) * 0.5, pw, ph) + draw_rect(pr, UiKit.DIGIT_PLATE, true) + draw_rect(pr, UiKit.DIGIT_PLATE_EDGE, false, 1.0) + + +## ⑤⑥⑦ 顶层域:路径点迹 + 焦点高亮 + 视野迷雾 + 中立移除退色 +func _draw_overlay_layers(rect: Rect2) -> void: # ⑤ 路径点迹 + 焦点高亮(悬停态覆盖层,不参与命中) - if dot_visible: + if st.dot_visible: var ds := 6.0 - draw_rect(Rect2(Vector2((edge - ds) * 0.5, (edge - ds) * 0.5), Vector2(ds, ds)), UiKit.HERO_BLUE, true) - if hl_visible: + draw_rect(Rect2(Vector2((st.edge - ds) * 0.5, (st.edge - ds) * 0.5), Vector2(ds, ds)), UiKit.HERO_BLUE, true) + if st.hl_visible: # 悬停高亮减淡(金框太明显——绘制时 55% alpha,hl_color 属性与探针断言面不动) - draw_rect(Rect2(1, 1, edge - 2.0, edge - 2.0), Color(hl_color, 0.55), false, 2.0) # 2px 内描边 - # ⑥ 视野迷雾层(最顶遮罩,§6.5 落文): - # 全黑=不透明纯黑盖全部叠层(含封印印/徽章/高亮——封印印记随格隐没,§6.5 显式推论); - # 记忆态=记忆板素色压平(纹理免疫——色牌/封印印/徽章随板隐没)+数字记忆墨重画(数字可读硬指标 ≥4.5:1) - if fog_state == 1: - draw_rect(Rect2(Vector2.ZERO, Vector2(edge, edge)), Color(0, 0, 0, 1.0), true) - elif fog_state == 2: - # 记忆态换装(demo 口径「地板纹理 + 灰罩 α.66」: - # 地板纹理原样直画 + FOG_MEMORY_PLATE α.66 灰罩压顶;素材零改动、格边长零改动。 - # 记忆墨 FOG_MEMORY_INK 对合成底归一后(真实渲染面)三关 5.71–5.93:1 ≥4.5:1(§7.2 硬指标; - # 测点行值与素材随动,见 ui_contrast_probe 迷雾段)) - if tex_base != null: - draw_texture_rect(tex_base, Rect2(Vector2.ZERO, Vector2(edge, edge)), false) - draw_rect(Rect2(Vector2.ZERO, Vector2(edge, edge)), Color(UiKit.FOG_MEMORY_PLATE, 0.66), true) - if glyph != "": - _draw_glyph(glyph, UiKit.FOG_MEMORY_INK, maxi(18, int(ceil(edge * 0.5)))) # 字号下限 18(§7.4 冻结公式同源) + draw_rect(Rect2(1, 1, st.edge - 2.0, st.edge - 2.0), Color(st.hl_color, 0.55), false, 2.0) # 2px 内描边 + _draw_fog_layer() # ⑦ 中立移除退色层(退色降档:暗幕压顶全层覆盖,暗淡记忆态同族呈现语义; # 顶层于迷雾之上——移除格不再参与迷雾语义。本实现移除集=未揭示危险格(无数字无封印印)) - if removed_dim: - draw_rect(Rect2(Vector2.ZERO, Vector2(edge, edge)), Color(0.12, 0.10, 0.09, 0.62), true) + if st.removed_dim: + draw_rect(Rect2(Vector2.ZERO, Vector2(st.edge, st.edge)), Color(0.12, 0.10, 0.09, 0.62), true) + + +## ⑥ 视野迷雾层(最顶遮罩,§6.5 落文): +## 全黑=不透明纯黑盖全部叠层(含封印印/徽章/高亮——封印印记随格隐没,§6.5 显式推论); +## 记忆态=记忆板素色压平(纹理免疫——色牌/封印印/徽章随板隐没)+数字记忆墨重画(数字可读硬指标 ≥4.5:1) +func _draw_fog_layer() -> void: + if fog_state == 1: + draw_rect(Rect2(Vector2.ZERO, Vector2(st.edge, st.edge)), Color(0, 0, 0, 1.0), true) + elif fog_state == 2: + _draw_fog_memory() + + +## 墙格轮廓域:地面朝向边补 2px 深墨线(各边内贴 2px 矩形,相邻边矩形全长相交=周界闭合) +func _draw_outline_sides() -> void: + var ow := 2.0 + var oc := UiKit.FOG_OUTLINE + for b in st.outline_sides: + var or_ := Rect2() + match int(b): + 0: or_ = Rect2(0, 0, st.edge, ow) + 1: or_ = Rect2(0, st.edge - ow, st.edge, ow) + 2: or_ = Rect2(0, 0, ow, st.edge) + 3: or_ = Rect2(st.edge - ow, 0, ow, st.edge) + draw_rect(or_, oc, true) + + +## 接触带微光域:正交整条边 + 斜向共享角点与两侧半条边 +func _draw_contact_bands() -> void: + var band := 4.0 + var half := st.edge * 0.5 + for b in st.contact_bands: + var r := Rect2() + match int(b): + 0: r = Rect2(0, 0, st.edge, band) + 1: r = Rect2(0, st.edge - band, st.edge, band) + 2: r = Rect2(0, 0, band, st.edge) + 3: r = Rect2(st.edge - band, 0, band, st.edge) + draw_rect(r, st.contact_col, true) + for cc in st.contact_corners: + _draw_contact_corner(int(cc), band, half) + + +## 斜向角点域:角点圆 r=6 贴角点圆心 + 近白芯 r=2.5 提亮 + 半条边(角点沿两条邻边各延伸 edge/2—— +## 坐标推导:横带沿 N/S 自角点向板心侧 half;NW/SE 半带 x=y=角点坐标,NE/SE 半带自 edge-half 收进—— +## 正交整边时重叠同色无害) +func _draw_contact_corner(cc: int, band: float, half: float) -> void: + var pt := Vector2.ZERO + match cc: + 0: pt = Vector2(0, 0) + 1: pt = Vector2(st.edge, 0) + 2: pt = Vector2(0, st.edge) + 3: pt = Vector2(st.edge, st.edge) + draw_circle(pt, 6.0, st.contact_col) + draw_circle(pt, 2.5, Color(1, 1, 1, minf(1.0, st.contact_col.a * 0.95))) + if cc == 0 or cc == 1: + # N 侧横带:NW 自 x=0、NE 自 x=edge-half + draw_rect(Rect2(pt.x if cc == 0 else pt.x - half, 0.0, half, band), st.contact_col, true) + if cc == 2 or cc == 3: + # S 侧横带:SW 自 x=0、SE 自 x=edge-half + draw_rect(Rect2(pt.x if cc == 2 else pt.x - half, st.edge - band, half, band), st.contact_col, true) + if cc == 0 or cc == 2: + # W 侧竖带:NW 自 y=0、SW 自 y=edge-half + draw_rect(Rect2(0.0, pt.y if cc == 0 else pt.y - half, band, half), st.contact_col, true) + if cc == 1 or cc == 3: + # E 侧竖带:NE 自 y=0、SE 自 y=edge-half + draw_rect(Rect2(st.edge - band, pt.y if cc == 1 else pt.y - half, band, half), st.contact_col, true) + + +## 顶面倒角域(光源左上——上/左内沿受光 2px、下/右内沿背光 2px;内缩 2px 让 +## 呼吸微光/主角蓝边框保持最外沿可见;强度随档位) +func _draw_bevel() -> void: + if st.bevel <= 0.0: + return + var base: Color = st.bg_sb.bg_color + var lit: Color = base.lightened(0.22 * st.bevel) + var dark: Color = base.darkened(0.22 * st.bevel) + var t := 2.0 + draw_rect(Rect2(t, t, st.edge - t * 2.0, t), lit, true) + draw_rect(Rect2(t, t, t, st.edge - t * 2.0), lit, true) + draw_rect(Rect2(t, st.edge - t * 2.0, st.edge - t * 2.0, t), dark, true) + draw_rect(Rect2(st.edge - t * 2.0, t, t, st.edge - t * 2.0), dark, true) + + +## 变异标记域:真视格=危险红内缩框/未开格伤害型变异=左下角危险红三角 +func _draw_mutation_marks() -> void: + if st.treasure_mark == "mine_reveal": + var inset := st.edge * 0.14 + draw_rect(Rect2(inset, inset, st.edge - inset * 2.0, st.edge - inset * 2.0), + Color(UiKit.RED_MINE, 0.9), false, 2.0) + if st.mutation_mark == "flat_damage": + var mw := st.edge * 0.28 + var mpts := PackedVector2Array([Vector2(0, st.edge - mw), Vector2(0, st.edge), Vector2(mw, st.edge)]) + draw_colored_polygon(mpts, Color(UiKit.RED_MINE, 0.85)) + + +## 记忆态换装域(demo 口径「地板纹理 + 灰罩 α.66」: +## 地板纹理原样直画 + FOG_MEMORY_PLATE α.66 灰罩压顶;素材零改动、格边长零改动。 +## 记忆墨 FOG_MEMORY_INK 对合成底归一后(真实渲染面)三关 5.71–5.93:1 ≥4.5:1(§7.2 硬指标; +## 测点行值与素材随动,见 ui_contrast_probe 迷雾段)) +func _draw_fog_memory() -> void: + if st.tex_base != null: + draw_texture_rect(st.tex_base, Rect2(Vector2.ZERO, Vector2(st.edge, st.edge)), false) + draw_rect(Rect2(Vector2.ZERO, Vector2(st.edge, st.edge)), Color(UiKit.FOG_MEMORY_PLATE, 0.66), true) + if glyph != "": + _draw_glyph(glyph, UiKit.FOG_MEMORY_INK, maxi(18, int(ceil(st.edge * 0.5)))) # 字号下限 18(§7.4 冻结公式同源) # 南立面(D 档件一——墙格南缘内侧,自底边向上:双段暗带在下、口沿高光线贴其上沿): # 坐标推导(自格底边起算):远段=[edge−far, edge],近段=[edge−far−near, edge−far], # 高光线=[edge−far−near−hl, edge−far−near]——与研究件「墙顶面→高光→暗段→更暗段→格界」下行序一致 func _draw_south_facade() -> void: - if not facade_south: + if not st.facade_south: return - var far_h := edge * FACADE_FAR_FRAC - var near_h := edge * FACADE_NEAR_FRAC - var y_far := edge - far_h + var far_h := st.edge * FACADE_FAR_FRAC + var near_h := st.edge * FACADE_NEAR_FRAC + var y_far := st.edge - far_h var y_near := y_far - near_h var y_hl := y_near - FACADE_HIGHLIGHT_PX - draw_rect(Rect2(0.0, y_hl, edge, FACADE_HIGHLIGHT_PX), UiKit.STAND_STONE_RIM, true) # 口沿高光(UiKit.STAND_STONE_RIM 并档——同值单点) - draw_rect(Rect2(0.0, y_near, edge, near_h), FACADE_NEAR_COL, true) - draw_rect(Rect2(0.0, y_far, edge, far_h), FACADE_FAR_COL, true) + draw_rect(Rect2(0.0, y_hl, st.edge, FACADE_HIGHLIGHT_PX), UiKit.STAND_STONE_RIM, true) # 口沿高光(UiKit.STAND_STONE_RIM 并档——同值单点) + draw_rect(Rect2(0.0, y_near, st.edge, near_h), FACADE_NEAR_COL, true) + draw_rect(Rect2(0.0, y_far, st.edge, far_h), FACADE_FAR_COL, true) # 地面投影(D 档件二——北邻墙格在本已开格北缘投渐隐暗带,自北缘向南三段衰减): # 坐标推导:三等分 proj_band,α 0.28/0.16/0.06(研究件实测贴墙 α≈0.28、带末渐融入地面) func _draw_north_projection() -> void: - if proj_band <= 0.0: + if st.proj_band <= 0.0: return - var seg := proj_band / 3.0 - draw_rect(Rect2(0.0, 0.0, edge, seg), PROJECTION_NEAR_COL, true) - draw_rect(Rect2(0.0, seg, edge, seg), PROJECTION_MID_COL, true) - draw_rect(Rect2(0.0, seg * 2.0, edge, seg), PROJECTION_EDGE_COL, true) + var seg := st.proj_band / 3.0 + draw_rect(Rect2(0.0, 0.0, st.edge, seg), PROJECTION_NEAR_COL, true) + draw_rect(Rect2(0.0, seg, st.edge, seg), PROJECTION_MID_COL, true) + draw_rect(Rect2(0.0, seg * 2.0, st.edge, seg), PROJECTION_EDGE_COL, true) # E 格高墙面(圣经 v3.5 冻结①——方向稿 draw_facade 移植):本格已开且南邻为墙 → @@ -312,31 +377,31 @@ func _draw_north_projection() -> void: # 墙脚阴线只画地面/墙面交界(strip 顶缘 1px);墙面底缘(格界处)不画任何线——直接接南侧墙顶 # (白边导致两截)。仅瓦片态服役(tex_facade 缺位 = 平色回退原语义不动)。 func _draw_e_facade() -> void: - if not facade_strip or tex_facade == null: + if not st.facade_strip or st.tex_facade == null: return - var strip_h := edge * E_FACADE_FRAC - var y0 := edge - strip_h - var ts := tex_facade.get_size() - draw_texture_rect_region(tex_facade, Rect2(0.0, y0, edge, strip_h), + var strip_h := st.edge * E_FACADE_FRAC + var y0 := st.edge - strip_h + var ts := st.tex_facade.get_size() + draw_texture_rect_region(st.tex_facade, Rect2(0.0, y0, st.edge, strip_h), Rect2(0.0, 0.0, ts.x, ts.y * E_FACADE_FRAC)) - draw_rect(Rect2(0.0, y0, edge, 1.0), UiKit.E_FACADE_FOOT_COL, true) + draw_rect(Rect2(0.0, y0, st.edge, 1.0), UiKit.E_FACADE_FOOT_COL, true) # 文字位绘制:水平居中 + 垂直居中(基线 = (格高 − 字高)/2 + 上伸部) func _draw_glyph(g: String, col: Color, fs: int) -> void: - var f := font if font != null else ThemeDB.fallback_font + var f := st.font if st.font != null else ThemeDB.fallback_font if f == null: return var asc := f.get_ascent(fs) var desc := f.get_descent(fs) - draw_string(f, Vector2(0.0, (edge - (asc + desc)) * 0.5 + asc), g, - HORIZONTAL_ALIGNMENT_CENTER, edge, fs, col) + draw_string(f, Vector2(0.0, (st.edge - (asc + desc)) * 0.5 + asc), g, + HORIZONTAL_ALIGNMENT_CENTER, st.edge, fs, col) # 微光子件生命周期:有组才建、组变即换、无组即撤——作用集 ≤8 格常驻, # 其余格零节点零绘制成本。与父件 _draw() 分离的三条理由见本件头注(命令序列/重绘预算/材质挂点)。 func _sync_glow_layer() -> void: - if glow_group == 0: + if st.glow_group == 0: if _glow_layer != null and is_instance_valid(_glow_layer): _glow_layer.queue_free() _glow_layer = null @@ -344,15 +409,15 @@ func _sync_glow_layer() -> void: if _glow_layer == null or not is_instance_valid(_glow_layer): _glow_layer = _GLOW_LAYER.new() add_child(_glow_layer) - _glow_layer.setup(edge, glow_group) - _glow_layer.set_badge_mask(badge_glyph != "") + _glow_layer.setup(st.edge, st.glow_group) + _glow_layer.set_badge_mask(st.badge_glyph != "") # 徽章绘制:右下角金(文字贴角内缩 2–3px) func _draw_badge(g: String) -> void: - var f := font if font != null else ThemeDB.fallback_font + var f := st.font if st.font != null else ThemeDB.fallback_font if f == null: return - var fs := maxi(10, int(edge * 0.28)) + var fs := maxi(10, int(st.edge * 0.28)) var sz := f.get_string_size(g, HORIZONTAL_ALIGNMENT_LEFT, -1, fs) - draw_string(f, Vector2(edge - sz.x - 2.0, edge - 3.0), g, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, UiKit.GOLD) + draw_string(f, Vector2(st.edge - sz.x - 2.0, st.edge - 3.0), g, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, UiKit.GOLD) diff --git a/game/presentation/views/board_fx_view.gd b/game/presentation/views/board_fx_view.gd index 5fc47c7a..ddd764ae 100644 --- a/game/presentation/views/board_fx_view.gd +++ b/game/presentation/views/board_fx_view.gd @@ -89,19 +89,24 @@ func play_boss_shadow() -> void: # 完美封印:金光带加倍扫射 + 横幅占位(§2.2 最高档) func play_perfect_sweep() -> void: - for i in 2: - var band := ColorRect.new() - band.color = Color(COL_GOLD, 0.35) - band.size = Vector2(size.x, 14.0 + 10.0 * i) - band.position = Vector2(-size.x, size.y * 0.3 + 40.0 * i) - band.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(band) - var tw := band.create_tween() - tw.tween_property(band, "position:x", size.x + 8.0, UiKit.motion(0.5 + 0.15 * i)) - tw.tween_callback(band.queue_free) + for i in 3: + _spawn_sweep_band(i, size) _show_banner("完美封印 ×2") +## 单光带域(循环体提函数——自左掠过渐出) +func _spawn_sweep_band(i: int, size: Vector2) -> void: + var band := ColorRect.new() + band.color = Color(COL_GOLD, 0.35) + band.size = Vector2(size.x, 14.0 + 10.0 * i) + band.position = Vector2(-size.x, size.y * 0.3 + 40.0 * i) + band.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(band) + var tw := band.create_tween() + tw.tween_property(band, "position:x", size.x + 8.0, UiKit.motion(0.5 + 0.15 * i)) + tw.tween_callback(band.queue_free) + + # 封印命中:封印化暴击弹道加重占位(自棋盘上缘飞向 B 区敌人卡方向——越界即收) func play_seal_hit_projectile(heavy: bool) -> void: var shot := ColorRect.new() diff --git a/game/presentation/views/board_view.gd b/game/presentation/views/board_view.gd index c805c20d..c15e5c67 100644 --- a/game/presentation/views/board_view.gd +++ b/game/presentation/views/board_view.gd @@ -182,7 +182,7 @@ func set_treasure_overlays(reveal_cells: Array, preview_cells: Array) -> void: func set_world_font(f: Font) -> void: _world.board_font = f for c in _cells: - _cells[c].font = f + _cells[c].st.font = f _cells[c].queue_redraw() @@ -201,7 +201,7 @@ func set_cell_area(avail: Vector2) -> void: var stride := _cell + float(SEP) for c in _cells: var u: Node2D = _cells[c] - u.edge = _cell + u.st.edge = _cell u.position = Vector2(c) * stride if _world.hero != null and _hero_sprite.last.x >= 0: _place_hero(_hero_sprite.last, _hero_sprite.last, true) # 格边长变更(窗口缩放)——英雄即时随格重置(杀位移 tween 防飞陈旧落点) @@ -247,17 +247,17 @@ func rebuild(board: BoardData, minesweeper: MinesweeperRules) -> void: for x in _board.width: var c := Vector2i(x, y) var u: Node2D = _CELL_UNIT.new() - u.cell = c - u.edge = _cell + u.st.cell = c + u.st.edge = _cell u.position = Vector2(c) * stride - u.bevel = DEPTH_BEVEL # 新建格单元继承 + u.st.bevel = DEPTH_BEVEL # 新建格单元继承 u.use_parent_material = true # 去饱和材质挂 BoardWorld——单元随世界材质灰化 if _palette.tiles_ok: # NEAREST 采样(32×32 源 × 45/36px 格边非整数缩放接受档, # 与英雄小人 1.5× 豁免同级(圣经 §8.2);只在瓦片 # 模式启用——程序化回退路径渲染零扰动(含格面文字采样不回退不变化) u.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST - u.font = _world.board_font + u.st.font = _world.board_font _world.add_child(u) _cells[c] = u set_cell_area(size) @@ -509,21 +509,21 @@ func _apply_hover_visuals(c: Vector2i) -> void: var seal: bool = _minesweeper.has_seal(c) var adjacent: bool = _diggable.has(c) if revealed: - u.hl_visible = true - u.hl_color = COL_HL_TARGET # 移动目标格边框(金) + u.st.hl_visible = true + u.st.hl_color = COL_HL_TARGET # 移动目标格边框(金) _set_cursor_shape(c, CURSOR_MOVE_SEM) _show_path_preview(c) elif adjacent: - u.hl_visible = true - u.hl_color = COL_HL_TARGET # 挖掘目标格边框(相邻封印格同此——左键直接挖) + u.st.hl_visible = true + u.st.hl_color = COL_HL_TARGET # 挖掘目标格边框(相邻封印格同此——左键直接挖) _set_cursor_shape(c, CURSOR_DIG) elif seal: - u.hl_visible = true - u.hl_color = COL_SEAL # 已承诺封印标记(封印黑;无交互可供) + u.st.hl_visible = true + u.st.hl_color = COL_SEAL # 已承诺封印标记(封印黑;无交互可供) _set_cursor_shape(c, Input.CURSOR_FORBIDDEN) else: - u.hl_visible = true - u.hl_color = UiKit.RED_FORBID # 不可及域灰禁框(禁止光标+首遇 toast 双通道) + u.st.hl_visible = true + u.st.hl_color = UiKit.RED_FORBID # 不可及域灰禁框(禁止光标+首遇 toast 双通道) _set_cursor_shape(c, Input.CURSOR_FORBIDDEN) hover_forbidden.emit(c) @@ -531,7 +531,7 @@ func _apply_hover_visuals(c: Vector2i) -> void: func _clear_hover_visuals(c: Vector2i) -> void: if not _cells.has(c): return - _cells[c].hl_visible = false + _cells[c].st.hl_visible = false _hide_path_preview() @@ -546,12 +546,12 @@ func _show_path_preview(c: Vector2i) -> void: var path: Array[Variant] = _minesweeper.compute_move_path(c) for pc in path: if _cells.has(pc): - _cells[pc].dot_visible = true + _cells[pc].st.dot_visible = true func _hide_path_preview() -> void: for c in _cells: - _cells[c].dot_visible = false + _cells[c].st.dot_visible = false # ---- 奖励徽章(已开格身份 icon——封印之下、纹带之上、不进数字区) ---- @@ -611,11 +611,11 @@ func _paint_cell(c: Vector2i, hero: Vector2i) -> void: var digit_col := COL_DIGIT # 封印槽统一维护:封印格=封印印记纹理;其余格清 null(封印格挖开后残留在 tex_seal # 会让 _draw 无条件把封印印画在已开格上——tex_seal 绘制无 revealed 条件)。缺盘走字形回退。 - u.tex_seal = _palette.seal_tex if (seal and not revealed and _palette.seal_tex != null) else null + u.st.tex_seal = _palette.seal_tex if (seal and not revealed and _palette.seal_tex != null) else null if removed: revealed = false # 移除格固定未开呈现基座(退色暗幕层承载;危险格本无数字,周边数字冻结) seal = false - u.tex_seal = null + u.st.tex_seal = null if revealed: var rec: Dictionary[Variant, Variant] = _minesweeper.reveal_record(c) # 已开记录经查询面 if rec.get("kind", "") == "mine": @@ -662,8 +662,8 @@ func _paint_cell(c: Vector2i, hero: Vector2i) -> void: # 贴图缺位回退字形「人」(项目纹理槽惯例——贴图在位=格内字形退役,单位本体由独立精灵承载) glyph = "人" digit_col = COL_HERO - u.tex_base = tile - u.bg_sb = sb + u.st.tex_base = tile + u.st.bg_sb = sb u.glyph = glyph u.glyph_color = digit_col # 色牌后备(数字直画退牌;「危」/「?」维持牌载保读性)+ v3.4:双峰纹理板 @@ -673,11 +673,11 @@ func _paint_cell(c: Vector2i, hero: Vector2i) -> void: digit_col = COL_DIGIT # 牌上深墨族(素牌 12:1——双峰纹理免疫) u.glyph_color = digit_col u.plate = revealed and (glyph == "危" or glyph == "?" or plate_digit) - u.outline_sides.assign([]) # typed Array[int](assign 保持引用语义) - u.facade_south = false - u.proj_band = 0.0 - u.tex_facade = null - u.facade_strip = false + u.st.outline_sides.assign([]) # typed Array[int](assign 保持引用语义) + u.st.facade_south = false + u.st.proj_band = 0.0 + u.st.tex_facade = null + u.st.facade_strip = false # 封印槽不在此重置:尾部重置会与此上方条件赋值构成双写踩踏(把封印纹理恒清 null=素材在位时封印不可见); # 上方 else 分支已是全路径清残留,尾部重置系冗余且有害 var south_n: Vector2i = c + Vector2i(0, 1) @@ -687,44 +687,44 @@ func _paint_cell(c: Vector2i, hero: Vector2i) -> void: if not revealed: # 高墙感 D 投影档:南邻地面格 → 墙格南缘立「口沿高光+双段暗带」立面 #(邻地面才画——墙-墙内界保墙群内聚);板外=墙不画 - u.facade_south = south_in and _minesweeper.is_revealed(south_n) + u.st.facade_south = south_in and _minesweeper.is_revealed(south_n) # 轮廓加强:墙格四邻中已开(地面)格朝向记描边码(板外=墙不描——保墙群外轮廓只在墙/地交界) for d in [Vector2i(0, -1), Vector2i(0, 1), Vector2i(-1, 0), Vector2i(1, 0)]: var n: Vector2i = c + d if n.x >= 0 and n.y >= 0 and n.x < _board.width and n.y < _board.height and _minesweeper.is_revealed(n): var code := 0 if d.y < 0 else (1 if d.y > 0 else (2 if d.x < 0 else 3)) - if code == 1 and u.facade_south: + if code == 1 and u.st.facade_south: continue # 南缘由立面件承载(高光+双段暗带)——2px 描边让位防三重叠黑 if code == 0 and _palette.tiles_ok: continue # v3.5 冻结① (b)-(c) 交界无边框:墙面直贴接墙顶(E 格墙面件在邻格承载)——北向描边不画(平色回退态保留描边) - u.outline_sides.append(code) + u.st.outline_sides.append(code) else: # 地面投影:北邻墙格在本已开格北缘投渐隐暗带(ITB 同款高度线索——带宽按格边比例常量) if north_in and not _minesweeper.is_revealed(north_n): - u.proj_band = u.edge * WALL_PROJECTION_FRAC + u.st.proj_band = u.st.edge * WALL_PROJECTION_FRAC # E 格高墙面(圣经 v3.5 冻结①——方向稿 is_facade_cell 口径):已开格南邻未开墙 → 南部 30% # 承接墙面(材质=同板墙顶 center 瓦片,原亮度直贴);仅瓦片态启用——平色回退保持原语义 if south_in and not _minesweeper.is_revealed(south_n) and _palette.tiles_ok: - u.facade_strip = true - u.tex_facade = _palette.tiles.get("wall_center") + u.st.facade_strip = true + u.st.tex_facade = _palette.tiles.get("wall_center") # ②( 秘宝叠加层:真视格=红内缩框;预读格=奖励身份徽章(identity_resolver 既有 # 只读面取字形——零数据复制,badge 位复用);已开格标记自然退位(refresh 逐拍重算) if not revealed: - u.treasure_mark = "mine_reveal" if _treasure_reveal.has(c) else "" + u.st.treasure_mark = "mine_reveal" if _treasure_reveal.has(c) else "" if _treasure_preview.has(c) and glyph == "": var pid = _identity_cb.call(c) if _identity_cb.is_valid() else null if pid != null and REWARD_BADGE.has(String(pid.get("cell_type", ""))): glyph = String(REWARD_BADGE[String(pid.get("cell_type", ""))]) digit_col = COL_GOLD - u.badge_glyph = glyph + u.st.badge_glyph = glyph # ②' 变异角标:未开格带伤害型变异 → 危险红角标(已开=威胁已兑现标记退位); # 回调缺位 = 无标记(程序化角标,tex_mutation 纹带槽暂留) if not revealed and _vmark_cb.is_valid(): - u.mutation_mark = String(_vmark_cb.call(c)) + u.st.mutation_mark = String(_vmark_cb.call(c)) else: - u.mutation_mark = "" - u.badge_glyph = _update_badge(c) - u.glow_group = int(_glow.get(c, UiKit.GLOW_NONE)) + u.st.mutation_mark = "" + u.st.badge_glyph = _update_badge(c) + u.st.glow_group = int(_glow.get(c, UiKit.GLOW_NONE)) # 视野迷雾呈态:曼哈顿距离 > 半径 → 区外——未开格全黑(现行事实源照抄)/ # 已开格记忆态(灰显可读);以英雄格为心逐格重算,refresh()/notify_hero_move() 既有重绘驱动=移动实时跟随。 # 纯呈现零逻辑——格态/数字真值/清图判定零触碰(🔴 契约 1);结算期去饱和叠加互斥由 CellUnit 记忆态终态档承载。 @@ -734,7 +734,7 @@ func _paint_cell(c: Vector2i, hero: Vector2i) -> void: u.fog_state = (2 if revealed else 1) if fogged else 0 else: u.fog_state = 0 - u.removed_dim = removed + u.st.removed_dim = removed u.queue_redraw() @@ -765,7 +765,7 @@ func _sync_glow_to_units() -> void: for c in touched: var u: Node2D = _cells.get(c) if u != null: - u.glow_group = int(_glow.get(c, UiKit.GLOW_NONE)) + u.st.glow_group = int(_glow.get(c, UiKit.GLOW_NONE)) func _process(delta: float) -> void: @@ -777,14 +777,14 @@ func _process(delta: float) -> void: if UiKit.motion_scale < 1.0: return # 减动效:呼吸停格(refresh/enable 时已施静态色——静态可辨、无持续运动) var k := 0.5 + 0.5 * sin(_breath_t * 3.0) # 3 rad/s≈0.48Hz——无 >3 次/秒频闪(AC 基线) - _contact_unit.contact_col = COL_CONTACT.lerp(UiKit.CONTACT_GLOW_DEEP, 0.55 - 0.35 * k) + _contact_unit.st.contact_col = COL_CONTACT.lerp(UiKit.CONTACT_GLOW_DEEP, 0.55 - 0.35 * k) _contact_unit.queue_redraw() # 带几何写入(refresh / set_dig_glow_enabled(true) 挂点):清旧承载件→按英雄格可挖域写边/角码→施色 func _apply_contact_bands() -> void: if _contact_unit != null and is_instance_valid(_contact_unit) and (_cells.get(_minesweeper.hero_pos()) != _contact_unit if _minesweeper != null else true): - _contact_unit.contact_col = Color(0, 0, 0, 0) + _contact_unit.st.contact_col = Color(0, 0, 0, 0) _contact_unit.queue_redraw() _contact_unit = null if _board == null or _minesweeper == null or not _dig_glow: @@ -801,19 +801,19 @@ func _apply_contact_bands() -> void: for d in [Vector2i(-1, -1), Vector2i(1, -1), Vector2i(-1, 1), Vector2i(1, 1)]: if _diggable.has(_minesweeper.hero_pos() + d): corners.append(0 if (d.x < 0 and d.y < 0) else (1 if d.x > 0 and d.y < 0 else (2 if d.x < 0 else 3))) - hu.contact_bands = bands - hu.contact_corners = corners + hu.st.contact_bands = bands + hu.st.contact_corners = corners # 减动效:呼吸停格——refresh/enable 重放时施**静态**色(本件头注「已施静态色」的落実); # 0.5 = 呼吸周期中值(正常态 k 的均值)。否则减动效下每次 refresh 都会按当前相位重施色, # 带色在 refresh 间跳变=持续运动假象,且令 on/off 双帧取证无法隔离单一变量。 var k := 0.5 if UiKit.motion_scale < 1.0 else 0.5 + 0.5 * sin(_breath_t * 3.0) - hu.contact_col = COL_CONTACT.lerp(UiKit.CONTACT_GLOW_DEEP, 0.55 - 0.35 * k) + hu.st.contact_col = COL_CONTACT.lerp(UiKit.CONTACT_GLOW_DEEP, 0.55 - 0.35 * k) hu.queue_redraw() func _reset_breath() -> void: if _contact_unit != null and is_instance_valid(_contact_unit): - _contact_unit.contact_col = Color(0, 0, 0, 0) + _contact_unit.st.contact_col = Color(0, 0, 0, 0) _contact_unit.queue_redraw() @@ -888,5 +888,5 @@ func cell_rect_probe(c: Vector2i) -> Rect2: if host.size.x <= 0.0 or host.size.y <= 0.0 or vp_size.x <= 0.0 or vp_size.y <= 0.0: return Rect2() var k: Vector2 = host.size / vp_size - var local := Rect2(u.global_position, Vector2(u.edge, u.edge)) + var local := Rect2(u.global_position, Vector2(u.st.edge, u.st.edge)) return Rect2(host.position + local.position * k, local.size * k) diff --git a/game/presentation/views/board_zone_view.gd b/game/presentation/views/board_zone_view.gd index 68573246..b6509338 100644 --- a/game/presentation/views/board_zone_view.gd +++ b/game/presentation/views/board_zone_view.gd @@ -23,6 +23,11 @@ func _init() -> void: var vb := VBoxContainer.new() vb.add_theme_constant_override("separation", 4) add_child(vb) + _build_views(vb) + + +## 视图三件域:AP 条+棋盘区(含动效与 toast 叠层) +func _build_views(vb: VBoxContainer) -> void: ap_bar = ApBarView.new() vb.add_child(ap_bar) var board_area := Control.new() @@ -31,6 +36,11 @@ func _init() -> void: vb.add_child(board_area) _board_area = board_area board_area.resized.connect(_on_board_area_resized) + _build_board_children(board_area, vb) + + +## 棋盘子件域:视图+动效+toast 三叠层挂接+状态角标+操作提示条(hint 回挂 vb——树序与拆分前一致) +func _build_board_children(board_area: Control, vb: VBoxContainer) -> void: board_view = BoardView.new() board_view.set_anchors_preset(Control.PRESET_FULL_RECT) board_area.add_child(board_view) diff --git a/game/presentation/views/camp_overlay_view.gd b/game/presentation/views/camp_overlay_view.gd index f9e6903d..77458ab6 100644 --- a/game/presentation/views/camp_overlay_view.gd +++ b/game/presentation/views/camp_overlay_view.gd @@ -2,6 +2,10 @@ ## 营地覆盖层:树淬炼/锻造工坊/秘宝匣/奖励格图鉴四柜台只读投影,购买与切换意图经信号外发。 ## 柜台锁定态(钥匙未点亮)开/关零副作用——零写档零报错、锁定行纯文案无可交互购买路径。 ## 开合与开始层互斥归组合根承接(打开前隐藏开始层)。 +## 内部状态件(u1 子域拆件——契约 9 职责轴):_TreeState=树淬炼柜台内容容器(单一变化原因=树行板结构); +## _ForgeState=锻造工坊与秘宝匣两柜台行板与锁定门(钥匙门同构);_AttuneState=同调装配段节点与钮册; +## _SideState=Y 刷新升级行与最近冒险账面。探针直读冻结成员(_balance_label/_tree_buy_btns/ +## _tree_fork_btns/_workshop_holder/_workshop_rows)住宿主不进状态件。 class_name CampOverlay extends Control @@ -12,37 +16,54 @@ signal tree_buy_requested(node_id: String) ## 天赋树购买意图(写口 signal tree_fork_toggled(group: String, side: String) ## 双态切换意图(一节点双态——零币切态) signal forge_buy_requested(route_id: String) ## 锻造路线购买意图(写口唯一归 MetaProgression.forge_buy_level) signal forge_unlock_requested(route_id: String) ## 锻造路线解锁意图(写口唯一归 MetaProgression.forge_buy_unlock) -signal forge_select_requested(route_id: String) ## 锻造路线选中意图(写口唯一归 MetaProgression.forge_select_weapon/armor) +signal forge_select_requested(route_id: String) ## 锻造路线选中意图(写口唯一归 MetaProgression.forge_select) # 方向稿取色(柜台头带深棕/行板暖米;双色墨=亮板配深墨)——值单点收 UiKit const HEADER_BAND_COL := UiKit.CAMP_HEADER_BAND const ROW_PLATE_COL := UiKit.CAMP_ROW_PLATE -var _balance_label: Label -var _tree_holder: VBoxContainer # 树淬炼柜台内容(主界面——脊柱两行+钥匙/socket 行板+Fork 双态钮) -var _tree_buy_btns: Array[Dictionary] = [] # [{node_id, btn}]——树购买/解锁钮登记面(open/refresh 重建时刷新;探针消费) -var _tree_fork_btns: Array[Dictionary] = [] # [{group, side, btn}]——Fork 双态切换钮登记面(探针消费) -var _forge_lock_l: Label # 锻造工坊柜台锁定态行(钥匙未点亮) -var _forge_workbench: VBoxContainer # 锻造工坊工作间(钥匙点亮后展开——两区六路线行板) -var _forge_zone_weapon: VBoxContainer # 武器锻造区行板容器(三路线) -var _forge_zone_armor: VBoxContainer # 护甲锻造区行板容器(三路线) -var _forge_rows: Array[Dictionary] = [] # [{route_id, name_l, lvl_l, status_l, buy_btn, unlock_btn, select_btn, next_price, unlock_price}](open/refresh 重建;探针消费) -var _ws_scroll: ScrollContainer # 秘宝匣柜台内容容器(钥匙点亮后可见) -var _ws_lock_l: Label # 秘宝匣柜台锁定态行 -var _workshop_holder: VBoxContainer # 秘宝匣柜台行板(六行+同调装配段+Y 升级行) -var _workshop_rows: Array[Dictionary] = [] # [{item_id, progress_l, status_l}] -var _attune_header_l: Label # 同调装配段(Slots 点亮呈现) -var _attune_btns: Array[Dictionary] = [] # [{item_id, btn}]——装配/卸下切换钮(满位禁选随刷) -var _attune_nodes: Array[Control] = [] # 段内节点册(open_camp 重建时定向清理,不扰工坊六行) -var _refresh_l: Label # Y 刷新升级行(双入口之一——树上 ks_refresh 同账) -var _refresh_btn: Button -var _recent_list: Label # 最近冒险(逐行拼接 label——只读账面,随 open_camp 重建) + +class _TreeState extends RefCounted: + var _tree_holder: VBoxContainer # 树淬炼柜台内容(主界面——脊柱两行+钥匙/socket 行板+Fork 双态钮) + + +class _ForgeState extends RefCounted: + var _forge_lock_l: Label # 锻造工坊柜台锁定态行(钥匙未点亮) + var _forge_workbench: VBoxContainer # 锻造工坊工作间(钥匙点亮后展开——两区六路线行板) + var _forge_zone_weapon: VBoxContainer # 武器锻造区行板容器(三路线) + var _forge_zone_armor: VBoxContainer # 护甲锻造区行板容器(三路线) + var _forge_rows: Array[Dictionary] = [] # [{route_id, name_l, lvl_l, status_l, buy_btn, unlock_btn, select_btn, next_price, unlock_price}](open/refresh 重建;探针消费) + var _ws_scroll: ScrollContainer # 秘宝匣柜台内容容器(钥匙点亮后可见) + var _ws_lock_l: Label # 秘宝匣柜台锁定态行 + + +class _AttuneState extends RefCounted: + var _attune_header_l: Label # 同调装配段(Slots 点亮呈现) + var _attune_btns: Array[Dictionary] = [] # [{item_id, btn}]——装配/卸下切换钮(满位禁选随刷) + var _attune_nodes: Array[Control] = [] # 段内节点册(open_camp 重建时定向清理,不扰工坊六行) + + +class _SideState extends RefCounted: + var _refresh_l: Label # Y 刷新升级行(双入口之一——树上 ks_refresh 同账) + var _refresh_btn: Button + var _recent_list: Label # 最近冒险(逐行拼接 label——只读账面,随 open_camp 重建) + + +var _balance_label: Label # 探针直读冻结(S17①——住宿主) +var _tree_buy_btns: Array[Dictionary] = [] # [{node_id, btn}]——树购买/解锁钮登记面(open/refresh 重建时刷新;探针消费;住宿主) +var _tree_fork_btns: Array[Dictionary] = [] # [{group, side, btn}]——Fork 双态切换钮登记面(探针消费;住宿主) +var _workshop_holder: VBoxContainer # 秘宝匣柜台行板(六行+同调装配段+Y 升级行;探针直读冻结——住宿主) +var _workshop_rows: Array[Dictionary] = [] # [{item_id, progress_l, status_l}](探针消费;住宿主) +var _tree := _TreeState.new() +var _forge := _ForgeState.new() +var _attune := _AttuneState.new() +var _side := _SideState.new() func _init() -> void: # 底=不透明暖亮档(半透 dim 会透出底下棋盘/战斗提示条,故须实底;demo #v-camp=bg-bright 实底, # 遮蔽语义由组合根输入锁兜底) - var dim := UiKit.modal_dim(self, UiKit.BG_BRIGHT) + var dim := UiKit._modal_dim(self, UiKit.BG_BRIGHT) # 石纹弱质感+暗角(demo .tilebg 0.14/.vignette;缺位不挂即回退平色) var bd := UiKit.backdrop_tile() if bd != null: @@ -61,6 +82,29 @@ func _init() -> void: root.add_child(col) # ── 顶栏:左铭牌(标题+副题)/ 右钥石徽 ── + _build_top_bar(col) + + # ── 四柜台并排(程序占位重排)── + # 宽度不足横向滚动(四柜 300 min 宽 > 窄窗可用宽时横滚兜底;纵向撑满余高) + var camp_scroll := ScrollContainer.new() + camp_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + camp_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO + camp_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + col.add_child(camp_scroll) + var mid := HBoxContainer.new() + mid.add_theme_constant_override("separation", 14) + mid.size_flags_vertical = Control.SIZE_EXPAND_FILL + camp_scroll.add_child(mid) + _build_tree_counter(mid) + _build_forge_counter(mid) + _build_workshop_counter(mid) + _build_atlas_counter(mid) + _build_bottom_bar(col) + visible = false + + +## 顶栏域:铭牌(标题+副题)+钥石徽章 +func _build_top_bar(col: VBoxContainer) -> void: var top := HBoxContainer.new() top.add_theme_constant_override("separation", 10) col.add_child(top) @@ -82,82 +126,78 @@ func _init() -> void: top.add_child(badge) _balance_label = badge.value_label - # ── 四柜台并排(程序占位重排)── - # 宽度不足横向滚动(四柜 300 min 宽 > 窄窗可用宽时横滚兜底;纵向撑满余高) - var camp_scroll := ScrollContainer.new() - camp_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - camp_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO - camp_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - col.add_child(camp_scroll) - var mid := HBoxContainer.new() - mid.add_theme_constant_override("separation", 14) - mid.size_flags_vertical = Control.SIZE_EXPAND_FILL - camp_scroll.add_child(mid) - # 柜一·树淬炼(树脊柱界面形态=主界面:一线一节点行板内升级+Fork 双态切换钮) - _tree_holder = VBoxContainer.new() - _tree_holder.add_theme_constant_override("separation", 6) +## 柜一·树淬炼(树脊柱界面形态=主界面:一线一节点行板内升级+Fork 双态切换钮) +func _build_tree_counter(mid: HBoxContainer) -> void: + _tree._tree_holder = VBoxContainer.new() + _tree._tree_holder.add_theme_constant_override("separation", 6) var tree_scroll := ScrollContainer.new() tree_scroll.custom_minimum_size = Vector2(300, 336) tree_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL tree_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - tree_scroll.add_child(_tree_holder) + tree_scroll.add_child(_tree._tree_holder) var tree_panel := _counter_panel("天赋树 · 淬炼", "", tree_scroll, 300) tree_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL tree_panel.size_flags_vertical = Control.SIZE_FILL # 四栏等高:任一栏长高时四栏同步拉伸 mid.add_child(tree_panel) - # 柜二·锻造工坊(钥匙门——forge_workshop 点亮前锁定态;点亮=工作间两区六路线行板) - _forge_lock_l = UiKit.label("🔒 锻造工坊未开放\n\n点亮树上「锻造工坊」钥匙节点后展开工作间。\n(钥匙免费、无前置——树上直接点亮)",UiKit.FS_HINT, false, UiKit.STONE_DEEP) - _forge_lock_l.size_flags_vertical = Control.SIZE_SHRINK_CENTER # 锁文在等高栏内垂直居中 - _forge_lock_l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _forge_lock_l.custom_minimum_size = Vector2(280, 0) - _forge_workbench = VBoxContainer.new() - _forge_workbench.add_theme_constant_override("separation", 6) - _forge_workbench.visible = false # 默认锁定态(钥匙门未判前零行板——门开才建) - _forge_zone_weapon = VBoxContainer.new() - _forge_zone_weapon.add_theme_constant_override("separation", 4) - _forge_zone_armor = VBoxContainer.new() - _forge_zone_armor.add_theme_constant_override("separation", 4) + +## 柜二·锻造工坊(钥匙门——forge_workshop 点亮前锁定态;点亮=工作间两区六路线行板) +func _build_forge_counter(mid: HBoxContainer) -> void: + _forge._forge_lock_l = UiKit.label("🔒 锻造工坊未开放\n\n点亮树上「锻造工坊」钥匙节点后展开工作间。\n(钥匙免费、无前置——树上直接点亮)",UiKit.FS_HINT, false, UiKit.STONE_DEEP) + _forge._forge_lock_l.size_flags_vertical = Control.SIZE_SHRINK_CENTER # 锁文在等高栏内垂直居中 + _forge._forge_lock_l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _forge._forge_lock_l.custom_minimum_size = Vector2(280, 0) + _forge._forge_workbench = VBoxContainer.new() + _forge._forge_workbench.add_theme_constant_override("separation", 6) + _forge._forge_workbench.visible = false # 默认锁定态(钥匙门未判前零行板——门开才建) + _forge._forge_zone_weapon = VBoxContainer.new() + _forge._forge_zone_weapon.add_theme_constant_override("separation", 4) + _forge._forge_zone_armor = VBoxContainer.new() + _forge._forge_zone_armor.add_theme_constant_override("separation", 4) var wb_wh := UiKit.label("── 武器锻造区 ──",UiKit.FS_HINT, true, UiKit.GOLD_INK) # 同款深金墨(对柜台板 5.21:1) - _forge_workbench.add_child(wb_wh) - _forge_workbench.add_child(_forge_zone_weapon) + _forge._forge_workbench.add_child(wb_wh) + _forge._forge_workbench.add_child(_forge._forge_zone_weapon) var wb_ah := UiKit.label("── 护甲锻造区 ──",UiKit.FS_HINT, true, UiKit.GOLD_INK) - _forge_workbench.add_child(wb_ah) - _forge_workbench.add_child(_forge_zone_armor) + _forge._forge_workbench.add_child(wb_ah) + _forge._forge_workbench.add_child(_forge._forge_zone_armor) var forge_holder := VBoxContainer.new() forge_holder.add_theme_constant_override("separation", 6) forge_holder.custom_minimum_size = Vector2(0, 336) # 四栏等高(与树/秘宝匣 scroll 336 min 对齐——锁定态仅锁文也撑住栏高) - forge_holder.add_child(_forge_lock_l) - forge_holder.add_child(_forge_workbench) + forge_holder.add_child(_forge._forge_lock_l) + forge_holder.add_child(_forge._forge_workbench) var forge_panel := _counter_panel("锻造工坊", UiKit.TEX_ICON_FORGE, forge_holder, 300) forge_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL forge_panel.size_flags_vertical = Control.SIZE_FILL # 四栏等高:任一栏长高时四栏同步拉伸 mid.add_child(forge_panel) - # 柜三·秘宝匣(钥匙门——ks_workshop 点亮前锁定态;点亮=现工坊内容整体迁入:六行+同调装配段+Y 升级行) - _ws_lock_l = UiKit.label("🔒 秘宝匣未开放\n\n点亮树上「秘宝匣」钥匙节点后展开。\n(前置=脊柱生命第 1 级)",UiKit.FS_HINT, false, UiKit.STONE_DEEP) - _ws_lock_l.size_flags_vertical = Control.SIZE_SHRINK_CENTER # 锁文在等高栏内垂直居中 - _ws_lock_l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _ws_lock_l.custom_minimum_size = Vector2(280, 0) - _ws_scroll = ScrollContainer.new() - _ws_scroll.custom_minimum_size = Vector2(300, 336) - _ws_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - _ws_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + +## 柜三·秘宝匣(钥匙门——ks_workshop 点亮前锁定态;点亮=现工坊内容整体迁入:六行+同调装配段+Y 升级行) +func _build_workshop_counter(mid: HBoxContainer) -> void: + _forge._ws_lock_l = UiKit.label("🔒 秘宝匣未开放\n\n点亮树上「秘宝匣」钥匙节点后展开。\n(前置=脊柱生命第 1 级)",UiKit.FS_HINT, false, UiKit.STONE_DEEP) + _forge._ws_lock_l.size_flags_vertical = Control.SIZE_SHRINK_CENTER # 锁文在等高栏内垂直居中 + _forge._ws_lock_l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _forge._ws_lock_l.custom_minimum_size = Vector2(280, 0) + _forge._ws_scroll = ScrollContainer.new() + _forge._ws_scroll.custom_minimum_size = Vector2(300, 336) + _forge._ws_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _forge._ws_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED _workshop_holder = VBoxContainer.new() _workshop_holder.add_theme_constant_override("separation", 6) - _ws_scroll.add_child(_workshop_holder) + _forge._ws_scroll.add_child(_workshop_holder) var ws_holder := VBoxContainer.new() ws_holder.add_theme_constant_override("separation", 6) ws_holder.custom_minimum_size = Vector2(0, 336) # 四栏等高(锁定态 scroll 隐、仅锁文也撑住栏高) - ws_holder.add_child(_ws_lock_l) - ws_holder.add_child(_ws_scroll) + ws_holder.add_child(_forge._ws_lock_l) + ws_holder.add_child(_forge._ws_scroll) var ws_panel := _counter_panel("秘宝匣", UiKit.TEX_ICON_WORKSHOP, ws_holder, 300) ws_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL ws_panel.size_flags_vertical = Control.SIZE_FILL # 四栏等高:任一栏长高时四栏同步拉伸 mid.add_child(ws_panel) - # 柜四·奖励格图鉴(常开独立位——结构位预留,内容排 Alpha) + +## 柜四·奖励格图鉴(常开独立位——结构位预留,内容排 Alpha) +func _build_atlas_counter(mid: HBoxContainer) -> void: var atlas_holder := VBoxContainer.new() atlas_holder.custom_minimum_size = Vector2(0, 336) # 四栏等高(图鉴仅锁文也撑住栏高) var atlas_panel := _counter_panel("奖励格图鉴", UiKit.TEX_ICON_ATLAS, atlas_holder, 300) @@ -170,7 +210,9 @@ func _init() -> void: lock_l.custom_minimum_size = Vector2(280, 0) atlas_holder.add_child(lock_l) - # ── 底栏:关闭钮(左)/ 操作注记(右)+ 最近冒险条带 ── + +## 底栏域:关闭钮(左)/ 操作注记(右)+ 最近冒险条带 +func _build_bottom_bar(col: VBoxContainer) -> void: var close_btn := UiKit.button("关闭营地(回开始界面)") close_btn.pressed.connect(_emit_closed) var bottom := HBoxContainer.new() @@ -182,11 +224,10 @@ func _init() -> void: var caption := UiKit.label("树上点亮 · 工作间随钥匙展开 · 图鉴常开",UiKit.FS_HINT, false, UiKit.WARM_SOFT) bottom.add_child(caption) col.add_child(bottom) - _recent_list = UiKit.label("",UiKit.FS_HINT, false, UiKit.WARM_SOFT) - _recent_list.clip_text = true - _recent_list.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - col.add_child(_recent_list) - visible = false + _side._recent_list = UiKit.label("",UiKit.FS_HINT, false, UiKit.WARM_SOFT) + _side._recent_list.clip_text = true + _side._recent_list.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + col.add_child(_side._recent_list) # 柜台列面板(方向稿同构):PanelContainer 米板身 + 头带深棕(图标+标题暖白) @@ -220,7 +261,7 @@ func _counter_panel(title_text: String, icon_path: String, holder: Control, min_ # 树数据 = 组合根 _camp_data().tree 投影 [{node_id, display_name, vein, kind, level, price, can_buy, # currency}...] + fork_groups [{group, node_id, active_side, owned, can_buy, price, sides:[{side, display_name, effects}]}]) func _rebuild_tree(data: Dictionary) -> void: - for c in _tree_holder.get_children(): + for c in _tree._tree_holder.get_children(): c.queue_free() _tree_buy_btns.clear() _tree_fork_btns.clear() @@ -233,52 +274,62 @@ func _rebuild_tree(data: Dictionary) -> void: if vein != last_vein: last_vein = vein var vh := UiKit.label(String(vein_names.get(vein, vein)),UiKit.FS_HINT, true, UiKit.GOLD_INK) # GOLD_DEEP 对柜台板 2.67:1 越带,换深金墨 5.21:1 - _tree_holder.add_child(vh) - var hb := HBoxContainer.new() - hb.add_theme_constant_override("separation", 6) - var lvl := int(row.get("level", 0)) - var cap := int(row.get("cap", 1)) - var name_l := UiKit.label("%s Lv%d/%d" % [String(row.get("display_name", "")), lvl, cap], 13, false, UiKit.INK_LABEL) - name_l.custom_minimum_size = Vector2(150, 0) - name_l.clip_text = true - var gate := String(row.get("gate_text", "")) - if gate != "": - name_l.text += " 🔒" # 等级阈值前置未达标标记(R1 机制位——悬停看门槛;达标后随 refresh 消失) - name_l.tooltip_text = "等级门槛:%s" % gate - hb.add_child(name_l) + _tree._tree_holder.add_child(vh) if String(row.get("kind", "")) == "fork": continue # Fork 行去行板化——由 fork_groups 段承载双态切换钮 - var btn := UiKit.button("购买 %d 钥石" % int(row.get("price", 0)), 0.0) # 柜台行板紧凑钮豁免宽度基准 - btn.pressed.connect(func(): tree_buy_requested.emit(String(row.get("node_id", "")))) - btn.disabled = not bool(row.get("can_buy", false)) - hb.add_child(btn) - _tree_buy_btns.append({"node_id": String(row.get("node_id", "")), "btn": btn}) - _tree_holder.add_child(hb) + _tree._tree_holder.add_child(_tree_row_line(row)) for fg in forks: - var group := String(fg.get("group", "")) - var active := String(fg.get("active_side", "")) - var owned := bool(fg.get("owned", false)) - var fb := HBoxContainer.new() - fb.add_theme_constant_override("separation", 6) - var state_txt := "未解锁" - if owned: - state_txt = "激活:%s" % active - var gl := UiKit.label("%s[%s]" % [String(fg.get("display_name", group)), state_txt], 13, false, UiKit.INK_LABEL) - fb.add_child(gl) - if not owned: - var bb := UiKit.button("解锁 %d 钥石" % int(fg.get("price", 0)), 0.0) # 行板紧凑钮豁免 - bb.pressed.connect(func(): tree_buy_requested.emit(String(fg.get("node_id", "")))) - bb.disabled = not bool(fg.get("can_buy", false)) - fb.add_child(bb) - _tree_buy_btns.append({"node_id": String(fg.get("node_id", "")), "btn": bb}) - else: - for side_row in (fg.get("sides", []) as Array): - var sd := String(side_row.get("side", "")) - var sb := UiKit.button(String(side_row.get("display_name", sd)), 0.0) # 行板紧凑钮豁免 - sb.pressed.connect(func(): tree_fork_toggled.emit(group, sd)) - fb.add_child(sb) - _tree_fork_btns.append({"group": group, "side": sd, "btn": sb}) - _tree_holder.add_child(fb) + _tree._tree_holder.add_child(_tree_fork_line(fg)) + + +## 树行板构建域:节点名(含门槛标记)+购买钮(追加序与锚句随段整体搬迁) +func _tree_row_line(row: Dictionary) -> HBoxContainer: + var hb := HBoxContainer.new() + hb.add_theme_constant_override("separation", 6) + var lvl := int(row.get("level", 0)) + var cap := int(row.get("cap", 1)) + var name_l := UiKit.label("%s Lv%d/%d" % [String(row.get("display_name", "")), lvl, cap], 13, false, UiKit.INK_LABEL) + name_l.custom_minimum_size = Vector2(150, 0) + name_l.clip_text = true + var gate := String(row.get("gate_text", "")) + if gate != "": + name_l.text += " 🔒" # 等级阈值前置未达标标记(R1 机制位——悬停看门槛;达标后随 refresh 消失) + name_l.tooltip_text = "等级门槛:%s" % gate + hb.add_child(name_l) + var btn := UiKit.button("购买 %d 钥石" % int(row.get("price", 0)), 0.0) # 柜台行板紧凑钮豁免宽度基准 + btn.pressed.connect(func(): tree_buy_requested.emit(String(row.get("node_id", "")))) + btn.disabled = not bool(row.get("can_buy", false)) + hb.add_child(btn) + _tree_buy_btns.append({"node_id": String(row.get("node_id", "")), "btn": btn}) + return hb + + +## Fork 双态行板构建域:组状态文案+解锁钮或双态切换钮 +func _tree_fork_line(fg: Dictionary) -> HBoxContainer: + var group := String(fg.get("group", "")) + var active := String(fg.get("active_side", "")) + var owned := bool(fg.get("owned", false)) + var fb := HBoxContainer.new() + fb.add_theme_constant_override("separation", 6) + var state_txt := "未解锁" + if owned: + state_txt = "激活:%s" % active + var gl := UiKit.label("%s[%s]" % [String(fg.get("display_name", group)), state_txt], 13, false, UiKit.INK_LABEL) + fb.add_child(gl) + if not owned: + var bb := UiKit.button("解锁 %d 钥石" % int(fg.get("price", 0)), 0.0) # 行板紧凑钮豁免 + bb.pressed.connect(func(): tree_buy_requested.emit(String(fg.get("node_id", "")))) + bb.disabled = not bool(fg.get("can_buy", false)) + fb.add_child(bb) + _tree_buy_btns.append({"node_id": String(fg.get("node_id", "")), "btn": bb}) + else: + for side_row in (fg.get("sides", []) as Array): + var sd := String(side_row.get("side", "")) + var sb := UiKit.button(String(side_row.get("display_name", sd)), 0.0) # 行板紧凑钮豁免 + sb.pressed.connect(func(): tree_fork_toggled.emit(group, sd)) + fb.add_child(sb) + _tree_fork_btns.append({"group": group, "side": sd, "btn": sb}) + return fb # 工作间钥匙门(锻造工坊/秘宝匣柜台锁定态切换:节点点亮=展开、未点亮=锁定行; @@ -289,86 +340,106 @@ func _refresh_key_gates(data: Dictionary) -> void: lvl_by_id[String(row.get("node_id", ""))] = int(row.get("level", 0)) var forge_open := int(lvl_by_id.get("forge_workshop", 0)) >= 1 var ws_open := int(lvl_by_id.get("ks_workshop", 0)) >= 1 - _forge_lock_l.visible = not forge_open - _forge_workbench.visible = forge_open - _ws_lock_l.visible = not ws_open - _ws_scroll.visible = ws_open + _forge._forge_lock_l.visible = not forge_open + _forge._forge_workbench.visible = forge_open + _forge._ws_lock_l.visible = not ws_open + _forge._ws_scroll.visible = ws_open # 锻造工坊行板重建(两区六路线;open_camp/refresh_camp 调用。行结构仿秘宝匣行板: # 件名+等级+解锁态+购买/解锁/选中钮。UI 只发意图信号〔契约 9——零核心写调用、零 MetaProgression # 持有〕;钮禁用=常态防线(余额/未解锁/满级/已选中),兜底拒绝可见归 presenter pattern 1) func _rebuild_forge(data: Dictionary) -> void: - for zone in [_forge_zone_weapon, _forge_zone_armor]: + for zone in [_forge._forge_zone_weapon, _forge._forge_zone_armor]: for c in zone.get_children(): c.queue_free() - _forge_rows.clear() - if not _forge_workbench.visible: + _forge._forge_rows.clear() + if not _forge._forge_workbench.visible: return # 锁定态(钥匙未点亮):零行板零购买路径——占位纪律延续 var bal := int(data.get("balance", 0)) for row in data.get("forge", []): - var rid := String(row.get("route_id", "")) var kind := String(row.get("kind", "")) - var zone: VBoxContainer = _forge_zone_weapon if kind == "weapon" else _forge_zone_armor - var unlocked := bool(row.get("unlocked", false)) - var initial := bool(row.get("initial_owned", false)) - var lvl := int(row.get("level", 0)) - var cap_v := int(row.get("cap", 0)) - var next_price := int(row.get("next_price", 0)) - var unlock_price := int(row.get("unlock_price", 0)) - var card := PanelContainer.new() - card.add_theme_stylebox_override("panel", UiKit.panel_flat(ROW_PLATE_COL, UiKit.PANEL_BORDER, UiKit.RADIUS_OBJECT, 1, Vector4(10, 4, 10, 4))) - var line := HBoxContainer.new() - line.add_theme_constant_override("separation", 6) - card.add_child(line) - var name_l := UiKit.label(String(row.get("display_name", "")), UiKit.FS_HINT, true, UiKit.INK) - name_l.custom_minimum_size = Vector2(104, 0) - name_l.clip_text = true - name_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - line.add_child(name_l) - var lvl_l := UiKit.label("Lv%d/%d" % [lvl, cap_v], 13, false, UiKit.INK_LABEL) - lvl_l.custom_minimum_size = Vector2(44, 0) - line.add_child(lvl_l) - # 解锁态/解锁通道呈现(并联双通道:练满前一档 or 一次性解锁价直购) - var st_text := "已解锁" - if not unlocked: - if initial: - st_text = "未解锁" - else: - st_text = "未解锁 · 练满%s 或 %d 钥石直购" % [String(row.get("prereq_name", "")), unlock_price] - elif lvl >= cap_v: - st_text = "已满级" - var status_l := UiKit.label(st_text, UiKit.FS_HINT, false, UiKit.STONE_DEEP) - status_l.clip_text = true - status_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - status_l.size_flags_horizontal = Control.SIZE_EXPAND_FILL - line.add_child(status_l) - # 购买钮(已解锁未满级——几何价显示;余额不足禁用) - var buy_btn: Button = null - if unlocked and lvl < cap_v: - buy_btn = UiKit.button("锻造 %d" % next_price, 0.0) # 行板紧凑钮豁免 - buy_btn.pressed.connect(forge_buy_requested.emit.bind(rid)) - buy_btn.disabled = bal < next_price - line.add_child(buy_btn) - # 解锁钮(待解锁非初始——一次性解锁价) - var unlock_btn: Button = null - if not unlocked and not initial: - unlock_btn = UiKit.button("解锁 %d" % unlock_price, 0.0) # 行板紧凑钮豁免 - unlock_btn.pressed.connect(forge_unlock_requested.emit.bind(rid)) - unlock_btn.disabled = bal < unlock_price - line.add_child(unlock_btn) - # 选中钮(已解锁——免费切换;当前=禁用态标记) - var select_btn: Button = null - if unlocked: - var sel := bool(row.get("selected", false)) - select_btn = UiKit.button("当前" if sel else "选中", 0.0) # 行板紧凑钮豁免 - select_btn.pressed.connect(forge_select_requested.emit.bind(rid)) - select_btn.disabled = sel - line.add_child(select_btn) - zone.add_child(card) - _forge_rows.append({"route_id": rid, "name_l": name_l, "lvl_l": lvl_l, "status_l": status_l, - "buy_btn": buy_btn, "unlock_btn": unlock_btn, "select_btn": select_btn, - "next_price": next_price, "unlock_price": unlock_price}) + var zone: VBoxContainer = _forge._forge_zone_weapon if kind == "weapon" else _forge._forge_zone_armor + var card := _forge_route_card(row, bal) + zone.add_child(card["card"]) + _forge._forge_rows.append(card) + + +## 锻造行板构建域:单路线卡(件名+等级+状态+购买/解锁/选中钮——并联双通道呈现面) +func _forge_route_card(row: Dictionary, bal: int) -> Dictionary: + var d := _forge_route_scalars(row) + var card := PanelContainer.new() + card.add_theme_stylebox_override("panel", UiKit.panel_flat(ROW_PLATE_COL, UiKit.PANEL_BORDER, UiKit.RADIUS_OBJECT, 1, Vector4(10, 4, 10, 4))) + var line := HBoxContainer.new() + line.add_theme_constant_override("separation", 6) + card.add_child(line) + var name_l := UiKit.label(String(row.get("display_name", "")), UiKit.FS_HINT, true, UiKit.INK) + name_l.custom_minimum_size = Vector2(104, 0) + name_l.clip_text = true + name_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + line.add_child(name_l) + var lvl_l := UiKit.label("Lv%d/%d" % [d["lvl"], d["cap_v"]], 13, false, UiKit.INK_LABEL) + lvl_l.custom_minimum_size = Vector2(44, 0) + line.add_child(lvl_l) + var status_l := UiKit.label(_forge_status_text(d, row), UiKit.FS_HINT, false, UiKit.STONE_DEEP) + status_l.clip_text = true + status_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + status_l.size_flags_horizontal = Control.SIZE_EXPAND_FILL + line.add_child(status_l) + var btns := _forge_route_buttons(d, row, bal, line) + return {"card": card, "route_id": d["rid"], "name_l": name_l, "lvl_l": lvl_l, "status_l": status_l, + "buy_btn": btns["buy_btn"], "unlock_btn": btns["unlock_btn"], "select_btn": btns["select_btn"], + "next_price": d["next_price"], "unlock_price": d["unlock_price"]} + + +## 锻造行标量束:路线 id/解锁态/初始持有/等级/cap/几何价/解锁价一次取数 +func _forge_route_scalars(row: Dictionary) -> Dictionary: + return { + "rid": String(row.get("route_id", "")), + "unlocked": bool(row.get("unlocked", false)), + "initial": bool(row.get("initial_owned", false)), + "lvl": int(row.get("level", 0)), + "cap_v": int(row.get("cap", 0)), + "next_price": int(row.get("next_price", 0)), + "unlock_price": int(row.get("unlock_price", 0)), + } + + +## 状态文案域(并联双通道:练满前一档 or 一次性解锁价直购;已满级态) +func _forge_status_text(d: Dictionary, row: Dictionary) -> String: + var st_text := "已解锁" + if not d["unlocked"]: + if d["initial"]: + st_text = "未解锁" + else: + st_text = "未解锁 · 练满%s 或 %d 钥石直购" % [String(row.get("prereq_name", "")), d["unlock_price"]] + elif d["lvl"] >= d["cap_v"]: + st_text = "已满级" + return st_text + + +## 三钮域:购买(已解锁未满级——余额不足禁用)/解锁(待解锁非初始——余额不足禁用)/选中(已解锁——当前=禁用态标记) +func _forge_route_buttons(d: Dictionary, row: Dictionary, bal: int, line: HBoxContainer) -> Dictionary: + var buy_btn: Button = null + if d["unlocked"] and d["lvl"] < d["cap_v"]: + buy_btn = UiKit.button("锻造 %d" % d["next_price"], 0.0) # 行板紧凑钮豁免 + buy_btn.pressed.connect(forge_buy_requested.emit.bind(d["rid"])) + buy_btn.disabled = bal < d["next_price"] + line.add_child(buy_btn) + var unlock_btn: Button = null + if not d["unlocked"] and not d["initial"]: + unlock_btn = UiKit.button("解锁 %d" % d["unlock_price"], 0.0) # 行板紧凑钮豁免 + unlock_btn.pressed.connect(forge_unlock_requested.emit.bind(d["rid"])) + unlock_btn.disabled = bal < d["unlock_price"] + line.add_child(unlock_btn) + var select_btn: Button = null + if d["unlocked"]: + var sel := bool(row.get("selected", false)) + select_btn = UiKit.button("当前" if sel else "选中", 0.0) # 行板紧凑钮豁免 + select_btn.pressed.connect(forge_select_requested.emit.bind(d["rid"])) + select_btn.disabled = sel + line.add_child(select_btn) + return {"buy_btn": buy_btn, "unlock_btn": unlock_btn, "select_btn": select_btn} # 打开营地(全量重建四柜台行 + 同调装配段 + 最近冒险;树段与钥匙门随 refresh_camp 重建—— @@ -389,6 +460,12 @@ func refresh_camp(data: Dictionary) -> void: _rebuild_tree(data) _refresh_key_gates(data) _rebuild_forge(data) # 行板随刷重建(购买/解锁/选中后等级/价/禁用态同步) + _refresh_workshop_rows(data, balance) + _refresh_attune_segment(data, balance) + + +## 秘宝匣行板刷新域:进度列(解锁混合制三形态)与状态色(亮板双色墨制)与购买钮余额联动 +func _refresh_workshop_rows(data: Dictionary, balance: int) -> void: var ws_by_id := {} for row in data.get("workshop", []): ws_by_id[String(row.get("item_id", ""))] = row @@ -397,21 +474,7 @@ func refresh_camp(data: Dictionary) -> void: if row.is_empty(): continue var unlocked := bool(row.get("unlocked", false)) - # 秘宝匣进度列(解锁混合制三形态:钥石购买价/条件进度拼接/已入手) - var prog_text := "" - var mode := String(row.get("mode", "")) - if unlocked: - prog_text = "已入手" - elif mode == "keystone": - prog_text = "钥石 %d" % int(row.get("price", 0)) - elif mode == "condition": - var parts: Array[String] = [] - for req_v in (row.get("reqs", []) as Array): - var req: Dictionary[Variant, Variant] = req_v as Dictionary - var lbl := String(UiKit.TREASURE_REQ_LABELS.get(String(req.get("key", "")), "条件")) - parts.append("%s %d/%d" % [lbl, int(req.get("progress", 0)), int(req.get("threshold", 0))]) - prog_text = "、".join(parts) - card.progress_l.text = prog_text + card.progress_l.text = _workshop_progress_text(row, unlocked) # 状态色用亮板双色墨制(深墨族替代暖白族——亮板上暖白不可读) var st_text := "未解锁" var st_col: Color = UiKit.STONE_DEEP @@ -424,111 +487,148 @@ func refresh_camp(data: Dictionary) -> void: # 购买钮余额联动(禁用态=余额不足,常态防线;pattern 1 兜底拒绝可见归 presenter) if card.get("buy_btn") != null: card.buy_btn.disabled = balance < int(card.get("price", 0)) - # 同调装配段状态就地刷(Slots 点亮/满位禁选/Y 升级行余额联动) + + +## 进度列文案域(解锁混合制三形态:钥石购买价/条件进度拼接/已入手) +func _workshop_progress_text(row: Dictionary, unlocked: bool) -> String: + if unlocked: + return "已入手" + var mode := String(row.get("mode", "")) + if mode == "keystone": + return "钥石 %d" % int(row.get("price", 0)) + if mode == "condition": + var parts: Array[String] = [] + for req_v in (row.get("reqs", []) as Array): + var req: Dictionary[Variant, Variant] = req_v as Dictionary + var lbl := String(UiKit.TREASURE_REQ_LABELS.get(String(req.get("key", "")), "条件")) + parts.append("%s %d/%d" % [lbl, int(req.get("progress", 0)), int(req.get("threshold", 0))]) + return "、".join(parts) + return "" + + +## 同调装配段刷新域(Slots 点亮/满位禁选/Y 升级行余额联动——就地刷) +func _refresh_attune_segment(data: Dictionary, balance: int) -> void: var att: Dictionary[Variant, Variant] = data.get("attune", {}) - if _attune_header_l != null: + if _attune._attune_header_l != null: var slots_n := int(att.get("slots", 0)) var eq: Array[Variant] = att.get("equipped", []) - _attune_header_l.text = "── 同调装配 Slots %d/%d ──" % [eq.size(), slots_n] + _attune._attune_header_l.text = "── 同调装配 Slots %d/%d ──" % [eq.size(), slots_n] var eq_set := {} for v in eq: eq_set[String(v)] = true - for card2 in _attune_btns: + for card2 in _attune._attune_btns: var on: bool = eq_set.has(String(card2.item_id)) card2.btn.text = "卸下" if on else "装配" # 满位禁选+未解锁禁装(已装件恒可卸下;unlocked 快照随 rebuild 刷新) card2.btn.disabled = (not on and eq.size() >= slots_n) or (not on and not bool(card2.get("unlocked", false))) - if _refresh_l != null: + if _side._refresh_l != null: var rf: Dictionary[Variant, Variant] = att.get("refresh", {}) - _refresh_l.text = "三选一刷新 +%d 次/冒险 · Lv%d" % [int(rf.get("charges", 0)), int(rf.get("level", 0))] - _refresh_btn.text = "已满级" if bool(rf.get("maxed", false)) else "升级 %d 钥石" % int(rf.get("price", 0)) - _refresh_btn.disabled = bool(rf.get("maxed", false)) or balance < int(rf.get("price", 0)) + _side._refresh_l.text = "三选一刷新 +%d 次/冒险 · Lv%d" % [int(rf.get("charges", 0)), int(rf.get("level", 0))] + _side._refresh_btn.text = "已满级" if bool(rf.get("maxed", false)) else "升级 %d 钥石" % int(rf.get("price", 0)) + _side._refresh_btn.disabled = bool(rf.get("maxed", false)) or balance < int(rf.get("price", 0)) func _rebuild_workshop(data: Dictionary) -> void: _workshop_rows.clear() for child in _workshop_holder.get_children(): child.queue_free() - _attune_nodes.clear() - _attune_btns.clear() - _attune_header_l = null - _refresh_l = null - _refresh_btn = null + _attune._attune_nodes.clear() + _attune._attune_btns.clear() + _attune._attune_header_l = null + _side._refresh_l = null + _side._refresh_btn = null for row in data.get("workshop", []): - var card := PanelContainer.new() - card.add_theme_stylebox_override("panel", UiKit.panel_flat(ROW_PLATE_COL, UiKit.PANEL_BORDER,UiKit.RADIUS_OBJECT, 1, Vector4(10, 4, 10, 4))) - var line := HBoxContainer.new() - line.add_theme_constant_override("separation", 12) - card.add_child(line) - var tier_name := String(UiKit.TREASURE_TIER_LABELS.get(String(row.get("tier", "")), "")) - var name_l := UiKit.label(String(row.get("display_name", "")) + ("·" + tier_name if tier_name != "" else ""),UiKit.FS_HINT, true, UiKit.INK) - name_l.custom_minimum_size = Vector2(132, 0) - name_l.clip_text = true - name_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - name_l.tooltip_text = String(row.get("desc", "")) # 件效果摘要悬停可见(分档呈现契约——槽位档位=名后缀) - line.add_child(name_l) - var progress_l := UiKit.label("",UiKit.FS_HINT, false, UiKit.STONE_DEEP) # 同行价格墨同案 - progress_l.custom_minimum_size = Vector2(150, 0) - progress_l.clip_text = true # 营地溢出修——文字全宽不撑柜 - progress_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - line.add_child(progress_l) - # 状态色用亮板双色墨制(深墨族替代暖白族)——色随刷新态条件赋值,非机械两行式 - var status_l := UiKit.label("",UiKit.FS_HINT, true, UiKit.STONE_DEEP) - status_l.clip_text = true # 同上——「已解锁 · 能力封存中」不撑柜(余宽吸收者) - status_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - status_l.size_flags_horizontal = Control.SIZE_EXPAND_FILL - line.add_child(status_l) - # 钥石购买钮(购买件且未解锁时在列;写口唯一=buy_treasure_unlock, - # 经 buy_requested(item_id) 信号走 presenter 分支;禁用态=余额不足) - var buy_btn: Button = null - if not bool(row.get("unlocked", false)) and String(row.get("mode", "")) == "keystone": - buy_btn = UiKit.button("购买 %d" % int(row.get("price", 0)), 0.0) # 行板紧凑钮豁免 - buy_btn.pressed.connect(_emit_buy_requested.bind(String(row.get("item_id", "")))) - line.add_child(buy_btn) - _workshop_holder.add_child(card) - _workshop_rows.append({"item_id": String(row.get("item_id", "")), "progress_l": progress_l, "status_l": status_l, "buy_btn": buy_btn, "price": int(row.get("price", 0)), "unlocked": bool(row.get("unlocked", false))}) + var card := _workshop_card(row) + _workshop_holder.add_child(card["card"]) + _workshop_rows.append(card) _rebuild_attune(data) +## 秘宝匣行板构建域:件名(分档后缀+效果摘要悬停)+进度列+状态列+钥石购买钮(购买件未解锁在列) +func _workshop_card(row: Dictionary) -> Dictionary: + var card := PanelContainer.new() + card.add_theme_stylebox_override("panel", UiKit.panel_flat(ROW_PLATE_COL, UiKit.PANEL_BORDER,UiKit.RADIUS_OBJECT, 1, Vector4(10, 4, 10, 4))) + var line := HBoxContainer.new() + line.add_theme_constant_override("separation", 12) + card.add_child(line) + var tier_name := String(UiKit.TREASURE_TIER_LABELS.get(String(row.get("tier", "")), "")) + var name_l := UiKit.label(String(row.get("display_name", "")) + ("·" + tier_name if tier_name != "" else ""),UiKit.FS_HINT, true, UiKit.INK) + name_l.custom_minimum_size = Vector2(132, 0) + name_l.clip_text = true + name_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + name_l.tooltip_text = String(row.get("desc", "")) # 件效果摘要悬停可见(分档呈现契约——槽位档位=名后缀) + line.add_child(name_l) + var progress_l := UiKit.label("",UiKit.FS_HINT, false, UiKit.STONE_DEEP) # 同行价格墨同案 + progress_l.custom_minimum_size = Vector2(150, 0) + progress_l.clip_text = true # 营地溢出修——文字全宽不撑柜 + progress_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + line.add_child(progress_l) + # 状态色用亮板双色墨制(深墨族替代暖白族)——色随刷新态条件赋值,非机械两行式 + var status_l := UiKit.label("",UiKit.FS_HINT, true, UiKit.STONE_DEEP) + status_l.clip_text = true # 同上——「已解锁 · 能力封存中」不撑柜(余宽吸收者) + status_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + status_l.size_flags_horizontal = Control.SIZE_EXPAND_FILL + line.add_child(status_l) + # 钥石购买钮(购买件且未解锁时在列;写口唯一=buy_treasure_unlock, + # 经 buy_requested(item_id) 信号走 presenter 分支;禁用态=余额不足) + var buy_btn: Button = null + if not bool(row.get("unlocked", false)) and String(row.get("mode", "")) == "keystone": + buy_btn = UiKit.button("购买 %d" % int(row.get("price", 0)), 0.0) # 行板紧凑钮豁免 + buy_btn.pressed.connect(_emit_buy_requested.bind(String(row.get("item_id", "")))) + line.add_child(buy_btn) + return {"item_id": String(row.get("item_id", "")), "progress_l": progress_l, "status_l": status_l, "buy_btn": buy_btn, "price": int(row.get("price", 0)), "unlocked": bool(row.get("unlocked", false)), "card": card} + + # 同调装配段重建(秘宝匣柜台尾部段;Slots 点亮+ # 九件行板+Y 升级行,程序占位面) func _rebuild_attune(data: Dictionary) -> void: var att: Dictionary[Variant, Variant] = data.get("attune", {}) - _attune_header_l = UiKit.label("",UiKit.FS_HINT, true, UiKit.INK) - _workshop_holder.add_child(_attune_header_l) - _attune_nodes.append(_attune_header_l) + _attune._attune_header_l = UiKit.label("",UiKit.FS_HINT, true, UiKit.INK) + _workshop_holder.add_child(_attune._attune_header_l) + _attune._attune_nodes.append(_attune._attune_header_l) for row in att.get("items", []): - var iid := String(row.get("item_id", "")) - var line := HBoxContainer.new() - line.add_theme_constant_override("separation", 6) - var name_l := UiKit.label(String(row.get("display_name", "")),UiKit.FS_HINT, true, UiKit.INK) - name_l.custom_minimum_size = Vector2(96, 0) - name_l.clip_text = true - name_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - line.add_child(name_l) - var form_l := UiKit.label("被动" if String(row.get("form", "")) == "passive" else "主动·充能",UiKit.FS_CAPTION, false, UiKit.STONE_DEEP) - form_l.custom_minimum_size = Vector2(58, 0) - line.add_child(form_l) - # 档位标签(槽位档位显示:件档=普通/优秀/稀有,装入须件档≤格档契约 2 的呈现半边) - var tier_l := UiKit.label(String(UiKit.TREASURE_TIER_LABELS.get(String(row.get("tier", "")), "—")),UiKit.FS_CAPTION, false, UiKit.STONE_DEEP) - tier_l.custom_minimum_size = Vector2(36, 0) - line.add_child(tier_l) - var btn := UiKit.button("装配", 0.0) # 行板紧凑钮豁免 - btn.pressed.connect(attune_toggled.emit.bind(iid)) - btn.disabled = not bool(row.get("unlocked", false)) # 未解锁禁装(解锁为装配前置——toggle 写口同守卫) - line.add_child(btn) + var line := _attune_item_line(row) _workshop_holder.add_child(line) - _attune_nodes.append(line) - _attune_btns.append({"item_id": iid, "btn": btn, "unlocked": bool(row.get("unlocked", false)), "equipped": bool(row.get("equipped", false))}) + _attune._attune_nodes.append(line) + _refresh_attune_tail(att) + + +## 同调件行板构建域:件名+形态+档位+装配切换钮(未解锁禁装——toggle 写口同守卫) +func _attune_item_line(row: Dictionary) -> HBoxContainer: + var iid := String(row.get("item_id", "")) + var line := HBoxContainer.new() + line.add_theme_constant_override("separation", 6) + var name_l := UiKit.label(String(row.get("display_name", "")),UiKit.FS_HINT, true, UiKit.INK) + name_l.custom_minimum_size = Vector2(96, 0) + name_l.clip_text = true + name_l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + line.add_child(name_l) + var form_l := UiKit.label("被动" if String(row.get("form", "")) == "passive" else "主动·充能",UiKit.FS_CAPTION, false, UiKit.STONE_DEEP) + form_l.custom_minimum_size = Vector2(58, 0) + line.add_child(form_l) + # 档位标签(槽位档位显示:件档=普通/优秀/稀有,装入须件档≤格档契约 2 的呈现半边) + var tier_l := UiKit.label(String(UiKit.TREASURE_TIER_LABELS.get(String(row.get("tier", "")), "—")),UiKit.FS_CAPTION, false, UiKit.STONE_DEEP) + tier_l.custom_minimum_size = Vector2(36, 0) + line.add_child(tier_l) + var btn := UiKit.button("装配", 0.0) # 行板紧凑钮豁免 + btn.pressed.connect(attune_toggled.emit.bind(iid)) + btn.disabled = not bool(row.get("unlocked", false)) # 未解锁禁装(解锁为装配前置——toggle 写口同守卫) + line.add_child(btn) + _attune._attune_btns.append({"item_id": iid, "btn": btn, "unlocked": bool(row.get("unlocked", false)), "equipped": bool(row.get("equipped", false))}) + return line + + +## Y 升级尾行构建域(双入口之一——树上 ks_refresh 同账) +func _refresh_attune_tail(att: Dictionary) -> void: var refresh_line := HBoxContainer.new() refresh_line.add_theme_constant_override("separation", 6) - _refresh_l = UiKit.label("",UiKit.FS_CAPTION, false, UiKit.STONE_DEEP) - refresh_line.add_child(_refresh_l) - _refresh_btn = UiKit.button("升级", 0.0) # 行板紧凑钮豁免 - _refresh_btn.pressed.connect(_emit_buy_requested.bind("attune_refresh")) - refresh_line.add_child(_refresh_btn) + _side._refresh_l = UiKit.label("",UiKit.FS_CAPTION, false, UiKit.STONE_DEEP) + refresh_line.add_child(_side._refresh_l) + _side._refresh_btn = UiKit.button("升级", 0.0) # 行板紧凑钮豁免 + _side._refresh_btn.pressed.connect(_emit_buy_requested.bind("attune_refresh")) + refresh_line.add_child(_side._refresh_btn) _workshop_holder.add_child(refresh_line) - _attune_nodes.append(refresh_line) + _attune._attune_nodes.append(refresh_line) func _rebuild_recent(data: Dictionary) -> void: @@ -544,7 +644,7 @@ func _rebuild_recent(data: Dictionary) -> void: verdict = "放弃" lines.append("最近冒险:%s · 完成 %d 关 · 钥石 +%d · 种子 %d" % [verdict, int(s.get("levels_cleared", 0)), int(s.get("keystone_total", 0)), int(s.get("master_seed", 0))]) - _recent_list.text = " | ".join(lines) + _side._recent_list.text = " | ".join(lines) func _emit_closed() -> void: diff --git a/game/presentation/views/confirm_dialog.gd b/game/presentation/views/confirm_dialog.gd index b7816983..5025b5b0 100644 --- a/game/presentation/views/confirm_dialog.gd +++ b/game/presentation/views/confirm_dialog.gd @@ -14,7 +14,7 @@ var _cancel_btn: Button func _init() -> void: var box: VBoxContainer = UiKit.modal_shell(self, Color(UiKit.DIM, 0.55), Vector2(460, 0), 12, - true, UiKit.panel_skinned(Vector4(14, 12, 14, 12))).box + UiKit.panel_skinned(Vector4(14, 12, 14, 12))).box _text = UiKit.label("",UiKit.FS_BODY, false, UiKit.INK_TITLE) _text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _text.custom_minimum_size = Vector2(430, 0) diff --git a/game/presentation/views/curio_pick_overlay.gd b/game/presentation/views/curio_pick_overlay.gd index 46e2eb67..9d187fb7 100644 --- a/game/presentation/views/curio_pick_overlay.gd +++ b/game/presentation/views/curio_pick_overlay.gd @@ -30,7 +30,14 @@ func _init() -> void: # bcard 三竖卡(D1 232×352)+ 底部确认主钮与免费选择注记。差异白名单=免费选择/✓章/无价签。 # 状态机不变:pick→result。M7 同构裁决遗产保留。 var page: Dictionary[Variant, Variant] = UiKit.page_shell(self, false) - var top: HBoxContainer = page.top_row + _build_top_row(page.top_row) + var box: VBoxContainer = page.box + _build_body(box) + visible = false + + +## 顶条域:标题+刷新图标钮(44×44 豁免宽度基准+角标叠加)+刷新余量行 +func _build_top_row(top: HBoxContainer) -> void: top.add_child(UiKit.label("战利品三选一",UiKit.FS_TITLE, true, UiKit.WARM)) var top_sp := Control.new() top_sp.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -51,7 +58,10 @@ func _init() -> void: _refresh_l = UiKit.label("",UiKit.FS_CAPTION, false, UiKit.WARM_SOFT) _refresh_l.visible = false top.add_child(_refresh_l) - var box: VBoxContainer = page.box + + +## 主体域:入账信息行+三竖卡列+底部确认/继续钮与免费选择注记 +func _build_body(box: VBoxContainer) -> void: _label = UiKit.label("",UiKit.FS_BODY, false, UiKit.WARM_SOFT) _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER box.add_child(_label) @@ -96,7 +106,7 @@ func open_pick(header: String, cards: Array) -> void: var idl := cid.to_lower() var band := UiKit.tg_band(idl) # 类型色带单点(TG_* 五族同源判定;aoe/blast 归 TG_AOE) # 类型脚注(T7 判官 checkpoint:demo .bcard .src 底部类型行——实机缺=卡下半空底;词=demo 同款) - var card := UiKit.bcard(title, desc, band, _icon_for_id(idl), "", _type_label(idl)) + var card := UiKit.bcard(title, desc, band, _icon_for_id(idl), {"footnote": _type_label(idl)}) card.gui_input.connect(func(ev: InputEvent) -> void: if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT: _select_card(cid)) @@ -189,19 +199,15 @@ func _emit_continue_pressed() -> void: # M7:id → 手绘图标键(子串映射容错 id 词表演进;缺位=BCard 空图标槽不炸) +## 图标映射表(id 子串→图标键——判定序 heal/blast/gp/attack/armor/ap 缺省钱袋逐字) func _icon_for_id(idl: String) -> String: - if idl.contains("heal"): - return UiKit.TEX_ICON_MEDKIT - if idl.contains("blast") or idl.contains("magic"): - return UiKit.TEX_ICON_BLAST - if idl.contains("gp") or idl.contains("coin"): - return UiKit.TEX_ICON_COINBAG - if idl.contains("attack"): - return UiKit.TEX_ICON_SWORD - if idl.contains("armor"): - return UiKit.TEX_ICON_SHIELD - if idl.contains("ap") or idl.contains("action"): - return UiKit.TEX_ICON_POUCH + var rules := [[["heal"], UiKit.TEX_ICON_MEDKIT], [["blast", "magic"], UiKit.TEX_ICON_BLAST], + [["gp", "coin"], UiKit.TEX_ICON_COINBAG], [["attack"], UiKit.TEX_ICON_SWORD], + [["armor"], UiKit.TEX_ICON_SHIELD], [["ap", "action"], UiKit.TEX_ICON_POUCH]] + for rule in rules: + for kw in (rule[0] as Array): + if idl.contains(String(kw)): + return String(rule[1]) return UiKit.TEX_ICON_COINBAG diff --git a/game/presentation/views/debug_overlay.gd b/game/presentation/views/debug_overlay.gd index 19128e92..80e80373 100644 --- a/game/presentation/views/debug_overlay.gd +++ b/game/presentation/views/debug_overlay.gd @@ -10,30 +10,36 @@ signal debug_levels_requested # 两层调试对局入口 signal diagnose_requested # 诊断转储入口(卡死取证编排归组合根) signal closed -var seed_edit: LineEdit # 组合根 _seed_value 唯一读取面 +## 内部状态件(u7 子域拆件——契约 9 职责语句「调试浮窗运行态」):单一变化原因=调试面板状态与 +## 当局数据投影。探针直读冻结成员(seed_edit/_sim_btn/_dbg_btn)住宿主; +## st._run_data/st._run_labels 经 run_data_probe()/update_run_data() 方法出口(字段直访面零外部依赖)。 +class _DebugState extends RefCounted: + var _db + var _base_atk := 0 + var _base_hp := 0 + var _atk: HSlider + var _hp: HSlider + var _atk_val: Label + var _hp_val: Label + var _eff: Label + var _sim_count: SpinBox + var _sim_out: RichTextLabel + var _sim_running := false # 批量模拟进行中(is_running 判据——与对局中禁用态分离防互扰) + var _ctx_locked := false # 对局中上下文锁定(组合根 _sync_debug_overlay 喂入——批量模拟/两层调试入口禁用) + var _run_labels := {} # 当局数据六项标签(只读投影,组合根 update_run_data 喂入) + var _run_data := {} # 最近一次投喂快照(探针值对面 run_data_probe) -var _db -var _base_atk := 0 -var _base_hp := 0 -var _atk: HSlider -var _hp: HSlider -var _atk_val: Label -var _hp_val: Label -var _eff: Label -var _sim_count: SpinBox -var _sim_btn: Button -var _dbg_btn: Button # 两层调试入口钮(对局中禁用承载面) -var _sim_out: RichTextLabel -var _sim_running := false # 批量模拟进行中(is_running 判据——与对局中禁用态分离防互扰) -var _ctx_locked := false # 对局中上下文锁定(组合根 _sync_debug_overlay 喂入——批量模拟/两层调试入口禁用) -var _run_labels := {} # 当局数据六项标签(只读投影,组合根 update_run_data 喂入) -var _run_data := {} # 最近一次投喂快照(探针值对面 run_data_probe) + +var st := _DebugState.new() +var seed_edit: LineEdit # 组合根 _seed_value 唯一读取面(探针直读冻结) +var _sim_btn: Button # 两层调试批量模拟入口钮(探针直读冻结) +var _dbg_btn: Button # 两层调试入口钮(对局中禁用承载面;探针直读冻结) func _init(db_ref, base_atk: int, base_hp: int) -> void: - _db = db_ref - _base_atk = base_atk - _base_hp = base_hp + st._db = db_ref + st._base_atk = base_atk + st._base_hp = base_hp _build() _sync_labels() visible = false # 显式初始化——不靠树序巧合(缺陷⑤家族纪律) @@ -42,78 +48,78 @@ func _init(db_ref, base_atk: int, base_hp: int) -> void: # ---- 对外语义面(组合根/探针)---- func atk_pct() -> int: - return int(_atk.value) + return int(st._atk.value) func hp_pct() -> int: - return int(_hp.value) + return int(st._hp.value) func set_sliders(atk: int, hp: int) -> void: - _atk.value = atk - _hp.value = hp + st._atk.value = atk + st._hp.value = hp func reset_sliders() -> void: - _atk.value = 0 - _hp.value = 0 + st._atk.value = 0 + st._hp.value = 0 _sync_labels() func effect_text() -> String: - return _eff.text + return st._eff.text func set_sim_count(n: int) -> void: - _sim_count.value = n + st._sim_count.value = n func is_running() -> bool: - return _sim_running # 批量模拟进行中(与对局中禁用态分离,置灰 ≠ 在跑) + return st._sim_running # 批量模拟进行中(与对局中禁用态分离,置灰 ≠ 在跑) func set_sim_running(running: bool) -> void: - _sim_running = running + st._sim_running = running _sync_sim_button() # 对局中上下文锁定(组合根 _sync_debug_overlay 喂入):批量模拟与两层调试入口 # 在活跃对局并存态不开放(长程异步模拟/并发对局编排与活跃结算并存态破坏状态机) func set_context_locked(locked: bool) -> void: - _ctx_locked = locked + st._ctx_locked = locked _sync_sim_button() # 当局数据只读投影喂入(六项——组合根 _debug_run_data 构造;纯标签重查零动效) func update_run_data(data: Dictionary) -> void: - _run_data = data.duplicate() - for key in _run_labels: - (_run_labels[key] as Label).text = str(data.get(key, "—")) + st._run_data = data.duplicate() + for key in st._run_labels: + (st._run_labels[key] as Label).text = str(data.get(key, "—")) # 当局数据快照读面(探针值对面——ui_tune_probe 断言六项与只读访问器逐项一致) func run_data_probe() -> Dictionary: - return _run_data.duplicate() + return st._run_data.duplicate() # 两钮同判据(模拟运行 / 对局上下文锁定对「模拟」「两层调试」两入口等价)—— # 判据单点:两 setter 都经此同步,禁在调用点各写一份 func _sync_sim_button() -> void: - _sim_btn.disabled = _sim_running or _ctx_locked - _sim_btn.text = "模拟中…" if _sim_running else "运行模拟" - _dbg_btn.disabled = _sim_running or _ctx_locked + _sim_btn.disabled = st._sim_running or st._ctx_locked + _sim_btn.text = "模拟中…" if st._sim_running else "运行模拟" + _dbg_btn.disabled = st._sim_running or st._ctx_locked func set_sim_progress(text: String) -> void: - _sim_out.text = text + st._sim_out.text = text func set_sim_report(text: String) -> void: - _sim_out.text = text + st._sim_out.text = text func output_text() -> String: - return _sim_out.get_parsed_text() + return st._sim_out.get_parsed_text() # ---- 探针接缝(真实翻译出口——按钮 pressed 信号路径,非直调组合根)---- @@ -141,11 +147,11 @@ func _reset_btn_pressed() -> void: func effective_atk() -> int: - return UiKit.scaled(_base_atk, atk_pct()) + return UiKit.scaled(st._base_atk, atk_pct()) func effective_max_hp() -> int: - return UiKit.scaled(_base_hp, hp_pct()) + return UiKit.scaled(st._base_hp, hp_pct()) # ---- 内部构建(标题/关闭固定于浮窗骨架,可扩展列表区居中滚动)---- @@ -153,15 +159,7 @@ func effective_max_hp() -> int: func _build() -> void: var shell: Dictionary[Variant, Variant] = UiKit.modal_shell(self, UiKit.BG_OVERLAY, Vector2(640, 0), 8) var box: VBoxContainer = shell.box - - var title := UiKit.label("调试",UiKit.FS_TITLE, true, UiKit.WARM) - box.add_child(title) - var desc := UiKit.label( - "开发调试专用(UI 最顶层「调试」浮层角标随时可开——任何界面不遮挡)。当局数据=本局只读投影,种子即本局种子唯一查看位。\n百分比叠加基础值:攻击 %d / 生命 %d。影响所有模式(手动与模拟),不落盘。" % [_base_atk, _base_hp], - 13, false, UiKit.WARM_SOFT) - box.add_child(desc) - box.add_child(HSeparator.new()) - + _build_intro(box) # 可扩展列表页(后续调试项按区块往下追加——超高滚动,滚轮下探) var scroll := ScrollContainer.new() scroll.custom_minimum_size = Vector2(0, 430) @@ -171,10 +169,32 @@ func _build() -> void: list.size_flags_horizontal = Control.SIZE_EXPAND_FILL list.add_theme_constant_override("separation", 8) scroll.add_child(list) + _build_run_data(list) + _build_fx_row(list) + _build_seed_row(list) + _build_slider_block(list) + _build_sim_block(list) + _build_debug_diag(list) + var close_btn := UiKit.button("关闭") + close_btn.pressed.connect(_emit_closed) + box.add_child(close_btn) - # 区块·当局数据(六项只读投影:种子=本局种子唯一查看位〔adventure - # .master_seed 现值,非 _seed_value() 重算〕/关卡/节点/HP/GP/钥石;数据由组合根开窗与模态开合 - # 时 update_run_data 喂入;纯标签零信号连接零动效) + +## 头部域:标题+说明(百分比叠加基础值——影响所有模式,不落盘) +func _build_intro(box: VBoxContainer) -> void: + var title := UiKit.label("调试",UiKit.FS_TITLE, true, UiKit.WARM) + box.add_child(title) + var desc := UiKit.label( + "开发调试专用(UI 最顶层「调试」浮层角标随时可开——任何界面不遮挡)。当局数据=本局只读投影,种子即本局种子唯一查看位。\n百分比叠加基础值:攻击 %d / 生命 %d。影响所有模式(手动与模拟),不落盘。" % [st._base_atk, st._base_hp], + 13, false, UiKit.WARM_SOFT) + box.add_child(desc) + box.add_child(HSeparator.new()) + + +## 区块·当局数据(六项只读投影:种子=本局种子唯一查看位〔adventure +## .master_seed 现值,非 _seed_value() 重算〕/关卡/节点/HP/GP/钥石;数据由组合根开窗与模态开合 +## 时 update_run_data 喂入;纯标签零信号连接零动效) +func _build_run_data(list: VBoxContainer) -> void: var run_head := UiKit.label("当局数据(只读)",UiKit.FS_HINT, true, UiKit.WARM) list.add_child(run_head) var run_grid := GridContainer.new() @@ -187,11 +207,13 @@ func _build() -> void: cap.custom_minimum_size = Vector2(52, 0) run_grid.add_child(cap) var val := UiKit.label("—",UiKit.FS_HINT, false, UiKit.WARM) - _run_labels[item[0]] = val + st._run_labels[item[0]] = val run_grid.add_child(val) list.add_child(HSeparator.new()) - # 区块⓪胜利演出风格(两套并存——纹章拼合[默认]/尘金粒子;会话级不落盘) + +## 区块⓪胜利演出风格(两套并存——纹章拼合[默认]/尘金粒子;会话级不落盘) +func _build_fx_row(list: VBoxContainer) -> void: var fx_row := HBoxContainer.new() fx_row.add_theme_constant_override("separation", 10) list.add_child(fx_row) @@ -201,8 +223,10 @@ func _build() -> void: fx_btn.pressed.connect(_on_fx_toggle.bind(fx_btn)) # lambda 转命名方法(只读巡检白名单方法名精确可判) fx_row.add_child(fx_btn) - # 区块①随机种子(种子三分判定归组合根 _seed_value 单点): - # 默认空框(空=随全局规则:首局恒 42/非首局随机;显式填入全场景优先) + +## 区块①随机种子(种子三分判定归组合根 _seed_value 单点): +## 默认空框(空=随全局规则:首局恒 42/非首局随机;显式填入全场景优先) +func _build_seed_row(list: VBoxContainer) -> void: var seed_row := HBoxContainer.new() list.add_child(seed_row) seed_row.add_child(UiKit.label("随机种子",UiKit.FS_BODY, false)) @@ -213,16 +237,17 @@ func _build() -> void: seed_row.add_child(seed_edit) list.add_child(HSeparator.new()) - # 区块②角色数值调整板 - var atk_row := _slider_row(list, "攻击加成") - _atk = atk_row.slider - _atk_val = atk_row.value_label - var hp_row := _slider_row(list, "生命加成") - _hp = hp_row.slider - _hp_val = hp_row.value_label - _eff = UiKit.label("",UiKit.FS_HINT, false) - list.add_child(_eff) +## 区块②角色数值调整板(攻击/生命两滑轨+生效值行+应用/重置钮) +func _build_slider_block(list: VBoxContainer) -> void: + var atk_row := _slider_row(list, "攻击加成") + st._atk = atk_row.slider + st._atk_val = atk_row.value_label + var hp_row := _slider_row(list, "生命加成") + st._hp = hp_row.slider + st._hp_val = hp_row.value_label + st._eff = UiKit.label("",UiKit.FS_HINT, false) + list.add_child(st._eff) var btn_row := HBoxContainer.new() list.add_child(btn_row) var apply_btn := UiKit.button("应用并关闭") @@ -233,34 +258,36 @@ func _build() -> void: btn_row.add_child(reset_btn) list.add_child(HSeparator.new()) - # 区块③批量模拟(与 headless 扫描同口径——编排归 tune_sim_controller) + +## 区块③批量模拟(与 headless 扫描同口径——编排归 tune_sim_controller) +func _build_sim_block(list: VBoxContainer) -> void: var sim_row := HBoxContainer.new() list.add_child(sim_row) sim_row.add_child(UiKit.label("批量模拟第一关",UiKit.FS_BODY, false)) - _sim_count = SpinBox.new() - _sim_count.min_value = 10 - _sim_count.max_value = 200 - _sim_count.step = 10 - _sim_count.value = 30 - _sim_count.custom_minimum_size = Vector2(90, 0) - sim_row.add_child(_sim_count) + st._sim_count = SpinBox.new() + st._sim_count.min_value = 10 + st._sim_count.max_value = 200 + st._sim_count.step = 10 + st._sim_count.value = 30 + st._sim_count.custom_minimum_size = Vector2(90, 0) + sim_row.add_child(st._sim_count) sim_row.add_child(UiKit.label("次(理性玩家策略,含面板当前加成)",UiKit.FS_HINT, false)) _sim_btn = UiKit.button("运行模拟") _sim_btn.pressed.connect(_emit_simulate_requested) sim_row.add_child(_sim_btn) - _sim_out = RichTextLabel.new() - _sim_out.bbcode_enabled = true - _sim_out.custom_minimum_size = Vector2(0, 300) - list.add_child(_sim_out) + st._sim_out = RichTextLabel.new() + st._sim_out.bbcode_enabled = true + st._sim_out.custom_minimum_size = Vector2(0, 300) + list.add_child(st._sim_out) list.add_child(HSeparator.new()) - # 区块④两层调试对局入口(HP 关内延续;对局中禁用) + +## 区块④⑤两层调试入口与诊断转储(对局中禁用;转储编排经信号外发归组合根——纯视图边界; +## dump_diagnosis 卡死取证功能保留,不设门控隐藏——本浮窗本就可全局可开,双轨无意义) +func _build_debug_diag(list: VBoxContainer) -> void: _dbg_btn = UiKit.button("调试 · 连续关卡两层(HP 关内延续)") _dbg_btn.pressed.connect(_emit_debug_levels) list.add_child(_dbg_btn) - - # 区块⑤诊断转储:dump_diagnosis 卡死取证功能保留;不设门控隐藏——本浮窗本就可全局可开,双轨无意义。 - # 纯视图边界:转储编排经信号外发归组合根。 var diag_row := HBoxContainer.new() diag_row.add_theme_constant_override("separation", 10) list.add_child(diag_row) @@ -270,10 +297,6 @@ func _build() -> void: diag_btn.pressed.connect(_emit_diagnose_requested) diag_row.add_child(diag_btn) - var close_btn := UiKit.button("关闭") - close_btn.pressed.connect(_emit_closed) - box.add_child(close_btn) - func _slider_row(box: VBoxContainer, title: String) -> Dictionary: var row := HBoxContainer.new() @@ -293,14 +316,14 @@ func _slider_row(box: VBoxContainer, title: String) -> Dictionary: func _sync_labels() -> void: - _atk_val.text = "+%d%%" % atk_pct() - _hp_val.text = "+%d%%" % hp_pct() - _eff.text = "生效值:攻击 %d → %d · 生命 %d → %d" % [ - _base_atk, effective_atk(), _base_hp, effective_max_hp()] + st._atk_val.text = "+%d%%" % atk_pct() + st._hp_val.text = "+%d%%" % hp_pct() + st._eff.text = "生效值:攻击 %d → %d · 生命 %d → %d" % [ + st._base_atk, effective_atk(), st._base_hp, effective_max_hp()] func _emit_simulate_requested() -> void: - simulate_requested.emit(int(_sim_count.value)) + simulate_requested.emit(int(st._sim_count.value)) func _emit_diagnose_requested() -> void: diff --git a/game/presentation/views/hero_info_panel_view.gd b/game/presentation/views/hero_info_panel_view.gd index 7b1c5ca5..7dd765d7 100644 --- a/game/presentation/views/hero_info_panel_view.gd +++ b/game/presentation/views/hero_info_panel_view.gd @@ -30,9 +30,14 @@ func _init() -> void: var col := VBoxContainer.new() col.add_theme_constant_override("separation", 14) # 布局充实(区段间距放大) add_child(col) + _build_data_row(col) + _build_slots(col) + _build_side_sections(col) + +## 数据行域(直角细线数据框——battle.md Component Inventory C 区行规格落形;同时消化纵向空白) +func _build_data_row(col: VBoxContainer) -> void: col.add_child(UiKit.label("探险者",UiKit.FS_SUB, true)) - # 数据行装框(直角细线数据框——battle.md Component Inventory C 区行规格落形;同时消化纵向空白) var data_box := PanelContainer.new() data_box.add_theme_stylebox_override("panel", UiKit.panel_flat(UiKit.PANEL_DATA, UiKit.PANEL_BORDER, 0, 1, Vector4(6, 4, 6, 4))) @@ -45,55 +50,60 @@ func _init() -> void: _armor_label = _data_line() data_col.add_child(_armor_label) - # 秘宝槽(预留):随身战斗用道具的唯一展示面——空槽占位承诺格子在此, - # 不预设交互(挖掘期指向交互归 E 区既有裁决) + +## 秘宝槽域(预留):随身战斗用道具的唯一展示面——空槽占位承诺格子在此, +## 不预设交互(挖掘期指向交互归 E 区既有裁决) +func _build_slots(col: VBoxContainer) -> void: col.add_child(UiKit.label("秘宝",UiKit.FS_BODY, true)) item_slots = HBoxContainer.new() item_slots.add_theme_constant_override("separation", 10) col.add_child(item_slots) for i in 4: - var slot := PanelContainer.new() - slot.custom_minimum_size = Vector2(48, 48) - slot.add_theme_stylebox_override("panel", - UiKit.panel_flat(UiKit.PANEL_DATA, UiKit.PANEL_BORDER, 0, 1, Vector4(4, 4, 4, 4))) - var idx := i - slot.gui_input.connect(func(ev: InputEvent): - if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT: - # 空槽/越界槽点击=取消选中(handle 0);有效槽发其 handle(组合根判 AP 即用/进选中态) - treasure_slot_clicked.emit(_slot_handles[idx] if idx < _slot_handles.size() else 0)) - item_slots.add_child(slot) - _slot_panels.append(slot) + _slot_panels.append(_build_slot(i)) - # 特性持有段(三选一特性兑付后常驻可读:逐件行「件名 ×N」;空态整节隐藏。 - # 上移承载——构筑核心信息优先级高于临时强化,且防 C 区下部裁切不可见) - _feat_section = VBoxContainer.new() - _feat_section.add_theme_constant_override("separation", 4) - _feat_section.visible = false - col.add_child(_feat_section) - _feat_section.add_child(UiKit.label("专长",UiKit.FS_BODY, true)) - _feat_list = VBoxContainer.new() - _feat_list.add_theme_constant_override("separation", 2) - _feat_section.add_child(_feat_list) - # 地形变异常驻角标(Boss 图整图型战斗全程可见防中局遗忘; - # 空态整节隐藏=特性段先例;摆位/文案 provisional) - _board_variant_section = VBoxContainer.new() - _board_variant_section.add_theme_constant_override("separation", 4) - _board_variant_section.visible = false - col.add_child(_board_variant_section) - _board_variant_section.add_child(UiKit.label("地形变异",UiKit.FS_BODY, true)) - _board_variant_list = VBoxContainer.new() - _board_variant_list.add_theme_constant_override("separation", 2) - _board_variant_section.add_child(_board_variant_list) - # 特性(预留):锻造/大奖励/关卡奖励节点的属性提升——中循环数据因改变战斗数学准入; - # 与战斗内状态效果(B 区站桩,随战斗消散)分区,防「战斗结束被清」误读;空态整节隐藏 - _curio_section = VBoxContainer.new() - _curio_section.add_theme_constant_override("separation", 4) - _curio_section.visible = false - col.add_child(_curio_section) - _curio_section.add_child(UiKit.label("属性值提升",UiKit.FS_BODY, true)) - _curio_list = VBoxContainer.new() - _curio_list.add_theme_constant_override("separation", 2) - _curio_section.add_child(_curio_list) + +## 单槽构建域:48px 槽牌+点击路由(空槽/越界槽点击=取消选中 handle 0;有效槽发其 handle) +func _build_slot(i: int) -> PanelContainer: + var slot := PanelContainer.new() + slot.custom_minimum_size = Vector2(48, 48) + slot.add_theme_stylebox_override("panel", + UiKit.panel_flat(UiKit.PANEL_DATA, UiKit.PANEL_BORDER, 0, 1, Vector4(4, 4, 4, 4))) + var idx := i + slot.gui_input.connect(func(ev: InputEvent): + if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT: + # 空槽/越界槽点击=取消选中(handle 0);有效槽发其 handle(组合根判 AP 即用/进选中态) + treasure_slot_clicked.emit(_slot_handles[idx] if idx < _slot_handles.size() else 0)) + item_slots.add_child(slot) + return slot + + +## 三区段域(特性持有段上移承载——构筑核心信息优先级高于临时强化,且防 C 区下部裁切不可见/ +## 地形变异常驻角标 Boss 图整图型战斗全程可见防中局遗忘/特性预留段防「战斗结束被清」误读;空态整节隐藏) +func _build_side_sections(col: VBoxContainer) -> void: + _feat_section = _labeled_section(col, "专长") + _feat_list = _list_of(_feat_section) + _board_variant_section = _labeled_section(col, "地形变异") + _board_variant_list = _list_of(_board_variant_section) + _curio_section = _labeled_section(col, "属性值提升") + _curio_list = _list_of(_curio_section) + + +## 区段骨架(标题+隐藏容器) +func _labeled_section(col: VBoxContainer, title: String) -> VBoxContainer: + var section := VBoxContainer.new() + section.add_theme_constant_override("separation", 4) + section.visible = false + col.add_child(section) + section.add_child(UiKit.label(title,UiKit.FS_BODY, true)) + return section + + +## 行板容器(separation 2) +func _list_of(section: VBoxContainer) -> VBoxContainer: + var list := VBoxContainer.new() + list.add_theme_constant_override("separation", 2) + section.add_child(list) + return list func set_attack(atk: int, mod_text: String) -> void: @@ -174,46 +184,69 @@ func set_treasure_inventory(items: Array, selected_handle: int) -> void: continue var rec: Dictionary[Variant, Variant] = items[i] _slot_handles.append(int(rec.get("handle", 0))) - var used := bool(rec.get("used", false)) - var usable := bool(rec.get("usable", true)) - var selected := selected_handle != 0 and int(rec.get("handle", 0)) == selected_handle - # 槽内容=手绘像素图标(demo ⑦;glyph 字符→图标键映射,未映射/used/不可用回退字形+条件墨色) - var glyph_ch := String(rec.get("glyph", "?")) - var icon_map := {"爆": UiKit.TEX_ICON_BLAST, "医": UiKit.TEX_ICON_MEDKIT, - "铲": UiKit.TEX_ICON_SHOVEL, "袋": UiKit.TEX_ICON_COINBAG, "钥": UiKit.TEX_ICON_KEYSHARD, - "剑": UiKit.TEX_ICON_SWORD, "盾": UiKit.TEX_ICON_SHIELD, "心": UiKit.TEX_ICON_HEART, - "行": UiKit.TEX_ICON_POUCH} - var icon_path: String = icon_map.get(glyph_ch, "") - var itex := UiKit.load_tex(icon_path) if icon_path != "" else null - if itex != null and not used and usable: - var tr := TextureRect.new() - tr.texture = itex - tr.custom_minimum_size = Vector2(40, 40) - tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - tr.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST - tr.set_anchors_preset(Control.PRESET_FULL_RECT) - tr.mouse_filter = Control.MOUSE_FILTER_IGNORE - slot.add_child(tr) - else: - var glyph := UiKit.label(glyph_ch,UiKit.FS_SUB, true) - glyph.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - glyph.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - glyph.set_anchors_preset(Control.PRESET_FULL_RECT) - glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE - if used: - # 「已用」占位墨=灰石深化(STONE_FAINT 对 PANEL_DATA 槽牌 2.7:1; - # STONE_DEEP = 5.6:1;语义分档保持双墨) - glyph.add_theme_color_override("font_color", UiKit.STONE_DEEP) - elif not usable: - # 「不可用」置灰墨=标签深墨(STONE 对槽牌 3.4:1;INK_LABEL = 10.4:1,虚位/失效更强置灰语义) - glyph.add_theme_color_override("font_color", UiKit.INK_LABEL) - slot.add_child(glyph) - var border_col: Color = UiKit.GOLD if selected else UiKit.PANEL_BORDER - slot.add_theme_stylebox_override("panel", - UiKit.panel_flat(UiKit.PANEL_DATA, border_col, 0, 1 if not selected else 2, Vector4(4, 4, 4, 4))) - # note=积点进度行(自动兑付件悬停浮窗补「积点 N/M」,进度随挖格经组合根重查刷新) - var note := String(rec.get("note", "")) - slot.tooltip_text = "%s%s%s" % [String(rec.get("name", "")), - "(%s)" % note if note != "" else "", - "(已用)" if used else ("(不可用——当前无有效目标)" if not usable else "")] + _fill_slot(slot, rec, selected_handle) + + +## 单槽内容填充域:图标优先(像素图标未映射/used/不可用回退字形+条件墨色)+选中金框+悬停浮窗 +func _fill_slot(slot: PanelContainer, rec: Dictionary, selected_handle: int) -> void: + var used := bool(rec.get("used", false)) + var usable := bool(rec.get("usable", true)) + var selected := selected_handle != 0 and int(rec.get("handle", 0)) == selected_handle + # 槽内容=手绘像素图标(demo ⑦;glyph 字符→图标键映射,未映射/used/不可用回退字形+条件墨色) + var itex := _slot_icon_tex(String(rec.get("glyph", "?"))) + if itex != null and not used and usable: + slot.add_child(_slot_icon_rect(itex)) + else: + slot.add_child(_slot_glyph_label(String(rec.get("glyph", "?")), used, usable)) + var border_col: Color = UiKit.GOLD if selected else UiKit.PANEL_BORDER + slot.add_theme_stylebox_override("panel", + UiKit.panel_flat(UiKit.PANEL_DATA, border_col, 0, 1 if not selected else 2, Vector4(4, 4, 4, 4))) + slot.tooltip_text = _slot_tooltip(rec, used, usable) + + +## 字形→图标纹理映射(demo ⑦ 键表单点) +func _slot_icon_tex(glyph_ch: String) -> Texture2D: + var icon_map := {"爆": UiKit.TEX_ICON_BLAST, "医": UiKit.TEX_ICON_MEDKIT, + "铲": UiKit.TEX_ICON_SHOVEL, "袋": UiKit.TEX_ICON_COINBAG, "钥": UiKit.TEX_ICON_KEYSHARD, + "剑": UiKit.TEX_ICON_SWORD, "盾": UiKit.TEX_ICON_SHIELD, "心": UiKit.TEX_ICON_HEART, + "行": UiKit.TEX_ICON_POUCH} + var icon_path: String = icon_map.get(glyph_ch, "") + return UiKit.load_tex(icon_path) if icon_path != "" else null + + +## 图标矩形域(40px 素描框,像素过滤) +func _slot_icon_rect(itex: Texture2D) -> TextureRect: + var tr := TextureRect.new() + tr.texture = itex + tr.custom_minimum_size = Vector2(40, 40) + tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + tr.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + tr.set_anchors_preset(Control.PRESET_FULL_RECT) + tr.mouse_filter = Control.MOUSE_FILTER_IGNORE + return tr + + +## 字形回退标签域(used=灰石深化墨 5.6:1/不可用=标签深墨 10.4:1 虚位失效更强置灰——语义分档双墨) +func _slot_glyph_label(glyph_ch: String, used: bool, usable: bool) -> Label: + var glyph := UiKit.label(glyph_ch,UiKit.FS_SUB, true) + glyph.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + glyph.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + glyph.set_anchors_preset(Control.PRESET_FULL_RECT) + glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE + if used: + # 「已用」占位墨=灰石深化(STONE_FAINT 对 PANEL_DATA 槽牌 2.7:1; + # STONE_DEEP = 5.6:1;语义分档保持双墨) + glyph.add_theme_color_override("font_color", UiKit.STONE_DEEP) + elif not usable: + # 「不可用」置灰墨=标签深墨(STONE 对槽牌 3.4:1;INK_LABEL = 10.4:1,虚位/失效更强置灰语义) + glyph.add_theme_color_override("font_color", UiKit.INK_LABEL) + return glyph + + +## 悬停浮窗域(note=积点进度行——自动兑付件悬停浮窗补「积点 N/M」,进度随挖格经组合根重查刷新) +func _slot_tooltip(rec: Dictionary, used: bool, usable: bool) -> String: + var note := String(rec.get("note", "")) + return "%s%s%s" % [String(rec.get("name", "")), + "(%s)" % note if note != "" else "", + "(已用)" if used else ("(不可用——当前无有效目标)" if not usable else "")] diff --git a/game/presentation/views/legend_overlay.gd b/game/presentation/views/legend_overlay.gd index 08454647..238d4c0c 100644 --- a/game/presentation/views/legend_overlay.gd +++ b/game/presentation/views/legend_overlay.gd @@ -23,7 +23,7 @@ var _rap_cost := -1 # <0 = 不渲染 RAP 行(构造参数注表值,禁字 func _init(rap_cost: int = -1) -> void: _rap_cost = rap_cost - var shell := UiKit.modal_shell(self, Color(UiKit.DIM, 0.55), Vector2(520, 0), 6, true, + var shell := UiKit.modal_shell(self, Color(UiKit.DIM, 0.55), Vector2(520, 0), 6, UiKit.panel_skinned(Vector4(12, 10, 12, 10))) # 石板皮(T2;缺位回退 flat) _panel = shell.panel (shell.dim as ColorRect).gui_input.connect(_on_dim_gui_input) @@ -32,17 +32,7 @@ func _init(rap_cost: int = -1) -> void: box.add_child(title) box.add_child(HSeparator.new()) for e in ENTRIES: - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 8) - var chip := ColorRect.new() - chip.custom_minimum_size = Vector2(18, 18) - chip.color = Color(e[0]) - row.add_child(chip) - var lab := UiKit.label("%s —— %s" % [e[1], e[2]], 13, false, UiKit.INK_LABEL) # 亮底墨字(§7.2 数据素面) - lab.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - lab.custom_minimum_size = Vector2(440, 0) - row.add_child(lab) - box.add_child(row) + box.add_child(_legend_row(e)) box.add_child(HSeparator.new()) # RAP 行(表值驱动,禁字面量:刷新代价读行动经济表) if _rap_cost >= 0: @@ -64,6 +54,24 @@ func _init(rap_cost: int = -1) -> void: visible = false + +## 单行域(循环体提函数——色块 chip+词条 18px 网格对齐) +func _legend_row(e: Array) -> HBoxContainer: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + var chip := ColorRect.new() + chip.custom_minimum_size = Vector2(18, 18) + chip.color = Color(e[0]) + row.add_child(chip) + var lab := UiKit.label("%s —— %s" % [e[1], e[2]], 13, false, UiKit.INK_LABEL) # 亮底墨字(§7.2 数据素面) + lab.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + lab.custom_minimum_size = Vector2(440, 0) + row.add_child(lab) + return row + + + + func is_open() -> bool: return visible diff --git a/game/presentation/views/level_map_screen.gd b/game/presentation/views/level_map_screen.gd index 65a53761..9f53deaa 100644 --- a/game/presentation/views/level_map_screen.gd +++ b/game/presentation/views/level_map_screen.gd @@ -31,43 +31,47 @@ const HIT_PAD := 6.0 # 图上命中外扩(指 const CONFIRM_WINDOW_S := 3.0 # 放弃确认态窗口(秒——超时回落) const COL_HP_BAR_BACK := UiKit.HP_BAR_BACK # 迷你血条深底(对暗面板可辨;值单点收 UiKit) -var _runner = null -var _title: Label -var _canvas: Control -var _level_label: Label -var _gp_value: Label -var _hp_value: Label -var _hp_bar: Control -var _attr_value: Label -var _bonus_row: HBoxContainer -var _bonus_value: Label -var _feat_value: Label # 特性持有行(组合根投喂逐件文案) -var _cand_ids: Array[int] = [] # 本停点候选 id 集(图上命中合法集) -var _content_size := Vector2(620, 240) # 图内容包络(open_choices 按图拟合——画布 min 尺寸随它) -var _hover_id := -1 # 悬停预承诺候选(-1 = 无) -var abandon_btn: Button # 放弃入口(可见性归组合根 set_abandon_available——只在冒险模式开) -var status_badge: Button # 状态面板入口角标(顶栏 GP 徽右侧;连接归组合根) -var _abandon_timer := ArmedTimer.new() # 放弃确认窗(_process 帧驱动倒计时机制收口;窗口=CONFIRM_WINDOW_S) -var _drawn_disp: Dictionary[int, Dictionary] = {} # 本停点实际绘制记录(_draw_graph 逐帧在实际绘制点重建——display_probe 消费面) -var _node_icons := {} # 节点图标贴图(load_tex 守卫——缺位 null → 字形回退,冒烟/无素材检出安全) -var _noise_rect: TextureRect # 屏底石纹(换层色板渐变承载面——null = 纯色回退无挂点) -var _tint_level := 0 # 当前已应用 tint 的层号(0 = 未应用) -var _layout_cache := {"nodes": [], "canvas_size": Vector2.ZERO, "pos": {}} # 布局缓存(_layout 纯函数于 (nodes, canvas)——键=nodes 内容等价+画布尺寸,图态换代或画布尺寸变即自然失效) +## 内部状态件(u2 子域拆件——契约 9 职责轴):_MapState=选路屏运行态与图绘制态(单一变化原因= +## 选路呈现)。_layer_decor 域字典承载换层装饰三件(图标贴图/石纹面/tint 层号——装饰域归并形态)。 +## 探针直读冻结成员(_cand_ids/_hover_id/_content_size/_layout_cache/abandon_btn/status_badge)住宿主。 +class _MapState extends RefCounted: + var _runner = null + var _title: Label + var _canvas: Control + var _level_label: Label + var _gp_value: Label + var _hp_value: Label + var _hp_bar: Control + var _attr_value: Label + var _bonus_row: HBoxContainer + var _bonus_value: Label + var _feat_value: Label # 特性持有行(组合根投喂逐件文案) + var _abandon_timer := ArmedTimer.new() # 放弃确认窗(_process 帧驱动倒计时机制收口;窗口=CONFIRM_WINDOW_S) + var _drawn_disp: Dictionary[int, Dictionary] = {} # 本停点实际绘制记录(_draw_graph 逐帧在实际绘制点重建——display_probe 消费面) + var _layer_decor := {"node_icons": {}, "noise_rect": null, "tint_level": 0} # 换层装饰域(节点图标贴图 load_tex 守卫缺位 null→字形回退/屏底石纹面/当前已应用 tint 层号 0=未应用) + +var _cand_ids: Array[int] = [] # 本停点候选 id 集(图上命中合法集;探针直读冻结——住宿主) +var _content_size := Vector2(620, 240) # 图内容包络(open_choices 按图拟合——画布 min 尺寸随它;探针直读冻结) +var _hover_id := -1 # 悬停预承诺候选(-1 = 无;探针直读冻结) +var abandon_btn: Button # 放弃入口(可见性归组合根 set_abandon_available——只在冒险模式开;探针直读冻结) +var status_badge: Button # 状态面板入口角标(顶栏 GP 徽右侧;连接归组合根;探针直读冻结) +var _layout_cache := {"nodes": [], "canvas_size": Vector2.ZERO, "pos": {}} # 布局缓存(_layout 纯函数于 (nodes, canvas)——键=nodes 内容等价+画布尺寸,图态换代或画布尺寸变即自然失效;探针直读冻结) +var _map := _MapState.new() func _init() -> void: - _abandon_timer.expired.connect(_reset_abandon_confirm) + _map._abandon_timer.expired.connect(_reset_abandon_confirm) # 节点图标贴图装载(load_tex 守卫,缺位回退现行字形) for t in TEX_NODE_ICONS: - _node_icons[t] = UiKit.load_tex(TEX_NODE_ICONS[t]) + _map._layer_decor.node_icons[t] = UiKit.load_tex(TEX_NODE_ICONS[t]) # 独立屏底(spec Layout——demo #v-map 暖棕竖向 # 渐变(#4A3B2A 0%→#3A2E22 40%)+石纹弱质感 0.14+暗角聚焦;底纹缺位回退纯色) - var base := UiKit.gradient_base(UiKit.BG_MAP_TOP, UiKit.BG_ROOT) + var base := UiKit._gradient_base(UiKit.BG_MAP_TOP, UiKit.BG_ROOT) add_child(base) var tex := UiKit.backdrop_tile() if tex != null: base.add_child(tex) - _noise_rect = tex # 世界侧 tint 承载面(换层色板渐变挂点——§2.7 切片档) + _map._layer_decor.noise_rect = tex # 世界侧 tint 承载面(换层色板渐变挂点——§2.7 切片档) base.add_child(UiKit.vignette_rect()) var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) @@ -75,9 +79,16 @@ func _init() -> void: var box := VBoxContainer.new() box.add_theme_constant_override("separation", 10) center.add_child(box) - # 顶栏语境(spec Layout:左关卡名 / 右 GP 常驻徽章——直角细线数据框; - # 对齐 demo ④:关卡名入框板+GP 徽章=烛光胶囊(同商店GP框)+ - # 左右两极排布) + _build_top_bar(box) + _build_canvas(box) + _build_status_bar(box) + visible = false + + +## 顶栏域(spec Layout:左关卡名 / 右 GP 常驻徽章——直角细线数据框; +## 对齐 demo ④:关卡名入框板+GP 徽章=烛光胶囊(同商店GP框)+ 左右两极排布; +## 状态角标置灰守卫归组合根 _sync_status_entry,信号连接归组合根——视图零连接) +func _build_top_bar(box: VBoxContainer) -> void: var top := HBoxContainer.new() top.add_theme_constant_override("separation", 12) top.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -86,8 +97,8 @@ func _init() -> void: lvl_plate.add_theme_stylebox_override("panel", UiKit.panel_flat(UiKit.DARK_UPCOMING, UiKit.PANEL_BORDER, 0, 1, Vector4(12, 3, 12, 3))) top.add_child(lvl_plate) - _level_label = UiKit.label("",UiKit.FS_BODY, true, UiKit.WARM) - lvl_plate.add_child(_level_label) + _map._level_label = UiKit.label("",UiKit.FS_BODY, true, UiKit.WARM) + lvl_plate.add_child(_map._level_label) var top_sp := Control.new() top_sp.size_flags_horizontal = Control.SIZE_EXPAND_FILL top.add_child(top_sp) @@ -98,31 +109,34 @@ func _init() -> void: var gp_row := HBoxContainer.new() gp_row.add_theme_constant_override("separation", 6) gp_box.add_child(gp_row) - _gp_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_ON_GOLD) - gp_row.add_child(_gp_value) - # 状态面板入口角标(顶栏常驻;本屏只留状态——调试角标归全局浮层。 - # 置灰守卫归组合根 _sync_status_entry(其他模态开着=不可点;本屏自身的登记开态不计——has_open_excluding); - # 信号连接归组合根(视图零连接)) + _map._gp_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_ON_GOLD) + gp_row.add_child(_map._gp_value) status_badge = UiKit.button("状态", 0.0) status_badge.add_theme_font_size_override("font_size", UiKit.FS_HINT) status_badge.tooltip_text = "查看本局冒险进度(只读)" status_badge.size_flags_vertical = Control.SIZE_SHRINK_CENTER top.add_child(status_badge) - _title = UiKit.label(TITLE_FORK,UiKit.FS_SUB, true, UiKit.WARM) - _title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - box.add_child(_title) - # 节点图画布(图上点击 = 唯一提交通道——布局决策 2;mouse_filter STOP 接悬停/点击) - _canvas = Control.new() - _canvas.custom_minimum_size = Vector2(620, 240) - _canvas.mouse_filter = Control.MOUSE_FILTER_STOP - _canvas.draw.connect(_draw_graph.bind(_canvas)) - _canvas.gui_input.connect(_on_canvas_input) - box.add_child(_canvas) - # 底部状态栏(合并单条:亮米面板一站读齐=头像+生命+属性+本关加成/流派件——demo ④; - # 道具槽不摆=道具读数无关卡画面成文出口(读快照私域越权); - # _hp_value/_attr_value/_bonus_value/_feat_value 投喂键全部保留) - # 底栏定高纪律(对齐 demo——单条 ~85px 紧凑亮条;任何子件不再能把栏撑爆: - # 特性持有行限宽截断+tooltip 全文) + _map._title = UiKit.label(TITLE_FORK,UiKit.FS_SUB, true, UiKit.WARM) + _map._title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + box.add_child(_map._title) + + +## 节点图画布域(图上点击 = 唯一提交通道——布局决策 2;mouse_filter STOP 接悬停/点击) +func _build_canvas(box: VBoxContainer) -> void: + _map._canvas = Control.new() + _map._canvas.custom_minimum_size = Vector2(620, 240) + _map._canvas.mouse_filter = Control.MOUSE_FILTER_STOP + _map._canvas.draw.connect(_draw_graph.bind(_map._canvas)) + _map._canvas.gui_input.connect(_on_canvas_input) + box.add_child(_map._canvas) + + +## 底部状态栏域(合并单条:亮米面板一站读齐=头像+生命+属性+本关加成/流派件——demo ④; +## 道具槽不摆=道具读数无关卡画面成文出口(读快照私域越权); +## _hp_value/_attr_value/_bonus_value/_feat_value 投喂键全部保留。 +## 底栏定高纪律(对齐 demo——单条 ~85px 紧凑亮条;任何子件不再能把栏撑爆: +## 特性持有行限宽截断+tooltip 全文)) +func _build_status_bar(box: VBoxContainer) -> void: var bottom := PanelContainer.new() bottom.custom_minimum_size = Vector2(0, 85) bottom.size_flags_vertical = Control.SIZE_SHRINK_END @@ -132,9 +146,37 @@ func _init() -> void: var strip := HBoxContainer.new() strip.add_theme_constant_override("separation", 18) bottom.add_child(strip) + strip.add_child(_build_face_rect()) + var life_box := VBoxContainer.new() + strip.add_child(life_box) + life_box.add_child(UiKit.label("生命",UiKit.FS_CAPTION, false, UiKit.INK_HINT)) + _map._hp_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_TITLE) + life_box.add_child(_map._hp_value) + _map._hp_bar = Control.new() + _map._hp_bar.custom_minimum_size = Vector2(130, 7) + _map._hp_bar.draw.connect(_draw_hp_bar.bind(_map._hp_bar)) + life_box.add_child(_map._hp_bar) + strip.add_child(_vdiv()) + var attr_box := VBoxContainer.new() + strip.add_child(attr_box) + attr_box.add_child(UiKit.label("属性",UiKit.FS_CAPTION, false, UiKit.INK_HINT)) + _map._attr_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_TITLE) + attr_box.add_child(_map._attr_value) + strip.add_child(_vdiv()) + _build_bonus_group(strip) + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + strip.add_child(spacer) + abandon_btn = UiKit.danger_button("放弃", "", 90.0) # 小号危险钮 ~90×38 + abandon_btn.pressed.connect(_on_abandon_pressed) + abandon_btn.visible = false + strip.add_child(abandon_btn) + + +## 头像域:定高 48px=1×、宽随源图比自适应(源 46×48 → 46×48;纵横异倍消除) +func _build_face_rect() -> TextureRect: var face := TextureRect.new() var face_tex := UiKit.load_tex(UiKit.TEX_HERO_PORTRAIT) - # 定高 48px=1×、宽随源图比自适应(源 46×48 → 46×48;纵横异倍消除) var face_h := 48.0 var face_w := face_h if face_tex != null: @@ -145,50 +187,26 @@ func _init() -> void: face.expand_mode = TextureRect.EXPAND_IGNORE_SIZE face.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED face.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST - strip.add_child(face) - var life_box := VBoxContainer.new() - strip.add_child(life_box) - life_box.add_child(UiKit.label("生命",UiKit.FS_CAPTION, false, UiKit.INK_HINT)) - _hp_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_TITLE) - life_box.add_child(_hp_value) - _hp_bar = Control.new() - _hp_bar.custom_minimum_size = Vector2(130, 7) - _hp_bar.draw.connect(_draw_hp_bar.bind(_hp_bar)) - life_box.add_child(_hp_bar) - strip.add_child(_vdiv()) - var attr_box := VBoxContainer.new() - strip.add_child(attr_box) - attr_box.add_child(UiKit.label("属性",UiKit.FS_CAPTION, false, UiKit.INK_HINT)) - _attr_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_TITLE) - attr_box.add_child(_attr_value) - strip.add_child(_vdiv()) - # 本关加成框(零加成隐藏——spec 布局决策 4)+ 流派件持有行(组合根投喂) - _bonus_row = HBoxContainer.new() - strip.add_child(_bonus_row) + return face + + +## 本关加成框域(零加成隐藏——spec 布局决策 4)+ 流派件持有行(组合根投喂;限宽单行截断+tooltip 全文) +func _build_bonus_group(strip: HBoxContainer) -> void: + _map._bonus_row = HBoxContainer.new() + strip.add_child(_map._bonus_row) var bonus_box := VBoxContainer.new() - _bonus_row.add_child(bonus_box) + _map._bonus_row.add_child(bonus_box) var bonus_inner := VBoxContainer.new() bonus_box.add_child(bonus_inner) var bonus_cap := UiKit.label("属性值提升",UiKit.FS_CAPTION, false, UiKit.INK_HINT) # 深墨(亮板可读) bonus_inner.add_child(bonus_cap) - _bonus_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_TITLE) # 淡墨→深墨(同上) - bonus_inner.add_child(_bonus_value) - # 特性持有行(组合根投喂) - # 限宽单行截断+tooltip 全文; - # 墨色 INK_HINT(暖白系对亮板 ≈1:1 不可读——§7.2) - _feat_value = UiKit.label("",UiKit.FS_HINT, false, UiKit.INK_HINT) - _feat_value.custom_minimum_size = Vector2(240, 0) - _feat_value.clip_text = true - _feat_value.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - bonus_box.add_child(_feat_value) - var spacer := Control.new() - spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL - strip.add_child(spacer) - abandon_btn = UiKit.danger_button("放弃", "", 90.0) # 小号危险钮 ~90×38 - abandon_btn.pressed.connect(_on_abandon_pressed) - abandon_btn.visible = false - strip.add_child(abandon_btn) - visible = false + _map._bonus_value = UiKit.label("",UiKit.FS_BODY, true, UiKit.INK_TITLE) # 淡墨→深墨(同上) + bonus_inner.add_child(_map._bonus_value) + _map._feat_value = UiKit.label("",UiKit.FS_HINT, false, UiKit.INK_HINT) + _map._feat_value.custom_minimum_size = Vector2(240, 0) + _map._feat_value.clip_text = true + _map._feat_value.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + bonus_box.add_child(_map._feat_value) # 竖分隔线(底栏分组线——亮面板上低对比细线) @@ -202,10 +220,10 @@ static func _vdiv() -> ColorRect: func _draw_hp_bar(bar: Control) -> void: # 迷你血条(深底 UiKit.HP_BAR_BACK #28221C + 小人蓝填充——与战斗站桩血条同语言, # 「蓝只说己方」§4.2)。比例 = hero_hp / effective_max_hp - if _runner == null: + if _map._runner == null: return - var max_hp := maxi(1, _runner.effective_max_hp()) - var ratio := clampf(float(_runner.hero_hp) / float(max_hp), 0.0, 1.0) + var max_hp := maxi(1, _map._runner.effective_max_hp()) + var ratio := clampf(float(_map._runner.hero_hp) / float(max_hp), 0.0, 1.0) var full := Rect2(Vector2.ZERO, bar.size) bar.draw_rect(full, COL_HP_BAR_BACK) if ratio > 0.0: @@ -215,8 +233,8 @@ func _draw_hp_bar(bar: Control) -> void: # 放弃入口可见性(组合根在冒险启动时开、默认关——语义对象 = 整局冒险) # 特性持有投影(组合根投喂逐件文案;空文案=行隐藏) func set_feat_holdings(text: String) -> void: - _feat_value.text = text - _feat_value.tooltip_text = text # 截断全量兜底(限宽单行省略——悬停看全文) + _map._feat_value.text = text + _map._feat_value.tooltip_text = text # 截断全量兜底(限宽单行省略——悬停看全文) func set_abandon_available(v: bool) -> void: @@ -226,8 +244,8 @@ func set_abandon_available(v: bool) -> void: # 放弃二次确认(布局决策 3):首击转确认态(文案+危险红脉冲约 3 秒超时回落),二击才提交。 # 单击不提交——ui_probe 战斗中 pressed.emit() 单击路径保持「未受理」语义(战斗中拒绝归组合根)。 func _on_abandon_pressed() -> void: - if not _abandon_timer.is_armed(): - _abandon_timer.arm(CONFIRM_WINDOW_S) + if not _map._abandon_timer.is_armed(): + _map._abandon_timer.arm(CONFIRM_WINDOW_S) abandon_btn.text = "再点一次确认放弃——不可逆" if ArmedTimer.motion_still(): abandon_btn.modulate = Color(1, 1, 1, 0.75) # 减动效静态基色(脉冲层不再驱动) @@ -237,45 +255,45 @@ func _on_abandon_pressed() -> void: func _reset_abandon_confirm() -> void: - _abandon_timer.disarm() + _map._abandon_timer.disarm() abandon_btn.modulate = Color(1, 1, 1, 1) abandon_btn.text = "放弃" # 文案收敛 level-map 4 字上限(确认态转义面见 _on_abandon_pressed) func _process(delta: float) -> void: - if not _abandon_timer.is_armed(): + if not _map._abandon_timer.is_armed(): return - _abandon_timer.tick(delta) - if not _abandon_timer.is_armed(): + _map._abandon_timer.tick(delta) + if not _map._abandon_timer.is_armed(): return # 窗口到点回落(expired 信号已驱动 _reset_abandon_confirm) # 危险红边框脉冲(单段呼吸约 1Hz——闪烁基线 ≤3 次/秒内;减动效 = 静态半透不闪——档判据单点 motion_still) if ArmedTimer.motion_still(): abandon_btn.modulate = Color(1, 1, 1, 0.75) else: - var k := 0.5 + 0.5 * sin(_abandon_timer.elapsed * TAU) + var k := 0.5 + 0.5 * sin(_map._abandon_timer.elapsed * TAU) abandon_btn.modulate = Color(1, 1, 1, 0.65 + 0.35 * k) # 打开选路口(cands = 候选节点引用数组;runner 只读用于整图渲染与状态栏刷新) func open_choices(cands: Array, runner: LevelRunner) -> void: var was_visible := visible # 进入转场判据(就地刷新/模态栈恢复不重放淡入) - _runner = runner + _map._runner = runner _hover_id = -1 _reset_abandon_confirm() var visited: Array[int] = [] if runner != null: visited = runner.visited_ids() if cands.size() > 1: - _title.text = TITLE_FORK + _map._title.text = TITLE_FORK elif visited.is_empty(): - _title.text = TITLE_ENTRY # 入口停点(尚未走出第一步) + _map._title.text = TITLE_ENTRY # 入口停点(尚未走出第一步) else: - _title.text = TITLE_SINGLE + _map._title.text = TITLE_SINGLE _cand_ids = [] for cand in cands: _cand_ids.append(_id_of(cand)) - _fit_canvas(_runner.nodes()) - _apply_level_tint(int(_runner.level_row.get("level_index", 0))) + _fit_canvas(_map._runner.nodes()) + _apply_level_tint(int(_map._runner.level_row.get("level_index", 0))) visible = true if not was_visible: # 独立屏淡入(spec Transitions:约 200ms;alpha 淡入非明度变化——art-bible §2 明度恒定) @@ -302,7 +320,7 @@ func _fit_canvas(nodes: Array) -> void: _content_size = Vector2(LAYER_BASE_X + maxl * LAYER_STEP_X + NODE_HALF.x + 8.0, SLOT_BASE_Y + maxslot * SLOT_STEP_Y + NODE_HALF.y + 24.0) # +24 = 类型文字标签行 _content_size = _content_size.max(Vector2(620, 256)) # 下限(抬高容纳标签行) - _canvas.custom_minimum_size = _content_size + _map._canvas.custom_minimum_size = _content_size # 换层色板渐变挂点(art-bible §2.7 下探过渡切片档——关卡结算确认→下一关→独立屏淡入链路上的 @@ -313,39 +331,39 @@ const BOARD_TINTS := {1: Color(1, 1, 1), 2: Color(0.84, 0.99, 1.28), 3: Color(0. func _apply_level_tint(level_index: int) -> void: - if _noise_rect == null or level_index == _tint_level: + if _map._layer_decor.noise_rect == null or level_index == _map._layer_decor.tint_level: return - _tint_level = level_index + _map._layer_decor.tint_level = level_index var target: Color = BOARD_TINTS.get(level_index, Color(1, 1, 1)) var tw := create_tween() - tw.tween_property(_noise_rect, "modulate", + tw.tween_property(_map._layer_decor.noise_rect, "modulate", Color(target.r, target.g, target.b, UiKit.BACKDROP_TILE_ALPHA), UiKit.motion(0.45)) # 0.14 弱质感档随 tint 保持 # 状态栏投喂(命令式纪律——组合根经 level_map_status_payload 取成品后喂入;零推送信号订阅、零核心态拼装) func apply_status(payload: Dictionary) -> void: - _level_label.text = String(payload["level_label"]) - _gp_value.text = String(payload["gp_text"]) - _hp_value.text = String(payload["hp_text"]) + _map._level_label.text = String(payload["level_label"]) + _map._gp_value.text = String(payload["gp_text"]) + _map._hp_value.text = String(payload["hp_text"]) if bool(payload["hp_bar_redraw"]): - _hp_bar.queue_redraw() - _attr_value.text = String(payload["attr_text"]) - _bonus_value.text = String(payload["bonus_text"]) + _map._hp_bar.queue_redraw() + _map._attr_value.text = String(payload["attr_text"]) + _map._bonus_value.text = String(payload["bonus_text"]) # 特性持有行可见性(与加成行并框——任一非空整框显示;文案归组合根 set_feat_holdings) - _bonus_row.visible = bool(payload["bonus_row_visible"]) or _feat_value.text != "" + _map._bonus_row.visible = bool(payload["bonus_row_visible"]) or _map._feat_value.text != "" # ---- 图上命中(布局决策 2——唯一提交通道;探针 press_candidate 同路径) ---- func _on_canvas_input(ev: InputEvent) -> void: - if _runner == null or not visible: + if _map._runner == null or not visible: return if ev is InputEventMouseMotion: var nid := _candidate_at(ev.position) # gui_input 事件坐标 = 接收控件局部坐标(push_input 同成立) if nid != _hover_id: _hover_id = nid - _canvas.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if nid >= 0 else Control.CURSOR_ARROW - _canvas.queue_redraw() + _map._canvas.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if nid >= 0 else Control.CURSOR_ARROW + _map._canvas.queue_redraw() elif ev is InputEventMouseButton and ev.button_index == MOUSE_BUTTON_LEFT and ev.pressed: var nid2 := _candidate_at(ev.position) if nid2 >= 0: @@ -354,10 +372,10 @@ func _on_canvas_input(ev: InputEvent) -> void: # 候选命中:位置 = 布局公式(与 _draw_graph 同源),矩形 = 节点方块 + 外扩容差;非候选格不响应 func _candidate_at(local_pos: Vector2) -> int: - if _runner == null: + if _map._runner == null: return -1 - var nodes: Array[Variant] = _runner.nodes() - var pos := _layout(nodes, _canvas) + var nodes: Array[Variant] = _map._runner.nodes() + var pos := _layout(nodes, _map._canvas) for nid in _cand_ids: var node := _node_by_id(nid, nodes) if node.is_empty(): @@ -372,7 +390,7 @@ func _candidate_at(local_pos: Vector2) -> int: func _submit_choice(nid: int) -> void: # 独立屏分支:战斗/Boss 节点 = 出屏切回战斗屏;奖励/恢复/商店节点 = # 本屏保持激活——兑付覆盖层由节点事件在本屏域内弹出(模态栈遮挡恢复语义复用),兑付确认后回图刷新 - var node := _node_by_id(nid, _runner.nodes()) + var node := _node_by_id(nid, _map._runner.nodes()) var t := String(node.get("type", "")) if t != "reward" and t != "rest" and t != "shop": visible = false @@ -381,13 +399,13 @@ func _submit_choice(nid: int) -> void: # 探针复用:按候选序号走图上命中同路径(候选节点中心坐标 → 命中检测 → 提交——布局决策 2 探针面改造) func press_candidate(index: int) -> void: - if index < 0 or index >= _cand_ids.size() or _runner == null: + if index < 0 or index >= _cand_ids.size() or _map._runner == null: return - var nodes: Array[Variant] = _runner.nodes() + var nodes: Array[Variant] = _map._runner.nodes() var node := _node_by_id(int(_cand_ids[index]), nodes) if node.is_empty(): return - var pos: Vector2 = _layout(nodes, _canvas)[node] + var pos: Vector2 = _layout(nodes, _map._canvas)[node] var nid := _candidate_at(pos) # 节点中心必命中自身(命中面含节点方块) if nid >= 0: _submit_choice(nid) @@ -395,7 +413,7 @@ func press_candidate(index: int) -> void: func _id_of(node: Dictionary) -> int: # 编号显示经行进器读面(编号空间解析单一出口——单链 = 下标 / 图 = node_depth) - return _runner.node_id_of(node) + return _map._runner.node_id_of(node) # ---- 节点呈现决策面(信息迷雾 v2.1:类型仅在可见集内出示,Boss 标记不入雾)---- @@ -421,20 +439,48 @@ static func node_display(node: Dictionary, nid: int, type_vis: Array, # ---- 整图自绘(程序化占位——分层布局:层 = 入口 BFS 深度,同层垂直排布)---- func _draw_graph(canvas: Control) -> void: - if _runner == null or not visible: + if _map._runner == null or not visible: return - _drawn_disp.clear() # 绘制记录随本帧重建——只反映实际发生的绘制(接线坏的判据面) - var nodes: Array[Variant] = _runner.nodes() - var pos := _layout(nodes, canvas) - var font := get_theme_default_font() # 绘制路径字体单次取用(三处 draw_string 共享——零语义差) - var current_id: int = _runner.current_node_id() - # 当前节点 id(单链=下标 / 图=编号)——visited_ids() 已按编号空间解析 - var visited: Array[Variant] = _runner.visited_ids() - var cand_ids: Array[Variant] = _runner.next_candidate_ids() - var type_vis: Array[int] = _runner.type_visible_ids() # 本停点类型可见集(每帧一取——纯函数零状态) - # 连线(通道)先行画(方块覆盖其上);悬停预承诺 = 所属通道再增亮(加粗) - # 选路停点(候选非空)非候选通道降透明——金岔路从结构网分离(干线共线重叠缺陷的 - # 视觉主因:不同源通道共用同一垂直干线,金边与石色边焊接成「整列可走」假象) + _map._drawn_disp.clear() # 绘制记录随本帧重建——只反映实际发生的绘制(接线坏的判据面) + var ctx := _draw_context(canvas) + _draw_passage_layer(canvas, ctx) + for node in ctx["nodes"]: + _draw_room(canvas, node, ctx) + + +## 绘制上下文束(单次取数——布局/字体/编号空间/可见集,绘制域共享只读面) +func _draw_context(canvas: Control) -> Dictionary: + var nodes: Array[Variant] = _map._runner.nodes() + return { + "nodes": nodes, + "pos": _layout(nodes, canvas), + "font": get_theme_default_font(), # 绘制路径字体单次取用(三处 draw_string 共享——零语义差) + "current_id": _map._runner.current_node_id(), + # 当前节点 id(单链=下标 / 图=编号)——visited_ids() 已按编号空间解析 + "visited": _map._runner.visited_ids(), + "cand_ids": _map._runner.next_candidate_ids(), + "type_vis": _map._runner.type_visible_ids(), # 本停点类型可见集(每帧一取——纯函数零状态) + } + + +## 房间底色域(行进态三色:已到暗金/当前奖励金/未达暗——绘制域与图标域共享) +func _room_color(node: Dictionary, ctx: Dictionary) -> Color: + var nid := _id_of(node) + var col := COL_ROOM_FUTURE + if (ctx["visited"] as Array).has(nid): + col = COL_ROOM_DONE + if nid == ctx["current_id"]: + col = COL_ROOM_CURRENT + return col + + +## 通道层绘制域:连线先行画(方块覆盖其上);悬停预承诺 = 所属通道再增亮(加粗)。 +## 选路停点(候选非空)非候选通道降透明——金岔路从结构网分离(干线共线重叠缺陷的 +## 视觉主因:不同源通道共用同一垂直干线,金边与石色边焊接成「整列可走」假象) +func _draw_passage_layer(canvas: Control, ctx: Dictionary) -> void: + var nodes: Array = ctx["nodes"] + var pos: Dictionary = ctx["pos"] + var cand_ids: Array = ctx["cand_ids"] var dim_passages: bool = not cand_ids.is_empty() for node in nodes: var from: Vector2 = pos[node] @@ -442,61 +488,74 @@ func _draw_graph(canvas: Control) -> void: var target := _node_by_id(tid, nodes) if target.is_empty(): continue - var to: Vector2 = pos[target] - var lit: bool = _id_of(node) == current_id and cand_ids.has(tid) - var hovered: bool = lit and _hover_id == tid + var lit: bool = _id_of(node) == ctx["current_id"] and cand_ids.has(tid) var col := COL_PASSAGE_LIT if lit else (Color(COL_PASSAGE, 0.35) if dim_passages else COL_PASSAGE) - var width := 4.0 if hovered else (3.0 if lit else 2.0) # 2/3/4 三档 - _draw_passage(canvas, from, to, col, width) - # 房间方块 - for node in nodes: - var p: Vector2 = pos[node] - var t := String(node.get("type", "")) - var nid := _id_of(node) - var col := COL_ROOM_FUTURE - if visited.has(nid): - col = COL_ROOM_DONE - if nid == current_id: - col = COL_ROOM_CURRENT - var rect := Rect2(p - NODE_HALF, NODE_HALF * 2.0) - canvas.draw_rect(rect, col) - var disp := node_display(node, nid, type_vis, nid == current_id, cand_ids.has(nid)) - var rec: Dictionary[String, Variant] = {"draw_icon": false, "draw_label": false, - "label_text": "", "boss_border": false} - if bool(disp["boss_border"]): - # Boss 描边 = 紫赤 2px(§4.2 色相独占)+ 外缘 1px 深墨环(灰度冗余通道强制: - # 紫赤灰度 72 对石纹噪底 65 灰度不可辨;灰度/色弱下 Boss 环经深墨环可辨) - canvas.draw_rect(Rect2(rect.position - Vector2(3, 3), rect.size + Vector2(6, 6)), UiKit.INK_ROUTE, false, 1.0) - canvas.draw_rect(rect, COL_BOSS_BORDER, false, 2.0) - rec["boss_border"] = true - elif cand_ids.has(nid): - # 悬停预承诺 = 亮金描边加粗(4px),常态候选 = 亮金描边 2px(spec States 悬停预承诺行) - canvas.draw_rect(rect, COL_PASSAGE_LIT, false, 4.0 if _hover_id == nid else 2.0) - # 节点图标(20px 烘焙 1:1 消费;双色墨制=乘法调制——当前金底深墨,暗底系 WARM 原色; - # 贴图缺位 → 字形回退=贴图在位字形退役惯例) - if bool(disp["draw_icon"]): - rec["draw_icon"] = true - var icon: Texture2D = _node_icons.get(t) - if icon != null: - var tint := UiKit.ICON_TINT_ON_GOLD if col == COL_ROOM_CURRENT else Color(1.0, 1.0, 1.0) - canvas.draw_texture_rect(icon, Rect2(p - Vector2(NODE_ICON_PX, NODE_ICON_PX) / 2.0, - Vector2(NODE_ICON_PX, NODE_ICON_PX)), false, tint) - else: - var label := String(NODE_LABELS.get(t, "?")) - canvas.draw_string(font, p + Vector2(-6, 2), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 16, UiKit.INK_ROUTE) - # 编号(右上角——与图标占位区零重叠;双色墨随态) - var num_ink := UiKit.INK_ROUTE if col == COL_ROOM_CURRENT else UiKit.WARM - canvas.draw_string(font, Vector2(rect.end.x - 16.0, rect.position.y + 8.0), - str(nid), HORIZONTAL_ALIGNMENT_RIGHT, 14, 10, num_ink) - # 类型文字标签(demo ④:节点下方 11px;当前位=「当前位置」金、候选=类型名+「 · 可选」烛光黄、 - # 其余=类型名暖米弱化;文字+图标+形状三通道) - if bool(disp["draw_label"]): - rec["draw_label"] = true - rec["label_text"] = String(disp["label_text"]) - var type_ink := COL_ROOM_CURRENT if nid == current_id else (UiKit.CANDLE if cand_ids.has(nid) else UiKit.WARM_FAINT) - canvas.draw_string(font, Vector2(p.x - 44.0, rect.end.y + 15.0), - String(disp["label_text"]), HORIZONTAL_ALIGNMENT_CENTER, 88, 11, type_ink) - _drawn_disp[nid] = rec + var width := 4.0 if lit and _hover_id == tid else (3.0 if lit else 2.0) # 2/3/4 三档 + _draw_passage(canvas, from, pos[target], col, width) + + +## 房间方块域:底色随行进态+Boss/候选描边+图标/字形+编号+类型文字标签 +func _draw_room(canvas: Control, node: Dictionary, ctx: Dictionary) -> void: + var pos: Dictionary = ctx["pos"] + var p: Vector2 = pos[node] + var t := String(node.get("type", "")) + var nid := _id_of(node) + var col := _room_color(node, ctx) + var rect := Rect2(p - NODE_HALF, NODE_HALF * 2.0) + canvas.draw_rect(rect, col) + var disp := node_display(node, nid, ctx["type_vis"], nid == ctx["current_id"], (ctx["cand_ids"] as Array).has(nid)) + var rec: Dictionary[String, Variant] = {"draw_icon": false, "draw_label": false, + "label_text": "", "boss_border": false} + if bool(disp["boss_border"]): + # Boss 描边 = 紫赤 2px(§4.2 色相独占)+ 外缘 1px 深墨环(灰度冗余通道强制: + # 紫赤灰度 72 对石纹噪底 65 灰度不可辨;灰度/色弱下 Boss 环经深墨环可辨) + canvas.draw_rect(Rect2(rect.position - Vector2(3, 3), rect.size + Vector2(6, 6)), UiKit.INK_ROUTE, false, 1.0) + canvas.draw_rect(rect, COL_BOSS_BORDER, false, 2.0) + rec["boss_border"] = true + elif (ctx["cand_ids"] as Array).has(nid): + # 悬停预承诺 = 亮金描边加粗(4px),常态候选 = 亮金描边 2px(spec States 悬停预承诺行) + canvas.draw_rect(rect, COL_PASSAGE_LIT, false, 4.0 if _hover_id == nid else 2.0) + if _draw_room_icon(canvas, node, disp, ctx): + rec["draw_icon"] = true + # 编号(右上角——与图标占位区零重叠;双色墨随态) + var num_ink := UiKit.INK_ROUTE if col == COL_ROOM_CURRENT else UiKit.WARM + canvas.draw_string(ctx["font"], Vector2(rect.end.x - 16.0, rect.position.y + 8.0), + str(nid), HORIZONTAL_ALIGNMENT_RIGHT, 14, 10, num_ink) + var label_rec := _draw_room_label(canvas, rect, disp, nid, ctx) + rec["draw_label"] = bool(label_rec.get("draw_label", false)) + rec["label_text"] = String(label_rec.get("label_text", "")) + _map._drawn_disp[nid] = rec + + +## 节点图标域(20px 烘焙 1:1 消费;双色墨制=乘法调制——当前金底深墨,暗底系 WARM 原色; +## 贴图缺位 → 字形回退=贴图在位字形退役惯例) +func _draw_room_icon(canvas: Control, node: Dictionary, disp: Dictionary, ctx: Dictionary) -> bool: + if not bool(disp["draw_icon"]): + return false + var p: Vector2 = ctx["pos"][node] + var col := _room_color(node, ctx) + var t := String(node.get("type", "")) + var icon: Texture2D = _map._layer_decor.node_icons.get(t) + if icon != null: + var tint := UiKit.ICON_TINT_ON_GOLD if col == COL_ROOM_CURRENT else Color(1.0, 1.0, 1.0) + canvas.draw_texture_rect(icon, Rect2(p - Vector2(NODE_ICON_PX, NODE_ICON_PX) / 2.0, + Vector2(NODE_ICON_PX, NODE_ICON_PX)), false, tint) + else: + var label := String(NODE_LABELS.get(t, "?")) + canvas.draw_string(ctx["font"], p + Vector2(-6, 2), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 16, UiKit.INK_ROUTE) + return true + + +## 类型文字标签域(demo ④:节点下方 11px;当前位=「当前位置」金、候选=类型名+「 · 可选」烛光黄、 +## 其余=类型名暖米弱化;文字+图标+形状三通道) +func _draw_room_label(canvas: Control, rect: Rect2, disp: Dictionary, nid: int, ctx: Dictionary) -> Dictionary: + if not bool(disp["draw_label"]): + return {} + var p: Vector2 = rect.position + NODE_HALF # 节点中心反推(方块原点=中心−半宽高) + var type_ink := COL_ROOM_CURRENT if nid == ctx["current_id"] else (UiKit.CANDLE if (ctx["cand_ids"] as Array).has(nid) else UiKit.WARM_FAINT) + canvas.draw_string(ctx["font"], Vector2(p.x - 44.0, rect.end.y + 15.0), + String(disp["label_text"]), HORIZONTAL_ALIGNMENT_CENTER, 88, 11, type_ink) + return {"draw_label": true, "label_text": String(disp["label_text"])} func _layout(nodes: Array, canvas: Control) -> Dictionary: @@ -520,20 +579,20 @@ func _layout(nodes: Array, canvas: Control) -> Dictionary: # ---- 探针只读出口(形态仿 fx_probe 先例:只读快照) ---- -# 选路布局只读快照(node → 局部位置;画布固定取现行 _canvas,调用侧零私有域触达) +# 选路布局只读快照(node → 局部位置;画布固定取现行 _map._canvas,调用侧零私有域触达) func layout_probe(nodes: Array) -> Dictionary: - return _layout(nodes, _canvas) + return _layout(nodes, _map._canvas) # 路由画布只读快照(origin=全局原点;min_size=最小拟合尺寸——画布拟合断言消费面) func canvas_probe() -> Dictionary: - return {"origin": _canvas.get_global_position(), "min_size": _canvas.custom_minimum_size} + return {"origin": _map._canvas.get_global_position(), "min_size": _map._canvas.custom_minimum_size} # 类型可见性绘制只读记录(id → 本帧实际绘制事实:图标/文字/描边/文案;_draw_graph 在实际绘制点写入—— # 接线层断言消费点:绕开决策面直接画,这里就露出「雾下也画了」) func display_probe() -> Dictionary: - return _drawn_disp + return _map._drawn_disp # 分层(layout/fit 共享件):layer = 入口 BFS 深度;同层 slot 垂直排布;未达节点兜底排末层 @@ -577,7 +636,7 @@ func _entry(nodes: Array) -> Dictionary: func _out_ids(node: Dictionary, _nodes: Array) -> Array: - return _runner.node_out_ids(node) + return _map._runner.node_out_ids(node) func _node_by_id(nid: int, nodes: Array) -> Dictionary: diff --git a/game/presentation/views/node_overlay.gd b/game/presentation/views/node_overlay.gd index e82f854c..cabfcdf5 100644 --- a/game/presentation/views/node_overlay.gd +++ b/game/presentation/views/node_overlay.gd @@ -11,7 +11,7 @@ var _btn: Button func _init() -> void: # M5(UI 落码批):rest 兑付面板 plate9 石板化+暖金头带字(demo ④休息弹层;暗字上亮板 §7.2) - var shell: Dictionary[Variant, Variant] = UiKit.modal_shell(self, Color(0, 0, 0, 0.6), Vector2.ZERO, 14, true, + var shell: Dictionary[Variant, Variant] = UiKit.modal_shell(self, Color(0, 0, 0, 0.6), Vector2.ZERO, 14, UiKit.panel_skinned(Vector4(26, 18, 26, 18))) var box: VBoxContainer = shell.box _label = UiKit.label("",UiKit.FS_SUB, true, UiKit.INK_TITLE) diff --git a/game/presentation/views/settings_overlay.gd b/game/presentation/views/settings_overlay.gd index adfe0d12..8850af4e 100644 --- a/game/presentation/views/settings_overlay.gd +++ b/game/presentation/views/settings_overlay.gd @@ -34,10 +34,18 @@ func _build() -> void: # 减动效与结算演出动画=同一设置,一行承载。 # 两列对齐(label 列定宽 140——demo .setrow label/控制两列)+ # 后果说明内联随行(demo .why 同排)+返回钮紧凑化(demo .btn9 紧凑)。 - var shell: Dictionary[Variant, Variant] = UiKit.modal_shell(self, UiKit.BG_BRIGHT, Vector2(560, 0), 8, true, + var shell: Dictionary[Variant, Variant] = UiKit.modal_shell(self, UiKit.BG_BRIGHT, Vector2(560, 0), 8, UiKit.panel_skinned(Vector4(20, 16, 20, 16))) var box: VBoxContainer = shell.box + _build_head(box) + _build_volume_row(box) + _build_fullscreen_row(box) + _build_motion_row(box) + _build_close_row(box) + +## 头部域(设置项零增减——只有主音量;减动效与结算演出动画=同一设置,一行承载) +func _build_head(box: VBoxContainer) -> void: var title := UiKit.label("设置",UiKit.FS_TITLE, true, UiKit.INK_TITLE) title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER box.add_child(title) @@ -46,7 +54,9 @@ func _build() -> void: box.add_child(hint) box.add_child(HSeparator.new()) - # 音量(master bus 实时生效——入口归设置、生效口径归 音频.md) + +## 音量行(master bus 实时生效——入口归设置、生效口径归 音频.md) +func _build_volume_row(box: VBoxContainer) -> void: var vol_row := HBoxContainer.new() vol_row.add_theme_constant_override("separation", 14) box.add_child(vol_row) @@ -66,38 +76,44 @@ func _build() -> void: _volume_val.custom_minimum_size = Vector2(60, 0) vol_row.add_child(_volume_val) - # 全屏(拨杆形——SwitchToggle;状态字随行) + +## 全屏行(拨杆形——SwitchToggle;状态字随行) +func _build_fullscreen_row(box: VBoxContainer) -> void: var fs_row := HBoxContainer.new() fs_row.add_theme_constant_override("separation", 14) box.add_child(fs_row) var fs_lab := UiKit.label("全屏",UiKit.FS_BODY, false, UiKit.INK_LABEL) fs_lab.custom_minimum_size = Vector2(140, 0) # 同上 fs_row.add_child(fs_lab) - _fs_btn = UiKit.switch_toggle(false) + _fs_btn = UiKit._switch_toggle(false) _fs_btn.toggled.connect(_on_fullscreen_toggled) fs_row.add_child(_fs_btn) _fs_st = UiKit.label("关",UiKit.FS_HINT, true, UiKit.INK_LABEL) fs_row.add_child(_fs_st) - # 结算演出动画(=减动效实称;拨杆 on=完整,off=快速时长减半。 - # reduced_motion 单设置同驱 UiKit.motion_scale 与结算演播——demo「减动效」行与 - # 本行系同一设置,不另立行(拆两行=一设置双行重复) + +## 结算演出动画行(=减动效实称;拨杆 on=完整,off=快速时长减半。 +## reduced_motion 单设置同驱 UiKit.motion_scale 与结算演播——demo「减动效」行与 +## 本行系同一设置,不另立行(拆两行=一设置双行重复)) +func _build_motion_row(box: VBoxContainer) -> void: var motion_row := HBoxContainer.new() motion_row.add_theme_constant_override("separation", 14) box.add_child(motion_row) var motion_lab := UiKit.label("结算演出动画",UiKit.FS_BODY, false, UiKit.INK_LABEL) motion_lab.custom_minimum_size = Vector2(140, 0) # 同上 motion_row.add_child(motion_lab) - _motion_btn = UiKit.switch_toggle(true) + _motion_btn = UiKit._switch_toggle(true) _motion_btn.toggled.connect(_on_motion_toggled) motion_row.add_child(_motion_btn) _motion_st = UiKit.label("完整",UiKit.FS_HINT, true, UiKit.INK_LABEL) motion_row.add_child(_motion_st) var why := UiKit.label("快速 = 时长减半(运动敏感 / 演示用)",UiKit.FS_CAPTION, false, UiKit.INK_HINT) motion_row.add_child(why) # 内联随行(demo .why 同排) - box.add_child(HSeparator.new()) - # 退出游戏归开始菜单——设置面不再承载(退出只在一级菜单出现) + + +## 返回钮域(退出游戏归开始菜单——设置面不再承载〔退出只在一级菜单出现〕) +func _build_close_row(box: VBoxContainer) -> void: var close_row := HBoxContainer.new() close_row.alignment = BoxContainer.ALIGNMENT_CENTER box.add_child(close_row) diff --git a/game/presentation/views/settlement_review_overlay.gd b/game/presentation/views/settlement_review_overlay.gd index 7cb886f5..1cdd4b43 100644 --- a/game/presentation/views/settlement_review_overlay.gd +++ b/game/presentation/views/settlement_review_overlay.gd @@ -10,7 +10,7 @@ var _log: RichTextLabel func _init() -> void: - var shell := UiKit.modal_shell(self, Color(UiKit.DIM, 0.62), Vector2(760, 560), 6, true) + var shell := UiKit.modal_shell(self, Color(UiKit.DIM, 0.62), Vector2(760, 560), 6, UiKit.PANEL_DEFAULT) (shell.dim as ColorRect).gui_input.connect(_on_dim_gui_input) var box: VBoxContainer = shell.box box.add_child(UiKit.label("结算明细回看(完整流水 · 逐行归因)",UiKit.FS_SUB, true)) diff --git a/game/presentation/views/shop_overlay_view.gd b/game/presentation/views/shop_overlay_view.gd index 2db37149..8ed5458e 100644 --- a/game/presentation/views/shop_overlay_view.gd +++ b/game/presentation/views/shop_overlay_view.gd @@ -211,7 +211,7 @@ func _build_shelf_card(holder: Control, c: Dictionary) -> Dictionary: body = "%s %+d(本冒险)" % [String(KIND_LABELS.get(kind, kind)), int(c.get("value", 0))] var card := UiKit.bcard(String(c.get("display_name", kind)), body, UiKit.tg_band(kl), icon_key, # 类型色带单点(N4c/Q4 甲——货架与三选一同源判定,原固定默认带) - "%d ◈" % int(c.get("price", 0)), String(KIND_LABELS.get(kind, kind))) + {"price": "%d ◈" % int(c.get("price", 0)), "footnote": String(KIND_LABELS.get(kind, kind))}) var btn := UiKit.button("购买") btn.pressed.connect(_emit_shelf_buy.bind(kind)) card.add_footer(btn) diff --git a/game/presentation/views/standee_body.gd b/game/presentation/views/standee_body.gd index d946c735..ed2b1f7e 100644 --- a/game/presentation/views/standee_body.gd +++ b/game/presentation/views/standee_body.gd @@ -2,6 +2,9 @@ ## 站桩本体:B 区主角/敌人无框立绘+底座+血条+顺位/意图徽标的世界精灵(Node2D)。 ## 站桩形态为定案——禁回卡片式;立绘素材缺位回退字形占位;击杀呈现禁瞬消(灰化塌陷)。 ## 无 class_name——经 preload 引用;壳层坐标换算单点在 BattleStageView.world_to_shell()。 +## 内部状态件(u4 子域拆件——契约 9 职责语句「站桩演出状态」):单一变化原因=站桩呈现与演出, +## 成员共同表达站桩的行进态/演出计数/资产挂点。外部直访冻结成员(box/font/slot_row/has_world_hp_bar) +## 与源码锚承载成员(_intent_charge——theme 源锚句所在行逐字保留)住宿主。 extends Node2D const CHARGE_SKILL_IDS := ["heavy"] @@ -12,50 +15,68 @@ const ARMOR_ICONS := {"light": "◇", "ward": "◆", "heavy": "▣"} # ≥8px 硬余量 + 字形下探预算 ⇒ 12;取值不属色板,不进 ui_kit) const STANDEE_FOOT_MARGIN := 12.0 -var box := Vector2(150, 176) # 站桩盒尺寸(实际高随壳层 row 布局注入) -var font: Font = null # 世界字体槽位(null = ThemeDB.fallback_font——world.world_font 注入) -var slot_row: StatusSlotRow # 状态效果唯一展示面 = 壳层伴随件(挂 BattleStageView 壳层,本体进世界) +const DEATH_SHARD_N := 5 # 碎块数(§8.4 冻结值) +const HIT_SPARK_N := 6 # 命中火花数(打击瞬间可读性一拍) -var _kind := "enemy" -var _portrait_sprite: Sprite2D = null # 资产挂点(null = 未落位,字形在位) -var _portrait_tex: Texture2D = null # 落位纹理引用(set_portrait_texture 装载;字形回退判据) -# 主角三态切换:state 名 → 纹理(battle_stage_view 组合点注册);缺态=保持现纹理 -# (缺位回退链延伸——三态素材缺任一不炸不空)。敌站桩不注册不消费。 -var _hero_tex := {} -enum HeroState { IDLE, RESET, ATTACK, HIT } # 主角三态+复位哨兵(外部 String 契约=register/set 形参零变化) -const _HS_NAME := {HeroState.IDLE: "idle", HeroState.RESET: "reset", HeroState.ATTACK: "attack", HeroState.HIT: "hit"} -const _HS_VALUE := {"idle": HeroState.IDLE, "reset": HeroState.RESET, "attack": HeroState.ATTACK, "hit": HeroState.HIT} -var _hero_state: int = HeroState.IDLE -var _state_revert_tw: Tween -var _order := 1 -var _intent_glyph := "" -var _intent_col := Color.WHITE -var _intent_charge := false # 蓄力档(pid ∈ CHARGE_SKILL_IDS——意图三档 ③ 分支判据) -var _intent_visible := false -var _intent_scale := 1.0: # 意图弹入动画参数 - set(v): - _intent_scale = v - queue_redraw() -var _glyph_rot := 0.0: # 立绘前倾角(贴图态无此动画) - set(v): - _glyph_rot = v - queue_redraw() -var _display_name := "" -var _enemy_id := "" -var _armor := "light" -var _dead := false -var _hp_max := 1.0 -var _hp_cur := 0 # 血量数字显示面(HUD框架 验收「血量数字可读」) -var hp_value := 1.0: # 血条值 0..1(直角递减——tween_property 目标,逐行刷新重启不累积) - set(v): - hp_value = clampf(v, 0.0, 1.0) - queue_redraw() -var _hp_tween: Tween -var _hp_flash_tw: Tween + +## 站桩演出状态(全部内部态单点——含 setter 触发宿主重绘语义,_owner 回引保持逐字) +class _StandeeState extends RefCounted: + var _owner: Node2D = null + var _kind := "enemy" + var _portrait_sprite: Sprite2D = null # 资产挂点(null = 未落位,字形在位) + var _portrait_tex: Texture2D = null # 落位纹理引用(set_portrait_texture 装载;字形回退判据) + # 主角三态切换:state 名 → 纹理(battle_stage_view 组合点注册);缺态=保持现纹理 + # (缺位回退链延伸——三态素材缺任一不炸不空)。敌站桩不注册不消费。 + var _hero_tex := {} + var _hero_state: int = 0 + var _state_revert_tw: Tween + var _order := 1 + var _intent_glyph := "" + var _intent_col := Color.WHITE + var _intent_visible := false + var _intent_scale := 1.0: # 意图弹入动画参数 + set(v): + _intent_scale = v + _owner.queue_redraw() + var _glyph_rot := 0.0: # 立绘前倾角(贴图态无此动画) + set(v): + _glyph_rot = v + _owner.queue_redraw() + var _display_name := "" + var _enemy_id := "" + var _armor := "light" + var _dead := false + var _hp_max := 1.0 + var _hp_cur := 0 # 血量数字显示面(HUD框架 验收「血量数字可读」) + var hp_value := 1.0: # 血条值 0..1(直角递减——tween_property 目标,逐行刷新重启不累积) + set(v): + hp_value = clampf(v, 0.0, 1.0) + _owner.queue_redraw() + var _hp_tween: Tween + var _hp_flash_tw: Tween + var _death_shards := 0 # 探针出口计数(碎块节点回收后仍保留读数) + var _hit_sparks := 0 # 探针出口计数(命中火花累计) + var _flash_count := 0 # 探针出口计数(受击闪白触发次数——主角路由断言面) + + +var st := _StandeeState.new() +var box := Vector2(150, 176) # 站桩盒尺寸(实际高随壳层 row 布局注入;外部直访冻结) +var font: Font = null # 世界字体槽位(null = ThemeDB.fallback_font——world.world_font 注入;外部直访冻结) +var slot_row: StatusSlotRow # 状态效果唯一展示面 = 壳层伴随件(挂 BattleStageView 壳层,本体进世界;外部直访冻结) +var _intent_charge := false # 蓄力档(pid ∈ CHARGE_SKILL_IDS——意图三档 ③ 分支判据;源码锚承载成员住宿主) +# 血条存在性探针出口(世界直角血条常在;外部直访冻结) +var has_world_hp_bar := true func _init(kind: String = "enemy") -> void: - _kind = kind + st._owner = self + st._hero_state = HeroState.IDLE + st._kind = kind + + +enum HeroState { IDLE, RESET, ATTACK, HIT } # 主角三态+复位哨兵(外部 String 契约=register/set 形参零变化) +const _HS_NAME := {HeroState.IDLE: "idle", HeroState.RESET: "reset", HeroState.ATTACK: "attack", HeroState.HIT: "hit"} +const _HS_VALUE := {"idle": HeroState.IDLE, "reset": HeroState.RESET, "attack": HeroState.ATTACK, "hit": HeroState.HIT} func _efont() -> Font: @@ -63,26 +84,22 @@ func _efont() -> Font: func display_name() -> String: - return _display_name + return st._display_name # 敌单位身份出口(d12 站桩解析主键——同名敌按 id 精确匹配;主角站桩恒空串) func enemy_id() -> String: - return _enemy_id - - -# 血条存在性探针出口(世界直角血条常在) -var has_world_hp_bar := true + return st._enemy_id # 意图徽标探针出口 func intent_probe() -> Dictionary: - return {"visible": _intent_visible, "text": _intent_glyph} + return {"visible": st._intent_visible, "text": st._intent_glyph} # 名字行探针出口 func name_line_probe() -> Dictionary: - return {"name": _display_name, "armor_icon": String(ARMOR_ICONS.get(_armor, "◇"))} + return {"name": st._display_name, "armor_icon": String(ARMOR_ICONS.get(st._armor, "◇"))} # ---- setter 面(presenter/组合根零改排) ---- @@ -90,35 +107,34 @@ func name_line_probe() -> Dictionary: # data = 战斗结算 presentation_snapshot enemies[] 投影:{order, display_name, hp, max_hp, enemy_id, # pending_skill/pending_type/pending_name, effects[](敌方 side 增益——各立绘同值呈现)} func set_data(d: Dictionary) -> void: - _display_name = String(d.get("display_name", "敌人")) - _enemy_id = String(d.get("enemy_id", "")) - _armor = String(d.get("armor_type", "light")) - _hp_max = float(maxi(1, int(d.get("max_hp", 1)))) + st._display_name = String(d.get("display_name", "敌人")) + st._enemy_id = String(d.get("enemy_id", "")) + st._armor = String(d.get("armor_type", "light")) + st._hp_max = float(maxi(1, int(d.get("max_hp", 1)))) _set_hp(int(d.get("hp", 0))) _set_order(int(d.get("order", 1))) set_pending(String(d.get("pending_type", "")), String(d.get("pending_skill", ""))) set_effects(d.get("effects", [])) - _refresh_portrait_glyph() - if _dead and int(d.get("hp", 0)) > 0: - _revive() queue_redraw() + if st._dead and int(d.get("hp", 0)) > 0: + _revive() func set_hero(hp: int, max_hp: int) -> void: - _hp_max = float(maxi(1, max_hp)) + st._hp_max = float(maxi(1, max_hp)) _set_hp(hp) - if _dead and hp > 0: + if st._dead and hp > 0: _revive() # 新战斗开场 HP 回正——主角死亡残位随之复活(敌侧 set_data 同款语义) # 血条短 tween 递减(直角递减占位);逐行刷新时重启 tween 不累积 func _set_hp(hp: int) -> void: - _hp_cur = clampi(hp, 0, int(_hp_max)) - var target := float(_hp_cur) / _hp_max - if _hp_tween != null and _hp_tween.is_valid(): - _hp_tween.kill() - _hp_tween = create_tween() - _hp_tween.tween_property(self, "hp_value", target, UiKit.motion(0.15)) + st._hp_cur = clampi(hp, 0, int(st._hp_max)) + var target := float(st._hp_cur) / st._hp_max + if st._hp_tween != null and st._hp_tween.is_valid(): + st._hp_tween.kill() + st._hp_tween = create_tween() + st._hp_tween.tween_property(self, "st.hp_value", target, UiKit.motion(0.15)) # 主角/敌方共用——状态效果唯一展示面(壳层伴随件转发;空态 = 图标行隐藏不占位) @@ -150,21 +166,21 @@ func set_effects_cleared_feedback() -> void: func _set_order(i: int) -> void: # 顺位徽标 = 行主序生成序(固定顺位——敌人系统 顺位标记义务) - _order = i + st._order = i queue_redraw() # 摇定动作渲染(三档可见性):pending_type = 战斗结算 快照投影(basic/damage/buff/debuff; # "" = 未摇定/死亡残位)。不显示技能名——类型级图标 + 一次性动作动画。 func set_pending(ptype: String, pid: String) -> void: - if _dead: + if st._dead: _intent_charge = false - _intent_visible = false + st._intent_visible = false queue_redraw() return if ptype == "" or ptype == "basic": _intent_charge = false - _intent_visible = false # 普通攻击零表示(完全不可预测——§2.4) + st._intent_visible = false # 普通攻击零表示(完全不可预测——§2.4) queue_redraw() return var glyph := "⚔" # 攻击系(damage) @@ -178,9 +194,9 @@ func set_pending(ptype: String, pid: String) -> void: _intent_charge = pid in CHARGE_SKILL_IDS if _intent_charge: col = UiKit.GOLD_INK # 重击类蓄力档(深金墨——浅垫牌上 ≥4.5:1) - _intent_glyph = glyph - _intent_col = col - _intent_visible = true + st._intent_glyph = glyph + st._intent_col = col + st._intent_visible = true _play_intent_motion() @@ -189,17 +205,17 @@ func set_pending(ptype: String, pid: String) -> void: # 三档运动签名互异,灰度/剪影层可辨)。摇定时一次性短动画,随后静帧保持。 func _play_intent_motion() -> void: var tw := create_tween() - tw.tween_property(self, "_intent_scale", 1.6, UiKit.motion(0.10)) - tw.tween_property(self, "_intent_scale", 1.0, UiKit.motion(0.16)) - tw.parallel().tween_property(self, "_glyph_rot", 4.0, UiKit.motion(0.10)) - tw.tween_property(self, "_glyph_rot", 0.0, UiKit.motion(0.14)) + tw.tween_property(self, "st._intent_scale", 1.6, UiKit.motion(0.10)) + tw.tween_property(self, "st._intent_scale", 1.0, UiKit.motion(0.16)) + tw.parallel().tween_property(self, "st._glyph_rot", 4.0, UiKit.motion(0.10)) + tw.tween_property(self, "st._glyph_rot", 0.0, UiKit.motion(0.14)) if _intent_charge: - tw.tween_property(self, "_intent_scale", 1.22, UiKit.motion(0.28)).set_trans(Tween.TRANS_SINE) # ③蓄力缓推 + tw.tween_property(self, "st._intent_scale", 1.22, UiKit.motion(0.28)).set_trans(Tween.TRANS_SINE) # ③蓄力缓推 tw.tween_interval(UiKit.motion(0.40)) # 定格微顿 - tw.tween_property(self, "_intent_scale", 1.0, UiKit.motion(0.18)) + tw.tween_property(self, "st._intent_scale", 1.0, UiKit.motion(0.18)) else: - tw.tween_property(self, "_intent_scale", 1.26, UiKit.motion(0.11)) # ②二次弹跳(高举) - tw.tween_property(self, "_intent_scale", 1.0, UiKit.motion(0.13)) + tw.tween_property(self, "st._intent_scale", 1.26, UiKit.motion(0.11)) # ②二次弹跳(高举) + tw.tween_property(self, "st._intent_scale", 1.0, UiKit.motion(0.13)) # 资产挂点:tex 落位后字形隐退、纹理立绘顶位——零代码换装契约;tex=null 回退字形。 @@ -207,45 +223,45 @@ func _play_intent_motion() -> void: # (圣经 §8.2 禁非整数缩放)——源换代时随源重算。 func set_portrait_texture(tex: Texture2D, flip: bool = false) -> void: if tex == null: - if _portrait_sprite != null: - _portrait_sprite.visible = false + if st._portrait_sprite != null: + st._portrait_sprite.visible = false queue_redraw() return - _portrait_tex = tex - if _portrait_sprite == null: - _portrait_sprite = Sprite2D.new() - _portrait_sprite.centered = false - _portrait_sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST # 像素帧放大防糊 - add_child(_portrait_sprite) - _portrait_sprite.texture = tex - _portrait_sprite.flip_h = flip - _portrait_sprite.visible = true + st._portrait_tex = tex + if st._portrait_sprite == null: + st._portrait_sprite = Sprite2D.new() + st._portrait_sprite.centered = false + st._portrait_sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST # 像素帧放大防糊 + add_child(st._portrait_sprite) + st._portrait_sprite.texture = tex + st._portrait_sprite.flip_h = flip + st._portrait_sprite.visible = true _place_portrait() func _place_portrait() -> void: # 立绘盒 = (0,44)-(box.x, box.y-36);48 源 ×2 整数档居中 - if _portrait_sprite == null or _portrait_tex == null: + if st._portrait_sprite == null or st._portrait_tex == null: return var box_h := box.y - 44.0 - 36.0 - var w := _portrait_tex.get_width() * 2.0 - var h := _portrait_tex.get_height() * 2.0 - _portrait_sprite.scale = Vector2(2.0, 2.0) # 整数 2×(C5) - _portrait_sprite.position = Vector2((box.x - w) * 0.5, 44.0 + (box_h - h) * 0.5) + var w := st._portrait_tex.get_width() * 2.0 + var h := st._portrait_tex.get_height() * 2.0 + st._portrait_sprite.scale = Vector2(2.0, 2.0) # 整数 2×(C5) + st._portrait_sprite.position = Vector2((box.x - w) * 0.5, 44.0 + (box_h - h) * 0.5) func mark_dead() -> void: - if _dead: + if st._dead: return - _dead = true + st._dead = true modulate = Color(0.62, 0.62, 0.62, 0.9) var tw := create_tween() tw.tween_property(self, "rotation_degrees", 2.5, UiKit.motion(0.12)) tw.tween_property(self, "rotation_degrees", -1.5, UiKit.motion(0.1)) tw.tween_property(self, "rotation_degrees", 0.0, UiKit.motion(0.08)) _spawn_death_shards() # 碎裂退场(§4.6.4 语义硬规则:灰化+碎裂禁瞬间消失;§8.4 v2 冻结 5 块 0.45s) - _refresh_portrait_glyph() - _intent_visible = false # 死亡残位意图退位 + _intent_charge = false + st._intent_visible = false # 死亡残位意图退位 _set_hp(0) if slot_row != null: slot_row.visible = false # 状态行随 mark_dead 同拍隐藏 @@ -253,37 +269,36 @@ func mark_dead() -> void: # ---- 演出密度(程序化 tween,零新帧资产) ---- -const DEATH_SHARD_N := 5 # 碎块数(§8.4 冻结值) -const HIT_SPARK_N := 6 # 命中火花数(打击瞬间可读性一拍) -var _death_shards := 0 # 探针出口计数(碎块节点回收后仍保留读数) -var _hit_sparks := 0 # 探针出口计数(命中火花累计) -var _flash_count := 0 # 探针出口计数(受击闪白触发次数——主角路由断言面) - # 死亡碎块弹飞:立绘盒中心五块碎石抛物线散落(程序化色块——确定性散布,禁全局随机) func _spawn_death_shards() -> void: - _death_shards = DEATH_SHARD_N + st._death_shards = DEATH_SHARD_N var center := _portrait_rect().get_center() for i in DEATH_SHARD_N: - var shard := ColorRect.new() - shard.color = UiKit.STONE if i % 2 == 0 else UiKit.STONE.darkened(0.2) - shard.size = Vector2(7, 5) + Vector2(2, 0) * float(i % 2) - shard.mouse_filter = Control.MOUSE_FILTER_IGNORE - shard.position = center + Vector2(float(i % 3) - 1.0, float(i / 3) - 0.5) * 6.0 - shard.rotation = float(i) * 0.7 - add_child(shard) - var dir := Vector2((float(i % 3) - 1.0) * 30.0, -36.0 - 6.0 * float(i / 3)) - var peak := shard.position + dir - var stw := shard.create_tween() - stw.tween_property(shard, "position", peak, UiKit.motion(0.18)).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT) - stw.tween_property(shard, "position", peak + Vector2(dir.x * 0.4, 52.0), UiKit.motion(0.27)).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) - stw.parallel().tween_property(shard, "modulate:a", 0.0, UiKit.motion(0.18)).set_delay(UiKit.motion(0.27)) - stw.tween_callback(shard.queue_free) + _spawn_death_shard(i, center) + + +## 单碎块域(循环体提函数——弹道两段+渐隐+回收) +func _spawn_death_shard(i: int, center: Vector2) -> void: + var shard := ColorRect.new() + shard.color = UiKit.STONE if i % 2 == 0 else UiKit.STONE.darkened(0.2) + shard.size = Vector2(7, 5) + Vector2(2, 0) * float(i % 2) + shard.mouse_filter = Control.MOUSE_FILTER_IGNORE + shard.position = center + Vector2(float(i % 3) - 1.0, float(i / 3) - 0.5) * 6.0 + shard.rotation = float(i) * 0.7 + add_child(shard) + var dir := Vector2((float(i % 3) - 1.0) * 30.0, -36.0 - 6.0 * float(i / 3)) + var peak := shard.position + dir + var stw := shard.create_tween() + stw.tween_property(shard, "position", peak, UiKit.motion(0.18)).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT) + stw.tween_property(shard, "position", peak + Vector2(dir.x * 0.4, 52.0), UiKit.motion(0.27)).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) + stw.parallel().tween_property(shard, "modulate:a", 0.0, UiKit.motion(0.18)).set_delay(UiKit.motion(0.27)) + stw.tween_callback(shard.queue_free) # 演出探针出口(fx_probe——死亡碎裂/命中火花/闪白计数断言面) func fx_probe() -> Dictionary: - return {"death_shards": _death_shards, "hit_sparks": _hit_sparks, "flash_count": _flash_count} + return {"death_shards": st._death_shards, "hit_sparks": st._hit_sparks, "flash_count": st._flash_count} # 命中瞬间打击特效:立绘盒中心六枚短火花放射(程序化色块,确定性方向 @@ -292,65 +307,65 @@ func fx_probe() -> Dictionary: func play_hit_impact() -> void: var center := _portrait_rect().get_center() for i in HIT_SPARK_N: - _hit_sparks += 1 - var spark := ColorRect.new() - spark.color = UiKit.GOLD_LIT if i % 2 == 0 else UiKit.WARM - spark.size = Vector2(4, 3) - spark.mouse_filter = Control.MOUSE_FILTER_IGNORE - spark.position = center - spark.size * 0.5 - add_child(spark) - var ang := TAU * float(i) / float(HIT_SPARK_N) - var dir := Vector2(cos(ang), sin(ang)) * 16.0 - var stw := spark.create_tween() - stw.tween_property(spark, "position", spark.position + dir, UiKit.motion(0.12)).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) - stw.parallel().tween_property(spark, "modulate:a", 0.0, UiKit.motion(0.12)) - stw.tween_callback(spark.queue_free) + _spawn_hit_spark(i, center) + + +## 单火花域(循环体提函数——放射弹道+渐隐+回收) +func _spawn_hit_spark(i: int, center: Vector2) -> void: + st._hit_sparks += 1 + var spark := ColorRect.new() + spark.color = UiKit.GOLD_LIT if i % 2 == 0 else UiKit.WARM + spark.size = Vector2(4, 3) + spark.mouse_filter = Control.MOUSE_FILTER_IGNORE + spark.position = center - spark.size * 0.5 + add_child(spark) + var ang := TAU * float(i) / float(HIT_SPARK_N) + var dir := Vector2(cos(ang), sin(ang)) * 16.0 + var stw := spark.create_tween() + stw.tween_property(spark, "position", spark.position + dir, UiKit.motion(0.12)).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + stw.parallel().tween_property(spark, "modulate:a", 0.0, UiKit.motion(0.12)) + stw.tween_callback(spark.queue_free) func is_dead() -> bool: - return _dead + return st._dead # 受击闪白(结算演出占位——§2.2 受击反馈;强度时长维持现行) func flash_hit() -> void: - _flash_count += 1 - if _hp_flash_tw != null and _hp_flash_tw.is_valid(): - _hp_flash_tw.kill() - _hp_flash_tw = create_tween() - _hp_flash_tw.tween_property(self, "modulate", Color(1.6, 1.4, 1.2), UiKit.motion(0.08)) - _hp_flash_tw.tween_property(self, "modulate", Color(1, 1, 1) if not _dead else Color(0.62, 0.62, 0.62, 0.9), UiKit.motion(0.2)) + st._flash_count += 1 + if st._hp_flash_tw != null and st._hp_flash_tw.is_valid(): + st._hp_flash_tw.kill() + st._hp_flash_tw = create_tween() + st._hp_flash_tw.tween_property(self, "modulate", Color(1.6, 1.4, 1.2), UiKit.motion(0.08)) + st._hp_flash_tw.tween_property(self, "modulate", Color(1, 1, 1) if not st._dead else Color(0.62, 0.62, 0.62, 0.9), UiKit.motion(0.2)) func _revive() -> void: - _dead = false + st._dead = false modulate = Color(1, 1, 1) rotation_degrees = 0.0 - if _kind == "hero": - _hero_state = HeroState.RESET # 强制回 idle 态(绕过同态早退) + if st._kind == "hero": + st._hero_state = HeroState.RESET # 强制回 idle 态(绕过同态早退) set_hero_state("idle") - _refresh_portrait_glyph() if slot_row != null: slot_row.visible = true queue_redraw() -func _refresh_portrait_glyph() -> void: - queue_redraw() - - func _portrait_glyph() -> String: # 字形回退仅作缺位兜底(八敌真图落位;相对体量语义由 48px 档统一比例承载)。 # 缺位态:主角「人」/死亡「✕」/其余「敌」。 - if _kind == "hero": + if st._kind == "hero": return "人" - if _dead: + if st._dead: return "✕" return "敌" # 立绘落位探针出口(站桩纹理非空断言面;缺位回退链保持的对照面) func portrait_probe() -> Dictionary: - return {"has_tex": _portrait_sprite != null and _portrait_sprite.texture != null} + return {"has_tex": st._portrait_sprite != null and st._portrait_sprite.texture != null} # ---- 主角三态切换(攻击演出→attack/受击→hit/结算回 idle;§8.4 帧数档挂点) ---- @@ -358,41 +373,41 @@ func portrait_probe() -> Dictionary: # 组合点注册(battle_stage_view _init——UiKit 常量 load_tex,缺位 null 不注册=该态保持现纹理) func register_hero_state(state: String, tex: Texture2D) -> void: if tex != null: - _hero_tex[state] = tex + st._hero_tex[state] = tex # 状态切换:现纹理换注册态(attack/hit);未注册态名或缺位 = 保持现纹理不炸。 # 非 idle 态自动短驻回归 idle(0.35s——演出一拍即回,结算收尾另有显式 idle 兜底)。 func set_hero_state(state: String) -> void: var target: int = _HS_VALUE.get(state, -1) - if _kind != "hero" or target == _hero_state: + if st._kind != "hero" or target == st._hero_state: return - if not _hero_tex.has(state) and state != "idle": + if not st._hero_tex.has(state) and state != "idle": return - _hero_state = target - if _portrait_sprite != null and _hero_tex.has(state): - _portrait_sprite.texture = _hero_tex[state] - if _state_revert_tw != null and _state_revert_tw.is_valid(): - _state_revert_tw.kill() + st._hero_state = target + if st._portrait_sprite != null and st._hero_tex.has(state): + st._portrait_sprite.texture = st._hero_tex[state] + if st._state_revert_tw != null and st._state_revert_tw.is_valid(): + st._state_revert_tw.kill() if state != "idle": - _state_revert_tw = create_tween() - _state_revert_tw.tween_interval(UiKit.motion(0.35)) - _state_revert_tw.tween_callback(_settle_hero_idle) + st._state_revert_tw = create_tween() + st._state_revert_tw.tween_interval(UiKit.motion(0.35)) + st._state_revert_tw.tween_callback(_settle_hero_idle) func _settle_hero_idle() -> void: - _hero_state = HeroState.IDLE - if _portrait_sprite != null and _hero_tex.has("idle"): - _portrait_sprite.texture = _hero_tex["idle"] + st._hero_state = HeroState.IDLE + if st._portrait_sprite != null and st._hero_tex.has("idle"): + st._portrait_sprite.texture = st._hero_tex["idle"] # 三态探针出口(fx_probe——当前态名+注册态集合) func hero_state_probe() -> Dictionary: - return {"state": _HS_NAME.get(_hero_state, "idle"), "registered": _hero_tex.keys()} + return {"state": _HS_NAME.get(st._hero_state, "idle"), "registered": st._hero_tex.keys()} func _portrait_color() -> Color: - return UiKit.HERO_BLUE if _kind == "hero" else UiKit.RED_DEEP + return UiKit.HERO_BLUE if st._kind == "hero" else UiKit.RED_DEEP # 立绘盒矩形(世界局部):(0,44)-(box.x, box.y-36)——贴图与字形共用 @@ -413,8 +428,19 @@ func content_rect() -> Rect2: func _draw() -> void: var f := _efont() - # 椭圆落影(圣经 §6.1 地台落影压脚:方向稿椭圆 (0,0,0,70),压在地台 - # 上缘周围;24 段多边形近似椭圆,中心=台面上方 6px、横半径=台宽一半) + _draw_ground(f) + _draw_hp_bar(f) + _draw_portrait(f) + _draw_intent_badge(f) + if st._kind == "enemy": + _draw_armor_name_line(f) + + +## 地面域:椭圆落影(圣经 §6.1 地台落影压脚:方向稿椭圆 (0,0,0,70),压在地台 +## 上缘周围;24 段多边形近似椭圆,中心=台面上方 6px、横半径=台宽一半) +## + 石板地台(v3.5——方向稿取色:板身/描边/顶缘受光线 2px——取色单点收 UiKit.STAND_STONE 族 +## ;几何沿用现行底座位〔margin 18/高 12——按方向稿对齐〕) +func _draw_ground(f: Font) -> void: var sh_center := Vector2(box.x * 0.5, box.y - 24.0) var sh_rx := (box.x - 36.0) * 0.5 var sh_ry := 9.0 @@ -423,28 +449,28 @@ func _draw() -> void: var ang := TAU * float(i) / 24.0 pts.append(sh_center + Vector2(cos(ang) * sh_rx, sin(ang) * sh_ry)) draw_colored_polygon(pts, Color(0, 0, 0, 0.27)) - # 石板地台(v3.5——方向稿取色:板身/描边/顶缘受光线 2px——取色单点收 UiKit.STAND_STONE 族 - # ;几何沿用现行底座位〔margin 18/高 12——按方向稿对齐〕) var ped := _pedestal_rect() draw_rect(ped, UiKit.STAND_STONE, true) draw_rect(ped, UiKit.STAND_STONE_EDGE, false, 1.0) draw_rect(Rect2(ped.position.x, ped.position.y, ped.size.x, 2.0), UiKit.STAND_STONE_RIM, true) - # 血条(直角递减):敌附顺位徽标(GOLD_BRIGHT ①②③);血条几何左 8 / 右 -8 + +## 血条域(直角递减):敌附顺位徽标(GOLD_BRIGHT ①②③);血条几何左 8 / 右 -8; +## 血量数字「当前/最大」居中压条(白字+深描边双通道——11px 于 10px 条带垂直居中; +## 数字=目标值即时刷新,条 tween 只管填充宽度;死残位 0/max 照常可读) +func _draw_hp_bar(f: Font) -> void: var bar_x := 8.0 - if _kind == "enemy": - var order_txt := String("①②③"[clampi(_order - 1, 0, 2)]) + if st._kind == "enemy": + var order_txt := String("①②③"[clampi(st._order - 1, 0, 2)]) f.draw_string(get_canvas_item(), Vector2(bar_x, 12), order_txt, HORIZONTAL_ALIGNMENT_LEFT, -1, 13, UiKit.GOLD_BRIGHT) bar_x += 20.0 var bar_w := box.x - 8.0 - bar_x draw_rect(Rect2(bar_x, 2, bar_w, 10), Color(0.10, 0.10, 0.11)) # 条底(引擎默认主题深底近似平移) - var fill_w := bar_w * hp_value + var fill_w := bar_w * st.hp_value if fill_w > 0.0: draw_rect(Rect2(bar_x, 2, fill_w, 10), Color(0.41, 0.61, 0.91)) # 直角填充(默认主题蓝近似——直角递减) - # 血量数字「当前/最大」居中压条(白字+深描边双通道——11px 于 10px 条带垂直居中; - # 数字=目标值即时刷新,条 tween 只管填充宽度;死残位 0/max 照常可读) - var hp_txt := "%d/%d" % [_hp_cur, int(_hp_max)] + var hp_txt := "%d/%d" % [st._hp_cur, int(st._hp_max)] var hts: Vector2 = f.get_string_size(hp_txt, HORIZONTAL_ALIGNMENT_LEFT, -1, 11) # 对比度垫牌(白 α.95 直压蓝条 2.67:1 违 §7.2; # 中央垫 HP_BAR_BACK 深素牌 = 14.3:1;牌宽=字宽+8〔白名单取 8〕,高=条带全高;§4.1 数字色牌原则) @@ -453,46 +479,53 @@ func _draw() -> void: f.draw_string(get_canvas_item(), hbase + Vector2(1, 1), hp_txt, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.10, 0.08, 0.06, 0.85)) f.draw_string(get_canvas_item(), hbase, hp_txt, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(1, 1, 1, 0.95)) - # 立绘:贴图在位 = Sprite2D(set_portrait_texture 落位);缺位 = 字形回退 - if _portrait_sprite == null or _portrait_sprite.texture == null or not _portrait_sprite.visible: - var prect := _portrait_rect() - var glyph := _portrait_glyph() - var center := prect.get_center() - # 坐标系注记:draw_set_transform 原点已置于 center——draw_string 的 - # pos 是**变换后的局部坐标**,须传相对 center 的偏移(-w/2 水平居中、+0.35h 基线下移); - # 传绝对 center 会成双重偏移、把字形画到盒外。 - draw_set_transform(center, deg_to_rad(_glyph_rot), Vector2.ONE) - var ts := f.get_string_size(glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, 42) - f.draw_string(get_canvas_item(), Vector2(-ts.x * 0.5, ts.y * 0.35), glyph, - HORIZONTAL_ALIGNMENT_LEFT, -1, 42, _portrait_color()) - draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) - # 意图徽标(敌 only——立绘上半部居中悬浮,CENTER_TOP 74..98 等价):弹入 scale 动画 - if _intent_visible: - var icenter := Vector2(box.x * 0.5, 86.0) - draw_set_transform(icenter, 0.0, Vector2(_intent_scale, _intent_scale)) - var its := f.get_string_size(_intent_glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, 15) - # 对比度垫牌(四色墨直压立绘纹理/暗舞台底 1.5-3.3:1 违 §7.2 - # 「文字禁直压纹理底」;垫 PANEL_DATA 素牌后 atk 5.3/buf 4.8/debuff 4.7/charge 4.9 全 ≥4.5:1; - # atk/debuff 零换墨、buf 换 HERO_BLUE_DEEP、charge 换 GOLD_INK;随弹入 transform 同步缩放) - draw_rect(Rect2(-its.x * 0.5 - 4.0, -its.y * 0.75, its.x + 8.0, its.y * 1.1), UiKit.PANEL_DATA, true) - f.draw_string(get_canvas_item(), Vector2(-its.x * 0.5, its.y * 0.35), _intent_glyph, - HORIZONTAL_ALIGNMENT_LEFT, -1, 15, _intent_col) # 局部坐标(同上注记——双重偏移修复) - draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) +## 立绘域:贴图在位 = Sprite2D(set_portrait_texture 落位);缺位 = 字形回退 +func _draw_portrait(f: Font) -> void: + if st._portrait_sprite != null and st._portrait_sprite.texture != null and st._portrait_sprite.visible: + return + var prect := _portrait_rect() + var glyph := _portrait_glyph() + var center := prect.get_center() + # 坐标系注记:draw_set_transform 原点已置于 center——draw_string 的 + # pos 是**变换后的局部坐标**,须传相对 center 的偏移(-w/2 水平居中、+0.35h 基线下移); + # 传绝对 center 会成双重偏移、把字形画到盒外。 + draw_set_transform(center, deg_to_rad(st._glyph_rot), Vector2.ONE) + var ts := f.get_string_size(glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, 42) + f.draw_string(get_canvas_item(), Vector2(-ts.x * 0.5, ts.y * 0.35), glyph, + HORIZONTAL_ALIGNMENT_LEFT, -1, 42, _portrait_color()) + draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) - # 底座下 护甲类型图标+名字 常驻行(敌 only——AC 数值不展示,类型图标化) - if _kind == "enemy": - var armor_txt := String(ARMOR_ICONS.get(_armor, "◇")) - var col: Color = UiKit.WARM_FAINT # 轻甲 = 暖白弱化(v3.5 暗底双色墨制) - match _armor: - "ward": - col = UiKit.HERO_BLUE_BRIGHT # 魔法护甲 = 小人蓝亮档 - "heavy": - col = UiKit.GOLD_BRIGHT # 重甲 = 金族亮档 - var name_w := f.get_string_size(_display_name, HORIZONTAL_ALIGNMENT_LEFT, -1, 12).x - var armor_w := f.get_string_size(armor_txt, HORIZONTAL_ALIGNMENT_LEFT, -1, 12).x - var total_w := armor_w + 3.0 + name_w - var baseline := Vector2((box.x - total_w) * 0.5, box.y - 1.0 - STANDEE_FOOT_MARGIN) # 纵向余量纪律 - f.draw_string(get_canvas_item(), baseline, armor_txt, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, col) - f.draw_string(get_canvas_item(), baseline + Vector2(armor_w + 3.0, 0), _display_name, - HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiKit.WARM) # v3.5 暗底双色墨制 + +## 意图徽标域(敌 only——立绘上半部居中悬浮,CENTER_TOP 74..98 等价):弹入 scale 动画 +func _draw_intent_badge(f: Font) -> void: + if not st._intent_visible: + return + var icenter := Vector2(box.x * 0.5, 86.0) + draw_set_transform(icenter, 0.0, Vector2(st._intent_scale, st._intent_scale)) + var its := f.get_string_size(st._intent_glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, 15) + # 对比度垫牌(四色墨直压立绘纹理/暗舞台底 1.5-3.3:1 违 §7.2 + # 「文字禁直压纹理底」;垫 PANEL_DATA 素牌后 atk 5.3/buf 4.8/debuff 4.7/charge 4.9 全 ≥4.5:1; + # atk/debuff 零换墨、buf 换 HERO_BLUE_DEEP、charge 换 GOLD_INK;随弹入 transform 同步缩放) + draw_rect(Rect2(-its.x * 0.5 - 4.0, -its.y * 0.75, its.x + 8.0, its.y * 1.1), UiKit.PANEL_DATA, true) + f.draw_string(get_canvas_item(), Vector2(-its.x * 0.5, its.y * 0.35), st._intent_glyph, + HORIZONTAL_ALIGNMENT_LEFT, -1, 15, st._intent_col) # 局部坐标(同上注记——双重偏移修复) + draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) + + +## 底座下 护甲类型图标+名字 常驻行(敌 only——AC 数值不展示,类型图标化) +func _draw_armor_name_line(f: Font) -> void: + var armor_txt := String(ARMOR_ICONS.get(st._armor, "◇")) + var col: Color = UiKit.WARM_FAINT # 轻甲 = 暖白弱化(v3.5 暗底双色墨制) + match st._armor: + "ward": + col = UiKit.HERO_BLUE_BRIGHT # 魔法护甲 = 小人蓝亮档 + "heavy": + col = UiKit.GOLD_BRIGHT # 重甲 = 金族亮档 + var name_w := f.get_string_size(st._display_name, HORIZONTAL_ALIGNMENT_LEFT, -1, 12).x + var armor_w := f.get_string_size(armor_txt, HORIZONTAL_ALIGNMENT_LEFT, -1, 12).x + var total_w := armor_w + 3.0 + name_w + var baseline := Vector2((box.x - total_w) * 0.5, box.y - 1.0 - STANDEE_FOOT_MARGIN) # 纵向余量纪律 + f.draw_string(get_canvas_item(), baseline, armor_txt, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, col) + f.draw_string(get_canvas_item(), baseline + Vector2(armor_w + 3.0, 0), st._display_name, + HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiKit.WARM) # v3.5 暗底双色墨制 diff --git a/game/presentation/views/status_panel.gd b/game/presentation/views/status_panel.gd index e97ac37f..e887e32d 100644 --- a/game/presentation/views/status_panel.gd +++ b/game/presentation/views/status_panel.gd @@ -14,7 +14,7 @@ var _feats_label: Label func _init() -> void: - var shell: Dictionary[Variant, Variant] = UiKit.modal_shell(self, Color(UiKit.DIM, 0.55), Vector2(560, 0), 10, true, + var shell: Dictionary[Variant, Variant] = UiKit.modal_shell(self, Color(UiKit.DIM, 0.55), Vector2(560, 0), 10, UiKit.panel_skinned(Vector4(20, 16, 20, 16))) # 石板皮(缺位回退数据板——同设置面先例) var box: VBoxContainer = shell.box diff --git a/game/presentation/views/ui_kit.gd b/game/presentation/views/ui_kit.gd index aabf3afd..c7894476 100644 --- a/game/presentation/views/ui_kit.gd +++ b/game/presentation/views/ui_kit.gd @@ -330,23 +330,9 @@ static func vignette_rect() -> TextureRect: # 程序纹理循环带,确定性生成零随机。光柱采用连续 alpha: # 带内 alpha 0→0.05→0 横向过渡+纵向衰减上亮下暗; # 纹理 filter=显式 LINEAR 白名单(与暗角/地图渐变同血统) -static func beams_rect() -> TextureRect: +static func _beams_rect() -> TextureRect: var img := Image.create(560, 560, false, Image.FORMAT_RGBA8) - var ca := cos(0.2094) # 12°(102deg 带轴的垂直倾角) - var sa := sin(0.2094) - var beam_col := Color(1.0, 236.0 / 255.0, 200.0 / 255.0) - var band_lo := 240.0 - var band_hi := 340.0 - var band_mid := (band_lo + band_hi) * 0.5 - var band_peak := 0.05 - var band_half := (band_hi - band_lo) * 0.5 - for y in 560: - var v_fall := 1.0 - float(y) / 560.0 # 纵向衰减:上亮下暗(连续 alpha) - for x in 560: - var d := fposmod(x * ca + y * sa, 560.0) - if d >= band_lo and d < band_hi: - var a: float = band_peak * (1.0 - absf(d - band_mid) / band_half) * v_fall - img.set_pixel(x, y, Color(beam_col.r, beam_col.g, beam_col.b, a)) + _paint_beams(img) var tr := TextureRect.new() tr.texture = ImageTexture.create_from_image(img) tr.stretch_mode = TextureRect.STRETCH_TILE @@ -356,8 +342,32 @@ static func beams_rect() -> TextureRect: return tr +# 光带几何常量(12° 斜向光带——带轴倾角/带域/峰值强度;_paint_beams 单消费) +const BEAMS_TILT_CA := 0.9135 # cos(0.2094)——12°(102deg 带轴的垂直倾角) +const BEAMS_TILT_SA := 0.4067 # sin(0.2094) +const BEAMS_BAND_LO := 240.0 +const BEAMS_BAND_HI := 340.0 +const BEAMS_PEAK := 0.05 + + +## 光带像素域(循环体提函数——12° 斜向光带双三角衰减:带内峰值×纵向衰减逐像素着色) +static func _paint_beams(img: Image) -> void: + var ca := BEAMS_TILT_CA + var sa := BEAMS_TILT_SA + var beam_col := Color(1.0, 236.0 / 255.0, 200.0 / 255.0) + var band_mid := (BEAMS_BAND_LO + BEAMS_BAND_HI) * 0.5 + var band_half := (BEAMS_BAND_HI - BEAMS_BAND_LO) * 0.5 + for y in 560: + var v_fall := 1.0 - float(y) / 560.0 # 纵向衰减:上亮下暗(连续 alpha) + for x in 560: + var d := fposmod(x * ca + y * sa, 560.0) + if d >= BEAMS_BAND_LO and d < BEAMS_BAND_HI: + var a: float = BEAMS_PEAK * (1.0 - absf(d - band_mid) / band_half) * v_fall + img.set_pixel(x, y, Color(beam_col.r, beam_col.g, beam_col.b, a)) + + # 竖向渐变底(demo #v-map linear-gradient(180deg, top 0%, low 40%)——stop 后恒 low) -static func gradient_base(top: Color, low: Color, stop := 0.4) -> TextureRect: +static func _gradient_base(top: Color, low: Color, stop := 0.4) -> TextureRect: var g := Gradient.new() g.offsets = PackedFloat32Array([0.0, stop]) g.colors = PackedColorArray([top, low]) @@ -442,7 +452,7 @@ static func label(text: String, size: int, bold: bool, color: Color = Color(-1, # PanelContainer(panel_sb 缺省 = 主题默认板——settlement_review 同款);box = 内容列 #(min_size/separation 参数化,min_size ZERO = 不设)。返回 Dictionary{dim, center, panel, box} #——探针直读的私有件由调用方按名摘引,结构同名同层级不变。 -static func modal_dim(parent: Control, dim_color: Color) -> ColorRect: +static func _modal_dim(parent: Control, dim_color: Color) -> ColorRect: var dim := ColorRect.new() dim.color = dim_color dim.set_anchors_preset(Control.PRESET_FULL_RECT) @@ -450,18 +460,30 @@ static func modal_dim(parent: Control, dim_color: Color) -> ColorRect: return dim +const PANEL_DEFAULT := true # modal_shell 面板哨兵(非 StyleBox 实例——null=无面板/本哨兵=默认样式盒面板/StyleBox=自定义) + + +# 面板哨兵解析(六参收拢五参——三态:null=无面板〔原 with_panel=false〕/PANEL_DEFAULT=默认样式盒面板 +# 〔原 with_panel=true+panel_sb=null〕/StyleBox 实例=自定义样式盒面板〔原双参形态〕; +# 判别用 is StyleBox——StyleBox 与布尔哨兵直接 != 属异类操作数违例) +static func _resolve_shell_panel(panel_sb) -> PanelContainer: + if panel_sb == null: + return null + var panel := PanelContainer.new() + if panel_sb is StyleBox: + panel.add_theme_stylebox_override("panel", panel_sb) + return panel + + static func modal_shell(parent: Control, dim_color: Color, min_size := Vector2.ZERO, separation := 12, - with_panel := false, panel_sb: StyleBox = null) -> Dictionary: - var dim := modal_dim(parent, dim_color) + panel_sb = null) -> Dictionary: + var dim := _modal_dim(parent, dim_color) var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) dim.add_child(center) - var panel: PanelContainer = null + var panel := _resolve_shell_panel(panel_sb) var host: Control = center - if with_panel: - panel = PanelContainer.new() - if panel_sb != null: - panel.add_theme_stylebox_override("panel", panel_sb) + if panel != null: center.add_child(panel) host = panel var box := VBoxContainer.new() @@ -563,19 +585,6 @@ static func panel_skinned(margin: Vector4 = Vector4.ZERO, tint := Color.WHITE) - # 仪式石框(空心角括框——营地容器框;缺位回退暗面板) -static func frame_skinned(margin: Vector4 = Vector4.ZERO) -> StyleBox: - var tex := load_tex(TEX_BORDER_STONE) - if tex == null: - return panel_flat(BG_OVERLAY, PANEL_BORDER,RADIUS_OBJECT, 1, margin) - var sb := _tex_sb(tex, 12.0) - sb.content_margin_left = margin.x - sb.content_margin_top = margin.y - sb.content_margin_right = margin.z - sb.content_margin_bottom = margin.w - return sb - - -# 图标槽(营地钥石/柜台件——缺位返回 null,消费点不挂 TextureRect 即回退纯文本) static func icon_rect(tex_path: String, px: float) -> TextureRect: var t := load_tex(tex_path) if t == null: @@ -659,19 +668,6 @@ static func _apply_button_skin(b: Button) -> void: # 消费方 = 标题/营地/设置/商店/三选一屏。单点纪律同 §7.1:新件只加不改旧件)── # 动词按钮(图标+动词+悬停说明三件套——标题屏按钮组/通用;icon 缺位=纯文字不炸) -static func verb_button(text: String, icon_path: String, tip := "") -> Button: - var b := button(text) - var tex := load_tex(icon_path) - if tex != null: - b.icon = tex - b.add_theme_constant_override("icon_max_width", 20) - b.expand_icon = false - if tip != "": - b.tooltip_text = tip - return b - - -# 主操作钮(烛光黄平涂+木描边——非奖励金;配色总纲「主按钮禁亮金」;缺 panel_stone 依赖=纯色可靠) static func primary_button(text: String, tip := "") -> Button: var b := button(text) var sb := panel_flat(CANDLE, Color8(110, 69, 52),RADIUS_OBJECT, 2, Vector4(10, 2, 10, 2)) @@ -749,7 +745,9 @@ class BCard: var _sb_normal: StyleBox var _sb_sel: StyleBox - func _init(title: String, desc: String, band: Color, icon_path: String, price := "", footnote := "") -> void: + func _init(title: String, desc: String, band: Color, icon_path: String, opts := {}) -> void: + var price: String = opts.get("price", "") + var footnote: String = opts.get("footnote", "") var tex := UiKit.load_tex(UiKit.TEX_PANEL_STONE) _sb_normal = UiKit.panel_skinned(Vector4(12, 10, 12, 10)) if tex != null else UiKit.panel_flat(PANEL_DATA, PANEL_BORDER,RADIUS_OBJECT, 1, Vector4(12, 10, 12, 10)) # 回退=数据板单点 _sb_sel = UiKit.panel_flat(PANEL_DATA, Color8(138, 90, 52), RADIUS_OBJECT, 2, Vector4(12, 10, 12, 10)) # 选中底=数据板单点 @@ -766,21 +764,7 @@ class BCard: mark.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT mark.visible = false box.add_child(mark) - # 图标槽(demo .ic 76×76 槽底+图标 60=20px 源 3 整数倍) - var slot := PanelContainer.new() - slot.add_theme_stylebox_override("panel", UiKit.panel_flat(SLOT_BACKDROP_SOFT, PANEL_BORDER, 0, 1)) # 明度差降档(柔和档;白图标 ≥3:1 硬约束在役) - slot.custom_minimum_size = Vector2(76, 76) - slot.size_flags_horizontal = Control.SIZE_SHRINK_CENTER - box.add_child(slot) - icon_tr = TextureRect.new() - var itex := UiKit.load_tex(icon_path) - if itex != null: - icon_tr.texture = itex - icon_tr.custom_minimum_size = Vector2(60, 60) - icon_tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - icon_tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - icon_tr.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST - slot.add_child(icon_tr) + box.add_child(_build_icon_slot(icon_path)) name_l = UiKit.label(title,FS_SUB, true, INK_TITLE) name_l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER box.add_child(name_l) @@ -800,6 +784,23 @@ class BCard: foot_l.visible = footnote != "" box.add_child(foot_l) + # 图标槽(demo .ic 76×76 槽底+图标 60=20px 源 3 整数倍;明度差降档——柔和档;白图标 ≥3:1 硬约束在役) + func _build_icon_slot(icon_path: String) -> PanelContainer: + var slot := PanelContainer.new() + slot.add_theme_stylebox_override("panel", UiKit.panel_flat(SLOT_BACKDROP_SOFT, PANEL_BORDER, 0, 1)) + slot.custom_minimum_size = Vector2(76, 76) + slot.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + icon_tr = TextureRect.new() + var itex := UiKit.load_tex(icon_path) + if itex != null: + icon_tr.texture = itex + icon_tr.custom_minimum_size = Vector2(60, 60) + icon_tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon_tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon_tr.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + slot.add_child(icon_tr) + return slot + func set_selected(v: bool) -> void: add_theme_stylebox_override("panel", _sb_sel if v else _sb_normal) mark.visible = v @@ -809,8 +810,9 @@ class BCard: body.move_child(c, foot_l.get_index()) -static func bcard(title: String, desc: String, band: Color, icon_path: String, price := "", footnote := "") -> BCard: - return BCard.new(title, desc, band, icon_path, price, footnote) +## 竖卡工厂(BCard._init 同形收拢 6→5 参——opts 键 {price, footnote} 缺省逐字同值) +static func bcard(title: String, desc: String, band: Color, icon_path: String, opts := {}) -> BCard: + return BCard.new(title, desc, band, icon_path, opts) # 拨杆开关(设置屏——46×22 滑柄形;状态字随行由消费方摆;会话级/持久由消费方接 SettingsStore) @@ -838,7 +840,7 @@ class SwitchToggle: _knob.size = Vector2(18, 16) -static func switch_toggle(initial: bool) -> SwitchToggle: +static func _switch_toggle(initial: bool) -> SwitchToggle: return SwitchToggle.new(initial) diff --git a/game/probe/probe_ui_util.gd b/game/probe/probe_ui_util.gd index f7ccfb68..1ea2f8db 100644 --- a/game/probe/probe_ui_util.gd +++ b/game/probe/probe_ui_util.gd @@ -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://_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() diff --git a/game/probe/screen_flow_probe.gd b/game/probe/screen_flow_probe.gd index 1ee71d1a..ac32ec58 100644 --- a/game/probe/screen_flow_probe.gd +++ b/game/probe/screen_flow_probe.gd @@ -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 进入存档钮缺失(卡② 交付物)") diff --git a/game/probe/ui_click_probe.gd b/game/probe/ui_click_probe.gd index c67df5cd..bc44cc7d 100644 --- a/game/probe/ui_click_probe.gd +++ b/game/probe/ui_click_probe.gd @@ -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]) diff --git a/game/probe/ui_input_probe.gd b/game/probe/ui_input_probe.gd index f804ce56..ba6c4ac7 100644 --- a/game/probe/ui_input_probe.gd +++ b/game/probe/ui_input_probe.gd @@ -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 之尾:取格若需前沿移位 diff --git a/game/probe/ui_probe.gd b/game/probe/ui_probe.gd index 26896b6d..b82bc2c1 100644 --- a/game/probe/ui_probe.gd +++ b/game/probe/ui_probe.gd @@ -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 区弃图刷新按钮文案缺失") diff --git a/game/probe/ui_struct_probe.gd b/game/probe/ui_struct_probe.gd index 9c687982..77c2f02c 100644 --- a/game/probe/ui_struct_probe.gd +++ b/game/probe/ui_struct_probe.gd @@ -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() @@ -157,7 +157,7 @@ func _ready() -> void: problems.append("[battle_hud] 候选中无战斗节点(驱动中断——候选=%s)" % str(main.level_screen._cand_ids)) # ---- 兑付屏(真实 open 面 + 合成最小数据——结构断言只看矩形/宽度/可见性)---- - main._end_overlay.open_victory("结构探针·胜利演算", "返回", Rect2(200, 200, 400, 300), "GP 0") + main._end_overlay.open_victory("结构探针·胜利演算", "返回", Rect2(200, 200, 400, 300), {"gp_summary": "GP 0"}) _settle() await _audit("battle_end", [main._end_overlay]) main._end_overlay.close() @@ -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(): diff --git a/game/probe/ui_tune_probe.gd b/game/probe/ui_tune_probe.gd index 51c39b7e..4cd3b9a7 100644 --- a/game/probe/ui_tune_probe.gd +++ b/game/probe/ui_tune_probe.gd @@ -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()) diff --git a/game/sim/adventure_monte_carlo.gd b/game/sim/adventure_monte_carlo.gd index 718a2e9c..64277218 100644 --- a/game/sim/adventure_monte_carlo.gd +++ b/game/sim/adventure_monte_carlo.gd @@ -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): diff --git a/game/sim/board_stat_probe.gd b/game/sim/board_stat_probe.gd index 70ef61d8..1c036e44 100644 --- a/game/sim/board_stat_probe.gd +++ b/game/sim/board_stat_probe.gd @@ -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)) diff --git a/game/sim/calibrate_boards.gd b/game/sim/calibrate_boards.gd index afdbe38a..71daea40 100644 --- a/game/sim/calibrate_boards.gd +++ b/game/sim/calibrate_boards.gd @@ -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), diff --git a/game/sim/damage_probe.gd b/game/sim/damage_probe.gd index ed146547..da833a38 100644 --- a/game/sim/damage_probe.gd +++ b/game/sim/damage_probe.gd @@ -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: diff --git a/game/sim/dump_battle.gd b/game/sim/dump_battle.gd index 2d44544e..e13a342a 100644 --- a/game/sim/dump_battle.gd +++ b/game/sim/dump_battle.gd @@ -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]) diff --git a/game/sim/experiment_density.gd b/game/sim/experiment_density.gd index 03c46dc6..26879000 100644 --- a/game/sim/experiment_density.gd +++ b/game/sim/experiment_density.gd @@ -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 diff --git a/game/sim/level_autoplay.gd b/game/sim/level_autoplay.gd index bae08513..61d2597c 100644 --- a/game/sim/level_autoplay.gd +++ b/game/sim/level_autoplay.gd @@ -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() diff --git a/game/sim/monte_carlo.gd b/game/sim/monte_carlo.gd index f9600231..93d1705c 100644 --- a/game/sim/monte_carlo.gd +++ b/game/sim/monte_carlo.gd @@ -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: diff --git a/game/sim/rhythm_batch.gd b/game/sim/rhythm_batch.gd index ab4a7f4a..057f2b78 100644 --- a/game/sim/rhythm_batch.gd +++ b/game/sim/rhythm_batch.gd @@ -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") diff --git a/game/sim/skill_rhythm_probe.gd b/game/sim/skill_rhythm_probe.gd index 456fe6c8..f2355fe5 100644 --- a/game/sim/skill_rhythm_probe.gd +++ b/game/sim/skill_rhythm_probe.gd @@ -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: diff --git a/game/sim/verifier_batch.gd b/game/sim/verifier_batch.gd index 39165bd3..f3038855 100644 --- a/game/sim/verifier_batch.gd +++ b/game/sim/verifier_batch.gd @@ -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: diff --git a/game/tests/integration/cross_system_flow_test.gd b/game/tests/integration/cross_system_flow_test.gd index 89b7bc25..c4593ae5 100644 --- a/game/tests/integration/cross_system_flow_test.gd +++ b/game/tests/integration/cross_system_flow_test.gd @@ -73,8 +73,8 @@ func _feats_breakpoint_roundtrip() -> void: {"feat_id": "x5", "display_name": "试五", "rarity": "legendary", "desc": "测试AC", "cap": 1, "triggers": [{"on": "seal_hit", "do": {"kind": "ac_bonus", "value": 1, "mode": "round"}}]}, ] - var live_rows: FeatTable = _db.feats - _db.feats = rt + var live_rows: FeatTable = _db.crossrun.feats + _db.crossrun.feats = rt var a_r = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(72)) a_r.start_adventure(72) # 源 :723(受理断言留单元宿主) a_r.stop()._pending = {"candidates": [{"kind": "relic", "tier": "rare", "feat_id": "x3"}, {"kind": "gp", "tier": "common"}], "payload": {}} @@ -98,7 +98,7 @@ func _feats_breakpoint_roundtrip() -> void: sm_r.clear_breakpoint() if FileAccess.file_exists(bp_path): # 段尾清理(与段首清场对称) DirAccess.remove_absolute(bp_path) - _db.feats = live_rows # 还原现行表(共享 _db 防污染——后续集成块零感知) + _db.crossrun.feats = live_rows # 还原现行表(共享 _db 防污染——后续集成块零感知) # ---- ③ 存档摘要集成(源 `_smoke_save` ⑨ 基点 :6807–6831 十条,主会话裁决三改判集成—— @@ -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))], diff --git a/game/tests/integration/node_map_determinism_test.gd b/game/tests/integration/node_map_determinism_test.gd index 1f85e314..773fce03 100644 --- a/game/tests/integration/node_map_determinism_test.gd +++ b/game/tests/integration/node_map_determinism_test.gd @@ -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") diff --git a/game/tests/integration/save_rng_roundtrip_test.gd b/game/tests/integration/save_rng_roundtrip_test.gd index edfba694..263b7842 100644 --- a/game/tests/integration/save_rng_roundtrip_test.gd +++ b/game/tests/integration/save_rng_roundtrip_test.gd @@ -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 的旧档按初始持有态可玩)") diff --git a/game/tests/smoke_boot.gd b/game/tests/smoke_boot.gd index 76a5c9c0..815dfd96 100644 --- a/game/tests/smoke_boot.gd +++ b/game/tests/smoke_boot.gd @@ -38,7 +38,7 @@ func _check_autoloads() -> void: var node: Node = get_node_or_null(NodePath("/root/" + autoload_name)) check(node != null, "autoload %s 在位" % autoload_name) var db: Node = get_node_or_null(NodePath("/root/ConfigDB")) - check(db != null and bool(db.get("is_ready")) and db.get("hero") != null, + check(db != null and bool(db.get("is_ready")) and db.get("combat") != null and db.get("combat").get("hero") != null, "ConfigDB 就绪且配置表全载(is_ready+hero 表在座,校验失败会致命中止)") check(load(MAIN_SCENE_PATH) != null, "主屏场景资源可加载(%s)" % MAIN_SCENE_PATH) check(load(FONT_PATH) != null, "全局字体资源可加载(%s)" % FONT_PATH) diff --git a/game/tests/system_test.gd b/game/tests/system_test.gd index d136015e..5002295f 100644 --- a/game/tests/system_test.gd +++ b/game/tests/system_test.gd @@ -154,7 +154,7 @@ func _smoke_boot_chain() -> void: await process_frame # ① ConfigDB 就绪且表已载 var db = main.get_node("/root/ConfigDB") - check(db.is_ready and db.hero != null, + check(db.is_ready and db.combat.hero != null, "V2a ConfigDB 就绪且表已载(is_ready+hero 表在座)〔装配生命批 V2〕") # ② 设置已应用(引擎态=settings_store 现值——真实设置文件照读,一致性判据与文件内容无关) var ss = main.settings_store @@ -174,9 +174,9 @@ func _smoke_boot_chain() -> void: # ④-1 boot 路径:启动态钉死(卡① sv2)——boot 无激活态、断点在档恒否,续玩钮恒隐 #(原「按钮态==真实档断点态」关系判据随启动态换代改写:test-conventions §9.5 申报在案,卡② 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 +185,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 +220,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 +276,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)") @@ -318,11 +318,11 @@ func _face_snapshot(main: Node) -> Dictionary: return { "treasure_settlement": tu._settlement_get.call(), "treasure_runner": tu._runner_get.call(), - "bonus_meta": bo._meta_get.call(), - "bonus_save": bo._save_get.call(), - "bonus_adventure": bo._adventure_get.call(), - "bonus_runner": bo._runner_get.call(), - "bonus_mode": bo._adventure_mode_get.call(), + "bonus_meta": bo.ctx.getters.meta_get.call(), + "bonus_save": bo.ctx.getters.save_get.call(), + "bonus_adventure": bo.ctx.getters.adventure_get.call(), + "bonus_runner": bo.ctx.getters.runner_get.call(), + "bonus_mode": bo.ctx.getters.adventure_mode_get.call(), "camp_meta": cp._meta_get.call(), "camp_save": cp._save_get.call(), "camp_runner": cp._runner_get.call(), @@ -418,7 +418,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 +511,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 +544,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 +576,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": [], diff --git a/game/tests/unit/battle_settlement/damage_controller_test.gd b/game/tests/unit/battle_settlement/damage_controller_test.gd index d2a58b9d..f3b343a5 100644 --- a/game/tests/unit/battle_settlement/damage_controller_test.gd +++ b/game/tests/unit/battle_settlement/damage_controller_test.gd @@ -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 diff --git a/game/tests/unit/battle_settlement/damage_math_test.gd b/game/tests/unit/battle_settlement/damage_math_test.gd index 24bac4be..db07970d 100644 --- a/game/tests/unit/battle_settlement/damage_math_test.gd +++ b/game/tests/unit/battle_settlement/damage_math_test.gd @@ -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)) diff --git a/game/tests/unit/battle_settlement/roll_multiplier_test.gd b/game/tests/unit/battle_settlement/roll_multiplier_test.gd index b6e8abe5..ac3c8980 100644 --- a/game/tests/unit/battle_settlement/roll_multiplier_test.gd +++ b/game/tests/unit/battle_settlement/roll_multiplier_test.gd @@ -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,20 @@ 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)) + 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 +171,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 +184,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 +200,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 +223,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 +258,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 +324,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 +342,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 +386,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 +442,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 +451,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) diff --git a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd index f9b09328..2b972ee1 100644 --- a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd +++ b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd @@ -68,7 +68,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 +92,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 +156,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 +175,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 +220,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 +250,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 +268,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 +282,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 +299,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 +328,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 +344,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 +363,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 +377,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 +492,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 +550,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 +591,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 +603,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 +615,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 +631,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 +644,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 +660,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 +763,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 +784,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 +817,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 +831,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 +855,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 +879,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 +891,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.combat.status_effects), "cast_side")) func _has_err_category(errs: Array, category: String) -> bool: @@ -914,9 +914,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 +925,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 +948,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: diff --git a/game/tests/unit/battle_settlement/seal_polarity_test.gd b/game/tests/unit/battle_settlement/seal_polarity_test.gd index 9df559a5..e10b1241 100644 --- a/game/tests/unit/battle_settlement/seal_polarity_test.gd +++ b/game/tests/unit/battle_settlement/seal_polarity_test.gd @@ -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 @@ -500,7 +500,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 +517,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 +574,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 +612,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 +642,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 +677,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 +698,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 +738,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 +795,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(): diff --git a/game/tests/unit/battle_settlement/skill_unlock_test.gd b/game/tests/unit/battle_settlement/skill_unlock_test.gd index 245cd099..dabb76d4 100644 --- a/game/tests/unit/battle_settlement/skill_unlock_test.gd +++ b/game/tests/unit/battle_settlement/skill_unlock_test.gd @@ -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) diff --git a/game/tests/unit/battle_settlement/trigger_cond_test.gd b/game/tests/unit/battle_settlement/trigger_cond_test.gd index 0929ed4f..cd7c0709 100644 --- a/game/tests/unit/battle_settlement/trigger_cond_test.gd +++ b/game/tests/unit/battle_settlement/trigger_cond_test.gd @@ -16,7 +16,7 @@ func _init() -> void: return # ---- V1 出域拒绝(六反例+合法行零 error——构造行夹具直改 rows 后重校验)---- - var skill = db.skill + var skill = db.combat.skill var base_rows: Array = skill.rows.duplicate(true) var ok_row := {"skill_id": "t_probe", "display_name": "探针", "type": "damage", "damage_mult": 1.5, "cooldown_beats": 2, "trigger": {"fact": "round_index", "op": "gte", "value": 3, "latch": false}} @@ -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, @@ -133,34 +133,34 @@ func _init() -> void: # ---- V7 池构成纪律(现行全表零违例+构造全条件池报错——跨表校验真实入口)---- var cv = load("res://core/config_db_cross_validator.gd").new(db) - var pool_errs: Array = cv.validate_skill_pool_links(db.skill, db.enemy, db.status_effects) + var pool_errs: Array = cv.validate_skill_pool_links(db.combat.skill, db.combat.enemy, db.combat.status_effects) var comp_violations := 0 for e in pool_errs: 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 - var errs2: Array = cv.validate_skill_pool_links(db.skill, db.enemy, db.status_effects) + db.combat.enemy.rows[0] = cond_only_row + var errs2: Array = cv.validate_skill_pool_links(db.combat.skill, db.combat.enemy, db.combat.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): diff --git a/game/tests/unit/battle_settlement/variant_placement_test.gd b/game/tests/unit/battle_settlement/variant_placement_test.gd index a623b242..0456555c 100644 --- a/game/tests/unit/battle_settlement/variant_placement_test.gd +++ b/game/tests/unit/battle_settlement/variant_placement_test.gd @@ -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()) diff --git a/game/tests/unit/contracts/system_contracts_test.gd b/game/tests/unit/contracts/system_contracts_test.gd index aacd48cc..11b0a352 100644 --- a/game/tests/unit/contracts/system_contracts_test.gd +++ b/game/tests/unit/contracts/system_contracts_test.gd @@ -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 diff --git a/game/tests/unit/core/adventure_feats_test.gd b/game/tests/unit/core/adventure_feats_test.gd index c9929b8b..b73b0265 100644 --- a/game/tests/unit/core/adventure_feats_test.gd +++ b/game/tests/unit/core/adventure_feats_test.gd @@ -124,10 +124,10 @@ func _smoke_adventure() -> void: var autoplay = load("res://sim/level_autoplay.gd") # ① 表与覆盖义务(序列关数 = 行集关数;节点源 = 参数档在固定种子下的生成图, # 每个战斗/Boss 节点经 ConfigDB 真实解析链查敌行集与棋盘行——顶 clamp 消解禁〔fv24〕) - Kit.check("序列关数 = 行集关数(三关正式序列生效)", int(_db.level.rows.size()) == 3) + Kit.check("序列关数 = 行集关数(三关正式序列生效)", int(_db.run.level.rows.size()) == 3) var cov_miss := 0 var cov_scan := 0 - for li in range(1, int(_db.level.rows.size()) + 1): + for li in range(1, int(_db.run.level.rows.size()) + 1): for n in _level_active_nodes(li): var nt := String(n.get("type", "")) if nt != "battle" and nt != "boss": @@ -135,10 +135,10 @@ func _smoke_adventure() -> void: var d: int = int(n.get("depth", -1)) cov_scan += 1 var rk := "boss" if nt == "boss" else "battle" - var rs: Array[Dictionary] = _db.resolve_enemy_rowset(li, d, rk) + var rs: Array[Dictionary] = _db.combat.enemy.resolve_rowset(li, d, rk) if rs.is_empty() or int(rs[0].get("depth_band", -1)) < d: cov_miss += 1 - if not (_db.resolve_board_gen(li, d) as Dictionary).is_empty(): + if not (_db.combat.board_gen.resolve_row(li, d) as Dictionary).is_empty(): continue cov_miss += 1 Kit.check("生成图每个战斗/Boss 节点经解析链命中敌行集与棋盘行(扫描 %d 节点,缺失 %d)〔fv24〕" % [cov_scan, cov_miss], @@ -149,8 +149,8 @@ func _smoke_adventure() -> void: Kit.check("满配冒险成功(42 / atk+900 / hp+400——cleared=true;v2 怪阶梯后旧 200/100 档不再满配)", String(st["result"]) == "completed" and bool(sum.get("cleared", false))) Kit.check("完成几关 = 3(levels_cleared)", int(sum.get("levels_cleared", -1)) == 3) var ks_exp := 0 - for li in range(1, int(_db.level.rows.size()) + 1): - ks_exp += int(_db.meta_boss_keystone_row(li).get("first_kill", 0)) + for li in range(1, int(_db.run.level.rows.size()) + 1): + ks_exp += int(_db.crossrun.meta.boss_keystone_row(li).get("first_kill", 0)) Kit.check("钥石合计 = ΣBoss 首杀行值(%d——逐杀掉落新口径;断点未注入恒首杀)" % int(sum.get("keystone_total", -1)), int(sum.get("keystone_total", -1)) == ks_exp) var drops: Array = sum.get("keystone_drops", []) as Array Kit.check("逐杀掉落明细恰 3 笔(L1/L2/L3——additive 摘要键 keystone_drops)", drops.size() == 3) @@ -169,7 +169,7 @@ func _smoke_adventure() -> void: # 奖励节点在生成图里是可选路位(旧单链模板的「L1 必经 1 个」形状前提随模板消亡)—— # 接线证明拆两半:图侧每关含 ≥1 奖励节点,行进侧种子扫描内确有经停(扫描零经停=三选一接线不可达) var reward_levels := 0 - for li in range(1, int(_db.level.rows.size()) + 1): + for li in range(1, int(_db.run.level.rows.size()) + 1): var rc := 0 for n in _level_active_nodes(li): if String(n.get("type", "")) == "reward": @@ -217,8 +217,8 @@ func _smoke_adventure() -> void: var cand_ok := picks.size() == 2 var ks_gain_ok := picks.size() == 2 var resolve_ok := level_resolves.size() == 2 - var max_flat := int(_db.adventure.curio_value_max_hp_common) - var heal_pct := int(_db.adventure.clear_heal_pct) + var max_flat := int(_db.run.adventure.value_segment.curio_value_max_hp_common) + var heal_pct := int(_db.run.adventure.clear_heal_pct) var hp_acc := 0 # max_hp 件累加(回血封顶断言的生效上限推导——TP0 起按兑付令牌实值,大小档分别计) var ks_prefix := 0 for i in picks.size(): @@ -240,18 +240,18 @@ func _smoke_adventure() -> void: # 账本生效上限口径:置满/回血封顶按生效上限(flat 后乘调试浮窗 pct)——本段 pct=400 # 加算区 = 树/锻造件(hp_acc)+购血观测值(level_autoplay 停点快照 bonus_max_hp)——精确断言不再窗口拟合 var extra_obs := int(p.get("bonus_max_hp", -1)) - var m_obs := int(round(float(int(_db.hero.max_hp) + hp_acc + extra_obs) * 5.0)) + var m_obs := int(round(float(int(_db.combat.hero.max_hp) + hp_acc + extra_obs) * 5.0)) var heal_exp := mini(int(floor(float(m_obs) * float(heal_pct) / 100.0)), m_obs - hp_before) if extra_obs < 0 or heal != heal_exp or hp_after != hp_before + heal or hp_after > m_obs: heal_ok = false - ks_prefix += int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) - if int(p.get("keystone_gained", -1)) != int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) or int(p.get("keystone_total", -1)) != ks_prefix: + ks_prefix += int(_db.crossrun.meta.boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) + if int(p.get("keystone_gained", -1)) != int(_db.crossrun.meta.boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) or int(p.get("keystone_total", -1)) != ks_prefix: ks_gain_ok = false if resolve_ok: var r: Dictionary = level_resolves[i] var rk := String(r.get("picked", "")) var rt := String(r.get("tier", "")) - if _smoke_find_token(p.get("candidates", []) as Array, rk, rt).is_empty() or int(r.get("value", -1)) != int(_db.adventure.curio_value(rk, rt)): + if _smoke_find_token(p.get("candidates", []) as Array, rk, rt).is_empty() or int(r.get("value", -1)) != int(_db.run.adventure.curio_value(rk, rt)): resolve_ok = false if rk == "max_hp": hp_acc += int(r.get("value", max_flat)) @@ -261,10 +261,10 @@ func _smoke_adventure() -> void: Kit.check("点选恰兑付选中一件(值=令牌档位表值;未选项零生效)", resolve_ok) var seq: Array = st["encounter_max_hp_seq"] var k := 5.0 # hp+400 ⇒ 调试浮窗百分比后乘 ×5(加算先加、百分比后乘——生效次序口径) - var exp_base := maxi(1, int(round(float(int(_db.hero.max_hp)) * k))) + var exp_base := maxi(1, int(round(float(int(_db.combat.hero.max_hp)) * k))) # 模拟档 v3.4 商店贪心购血合法抬升——上限窗口 = 基础 + 加算区 3 件 + 购血余量; # 大生命 +10(优秀档)——上界按大件最坏构成 3×10+购血 15=+45(TP0 放宽承袭) - var hp_ceiling := maxi(1, int(round(float(int(_db.hero.max_hp) + 3 * int(_db.adventure.curio_value("max_hp", "excellent")) + 15) * k))) + var hp_ceiling := maxi(1, int(round(float(int(_db.combat.hero.max_hp) + 3 * int(_db.run.adventure.curio_value("max_hp", "excellent")) + 15) * k))) var seq_ok := seq.size() > 0 and int(seq[0]) == exp_base for v in (seq as Array): if int(v) < exp_base or int(v) > hp_ceiling: @@ -303,7 +303,7 @@ func _smoke_adventure() -> void: var sh: Dictionary = half["st"]["summary"] Kit.check("L2 败北摘要:L1 已过(钥石=L1 首杀掉落)· 止步第 2 层", not bool(sh["cleared"]) and int(sh["levels_cleared"]) == 1 \ - and int(sh["keystone_total"]) == int(_db.meta_boss_keystone_row(1).get("first_kill", 0)) \ + and int(sh["keystone_total"]) == int(_db.crossrun.meta.boss_keystone_row(1).get("first_kill", 0)) \ and int(sh["stopped_at"]) == 2) var dh: Dictionary = sh.get("defeat", {}) Kit.check("L2 败北败退明细:层号=2 · 节点在案(数值验证取数面 2026-09-22)", @@ -353,11 +353,11 @@ func _smoke_adventure() -> void: var r8 = a8.runner() var gp0: int = int(r8.gp) var hp_price := -1 - for p in _db.shop.products: + for p in _db.run.shop.products: if String(p.get("product_id", "")) == "hp_up": hp_price = int(p.get("base_price", -1)) var l2_price_bonus := 0 - for pb in _db.shop.price_level_bonus: + for pb in _db.run.shop.price_level_bonus: if int(pb.get("level_index", -1)) == 2: l2_price_bonus = int(pb.get("bonus", 0)) Kit.check("进店余额 ≥ hp_up L2 首价(%d = 基价 %d + 关卡加成 %d——配置推导)" % [hp_price + l2_price_bonus, hp_price, l2_price_bonus], gp0 >= hp_price + l2_price_bonus) @@ -448,7 +448,7 @@ func _smoke_adventure() -> void: a13.stop().present_node_curio(ncands13) a13.stop().present_node_curio([{"kind": "gp", "tier": "common"}]) var gp13: int = a13.wallet().gp - Kit.check("奖励节点 GP 件入账(钱包「奖励入账」收口——+%d)" % int(_db.adventure.curio_value("gp", "common")), a13.stop().apply_node_curio("gp", "common") and a13.wallet().gp == gp13 + int(_db.adventure.curio_value("gp", "common"))) + Kit.check("奖励节点 GP 件入账(钱包「奖励入账」收口——+%d)" % int(_db.run.adventure.curio_value("gp", "common")), a13.stop().apply_node_curio("gp", "common") and a13.wallet().gp == gp13 + int(_db.run.adventure.curio_value("gp", "common"))) Kit.check("节点停点态兑付后清零(恰一次语义同构)", (a13.stop().node_curio_pending_state().get("candidates", []) as Array).is_empty()) var s10: Dictionary = a10.summary Kit.check("放弃摘要:abandoned · 未打穿(已获大奖励不追回——同失败型结算)", String(s10.get("result", "")) == "abandoned" and not bool(s10.get("cleared", true))) @@ -462,8 +462,8 @@ func _smoke_adventure() -> void: func _smoke_feats() -> void: print("--- 专长重设计实装(feat_table 四级 · 专长带池接线 · 订阅面逐件)---") # ① 现行表牌库构成:30 件全量入池——102 数值令牌+30 relic 令牌(四级 rarity 直映射) - Kit.check("feat_table 已加载(30 件=基础 27+迁入 2+卡② 完美保险 1——迭代 31 卡② 换代)", _db.feats != null and (_db.feats.rows as Array).size() == 30) - Kit.check("feat_table.validate 零违例(四级 rarity/cap 域/trigger 词表/行键白名单全过)", _db.feats.validate().is_empty()) + Kit.check("feat_table 已加载(30 件=基础 27+迁入 2+卡② 完美保险 1——迭代 31 卡② 换代)", _db.crossrun.feats != null and (_db.crossrun.feats.rows as Array).size() == 30) + Kit.check("feat_table.validate 零违例(四级 rarity/cap 域/trigger 词表/行键白名单全过)", _db.crossrun.feats.validate().is_empty()) var a_r0 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(71)) Kit.check("专长带场景:冒险启动(seed 71)", a_r0.start_adventure(71)) var feat_tokens := 0 @@ -475,32 +475,32 @@ func _smoke_feats() -> void: tier_counts[tk] = int(tier_counts.get(tk, 0)) + 1 Kit.check("专长带入池:30 件四级直映射(普通 12/优秀 9/稀有 9/传说 0)——牌库 102+30=132〔专长批 V2+卡② 完美保险迁入〕", feat_tokens == 30 and int(tier_counts.get("common", 0)) == 12 and int(tier_counts.get("excellent", 0)) == 9 and int(tier_counts.get("rare", 0)) == 9 and a_r0.deck().deck.size() == 132) # ② 旧 27 件零残留钉面(V2——arcane_ember 系旧件表首行;全仓 grep 零残留归静态核查) - Kit.check("旧 27 件已退役:arcane_ember/magma_body 表内零残留〔专长批 V2〕", _db.feats.row_by_id("arcane_ember").is_empty() and _db.feats.row_by_id("magma_body").is_empty()) - Kit.check("迁出收尾:treasure_items 无 venom_edge/seal_burst 行(同调目录自然收窄)〔专长批 V13〕", _db.treasure_items.row_by_id("venom_edge").is_empty() and _db.treasure_items.row_by_id("seal_burst").is_empty()) + Kit.check("旧 27 件已退役:arcane_ember/magma_body 表内零残留〔专长批 V2〕", _db.crossrun.feats.row_by_id("arcane_ember").is_empty() and _db.crossrun.feats.row_by_id("magma_body").is_empty()) + Kit.check("迁出收尾:treasure_items 无 venom_edge/seal_burst 行(同调目录自然收窄)〔专长批 V13〕", _db.crossrun.treasure_items.row_by_id("venom_edge").is_empty() and _db.crossrun.treasure_items.row_by_id("seal_burst").is_empty()) # ③ 契约面静态钉扎(V18/V19/V21/V22——人工核对 V20/V21 裁决面以外的可机检半) var kinds_ok := true for t in a_r0._curio.deck: var kk := String((t as Dictionary).get("kind", "")) - if kk != "relic" and not _db.adventure.CURIO_KINDS.has(kk): + if kk != "relic" and not _db.run.adventure.CURIO_KINDS.has(kk): kinds_ok = false Kit.check("层级纪律:牌库 kind 全集=CURIO_KINDS+relic(秘宝 item_id 零入池)〔专长批 V18〕", kinds_ok) var rewrite_rows := 0 - for row_v in (_db.feats.rows as Array): + for row_v in (_db.crossrun.feats.rows as Array): for tv in ((row_v as Dictionary).get("triggers", []) as Array): if String(((tv as Dictionary).get("do", {}) as Dictionary).get("kind", "")) == "rewrite_board": rewrite_rows += 1 Kit.check("棋盘事实不可变:新 30 行零棋盘改写类订阅(rewrite_board 已除名消费面)〔专长批 V19〕", rewrite_rows == 0) var bad_words := 0 - for row_v in (_db.feats.rows as Array): + for row_v in (_db.crossrun.feats.rows as Array): var r_d: Dictionary = row_v as Dictionary for txt_v in [String(r_d.get("display_name", "")), String(r_d.get("desc", ""))]: if String(txt_v).contains("雷") or String(txt_v).contains("旗"): bad_words += 1 Kit.check("对外命名纪律:30 行 display_name/desc 零「雷/旗」字面(封印/陷阱风味系)〔专长批 V21 初筛〕", bad_words == 0) Kit.check("数值纪律:陷阱爆发倍率>1 · 血蜜回复<典型陷阱伤 · 石肤上限 3 在册(结构锚形态内 provisional)〔专长批 V22 机检半〕", - float((_db.feats.row_by_id("trap_burst").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("mult", 0)) > 1.0 - and int((_db.feats.row_by_id("blood_honey").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("value", 99)) < 5 - and int((_db.feats.row_by_id("stone_skin").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("cap", 0)) == 3) + float((_db.crossrun.feats.row_by_id("trap_burst").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("mult", 0)) > 1.0 + and int((_db.crossrun.feats.row_by_id("blood_honey").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("value", 99)) < 5 + and int((_db.crossrun.feats.row_by_id("stone_skin").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("cap", 0)) == 3) # ④ 临时表注入(新 schema 形态——零 archetype/cap 直用):5 行 relic 入牌库/兑付/踢池/断点往返 var rt := FeatTable.new() rt.rows = [ @@ -516,8 +516,8 @@ func _smoke_feats() -> void: "triggers": [{"on": "seal_hit", "do": {"kind": "ac_bonus", "value": 1, "mode": "round"}}]}, ] Kit.check("新 schema 构造表 validate 零违例(四级 rarity/cap 字段/词表全过——流派标签已退役零 archetype)", rt.validate().is_empty()) - var live_rows: FeatTable = _db.feats - _db.feats = rt + var live_rows: FeatTable = _db.crossrun.feats + _db.crossrun.feats = rt var a_r = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(72)) Kit.check("专长带场景:冒险启动(seed 72——注入 5 行临时表)", a_r.start_adventure(72)) var deck_relic := 0 @@ -558,12 +558,12 @@ func _smoke_feats() -> void: Kit.check("A5 专长面兑付受理(专长凭证开出候选可正常入持有集)", (a_t.feats_held() as Array).size() == feats_n + 1) # A6 保底:pity 直填 5 → 下次展示必出(第 6 次必出口径);限购满 → 恒不触发 a_t.stop()._deck.ticket_sold = 0 # A5 已兑付一枚专长凭证(sold=1=limit)——A6 段重置计数隔离验证 - a_t.stop()._deck.ticket_pity = int(a_t.db.adventure.feat_ticket_pity_step) + a_t.stop()._deck.ticket_pity = int(a_t.db.run.adventure.feat_ticket_pity_step) var rg_t: RandomNumberGenerator = a_t.rng.open_stream("reward_pick") Kit.check("A6 保底触发(pity=5 → 第 6 次展示必出——概率无关)", a_t.deck().feat_ticket_consider(rg_t)) Kit.check("A6 触发后清零", a_t.deck().ticket_pity == 0) - a_t.stop()._deck.ticket_sold = int(a_t.db.adventure.feat_ticket_limit) - a_t.stop()._deck.ticket_pity = int(a_t.db.adventure.feat_ticket_pity_step) + a_t.stop()._deck.ticket_sold = int(a_t.db.run.adventure.feat_ticket_limit) + a_t.stop()._deck.ticket_pity = int(a_t.db.run.adventure.feat_ticket_pity_step) Kit.check("A6 限购满不再触发(保底冻结)", not a_t.deck().feat_ticket_consider(rg_t)) a_t.stop()._deck.ticket_sold = 0 a_t.stop()._deck.ticket_pity = 0 @@ -577,7 +577,7 @@ func _smoke_feats() -> void: Kit.check("relic 兑付:入持有集 + 牌库摘牌(选中踢池——池库存 %d→%d)" % [deck_relic, deck_relic - 1], a_r.stop().pick_curio("relic", "rare", "x3") and (a_r.feats_held() as Array).has("x3") and a_r.deck().pool_stock("relic") == deck_relic - 1) # 断点往返三条(基点 1420–1426:relics 快照/计数器键/余库逐枚还原)按主会话裁决②改判集成宿主—— # 驱动代码(save_manager 装配+_on_level_finished+快照取读)随三条同去,集成批落位时重建。 - _db.feats = live_rows # 还原现行表(共享 _db 防污染——后续冒烟段零感知) + _db.crossrun.feats = live_rows # 还原现行表(共享 _db 防污染——后续冒烟段零感知) # ⑤ V3 订阅面逐件(附表 A 三列——命中面=EffectEngine dispatch 直测;账本面=counter 直读; # 落地行为=发射点施加面〔T1 施加段承载——本段按声明逐件钉命中与边界/反例〕) var orch = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(73)) @@ -910,4 +910,4 @@ func _fresh_rng(seed_val: int): # 生成图取数面(图 = f(关卡行参数档, 种子 42)——与 runner 起关同图;节点键 id/type/depth/next) func _level_active_nodes(level_index: int) -> Array: var gen = NodeMapGenerator.new(_fresh_rng(42)) - 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", []) diff --git a/game/tests/unit/core/board_gen_reward_test.gd b/game/tests/unit/core/board_gen_reward_test.gd index 64c285ca..347d2b42 100644 --- a/game/tests/unit/core/board_gen_reward_test.gd +++ b/game/tests/unit/core/board_gen_reward_test.gd @@ -105,7 +105,7 @@ func _smoke_reward_identity_unit() -> void: var cs2: Dictionary = reward_economy.grant_clear() Kit.check("清图批尾身份 = clear_blast(clear_default 唯一活行)", String(cs1.cell_type) == "blast" and String(cs1.reward_id) == "clear_blast") var cb_range := [-1, -1] - for row in _db.reward.rows: + for row in _db.run.reward.rows: if String(row.reward_id) == "clear_blast": cb_range = [int(row.get("power_min", row.power)), int(row.get("power_max", row.power))] Kit.check("清图奖励 power ∈ 表区间 [%d, %d](roll 值 %d——配置推导)" % [cb_range[0], cb_range[1], int(cs1.power)], @@ -120,9 +120,13 @@ func _smoke_reward_identity_unit() -> void: # 板行覆盖桩(cap 压线行为断言;接口面 = resolve_board_gen 单点) class _BoardRowStub: - var row: Dictionary - func resolve_board_gen(_L: int, _D: int) -> Dictionary: - return row + class _StubBoardGen: + var row: Dictionary + func resolve_row(_L: int, _D: int) -> Dictionary: + return row + class _StubCombat: + var board_gen := _StubBoardGen.new() + var combat := _StubCombat.new() func status_effect_row(_id: String) -> Dictionary: return {} @@ -131,27 +135,27 @@ func _smoke_board_generator() -> void: print("--- 棋盘生成 ---") var board_generator = load("res://core/board_generator.gd").new() for L in [1, 2]: - var row = _db.resolve_board_gen(L, 0) + var row = _db.combat.board_gen.resolve_row(L, 0) Kit.check("BoardGen 行解析 (%d,0)" % L, not row.is_empty()) if L == 2: # 迭代 35 生成图深度扩面覆盖批随动(声明):旧口径「(2,3) 无精确行→ceil 上取到 4」的判据对象 # 是模板时代键域缺口;覆盖批补齐 0–13 后 (2,3) 有精确行,生产表上不再存在可走 ceil 的空位。 # 判据不丢——精确命中改判在册,ceil 能力用「临时摘除该行」的夹具面保留,随后原样还原。 - var exact_row: Dictionary = _db.resolve_board_gen(2, 3) + var exact_row: Dictionary = _db.combat.board_gen.resolve_row(2, 3) Kit.check("BoardGen 精确命中 (2,3)(覆盖批键域补齐)", int(exact_row.get("node_depth", -1)) == 3) var gap_idx := -1 - for i in _db.board_gen.rows.size(): - if int(_db.board_gen.rows[i].get("level_index", -1)) == 2 and int(_db.board_gen.rows[i].get("node_depth", -1)) == 3: + for i in _db.combat.board_gen.rows.size(): + if int(_db.combat.board_gen.rows[i].get("level_index", -1)) == 2 and int(_db.combat.board_gen.rows[i].get("node_depth", -1)) == 3: gap_idx = i break - var saved: Dictionary = _db.board_gen.rows[gap_idx] - var rows_before: int = _db.board_gen.rows.size() - _db.board_gen.rows.remove_at(gap_idx) - var ceil_row: Dictionary = _db.resolve_board_gen(2, 3) + var saved: Dictionary = _db.combat.board_gen.rows[gap_idx] + var rows_before: int = _db.combat.board_gen.rows.size() + _db.combat.board_gen.rows.remove_at(gap_idx) + var ceil_row: Dictionary = _db.combat.board_gen.resolve_row(2, 3) Kit.check("R1 ceil 能力不失(夹具摘除 (2,3)→向上取整到 4·偏难口径)", int(ceil_row.get("node_depth", -1)) == 4) - _db.board_gen.rows.insert(gap_idx, saved) + _db.combat.board_gen.rows.insert(gap_idx, saved) Kit.check("夹具表原样还原(行数与解析回到覆盖批现值)", - _db.board_gen.rows.size() == rows_before and int(_db.resolve_board_gen(2, 3).get("node_depth", -1)) == 3) + _db.combat.board_gen.rows.size() == rows_before and int(_db.combat.board_gen.resolve_row(2, 3).get("node_depth", -1)) == 3) var b1 = board_generator.generate(L, 0, _rng.open_stream("board_gen"), _db) Kit.check("L%d 生成产物非空" % L, b1 != null) if b1 == null: @@ -200,7 +204,7 @@ func _smoke_board_generator() -> void: row9["r0_max_cap"] = BoardData.SPAWN_AREA_CELLS row9["max_gen_attempts"] = 4 var stub9 := _BoardRowStub.new() - stub9.row = row9 + stub9.combat.board_gen.row = row9 var b9 = board_generator.generate(1, 0, _mk_stream("board_gen", 909), stub9) Kit.check("L1 cap 压线 9:出图 R₀=%d %s(三分出口诚实降级——超阈即 accept_worst 非法标记,玩家永不卡死)" % [int(b9.metrics.r0_count), "≤9" if int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS else ">9→illegal_product"], b9 != null and (int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS or b9.illegal_product)) @@ -227,7 +231,7 @@ func _smoke_power_interval() -> void: # 行清单与区间值全部随表走——现行区间行 heal_minor/clear_blast 只是恰然,不再是锚。 var interval_rows := {} # rid → [lo, hi](读表) var anchors_ok := true - for row in _db.reward.rows: + for row in _db.run.reward.rows: var rid := String(row.reward_id) if row.has("power_min") or row.has("power_max"): var lo: int = int(row.get("power_min", -1)) @@ -297,7 +301,7 @@ func _smoke_power_interval() -> void: func _row_power(rid: String) -> int: - for row in _db.reward.rows: + for row in _db.run.reward.rows: if String(row.reward_id) == rid: return int(row.power) return -1 diff --git a/game/tests/unit/core/board_generator_test.gd b/game/tests/unit/core/board_generator_test.gd index 3e7cc062..e4340644 100644 --- a/game/tests/unit/core/board_generator_test.gd +++ b/game/tests/unit/core/board_generator_test.gd @@ -41,7 +41,7 @@ func _test_generate_shape_and_mines() -> void: if b == null: Kit.check("生成返回棋盘", false) return - var row: Dictionary = _db.resolve_board_gen(1, 0) + var row: Dictionary = _db.combat.board_gen.resolve_row(1, 0) var want_w: int = int(row.get("board_width", 0)) var want_h: int = int(row.get("board_height", 0)) Kit.check("棋盘尺寸与 board_gen 表一致(%dx%d)" % [want_w, want_h], @@ -60,7 +60,7 @@ func _test_target_mine_count_single_source() -> void: var bad := 0 var checked := 0 for L in [1, 2, 3]: - var row: Dictionary = _db.resolve_board_gen(L, 0) + var row: Dictionary = _db.combat.board_gen.resolve_row(L, 0) if row.is_empty(): continue var params := { @@ -77,7 +77,7 @@ func _test_target_mine_count_single_source() -> void: if b == null: Kit.check("生成返回棋盘(雷数单点段)", false) return - var row1: Dictionary = _db.resolve_board_gen(1, 0) + var row1: Dictionary = _db.combat.board_gen.resolve_row(1, 0) Kit.check("产物断言读数 = target_mine_count 同源(metrics.target_mines)〔wd14〕", int(b.metrics.get("target_mines", -1)) == BoardGeneratorScript.target_mine_count({ "w": int(row1.board_width), "h": int(row1.board_height), "density": float(row1.mine_density)})) diff --git a/game/tests/unit/core/curio_refresh_paid_test.gd b/game/tests/unit/core/curio_refresh_paid_test.gd index 527ad078..48932e26 100644 --- a/game/tests/unit/core/curio_refresh_paid_test.gd +++ b/game/tests/unit/core/curio_refresh_paid_test.gd @@ -13,8 +13,8 @@ func _init() -> void: print("[curio_refresh_paid_test] FAIL — 数值表加载失败") quit(1) return - var base: int = int(db.adventure.shop_refresh_cost_base) - var curve: float = float(db.adventure.shop_refresh_cost_curve) + var base: int = int(db.run.adventure.shop_refresh_cost_base) + var curve: float = float(db.run.adventure.shop_refresh_cost_curve) Kit.check_quiet(base > 0 and curve > 1.0, "商店刷新旋钮在档(base=%d curve=%.2f——三选一付费档同源读取)" % [base, curve]) # ---- ① 停点在途谓词:无停点时免费/付费刷新皆拒绝(付费路径扣费前置校验防空转烧钱包)---- diff --git a/game/tests/unit/core/feat_vouchers_test.gd b/game/tests/unit/core/feat_vouchers_test.gd index 0a78989e..ee376c43 100644 --- a/game/tests/unit/core/feat_vouchers_test.gd +++ b/game/tests/unit/core/feat_vouchers_test.gd @@ -61,15 +61,15 @@ func _init() -> void: Kit.check_quiet(a.stop()._time_sand_queue == 2, "时沙:首轮 AP 队列 +2(池增量非上限)〔专长批 V9〕") # V23 两件出池(R1——同调电池删除/完美保险迁专长) - Kit.check_quiet(not db.adventure.CURIO_KINDS.has("attune_battery") - and not db.adventure.CURIO_KINDS.has("perfect_insurance"), + Kit.check_quiet(not db.run.adventure.CURIO_KINDS.has("attune_battery") + and not db.run.adventure.CURIO_KINDS.has("perfect_insurance"), "出池词表:CURIO_KINDS 零两件(三选一池物理不可再出)〔卡② R1〕") Kit.check_quiet(not a.stop().consume_pool_purchase("attune_battery", "rare") and not a.stop().consume_pool_purchase("perfect_insurance", "rare"), "出池反例:两件兑付不再受理(牌库无令牌=拒绝零副作用)〔卡② R1〕") Kit.check_quiet(not a.has_method("choose_battery_target") and not a.has_method("battery_pending"), "电池符号清零:runner 无点选函数与 getter(编译级孤儿面)〔卡② R1〕") - Kit.check_quiet(int(db.adventure.curio_cap("attune_battery")) == 99, + Kit.check_quiet(int(db.run.adventure.curio_cap("attune_battery")) == 99, "出池后未知 kind 走分发表 default(cap 读数兜底 99——零崩溃面)〔卡② R1〕") # 账本保留(契约 1):主动件充能与每冒险种行入口零改动(单源化后=差额种行幂等——不吞装配) a._attunement = ["incense_stick"] @@ -85,16 +85,16 @@ func _init() -> void: # V24 配额净删锚(R3——「净删」口径,rare 9→7/总量 104→102) var rare_sum := 0 var rare_kinds: Array[String] = [] - for k in db.adventure.CURIO_KINDS: - var q := int(db.adventure.curio_quota(k, "rare")) + for k in db.run.adventure.CURIO_KINDS: + var q := int(db.run.adventure.curio_quota(k, "rare")) rare_sum += q if q > 0: rare_kinds.append(k) - Kit.check_quiet(int(db.adventure.TIER_QUOTAS["rare"]) == 7 and rare_sum == 7 - and int(db.adventure.deck_total()) == 102 and rare_kinds.has("ap") and rare_kinds.has("rap") and rare_kinds.has("time_sand") + Kit.check_quiet(int(db.run.adventure.TIER_QUOTAS["rare"]) == 7 and rare_sum == 7 + and int(db.run.adventure.deck_total()) == 102 and rare_kinds.has("ap") and rare_kinds.has("rap") and rare_kinds.has("time_sand") and rare_kinds.size() == 3, "配额锚:稀有档合计 7、投放品种={ap,rap,time_sand}、令牌池总量 102(validate 硬断言同批)〔卡② R3〕") - Kit.check_quiet(db.adventure.validate().is_empty(), "AdventureTable.validate() 零违例(两件出池后)〔卡② R3〕") + Kit.check_quiet(db.run.adventure.validate().is_empty(), "AdventureTable.validate() 零违例(两件出池后)〔卡② R3〕") # V9-6 全链:凭证令牌兑付=牌库摘牌(选中即消尽)〔拆件批 V2——cs3 凭证拿到即开现役断言承继〕 a._curio.deck = [{"kind": "rap", "tier": "rare"}, {"kind": "attack", "tier": "common"}] @@ -110,13 +110,13 @@ func _init() -> void: "留任小GP 袋:GP 通道兑付受理(gp 计数入账——路由零变化)〔专长批 V10〕") var hp_before: int = a.stats().hero_hp a.stop()._pending = {"candidates": [{"kind": "heal", "tier": "excellent"}]} - Kit.check_quiet(a.stop().pick_curio("heal", "excellent") and a.stats().hero_hp == mini(hp_before + int(db.adventure.curio_value("heal", "excellent")), a.effective_max_hp()), + Kit.check_quiet(a.stop().pick_curio("heal", "excellent") and a.stats().hero_hp == mini(hp_before + int(db.run.adventure.curio_value("heal", "excellent")), a.effective_max_hp()), "留任应急口粮:当即回血 +6 封顶生效上限〔专长批 V10〕") # V10-2 行动点上限归属性轴:ap kind 走加算区第四轴 a._curio.deck = [{"kind": "ap", "tier": "rare"}] a.stop()._pending = {"candidates": a._curio.deck.duplicate()} var ap_bonus_before: int = a.journal().bonus_total("ap") - Kit.check_quiet(a.stop().pick_curio("ap", "rare") and a.journal().bonus_total("ap") == ap_bonus_before + int(db.adventure.curio_value("ap", "rare")), + Kit.check_quiet(a.stop().pick_curio("ap", "rare") and a.journal().bonus_total("ap") == ap_bonus_before + int(db.run.adventure.curio_value("ap", "rare")), "行动点上限归属性轴:ap 兑付写加算区第四轴(bonus.ap 承载)〔专长批 V10〕") # V16——断点扩容往返:新账本随断点快照/续玩回填(breakpoint_resume_test P5 同构) @@ -149,11 +149,11 @@ func _init() -> void: Kit.check_quiet(int(b2.adventure_counters.get("pain_layers", -1)) == 3 and b2.refresh_bank().refresh_stock() == 2, "V16:续玩回填逐项一致(adventure 桶随断点往返·计数恢复)〔无状态化 V3〕") # V25 完美保险专长验收组(R2 全项:装载/限次一次/计数纯净/两账相减/跨战清零/事件不吞) - var fi: Dictionary = db.feats.row_by_id("perfect_insurance") + var fi: Dictionary = db.crossrun.feats.row_by_id("perfect_insurance") Kit.check_quiet(not fi.is_empty() and String(fi.get("rarity", "")) == "rare" and String(fi.get("desc", "")).find("雷") == -1 and String(fi.get("desc", "")).find("封印") >= 0, "专长行合法:第 30 行 perfect_insurance 在表、rarity rare、desc 零「雷」字面〔卡② R2〕") - Kit.check_quiet(db.feats.validate().is_empty(), "feats.validate() 零违例(新行订阅形态与词表合法)〔卡② R2〕") + Kit.check_quiet(db.crossrun.feats.validate().is_empty(), "feats.validate() 零违例(新行订阅形态与词表合法)〔卡② R2〕") var RigScript := load("res://core/battle_orchestrator.gd") var RngScript := load("res://core/rng_service.gd") var rig_rng = RngScript.new() diff --git a/game/tests/unit/core/hero_ledger_test.gd b/game/tests/unit/core/hero_ledger_test.gd index a32a8021..74a25f21 100644 --- a/game/tests/unit/core/hero_ledger_test.gd +++ b/game/tests/unit/core/hero_ledger_test.gd @@ -23,18 +23,18 @@ func _init() -> void: # ---- hr1:生效公式逐值(表值+永久强化 flat+加算区后乘调试 pct——与改前同式)---- var journal_a = GainJournalScript.new() var hero = HeroStatsScript.new(db, {"atk": 3, "max_hp": 5, "armor_class": 2}, journal_a) - Kit.check_quiet(hero.effective_max_hp() == int(db.hero.max_hp) + 5, + Kit.check_quiet(hero.effective_max_hp() == int(db.combat.hero.max_hp) + 5, "hr1 生效上限 = 表值+永久强化 flat(pct=0 乘法恒等)") - Kit.check_quiet(hero.effective_atk() == int(db.hero.atk) + 3, + Kit.check_quiet(hero.effective_atk() == int(db.combat.hero.atk) + 3, "hr1 生效攻击 = 表值+永久强化 flat") - Kit.check_quiet(hero.effective_ac() == int(db.forge_armor_route_by_type(db.forge_default_armor_type()).get("base_ac", 0)) + 2, + Kit.check_quiet(hero.effective_ac() == int(db.crossrun.forge.armor_route_by_type(db.crossrun.forge.default_armor_type).get("base_ac", 0)) + 2, "hr1 生效护甲 = 甲型基础 AC+甲路线等级加值(纯 flat 无 pct)") hero.set_debug_override(50, 50) - Kit.check_quiet(hero.effective_max_hp() == int(round(float(int(db.hero.max_hp) + 5) * 1.5)), + Kit.check_quiet(hero.effective_max_hp() == int(round(float(int(db.combat.hero.max_hp) + 5) * 1.5)), "hr1 调试 pct=50 后乘——上限 ×1.5(叠加次序:flat 先加 pct 后乘)") hero.set_debug_override(0, 0) journal_a.gain_bonus("max_hp", 4, "battle_export", "max_hp") - Kit.check_quiet(hero.effective_max_hp() == int(db.hero.max_hp) + 5 + 4, + Kit.check_quiet(hero.effective_max_hp() == int(db.combat.hero.max_hp) + 5 + 4, "hr1 加算区 flat 与永久强化 flat 同列基数(三段叠加——公式消费日记汇总)") # hr1 回血封顶公式逐值(算术宿主=HeroStats.rest_heal 单点——改前 LevelRunner.rest_heal 同式) diff --git a/game/tests/unit/core/level_map_type_vis_test.gd b/game/tests/unit/core/level_map_type_vis_test.gd index 94664ad8..d1bade78 100644 --- a/game/tests/unit/core/level_map_type_vis_test.gd +++ b/game/tests/unit/core/level_map_type_vis_test.gd @@ -85,7 +85,7 @@ func _runner(db, nodes: Array, anchor_id: int, candidates: Array, n: int = 2): var t := LevelTableScript.new() t.info_visible_layers = n t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}] - db.level = t + db.run.level = t var r = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new()) r.level_row.assign({"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}) r.visited.clear() @@ -102,7 +102,7 @@ func _runner(db, nodes: Array, anchor_id: int, candidates: Array, n: int = 2): # ---- ⑥ N 进数据表(含越域校验)〔fv6〕---- func _test_field_in_table(db) -> void: - var n_in_table: int = int(db.level.info_visible_layers) + var n_in_table: int = int(db.run.level.info_visible_layers) Kit.check("N 落表:level.tres 读数 2(实际 %d)〔fv6〕" % n_in_table, n_in_table == 2) var t := LevelTableScript.new() t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": _valid_graph_nodes()}] @@ -184,11 +184,11 @@ func _test_single_chain_entry(db) -> void: func _test_n_from_table(db) -> void: var nodes := _graph_nodes() var r = _runner(db, nodes, 1, [nodes[2]]) # 锚层 1 - db.level.info_visible_layers = 1 + db.run.level.info_visible_layers = 1 Kit.check("N 读表:N=1 时节点 4(层距 2)出窗〔fv6〕", not r.type_visible_ids().has(4)) - db.level.info_visible_layers = 3 + db.run.level.info_visible_layers = 3 Kit.check("N 读表:N=3 时节点 5(层距 3)入窗〔fv6〕", r.type_visible_ids().has(5)) - db.level.info_visible_layers = 10 + db.run.level.info_visible_layers = 10 var all_in := true for nid in 7: # 非豁免节点 0..6(7 = Boss 恒显,不作判据) if not r.type_visible_ids().has(nid): @@ -202,7 +202,7 @@ func _test_resume_rebuild(db) -> void: var t := LevelTableScript.new() t.info_visible_layers = 2 t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30}] - db.level = t + db.run.level = t var resumed = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new()) resumed.hero_hp = 10 # 冒险模式生命经主角账预置(旧 ledger 注入参数的等效构造面) var ok: bool = resumed.start_level_external(1, Callable(), false, diff --git a/game/tests/unit/core/level_shop_economy_test.gd b/game/tests/unit/core/level_shop_economy_test.gd index f75a944d..a29a2bff 100644 --- a/game/tests/unit/core/level_shop_economy_test.gd +++ b/game/tests/unit/core/level_shop_economy_test.gd @@ -33,7 +33,7 @@ var _graph_gen = null # 生成图取数单实例(图 = f(参数档, 种子) # 参数档 × 种子产图(与 LevelRunner._assign_generated_graph 同一产图路径与同一参数档表) func _generated_graph(level_index: int, seed_val: int) -> Dictionary: - var params: Dictionary = _db.level_gen_params_row(level_index) + var params: Dictionary = _db.run.level_gen_params.row_by_level_index(level_index) if params.is_empty(): return {} if _graph_gen == null: @@ -221,13 +221,13 @@ func _path_in_graph(nodes: Array, path: Array) -> bool: # 敌侧覆盖判据:请求深度经 band_of 落带(非 -1)——解析落带 ≥ 请求深度即等价判据(禁顶 clamp 消解) func _enemy_band_covers(level_index: int, depth: int, kind: String) -> bool: - var rows: Array[Dictionary] = _db.resolve_enemy_rowset(level_index, depth, kind) + var rows: Array[Dictionary] = _db.combat.enemy.resolve_rowset(level_index, depth, kind) return not rows.is_empty() and int(rows[0].get("depth_band", -1)) >= depth # 板侧覆盖判据:该深度在 board_gen 精确命中(node_depth 等值) func _board_row_exact(level_index: int, depth: int) -> bool: - var board: Dictionary = _db.resolve_board_gen(level_index, depth) + var board: Dictionary = _db.combat.board_gen.resolve_row(level_index, depth) return not board.is_empty() and int(board.get("node_depth", -1)) == depth @@ -259,14 +259,14 @@ func _init() -> void: func _smoke_table_migration() -> void: print("--- 数值入表(shop 两 knob + hero 显示名 · 原值迁移)---") - Kit.check("shop.relic_base_price 表值 == 迁移前字面量(15.0)〔wd11〕", _db.shop.relic_base_price == 15.0) - Kit.check("shop.spend_discount 表值 == 迁移前字面量(0.8)〔wd11〕", _db.shop.spend_discount == 0.8) - Kit.check("hero.display_name 表值 == 迁移前字面量(探险者)〔wd16〕", _db.hero.display_name == "探险者") + Kit.check("shop.relic_base_price 表值 == 迁移前字面量(15.0)〔wd11〕", _db.run.shop.relic_base_price == 15.0) + Kit.check("shop.spend_discount 表值 == 迁移前字面量(0.8)〔wd11〕", _db.run.shop.spend_discount == 0.8) + Kit.check("hero.display_name 表值 == 迁移前字面量(探险者)〔wd16〕", _db.combat.hero.display_name == "探险者") # 消费端读表:spawn 真实构造链的英雄单位显示名与表值同 var units = load("res://core/unit_container.gd").new(_db) - var spawned: bool = units.spawn(1, 0, int(_db.hero.max_hp)) + var spawned: bool = units.spawn({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)}) Kit.check("spawn 后英雄单位 display_name 读表同值(探险者)〔wd16〕", - spawned and String(units.hero_unit().get("display_name", "")) == String(_db.hero.display_name)) + spawned and String(units.hero_unit().get("display_name", "")) == String(_db.combat.hero.display_name)) # ---- 生成图深度扩面覆盖断言(迭代 35 卡② S5/V11——节点源=卡① 生成器产图, @@ -279,7 +279,7 @@ func _smoke_generated_coverage() -> void: var scanned := 0 var readings: Array = [] for L in [1, 2, 3]: - if _db.level_gen_params_row(L).is_empty(): + if _db.run.level_gen_params.row_by_level_index(L).is_empty(): Kit.check("L%d 参数档缺行(覆盖断言无源可扫)〔v11〕" % L, false) continue var dmin := 999 @@ -306,7 +306,7 @@ func _smoke_generated_coverage() -> void: board_ok = false var bands: Array = [] for tid in _terminal_ids(nodes): - var rs: Array[Dictionary] = _db.resolve_enemy_rowset(L, int(by_id[int(tid)].get("depth", -1)), "boss") + var rs: Array[Dictionary] = _db.combat.enemy.resolve_rowset(L, int(by_id[int(tid)].get("depth", -1)), "boss") bands.append(int(rs[0].get("depth_band", -1)) if not rs.is_empty() else -1) for i in range(bands.size()): for j in range(i + 1, bands.size()): @@ -325,9 +325,9 @@ func _smoke_generated_coverage() -> void: func _smoke_level() -> void: print("--- 关卡节点图·生成图推进(参数档×种子产图·满配冒险成功/恢复公式/败北线/回放确定性)---") var autoplay = load("res://sim/level_autoplay.gd") - var hero_base := {"atk": int(_db.hero.atk), "hp": int(_db.hero.max_hp), "ac": int(_db.hero.armor_class)} # 配置基线快照——⑤ 回基线对比用(改表自动跟随,不再硬编码) + var hero_base := {"atk": int(_db.combat.hero.atk), "hp": int(_db.combat.hero.max_hp), "ac": int(_db.combat.hero.armor_class)} # 配置基线快照——⑤ 回基线对比用(改表自动跟随,不再硬编码) var nodes1: Array = _level_active_nodes(1) - var params1: Dictionary = _db.level_gen_params_row(1) + var params1: Dictionary = _db.run.level_gen_params.row_by_level_index(1) Kit.check("L1 现行起关图取数非空(种子 %d × 参数档 → 节点 %d 颗)" % [ACTIVE_GRAPH_SEED, nodes1.size()], not nodes1.is_empty()) # ① 满配冒险成功路径(atk+200%/hp+100%——测试面板同源的属性覆盖) var win = autoplay.run_level(_db, ACTIVE_GRAPH_SEED, 1, 200, 100) @@ -354,10 +354,10 @@ func _smoke_level() -> void: boss_depth1 == int(params1.get("layers", -1)) and int(by_id1.get(last_id, {}).get("depth", -1)) == boss_depth1) Kit.check("L1 终点恰一个(大岔口自 L2 起的图侧投影)", _terminal_ids(nodes1).size() == 1 and _ids_of_type(nodes1, "boss").size() == 1) Kit.check("L1 生成图含岔口(出度 ≥2 节点 %d 处)" % _fork_ids(nodes1).size(), _fork_ids(nodes1).size() >= 1) - Kit.check("进 Boss HP 携带在域内(%d)" % win.hp_at_boss, win.hp_at_boss >= 1 and win.hp_at_boss <= int(_db.hero.max_hp) * 3) + Kit.check("进 Boss HP 携带在域内(%d)" % win.hp_at_boss, win.hp_at_boss >= 1 and win.hp_at_boss <= int(_db.combat.hero.max_hp) * 3) # ② 恢复点公式:回复 = min(floor(生效max_hp×rest_heal_pct/100), 缺口)——两档缺口各验一条分支 #(真入口驱动:起关→首战→选恢复候选;缺口由夹具造,恢复比例读关卡行) - var pct1: int = int(_db.level_row(1).get("rest_heal_pct", 0)) + var pct1: int = int(_db.run.level.row_by_level_index(1).get("rest_heal_pct", 0)) var rest_wide := _drive_to_rest(1, ACTIVE_GRAPH_SEED, 100000) if rest_wide.is_empty(): Kit.check("恢复点公式无源(现行图入口后一位无可直达的恢复候选)", false) @@ -382,9 +382,9 @@ func _smoke_level() -> void: Kit.check("关卡回放断言:同种子两遍结算步骤流逐行相同", JSON.stringify(rep1.rows) == JSON.stringify(rep2.rows)) Kit.check("关卡步骤流非空(%d 行)" % rep1.rows.size(), rep1.rows.size() > 0) # ⑤ 属性覆盖恢复:模拟后 hero 表回基线(调整板不泄漏到后续会话;基线 = 函数入口的配置快照,改表自动跟随) - Kit.check("模拟后 hero 表恢复基线(atk=%d hp=%d ac=%d——对配置快照)" % [int(_db.hero.atk), int(_db.hero.max_hp), int(_db.hero.armor_class)], - int(_db.hero.atk) == int(hero_base.atk) and int(_db.hero.max_hp) == int(hero_base.hp) \ - and int(_db.hero.armor_class) == int(hero_base.ac)) + Kit.check("模拟后 hero 表恢复基线(atk=%d hp=%d ac=%d——对配置快照)" % [int(_db.combat.hero.atk), int(_db.combat.hero.max_hp), int(_db.combat.hero.armor_class)], + int(_db.combat.hero.atk) == int(hero_base.atk) and int(_db.combat.hero.max_hp) == int(hero_base.hp) \ + and int(_db.combat.hero.armor_class) == int(hero_base.ac)) # 手搓战斗状态袋单点(7 处字面量收在一处;_autoplay_battle 直接消费的最小字段族。 @@ -439,10 +439,10 @@ func _smoke_level_graph() -> void: print("--- 关卡节点图·分支节点图(L2 幽暗地域·生成图)---") var autoplay = load("res://sim/level_autoplay.gd") # ① 关卡表行级校验零错误(关卡表只承载序号/显示名/恢复比例——图合法性由 validate_graph 在起关时承担) - Kit.check("LevelTable 行级校验零错误(图合法性移交 validate_graph)", _db.level.validate().is_empty()) + Kit.check("LevelTable 行级校验零错误(图合法性移交 validate_graph)", _db.run.level.validate().is_empty()) # ② L2 生成图形状读数(性质断言——规模/型别/岔口全部由参数档与逐节点扫描互校) var nodes2: Array = _level_active_nodes(2) - var params2: Dictionary = _db.level_gen_params_row(2) + var params2: Dictionary = _db.run.level_gen_params.row_by_level_index(2) Kit.check("L2 现行起关图取数非空(种子 %d × 参数档 → 节点 %d 颗)" % [ACTIVE_GRAPH_SEED, nodes2.size()], not nodes2.is_empty()) var tcount := _type_tally(nodes2) var branch := _fork_ids(nodes2).size() @@ -600,7 +600,7 @@ func _smoke_level_graph() -> void: # ⑦a 定点:L2 首遇带行集与各终点 Boss 行集(首遇带 = 数据驱动最低带,契约 8——禁写死坐标; # 多终点图逐终点解析,断言行集构成而非单行:行序 = 文件序,首行即叙述取数面) var l2_spider_band: int = _first_encounter_band(2, "giant_spider") - var l2_spider_set: Array = _db.resolve_enemy_rowset(2, l2_spider_band, "battle") + var l2_spider_set: Array = _db.combat.enemy.resolve_rowset(2, l2_spider_band, "battle") var l2_spider_ids := {} for r in l2_spider_set: l2_spider_ids[String(r.get("enemy_id", ""))] = true @@ -610,14 +610,14 @@ func _smoke_level_graph() -> void: var l2_terms := _terminal_depths(nodes2) var l2_boss_ok := not l2_terms.is_empty() for d in l2_terms: - var l2_boss_set: Array[Dictionary] = _db.resolve_enemy_rowset(2, int(d), "boss") + var l2_boss_set: Array[Dictionary] = _db.combat.enemy.resolve_rowset(2, int(d), "boss") if l2_boss_set.size() != 1 or String(l2_boss_set[0].get("enemy_id", "")) != "drider" \ or String(l2_boss_set[0].get("armor_type", "")) != "ward": l2_boss_ok = false Kit.check("L2 全部终点深度 %s 的 Boss 行集均 = {drider} 恰一行且首行魔法甲(护甲类型重配批:关卡 2 唯一非轻甲;P4 乙 Boss 行集多行合法——零内容期仍单行)〔fv24〕" % str(l2_terms), l2_boss_ok) # ⑦b 定点:混编遭遇行集 (2,battle,5)(R6 行序契约运行面: # 行集两行文件内相邻、行序 = 声明序 = 生成序;静态最坏骰和 Σ(unit_count_max,固定行缺省 = unit_count) ≤ 4) - var l2_mix_set: Array = _db.resolve_enemy_rowset(2, 5, "battle") + var l2_mix_set: Array = _db.combat.enemy.resolve_rowset(2, 5, "battle") var mix_shape_ok: bool = l2_mix_set.size() == 2 \ and String(l2_mix_set[0].get("enemy_id", "")) == "bugbear" \ and String(l2_mix_set[1].get("enemy_id", "")) == "giant_spider" @@ -632,7 +632,7 @@ func _smoke_level_graph() -> void: # ---- L3 灰矮人矿城断言段 ---- # ⑧ L3 生成图形状读数(与 ② 同一性质判据面——规模/型别/岔口/多终点全部对参数档与扫描读数互校) var nodes3: Array = _level_active_nodes(3) - var params3: Dictionary = _db.level_gen_params_row(3) + var params3: Dictionary = _db.run.level_gen_params.row_by_level_index(3) Kit.check("L3 现行起关图取数非空(种子 %d × 参数档 → 节点 %d 颗)" % [ACTIVE_GRAPH_SEED, nodes3.size()], not nodes3.is_empty()) var tcount3 := _type_tally(nodes3) var branch3 := _fork_ids(nodes3).size() @@ -654,7 +654,7 @@ func _smoke_level_graph() -> void: Kit.check("L3 现行图每节点深度 board_gen 精确命中(非空且 node_depth 等值·违例 %d 处)" % int(cov3["board_bad"]), int(cov3["board_bad"]) == 0) # ⑩ 定点:L3 首遇带行集与各终点 Boss 行集(克制面重轻错开——重甲灰矮人首次登台;首遇带数据驱动定位同 ⑦a) var l3_lead_band: int = _first_encounter_band(3, "duergar") - var l3_lead_set: Array = _db.resolve_enemy_rowset(3, l3_lead_band, "battle") + var l3_lead_set: Array = _db.combat.enemy.resolve_rowset(3, l3_lead_band, "battle") var l3_lead_ids := {} for r in l3_lead_set: l3_lead_ids[String(r.get("enemy_id", ""))] = true @@ -665,7 +665,7 @@ func _smoke_level_graph() -> void: var l3_terms := _terminal_depths(nodes3) var l3_boss_ok := not l3_terms.is_empty() for d in l3_terms: - var l3_king_set: Array[Dictionary] = _db.resolve_enemy_rowset(3, int(d), "boss") + var l3_king_set: Array[Dictionary] = _db.combat.enemy.resolve_rowset(3, int(d), "boss") if l3_king_set.size() != 1 or String(l3_king_set[0].get("enemy_id", "")) != "duergar_king" \ or String(l3_king_set[0].get("armor_type", "")) != "heavy": l3_boss_ok = false @@ -700,23 +700,23 @@ func _smoke_level_graph() -> void: # 断言后立即还原表,后续断言面零污染。R3 fail-fast 正反验证 + R6 行序运行面验证。 var mix_row_a := {"level_index": 1, "row_kind": "battle", "depth_band": 5, "enemy_id": "goblin", "display_name": "地精", "unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]} var mix_row_b := {"level_index": 1, "row_kind": "battle", "depth_band": 5, "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 2, "hp": 20, "atk": 3, "pool": [{"skill_id": "attack", "weight": 9}]} - _db.enemy.rows.append(mix_row_a) - _db.enemy.rows.append(mix_row_b) + _db.combat.enemy.rows.append(mix_row_a) + _db.combat.enemy.rows.append(mix_row_b) var mix_sys = load("res://core/unit_container.gd").new(_db) - Kit.check("混编行集 spawn 受理((1,battle,5) 双行 地精1+熊地精2——定值无区间路径,行集构成纪律放开面正向)", mix_sys.spawn(1, 5, int(_db.hero.max_hp))) + Kit.check("混编行集 spawn 受理((1,battle,5) 双行 地精1+熊地精2——定值无区间路径,行集构成纪律放开面正向)", mix_sys.spawn({"level_index": 1, "node_depth": 5, "hero_hp": int(_db.combat.hero.max_hp)})) var mix_units: Array = mix_sys.all_enemies() Kit.check("混编实例总数 = 各行数量求和(1+2=3——行集实例总数 ≤4 守卫内)", mix_units.size() == 3) Kit.check("混编生成序 = 文件行序(行序即契约——地精在前熊地精在后,handle_id 跨行连续派发)", String(mix_units[0].get("enemy_id")) == "goblin" and String(mix_units[1].get("enemy_id")) == "bugbear" and String(mix_units[2].get("enemy_id")) == "bugbear") Kit.check("混编各行行值独立投影(display_name/hp/atk 按各自行值——同场不同种不同值)", String(mix_units[0].get("display_name")) == "地精" and int(mix_units[1].get("hp")) == 20 and int(mix_units[1].get("atk")) == 3) - _db.enemy.rows.append({"level_index": 1, "row_kind": "battle", "depth_band": 6, "enemy_id": "goblin", "display_name": "地精", "unit_count": 3, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]}) - _db.enemy.rows.append({"level_index": 1, "row_kind": "battle", "depth_band": 6, "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 3, "hp": 20, "atk": 3, "pool": [{"skill_id": "attack", "weight": 9}]}) + _db.combat.enemy.rows.append({"level_index": 1, "row_kind": "battle", "depth_band": 6, "enemy_id": "goblin", "display_name": "地精", "unit_count": 3, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]}) + _db.combat.enemy.rows.append({"level_index": 1, "row_kind": "battle", "depth_band": 6, "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 3, "hp": 20, "atk": 3, "pool": [{"skill_id": "attack", "weight": 9}]}) var over_sys = load("res://core/unit_container.gd").new(_db) - Kit.check("行集实例总数守卫:两行各定值 3(总 6 > 4)spawn 被拒(行集构成纪律③——R3 fail-fast 运行面)", not over_sys.spawn(1, 6, int(_db.hero.max_hp))) - _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) - _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) - _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) - _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) - Kit.check("混编断言段注入行全量还原(表回 28 行——迭代 35 生成图深度扩面覆盖批后的基线行数;首批混编投放基线 20 行+覆盖 8 行)", int(_db.enemy.rows.size()) == 28) + Kit.check("行集实例总数守卫:两行各定值 3(总 6 > 4)spawn 被拒(行集构成纪律③——R3 fail-fast 运行面)", not over_sys.spawn({"level_index": 1, "node_depth": 6, "hero_hp": int(_db.combat.hero.max_hp)})) + _db.combat.enemy.rows.remove_at(_db.combat.enemy.rows.size() - 1) + _db.combat.enemy.rows.remove_at(_db.combat.enemy.rows.size() - 1) + _db.combat.enemy.rows.remove_at(_db.combat.enemy.rows.size() - 1) + _db.combat.enemy.rows.remove_at(_db.combat.enemy.rows.size() - 1) + Kit.check("混编断言段注入行全量还原(表回 28 行——迭代 35 生成图深度扩面覆盖批后的基线行数;首批混编投放基线 20 行+覆盖 8 行)", int(_db.combat.enemy.rows.size()) == 28) # ⑬ 真图类型可见性(信息迷雾 v2.1):L2 入口停点纯核心面读数——候选 ⊆ 可见集 / # 终点恒可见 / 入口 BFS 层距 > N 的非豁免节点入雾。N 取数据表(代码零硬编码)。 @@ -734,7 +734,7 @@ func _smoke_level_graph() -> void: if not vis_ids.has(int(bid)): vis_boss_ok = false Kit.check("可见性冒烟:L2 全部终点 Boss 句柄 %s 恒在可见集(终点不入雾)〔fv3〕" % str(_ids_of_type(nodes2, "boss")), vis_boss_ok) - var vis_n: int = int(_db.level.info_visible_layers) + var vis_n: int = int(_db.run.level.info_visible_layers) Kit.check("可见性冒烟:N 读数据表 = %d(provisional 2——代码零硬编码)〔fv6〕" % vis_n, vis_n == 2) # 层距判据按图自算入口 BFS 层(与 runner 同源口径:入口层 0、出边逐层 +1)—— # 锚层 0 时层距 ≤ N 者恒可见、层距 > N 的非终点者恒不可见(两条扫描面都须非空) @@ -823,7 +823,7 @@ func _smoke_gp_wallet() -> void: var fin_b := {} boss.step_row.connect(func(row): rows_b.append(row)) boss.battle_finished.connect(func(p): fin_b["p"] = p) - Kit.check("L2 Boss 战启动((2,9)——Boss 档掷定 [6,10] + 关级加值 1)", boss.begin_battle({"level_index": 2, "node_depth": 9, "node_type": "boss", "hero_hp": int(_db.hero.max_hp)})) + Kit.check("L2 Boss 战启动((2,9)——Boss 档掷定 [6,10] + 关级加值 1)", boss.begin_battle({"level_index": 2, "node_depth": 9, "node_type": "boss", "hero_hp": int(_db.combat.hero.max_hp)})) var guard_b := 60 while boss.battle_result == "" and guard_b > 0: guard_b -= 1 @@ -862,7 +862,7 @@ func _smoke_gp_escrow() -> void: settlement.step_row.connect(func(row): rows.append(row)) var fin := {} settlement.battle_finished.connect(func(p): fin["p"] = p) - if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}): + if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp), "hero_atk": 999}): continue var target := _find_gp_dig(settlement.minesweeper, settlement) if target.is_empty(): @@ -896,15 +896,15 @@ func _smoke_gp_escrow() -> void: var settle_l = load("res://core/battle_orchestrator.gd").new(_db, rng_l) var fin_l := {} settle_l.battle_finished.connect(func(p): fin_l["p"] = p) - Kit.check("破财直验战斗启动", settle_l.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999, "node_type": "boss"})) + Kit.check("破财直验战斗启动", settle_l.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.combat.hero.max_hp), "hero_atk": 999, "node_type": "boss"})) for i in range(3): settle_l.reward_grant().grant_routed({"reward_id": "gp_lose", "cell_type": "gp", "polarity": "negative", "display_name": "破财", "power": 2, "effect_id": "gp_loss", "stacks": 1}, "negative", {"source_class": "mine_hit", "cell": Vector2i(0, 0), "ri": 1}) Kit.check("破财 ×3 → escrow −6(负向缓冲记账,零钱包触达)", int(settle_l.reward_grant().escrow) == -6) settle_l.minesweeper.settle_batch([{"action": "stop"}]) var gp_l: int = int(fin_l.get("p", {}).get("gp_dropped", 999)) - var boss_drop: Dictionary = _db.shop_gp_drop_row("boss") - var boss_bonus1: int = _db.shop_level_gp_bonus(1) + var boss_drop: Dictionary = _db.run.shop.gp_drop_row("boss") + var boss_bonus1: int = _db.run.shop.level_gp_bonus_at(1) Kit.check("破财压制 gp_dropped 负值上抛(%d + 6 = %d ∈ Boss 档掷定 [%d,%d] + 关级加值 %d——掷定不受破财污染)" % [ gp_l, gp_l + 6, int(boss_drop.get("roll_min", 0)), int(boss_drop.get("roll_max", 0)), boss_bonus1], gp_l + 6 >= int(boss_drop.get("roll_min", 0)) + boss_bonus1 and gp_l + 6 <= int(boss_drop.get("roll_max", 0)) + boss_bonus1) @@ -1048,7 +1048,7 @@ func _smoke_shop_room() -> void: Kit.check("当前生命同步 +3 封顶新上限(%d → %d)" % [hp_before, runner.hero_hp], runner.hero_hp == mini(hp_before + 3, runner.effective_max_hp())) # armor_up:AC 10+1 Kit.check("购买 armor_up 受理(现价 6)", runner.buy_product("armor_up")) - Kit.check("AC = 10+1(armor_class 加算区轴,无百分比通道)", runner.effective_ac() == int(_db.hero.armor_class) + 1) + Kit.check("AC = 10+1(armor_class 加算区轴,无百分比通道)", runner.effective_ac() == int(_db.combat.hero.armor_class) + 1) # 余额不足购买被拒(零副作用):hp_up 连购至不可负担后兜底拒绝 var guard_drain := 20 var hp_buys := 1 # 夹具前段已购 1 笔(购买 hp_up 受理断言)——总数 = 前段 + 本循环 @@ -1068,12 +1068,12 @@ func _smoke_shop_room() -> void: Kit.check("离开商店 → 回选路口(候选 = 店出边 %s——零自动推进收口)" % str(shop_outs), runner.continue_after_node() and runner.awaiting_choice() and _sorted_ids(runner.next_candidate_ids()) == _sorted_ids(shop_outs)) # 下一战参数 = 基础表值 + 加算区(百分比已归零——加算先乘后乘序的前半在案) - Kit.check("主角攻击生效值 = 2+1(基础表值 + 加算区)", runner.effective_atk() == int(_db.hero.atk) + 1) + Kit.check("主角攻击生效值 = 2+1(基础表值 + 加算区)", runner.effective_atk() == int(_db.combat.hero.atk) + 1) Kit.check("选终点 Boss(句柄 %d)开战" % int(shop_outs[0]), runner.pick_next_node(int(shop_outs[0])) and runner.current_battle() != null) var snap: Dictionary = runner.current_battle().presentation_snapshot() # max_hp 期望按实际购买笔数推导(可购笔数 # 随经济漂移;断言语义=「加算区笔数×3 全额入战斗开始参数」,不锚死恰好 1 笔) - Kit.check("战斗开始参数 = 基础表值 + 加算区(atk %d = 2+1 / max_hp %d = %d+%d×3)" % [int(snap["hero"].get("atk", -1)), int(snap["hero"].get("max_hp", -1)), int(_db.hero.max_hp), hp_buys], int(snap["hero"].get("atk", -1)) == int(_db.hero.atk) + 1 and int(snap["hero"].get("max_hp", -1)) == int(_db.hero.max_hp) + 3 * hp_buys) + Kit.check("战斗开始参数 = 基础表值 + 加算区(atk %d = 2+1 / max_hp %d = %d+%d×3)" % [int(snap["hero"].get("atk", -1)), int(snap["hero"].get("max_hp", -1)), int(_db.combat.hero.max_hp), hp_buys], int(snap["hero"].get("atk", -1)) == int(_db.combat.hero.atk) + 1 and int(snap["hero"].get("max_hp", -1)) == int(_db.combat.hero.max_hp) + 3 * hp_buys) # 冒险收束(Boss 胜或败北——基线裸装口径任一结局)→ 钱包清零 var fin_end := {} runner.level_finished.connect(func(p): fin_end["p"] = p) @@ -1106,7 +1106,7 @@ func _smoke_shop_graph() -> void: var path_total := 0 var graph_total := 0 for L in [1, 2, 3]: - if _db.level_gen_params_row(L).is_empty(): + if _db.run.level_gen_params.row_by_level_index(L).is_empty(): Kit.check("L%d 参数档缺行(商店编排断言无源可扫)" % L, false) continue for seed_val in range(SWEEP_SEED_FROM, SWEEP_SEED_TO): @@ -1238,7 +1238,7 @@ func _smoke_shop_pool() -> void: print("--- 商店共享池(抽3买1全重抽 · 乘法定价 ceil · 池空硬闸 · 切边零入店)---") var autoplay = load("res://sim/level_autoplay.gd") var shelf_kinds: Array = [] - for row in _db.shop_pool_rows(): + for row in _db.run.shop.pool_rows(): shelf_kinds.append(String(row.get("kind_id", ""))) # ① 池模式场景可达(_adv_drive_to_shop 复用——L1 全程+L2 推进到商店房;pool 模式进店即抽架) var scen := {} @@ -1268,12 +1268,12 @@ func _smoke_shop_pool() -> void: var r8 = scen["r"] var gp0: int = int(r8.gp) var cost0: int = r8.shop_refresh_cost() - Kit.check("刷新首价=基价(%d)" % cost0, cost0 == int(_db.adventure.shop_refresh_cost_base)) + Kit.check("刷新首价=基价(%d)" % cost0, cost0 == int(_db.run.adventure.shop_refresh_cost_base)) Kit.check("基础免费 0 次(Q2——天赋增益位后续添加)", r8.shop_refresh_free_left() == 0) var shelf_pre: Array = (r8.shop_shelf() as Array).duplicate(true) Kit.check("GP 刷新受理(gp=%d cost=%d)" % [gp0, cost0], r8.shop_refresh()) Kit.check("GP 刷新扣费(%d→%d)" % [gp0, int(r8.gp)], int(r8.gp) == gp0 - cost0) - Kit.check("刷新爬价 ×curve(%d)" % int(r8.shop_refresh_cost()), int(r8.shop_refresh_cost()) == int(ceil(float(cost0) * float(_db.adventure.shop_refresh_cost_curve)))) + Kit.check("刷新爬价 ×curve(%d)" % int(r8.shop_refresh_cost()), int(r8.shop_refresh_cost()) == int(ceil(float(cost0) * float(_db.run.adventure.shop_refresh_cost_curve)))) var same_shelf := first_shelf_a.size() == first_shelf_b.size() if same_shelf: for i in first_shelf_a.size(): @@ -1290,7 +1290,7 @@ func _smoke_shop_pool() -> void: var l2 := int(r8.level_row.level_index) # 现价等价证明(d11——消费端读表推导 vs 迁移前硬编码字面量推导:同输入逐位一致) var relic_price_now: int = r8.pool_price("relic") - var relic_price_pre: int = int(ceil(15.0 * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0)) + var relic_price_pre: int = int(ceil(15.0 * pow(_db.run.shop.price_b, float(l2 - 1)) * 1.0)) Kit.check("relic 现价 = ceil(15.0×b^(关序−1))(读表与迁移前字面量逐位一致,%d)〔wd11〕" % relic_price_now, relic_price_now == relic_price_pre) for c in r8.shop_shelf(): # T6 商店保底后 relic 卡合法入架(保底占位)——词表断言放行 relic;价格断言分叉 @@ -1300,10 +1300,10 @@ func _smoke_shop_pool() -> void: Kit.check("货架 kind 在封闭词表或保底 relic 卡(%s)" % ckind, is_relic or shelf_kinds.has(ckind)) var expect_p: int if is_relic: - expect_p = int(ceil(float(_db.shop.relic_base_price) * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0)) + expect_p = int(ceil(float(_db.run.shop.relic_base_price) * pow(_db.run.shop.price_b, float(l2 - 1)) * 1.0)) else: - var prow: Dictionary = _db.shop_pool_row(ckind) - expect_p = int(ceil(float(int(prow.get("base_price", 0))) * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0)) + var prow: Dictionary = _db.run.shop.pool_row(ckind) + expect_p = int(ceil(float(int(prow.get("base_price", 0))) * pow(_db.run.shop.price_b, float(l2 - 1)) * 1.0)) Kit.check("首购现价 = ceil(基价×b^(关序−1))(%s: %d = %d 推导)" % [ckind, int(c.get("price", -1)), expect_p], int(c.get("price", -1)) == expect_p) # ③ 买 1 全重抽:出账+加算区+账本三落点 + 现价 A 链递增(买后 visit 计数=1;TP0——消费在架令牌本体) var buy_c: Dictionary = {} @@ -1321,7 +1321,7 @@ func _smoke_shop_pool() -> void: var val0: int = a8.journal().bonus_total(String(val_map[kind])) Kit.check("购买受理(pool 口——%s|%s)" % [kind, tier], r8.buy_pool_item(kind)) Kit.check("钱包扣现价(%d → %d)" % [gp0, int(r8.gp)], int(r8.gp) == gp0 - int(buy_c.get("price", 0))) - Kit.check("加算区 +令牌档位件值(池件=奖励件本体——%s|%s %+d)" % [kind, tier, int(_db.adventure.curio_value(kind, tier))], a8.journal().bonus_total(String(val_map[kind])) == val0 + int(_db.adventure.curio_value(kind, tier))) + Kit.check("加算区 +令牌档位件值(池件=奖励件本体——%s|%s %+d)" % [kind, tier, int(_db.run.adventure.curio_value(kind, tier))], a8.journal().bonus_total(String(val_map[kind])) == val0 + int(_db.run.adventure.curio_value(kind, tier))) Kit.check("共享池账本 -1(跨渠道单点——taken %d → %d)" % [taken0, int(a8.deck().taken.get(kind, 0))], int(a8.deck().taken.get(kind, 0)) == taken0 + 1) # A 链:同visit 第二购现价 = ceil(base×b^(l2−1)×A^1)(货架已全重抽——新卡新价同公式) # A 链:白盒抬 GP 保证第二购场景确定构造(直改测试基建——非生产行为) @@ -1332,8 +1332,8 @@ func _smoke_shop_pool() -> void: second = c break if not second.is_empty(): - var prow2: Dictionary = _db.shop_pool_row(String(second.get("kind_id", ""))) - var expect_p2: int = int(ceil(float(int(prow2.get("base_price", 0))) * pow(_db.shop_price_b(), float(l2 - 1)) * _db.shop_price_a())) + var prow2: Dictionary = _db.run.shop.pool_row(String(second.get("kind_id", ""))) + var expect_p2: int = int(ceil(float(int(prow2.get("base_price", 0))) * pow(_db.run.shop.price_b, float(l2 - 1)) * _db.run.shop.price_a)) Kit.check("A 链爬价 = ceil(base×b^(关序−1)×A^1)(进店第 2 购——%d)" % expect_p2, int(second.get("price", -1)) == expect_p2) Kit.check("同 visit 第二购受理(A 链软闸在价不在限)", r8.buy_pool_item(String(second.get("kind_id", "")))) else: @@ -1384,8 +1384,8 @@ func _smoke_shop_pool() -> void: var ps_ok := true for k in ps.keys(): var quota := 0 - for tier in _db.adventure.TIERS: - quota += int(_db.adventure.curio_quota(String(k), tier)) + for tier in _db.run.adventure.TIERS: + quota += int(_db.run.adventure.curio_quota(String(k), tier)) if int(ps[k]) < 0 or int(ps[k]) > quota: ps_ok = false Kit.check("终局池余量在域 [0,配额](%s)" % str(ps), ps_ok) @@ -1458,7 +1458,7 @@ func _adv_st() -> Dictionary: # 首遇带数据驱动定位(契约 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: diff --git a/game/tests/unit/core/meta_progression_forge_test.gd b/game/tests/unit/core/meta_progression_forge_test.gd index d1dd3429..f1660fa9 100644 --- a/game/tests/unit/core/meta_progression_forge_test.gd +++ b/game/tests/unit/core/meta_progression_forge_test.gd @@ -89,7 +89,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"}) @@ -132,22 +132,22 @@ func _meta_pricing_and_buy() -> void: var sv = _meta_new_save(_META_SMOKE_PATHS[0]) var mp = load("res://core/meta_progression.gd").new(_db, sv) # ③ 购买成功:级+1、余额−价;buy 内含写盘——reload 后持久(树全钥石单币) - sv.add_keystone(1000) - var bal0: int = sv.keystone_balance() - var price0: int = int(_db.tree_node("sp_hp").get("price", 0)) + sv.meta_state._add_keystone(1000) + var bal0: int = sv.meta_state.keystone_balance() + var price0: int = int(_db.crossrun.meta_tree.row_by_id("sp_hp").get("price", 0)) Kit.check("树购买受理(Lv 0→1、余额 %d−%d)" % [bal0, price0], - mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1 and sv.keystone_balance() == bal0 - price0) + mp.tree_buy("sp_hp") and sv.meta_state.tree_state().nodes.get("sp_hp", 0) == 1 and sv.meta_state.keystone_balance() == bal0 - price0) var sv_r = _meta_new_save(_META_SMOKE_PATHS[0]) Kit.check("购买持久(reload 后级与余额不变)", - sv_r.tree_level("sp_hp") == 1 and sv_r.keystone_balance() == bal0 - price0) + sv_r.meta_state.tree_state().nodes.get("sp_hp", 0) == 1 and sv_r.meta_state.keystone_balance() == bal0 - price0) # ③ 余额不足 / 未知节点:false 零副作用 var sv_dry = _meta_new_save(_META_SMOKE_PATHS[1]) var mp_dry = load("res://core/meta_progression.gd").new(_db, sv_dry) - var tn_before: Dictionary = sv_dry.tree_nodes() - var fs_before: Dictionary = sv_dry.fork_sides() + var tn_before: Dictionary = sv_dry.meta_state.tree_state().nodes + var fs_before: Dictionary = sv_dry.meta_state.tree_state().fork_sides Kit.check("余额不足 false 零副作用(0 余额购买拒绝——三域零变化〔天赋树批 V3〕)", - not mp_dry.tree_buy("sp_hp") and sv_dry.keystone_balance() == 0 - and sv_dry.tree_nodes() == tn_before and sv_dry.fork_sides() == fs_before) + not mp_dry.tree_buy("sp_hp") and sv_dry.meta_state.keystone_balance() == 0 + and sv_dry.meta_state.tree_state().nodes == tn_before and sv_dry.meta_state.tree_state().fork_sides == fs_before) Kit.check("未知节点 false 零副作用(词表外出域)", not mp_dry.tree_buy("no_such_node")) @@ -156,8 +156,8 @@ func _meta_settle_and_workshop() -> void: print("--- 元进度·收束入账与工坊(隔离档 smoke_meta_settle/workshop/cap)---") var sv = _meta_new_save(_META_SMOKE_PATHS[2]) var mp = load("res://core/meta_progression.gd").new(_db, sv) - var row1: Dictionary = _db.meta_boss_keystone_row(1) - var row2: Dictionary = _db.meta_boss_keystone_row(2) + var row1: Dictionary = _db.crossrun.meta.boss_keystone_row(1) + var row2: Dictionary = _db.crossrun.meta.boss_keystone_row(2) # ⑥ 逐杀掉落收束入账(掉落已在冒险内逐杀入账〔summary.keystone_total 含全部〕, # keystone_drops 明细随摘要归档;首杀标记仍由 apply 逐关卡完成关次置位) var d1: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, @@ -171,7 +171,7 @@ func _meta_settle_and_workshop() -> void: Kit.check("明细 total = keystone_total(逐杀掉落全源——无二次派生加账)", int(d1.get("total", 0)) == int(row1.get("first_kill", 0))) Kit.check("余额 = 累计入账(逐杀掉落合计——无重复加账)", - sv.keystone_balance() == int(row1.get("first_kill", 0))) + sv.meta_state.keystone_balance() == int(row1.get("first_kill", 0))) # ⑥ 再通转复杀档(首杀标记置位后小额恒定不递减——标记面在 apply,金额面在逐杀裁决) var d2: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, "stopped_at": 2, "keystone_total": int(row1.get("repeat_kill", 0)), @@ -196,39 +196,39 @@ func _meta_settle_and_workshop() -> void: not bool(d4["boss_drops"][0].get("first", true)) and bool(d4["boss_drops"][1].get("first", false)) and int(d4["boss_drops"][1].get("amount", 0)) == int(row2.get("first_kill", -1))) # ⑦ 败北零关卡完成摘要:余额只增不减(零入账)、clear_counts 不变 - var bal_before: int = sv.keystone_balance() - var cc1_before: int = sv.clear_count(1) + var bal_before: int = sv.meta_state.keystone_balance() + var cc1_before: int = sv.meta_state.career_state().clear_counts.get(str(1), 0) var dz: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 0, "stopped_at": 1, "keystone_total": 0, "master_seed": 15}) Kit.check("败北零关卡完成摘要:零入账零标记(钥石只增不减——验收1;败北局 clear_counts 不变)", - dz["boss_drops"].is_empty() and sv.keystone_balance() == bal_before and sv.clear_count(1) == cc1_before) + dz["boss_drops"].is_empty() and sv.meta_state.keystone_balance() == bal_before and sv.meta_state.career_state().clear_counts.get(str(1), 0) == cc1_before) # ⑧ 秘宝解锁服务(V3 钥石购买/V4 条件解锁;注入=直调解锁写口+直置存档生涯计数, # 不走正常资源积累流程——卡片 7.1 测试方式) var sv_u = _meta_new_save(_META_SMOKE_PATHS[11]) var mp_u = load("res://core/meta_progression.gd").new(_db, sv_u) - sv_u.add_keystone(10) + sv_u.meta_state._add_keystone(10) Kit.check("条件件误购拒绝(购买件专属——契约 8 分配面)〔秘宝批 V3 反例〕", - not mp_u.buy_treasure_unlock("sky_eye") and not sv_u.item_unlocked("sky_eye") + not mp_u.buy_treasure_unlock("sky_eye") and not sv_u.meta_state.item_unlocked("sky_eye") and not mp_u.buy_treasure_unlock("ranged_pick")) Kit.check("余额不足购买拒绝且零副作用(余额 10 < 价 25)〔秘宝批 V3〕", - not mp_u.buy_treasure_unlock("twin_loader") and sv_u.keystone_balance() == 10) + not mp_u.buy_treasure_unlock("twin_loader") and sv_u.meta_state.keystone_balance() == 10) Kit.check("钥石购买解锁:余额足→解锁态落档+余额扣减正确(价 10)〔秘宝批 V3〕", - mp_u.buy_treasure_unlock("morning_order") and sv_u.item_unlocked("morning_order") - and sv_u.keystone_balance() == 0) + mp_u.buy_treasure_unlock("morning_order") and sv_u.meta_state.item_unlocked("morning_order") + and sv_u.meta_state.keystone_balance() == 0) Kit.check("已解锁重复购买拒绝〔秘宝批 V3〕", not mp_u.buy_treasure_unlock("morning_order")) Kit.check("条件未达不解锁(远程镐=冒险成功 1 次)〔秘宝批 V4〕", - mp_u.refresh_treasure_unlocks().is_empty() and not sv_u.item_unlocked("ranged_pick")) + mp_u.refresh_treasure_unlocks().is_empty() and not sv_u.meta_state.item_unlocked("ranged_pick")) sv_u._data["meta"]["adventure_cleared"] = 1 # 直置存档生涯计数(调试注入口径) Kit.check("远程镐=adventure_cleared≥1 达标恰解锁一次+重复刷新幂等(C6:冒险成功=打通整局冒险)〔秘宝批 V4〕", (mp_u.refresh_treasure_unlocks() as Array).has("ranged_pick") - and sv_u.item_unlocked("ranged_pick") and mp_u.refresh_treasure_unlocks().is_empty()) + and sv_u.meta_state.item_unlocked("ranged_pick") and mp_u.refresh_treasure_unlocks().is_empty()) sv_u._data["meta"]["boss_kill_count"] = 3 Kit.check("刷新执照=boss_kill_count≥3(阈值读 unlock_requires 行值不硬编)〔秘宝批 V4〕", (mp_u.refresh_treasure_unlocks() as Array).has("refresh_license")) sv_u._data["meta"]["adventure_cleared"] = 3 Kit.check("天眼单条件不足不解锁(AND 语义——cleared 3 达 boss 4 未达)〔秘宝批 V4〕", - not sv_u.item_unlocked("sky_eye")) + not sv_u.meta_state.item_unlocked("sky_eye")) sv_u._data["meta"]["boss_kill_count"] = 4 Kit.check("天眼双条件 AND(冒险成功≥3 且 Boss≥4)达标解锁〔秘宝批 V4〕", (mp_u.refresh_treasure_unlocks() as Array).has("sky_eye")) @@ -240,7 +240,7 @@ func _meta_settle_and_workshop() -> void: r_n += 1 if bool(st_row.get("unlocked", false)) else 0 Kit.check("解锁态持久投影一致(reload 面同源读数;5 件=购买 1+条件 4)〔秘宝批 V4〕", r_n == 5) # ⑩ recent_summaries:元素含 boss_drops 副本、最新在前、cap 10 - var rs: Array = sv.recent_summaries() + var rs: Array = sv.meta_state.career_state().recent_summaries Kit.check("摘要最新在前 + 元素含掉落明细副本(复盘可归因——验收5)", rs.size() >= 5 and String(rs[0].get("result", "")) != "" and rs[0].has("boss_drops") and (rs[1] as Dictionary).has("keystone_drops") @@ -251,7 +251,7 @@ func _meta_settle_and_workshop() -> void: mp_c.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 0, "stopped_at": 1, "keystone_total": 0, "master_seed": i}) Kit.check("摘要 cap 10 且最新在前(12 连 settle 截断后 seed 11 居首)", - sv_c.recent_summaries().size() == 10 and int(sv_c.recent_summaries()[0].get("master_seed", -1)) == 11) + sv_c.meta_state.career_state().recent_summaries.size() == 10 and int(sv_c.meta_state.career_state().recent_summaries[0].get("master_seed", -1)) == 11) # ⑪ 同调位账本(装配持久/满位禁选/Y 升级语义;写口 = MetaProgression 同调段) var sv_a = _meta_new_save(_META_SMOKE_PATHS[6]) var mp_a = load("res://core/meta_progression.gd").new(_db, sv_a) @@ -259,17 +259,17 @@ func _meta_settle_and_workshop() -> void: Attunement.slots(_db, sv_a) == 0 and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 0) Kit.check("满位禁选(Slots 0 时装配拒绝;兼未解锁拒绝前置——直置解锁后下方装配)〔秘宝批 V5〕", mp_a.toggle_attunement("morning_order") == false) - sv_a.add_keystone(200) - sv_a.unlock_item("morning_order") - sv_a.unlock_item("twin_loader") + sv_a.meta_state._add_keystone(200) + sv_a.meta_state.unlock_item("morning_order") + sv_a.meta_state.unlock_item("twin_loader") Kit.check("Y 升级购买入账(Lv1 → 每冒险 +1 次刷新)", - mp_a.buy_attune_refresh() and sv_a.attune_refresh_level() == 1 + mp_a.buy_attune_refresh() and sv_a.meta_state.attune_state().refresh_level == 1 and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 1) mp_a.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, "stopped_at": 2, "keystone_total": 0, "master_seed": 21}) Kit.check("完成 1 关 → Slots 恒 0(旧「按击杀 Boss 次数自动涨」条件已删——槽位唯一来源=树点亮)且 Boss 击杀总数=1(计数域保留)〔天赋树批 V14〕", - Attunement.slots(_db, sv_a) == 0 and sv_a.boss_kill_count() == 1) - sv_a.set_tree_level("att_socket1", 1) + Attunement.slots(_db, sv_a) == 0 and sv_a.meta_state.career_state().boss_kill_count == 1) + sv_a.meta_state.set_tree_level("att_socket1", 1) Kit.check("树上点亮同调位 1 → Slots=1(槽位收编树节点——Q2 裁甲)", Attunement.slots(_db, sv_a) == 1) Kit.check("稀有件入普通格拒绝(件档 rank 3 > 装入位序 1——契约 2 档位格校验)〔秘宝批 V5〕", @@ -279,7 +279,7 @@ func _meta_settle_and_workshop() -> void: Kit.check("卸下恒可再装配(自卸自装——营地换装=冒险开始前合法面,装配不可变只锁运行中)〔秘宝批换主语〕", mp_a.toggle_attunement("morning_order") and mp_a.toggle_attunement("morning_order")) var sv_a2 = _meta_new_save(_META_SMOKE_PATHS[7]) - sv_a2.set_attunement_equipped(["morning_order"]) + sv_a2.meta_state.set_attunement_equipped(["morning_order"]) var mp_a2 = load("res://core/meta_progression.gd").new(_db, sv_a2) Kit.check("装配关系读回持久(reload 后投影一致——写新件 id)〔秘宝批 V15〕", (mp_a2.attune_camp_status()["equipped"] as Array).has("morning_order")) @@ -305,9 +305,9 @@ func _meta_settle_and_workshop() -> void: # 三键恒空(缺陷①),冒烟夹具直呼 runner 参数面拦不住——此处直呼 main.build_adventure_params # 单测统一构造面(static 免实例化整菜单场景;真实两入口共用本构造),取值与营地投影同源) var sv_e = _meta_new_save(_META_SMOKE_PATHS[8]) - sv_e.set_attunement_equipped(["morning_order"]) - sv_e.set_attune_refresh_level(2) - sv_e.set_fork_side("fg_posture", "a") + sv_e.meta_state.set_attunement_equipped(["morning_order"]) + sv_e.meta_state.set_attune_refresh_level(2) + sv_e.meta_state.set_fork_side("fg_posture", "a") var mp_e = load("res://core/meta_progression.gd").new(_db, sv_e) var rules_e: Array = mp_e.tree_rule_keys() Kit.check("开局传参夹具前置:Fork 侧 a 激活 rule 键非空 + Y=Lv2 行表值 2(防断言空转)", @@ -315,7 +315,7 @@ func _meta_settle_and_workshop() -> void: var p_new: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e, {"treasure_unlocked": {"wide_scout_band": true}, "shop_mode": "pool"}) Kit.check("主菜单开新冒险参数面:同调装配 = 存档装配集(营地装了什么开局就带什么)", - (p_new.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array)) + (p_new.get("attunement", []) as Array) == (sv_e.meta_state.attune_state().equipped as Array)) Kit.check("主菜单开新冒险参数面:Y 刷新池 = 当前等级行表值(营地升几档开局就给几次)", int(p_new.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges()) Kit.check("主菜单开新冒险参数面:树 rule 键集 = Fork 激活侧 effects(点哪侧带哪侧)", @@ -325,7 +325,7 @@ func _meta_settle_and_workshop() -> void: var p_resume: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e, {"treasure_unlocked": {}}) Kit.check("主菜单继续上局参数面:三键与开新冒险同源同值(续玩不吃白板开局)", - (p_resume.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array) + (p_resume.get("attunement", []) as Array) == (sv_e.meta_state.attune_state().equipped as Array) and int(p_resume.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges() and (p_resume.get("tree_rules", []) as Array) == rules_e) # 续玩路径 tree_rules 受理断言:直呼 runner 续玩参数面 @@ -350,9 +350,9 @@ func _meta_injection() -> void: var mp = load("res://core/meta_progression.gd").new(_db, sv) # 树生命淬炼注入(公式:基础值+树淬炼 flat+锻造路线加值位预留 0——2 级 = +10; # 注入走调试口径直置等级〔两分法——购买链已在 _smoke_tree 覆盖,此处隔离行门槛干扰〕) - sv.set_tree_level("sp_hp", 2) + sv.meta_state.set_tree_level("sp_hp", 2) Kit.check("生命淬炼 2 级 → hero_override().max_hp == Σ 节点 effects×等级(验收4 换代〔天赋树批 V12〕)", - int(mp.hero_override().get("max_hp", -1)) == 2 * int(_db.tree_node("sp_hp").get("effects", {}).get("max_hp", 0))) + int(mp.hero_override().get("max_hp", -1)) == 2 * int(_db.crossrun.meta_tree.row_by_id("sp_hp").get("effects", {}).get("max_hp", 0))) Kit.check("攻/AC 树零来源(契约 9——树表零 atk/armor_class 行,override 二轴恒 0+预留位 0)", int(mp.hero_override().get("atk", -1)) == 0 and int(mp.hero_override().get("armor_class", -1)) == 0) Kit.check("命中线在账未注入(契约 6——override 无 hit 字段〔天赋树批 V13〕)", @@ -363,10 +363,10 @@ func _meta_injection() -> void: var r = adr.runner() var ov: Dictionary = mp.hero_override() Kit.check("首战 hero_atk = 表值 + 永久强化 flat(begin_battle 同表达式——下一局能用到强化)", - int(r.effective_atk()) == int(_db.hero.atk) + int(ov.get("atk", 0))) + int(r.effective_atk()) == int(_db.combat.hero.atk) + int(ov.get("atk", 0))) Kit.check("首战 hero_max_hp 与开局生命账本 = 表值 + 注入档", - int(r.effective_max_hp()) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0)) - and int(adr.stats().hero_hp) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0))) + int(r.effective_max_hp()) == int(_db.combat.hero.max_hp) + int(ov.get("max_hp", 0)) + and int(adr.stats().hero_hp) == int(_db.combat.hero.max_hp) + int(ov.get("max_hp", 0))) # 白板:同种子下 空参数 = 零强化参数 = 纯表值(逐位一致——注入通道默认值漏改在此转红) var adr_w = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251)) Kit.check("白板冒险启动(无 params 实参——B1 空参数路径)", adr_w.start_adventure(920251)) @@ -377,11 +377,11 @@ func _meta_injection() -> void: var rw = adr_w.runner() var rz = adr_z.runner() Kit.check("白板成立:空参数 = 零强化参数 = 纯表值(atk/max_hp/ac 三轴逐位一致——验收 2/3)", - int(rw.effective_atk()) == int(_db.hero.atk) and int(rw.effective_max_hp()) == int(_db.hero.max_hp) + int(rw.effective_atk()) == int(_db.combat.hero.atk) and int(rw.effective_max_hp()) == int(_db.combat.hero.max_hp) and int(rz.effective_atk()) == int(rw.effective_atk()) and int(rz.effective_max_hp()) == int(rw.effective_max_hp()) and int(rz.effective_ac()) == int(rw.effective_ac())) Kit.check("ac 轴:白板恒表值、未换强化时注入零位移", - int(rw.effective_ac()) == int(_db.hero.armor_class) and int(r.effective_ac()) == int(_db.hero.armor_class)) + int(rw.effective_ac()) == int(_db.combat.hero.armor_class) and int(r.effective_ac()) == int(_db.combat.hero.armor_class)) _meta_smoke_cleanup() @@ -391,13 +391,13 @@ func _smoke_forge_axes() -> void: print("--- 锻造工坊·类型轴所有权移交(T2)---") # V5:hero.tres 静态表两轴已删(全表加载零违例——config_db_test 同款闸;此处钉字段不在位) Kit.check("hero.tres 无 attack_type/armor_type 字段(契约 2——静态表两轴删除)〔锻造工坊批 V5〕", - not ("attack_type" in _db.hero) and not ("armor_type" in _db.hero)) + not ("attack_type" in _db.combat.hero) and not ("armor_type" in _db.combat.hero)) # 锻造配置默认值 = normal/light(常驻持有——与柜台解锁状态解耦,未点亮也照常可读) Kit.check("锻造配置默认值 attack_type=normal / armor_type=light(常驻持有)〔锻造工坊批 V5〕", - _db.forge_default_attack_type() == "normal" and _db.forge_default_armor_type() == "light") + _db.crossrun.forge.default_attack_type == "normal" and _db.crossrun.forge.default_armor_type == "light") # 单位快照回退面:spawn 三参形态(零注入)= 锻造配置默认值 var uc = load("res://core/unit_container.gd").new(_db) - Kit.check("spawn 受理(三参形态——两轴缺省回退面)", uc.spawn(1, 0, int(_db.hero.max_hp))) + Kit.check("spawn 受理(三参形态——两轴缺省回退面)", uc.spawn({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)})) Kit.check("主角单位快照两轴 = 锻造配置默认值 normal/light(零注入回退——行为连续)〔锻造工坊批 V5〕", String(uc.hero_unit().get("attack_type", "")) == "normal" and String(uc.hero_unit().get("armor_type", "")) == "light") @@ -407,71 +407,71 @@ func _smoke_forge_axes() -> void: var sv2 = _meta_new_save(_META_SMOKE_PATHS[13]) var mp2 = load("res://core/meta_progression.gd").new(_db, sv2) # V2①:长剑首购=20(0 级位几何价)、等级 0→1、扣币即盘、reload 持久 - sv2.add_keystone(100) + sv2.meta_state._add_keystone(100) Kit.check("长剑锻造首购:扣 20 钥石、等级 0→1(Q1——第一级=获得装备=首个 +1)〔锻造工坊批 V2〕", - mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 1 - and sv2.keystone_balance() == 80) + mp2.forge_buy_level("sword") and sv2.meta_state.forge_state().levels.get("sword", 0) == 1 + and sv2.meta_state.keystone_balance() == 80) var sv2r = _meta_new_save(_META_SMOKE_PATHS[13]) Kit.check("购买持久(reload 后等级与余额不变——即时写盘)〔锻造工坊批 V2〕", - sv2r.forge_route_level("sword") == 1 and sv2r.keystone_balance() == 80) + sv2r.meta_state.forge_state().levels.get("sword", 0) == 1 and sv2r.meta_state.keystone_balance() == 80) # V2②:二级=30(1 级位);六路线独立(买长剑不动轻甲) Kit.check("长剑二级购:扣 30、等级→2;轻甲独立零扰动(六路线各练各的)〔锻造工坊批 V2〕", - mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 2 - and sv2.keystone_balance() == 50 and sv2.forge_route_level("light") == 0) + mp2.forge_buy_level("sword") and sv2.meta_state.forge_state().levels.get("sword", 0) == 2 + and sv2.meta_state.keystone_balance() == 50 and sv2.meta_state.forge_state().levels.get("light", 0) == 0) # V2③:余额不足拒且零副作用(等级/选中/解锁三面零变化) var sv2d = _meta_new_save(_META_SMOKE_PATHS[14]) var mp2d = load("res://core/meta_progression.gd").new(_db, sv2d) - var fr_dry: Dictionary = sv2d.forge_routes_snapshot() + var fr_dry: Dictionary = sv2d.meta_state.forge_state() Kit.check("余额不足购买拒绝且零副作用(0 余额——三域零变化)〔锻造工坊批 V2〕", - not mp2d.forge_buy_level("sword") and sv2d.keystone_balance() == 0 - and sv2d.forge_routes_snapshot() == fr_dry) + not mp2d.forge_buy_level("sword") and sv2d.meta_state.keystone_balance() == 0 + and sv2d.meta_state.forge_state() == fr_dry) # V2④:已满级拒购(cap 闸);未解锁拒购 - sv2.set_forge_route_level("sword", 5) + sv2.meta_state.set_forge_route_level("sword", 5) Kit.check("已满级拒购/未解锁拒购(cap 闸+解锁闸——零副作用)〔锻造工坊批 V2〕", not mp2.forge_buy_level("sword") and not mp2d.forge_buy_level("spear")) # V3① 通道一:练满前一档(长剑 cap)→ 长枪/法杖派生解锁(零收费);轻甲未满→甲线仍锁 Kit.check("通道一:长剑练满→长枪/法杖派生解锁;轻甲未练满→重甲/魔法护甲仍锁〔锻造工坊批 V3〕", - mp2.forge_route_unlocked("spear") and mp2.forge_route_unlocked("staff") - and not mp2.forge_route_unlocked("heavy") and not mp2.forge_route_unlocked("ward")) + mp2._forge_route_unlocked("spear") and mp2._forge_route_unlocked("staff") + and not mp2._forge_route_unlocked("heavy") and not mp2._forge_route_unlocked("ward")) Kit.check("派生解锁后长枪可购(首购 20——余额 50−20)〔锻造工坊批 V3〕", - mp2.forge_buy_level("spear") and sv2.forge_route_level("spear") == 1 - and sv2.keystone_balance() == 30) + mp2.forge_buy_level("spear") and sv2.meta_state.forge_state().levels.get("spear", 0) == 1 + and sv2.meta_state.keystone_balance() == 30) # V3② 通道二:一次性解锁价直购(未练满也可买——两通道互不影响) var sv3 = _meta_new_save(_META_SMOKE_PATHS[15]) var mp3 = load("res://core/meta_progression.gd").new(_db, sv3) - sv3.add_keystone(500) + sv3.meta_state._add_keystone(500) Kit.check("通道二:魔法护甲一次性解锁价 200 直购开(轻甲未练满也可买)〔锻造工坊批 V3〕", - not mp3.forge_route_unlocked("ward") and mp3.forge_buy_unlock("ward") - and sv3.keystone_balance() == 300 and mp3.forge_route_unlocked("ward")) + not mp3._forge_route_unlocked("ward") and mp3.forge_buy_unlock("ward") + and sv3.meta_state.keystone_balance() == 300 and mp3._forge_route_unlocked("ward")) # V3③ 直购后练满不重复收(练满通道=派生零收费;已解锁再直购=拒绝) - sv3.set_forge_route_level("light", 5) + sv3.meta_state.set_forge_route_level("light", 5) Kit.check("直购后练满不重复收(已解锁=拒绝零收费——两通道互不影响)〔锻造工坊批 V3〕", - mp3.forge_route_unlocked("ward") and not mp3.forge_buy_unlock("ward") - and sv3.keystone_balance() == 300) + mp3._forge_route_unlocked("ward") and not mp3.forge_buy_unlock("ward") + and sv3.meta_state.keystone_balance() == 300) # V3④ 不设串联门槛(法杖直购开——长枪零状态不阻;dry 档充值走直购) - sv2d.add_keystone(500) + sv2d.meta_state._add_keystone(500) Kit.check("不设串联门槛(法杖直购开——长枪零状态不阻)〔锻造工坊批 V3〕", - mp2d.forge_buy_unlock("staff") and mp2d.forge_route_unlocked("staff") - and sv2d.forge_route_level("spear") == 0 and not mp2d.forge_route_unlocked("spear")) + mp2d.forge_buy_unlock("staff") and mp2d._forge_route_unlocked("staff") + and sv2d.meta_state.forge_state().levels.get("spear", 0) == 0 and not mp2d._forge_route_unlocked("spear")) # V4:任一购买/解锁只扣钥石——GP 侧零触碰(meta 域无 GP 键=域界扫描同款;此处钉余额差=当级价) - var ks4: int = sv3.keystone_balance() + var ks4: int = sv3.meta_state.keystone_balance() Kit.check("两币分工:购买只扣钥石(余额差=当级价 20——GP 零触碰、无材料计数)〔锻造工坊批 V4〕", - mp3.forge_buy_level("ward") and sv3.keystone_balance() == ks4 - 20 - and sv3.forge_route_level("ward") == 1) + mp3.forge_buy_level("ward") and sv3.meta_state.keystone_balance() == ks4 - 20 + and sv3.meta_state.forge_state().levels.get("ward", 0) == 1) # V6①:营地切换=写存档域+免费(余额零动);旧路线练度保留(只不生效) - var ks6: int = sv3.keystone_balance() + var ks6: int = sv3.meta_state.keystone_balance() Kit.check("选中切换免费(余额零动——写存档域即盘)〔锻造工坊批 V6〕", - mp3.forge_select_armor("ward") and sv3.forge_armor() == "ward" - and sv3.keystone_balance() == ks6) + mp3.forge_select("armor", "ward") and sv3.meta_state.forge_state().armor == "ward" + and sv3.meta_state.keystone_balance() == ks6) Kit.check("切换后旧路线练度保留(轻甲 5 级不丢——回家待命只不生效)〔锻造工坊批 V6〕", - sv3.forge_route_level("light") == 5) + sv3.meta_state.forge_state().levels.get("light", 0) == 5) # V6②:未解锁路线拒选零副作用(dry 档:长枪未解锁) Kit.check("未解锁路线拒选零副作用〔锻造工坊批 V6〕", - not mp2d.forge_select_weapon("spear") and sv2d.forge_weapon() == "sword") + not mp2d.forge_select("weapon", "spear") and sv2d.meta_state.forge_state().weapon == "sword") # V6③:类型轴读数随选中走(切换后读 ward;未解锁选中态防御回退默认值) Kit.check("类型轴当前生效值随选中走(ward;未解锁档回退默认 normal/light)〔锻造工坊批 V5/V6〕", - mp3.hero_armor_type() == "ward" and mp2d.hero_attack_type() == "normal" - and mp2d.hero_armor_type() == "light") + mp3.hero_type_axes()["armor"] == "ward" and mp2d.hero_type_axes()["attack"] == "normal" + and mp2d.hero_type_axes()["armor"] == "light") # ---- T4 注入接线(V5 后半 · V6 锁死 · V7 公式对拍 · V10 树零攻防复核)---- # V5:build_adventure_params 注入默认值(新档=树钥匙未点亮)+点亮后读存档选中覆盖 @@ -481,9 +481,9 @@ func _smoke_forge_axes() -> void: Kit.check("build_adventure_params 注入默认值 normal/light(树钥匙未点亮——新档初始持有态)〔锻造工坊批 V5〕", String(bp_params.get("attack_type", "")) == "normal" and String(bp_params.get("armor_type", "")) == "light") - sv4.add_keystone(1000) + sv4.meta_state._add_keystone(1000) Kit.check("长枪直购解锁+首购+选中(注入面夹具——200+20 钥石)", - mp4.forge_buy_unlock("spear") and mp4.forge_buy_level("spear") and mp4.forge_select_weapon("spear")) + mp4.forge_buy_unlock("spear") and mp4.forge_buy_level("spear") and mp4.forge_select("weapon", "spear")) var bp_sel: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv4, mp4) Kit.check("build_adventure_params 读存档选中覆盖(选中长枪→pierce)〔锻造工坊批 V5〕", String(bp_sel.get("attack_type", "")) == "pierce" @@ -494,17 +494,17 @@ func _smoke_forge_axes() -> void: and int(mp4.hero_override().get("max_hp", -1)) == 0 and int(mp4.hero_override().get("armor_class", -1)) == 0 and not mp4.hero_override().has("hit")) - sv4.set_forge_route_level("sword", 3) # 长剑练度待命(未选中) + sv4.meta_state.set_forge_route_level("sword", 3) # 长剑练度待命(未选中) Kit.check("换当前武器→攻击加值随切换(长剑 3 级待命不生效——只认选中)〔锻造工坊批 V7〕", int(mp4.hero_override().get("atk", -1)) == 1) Kit.check("切回长剑→加值=3 级量(旧路线练度保留且生效)〔锻造工坊批 V7〕", - mp4.forge_select_weapon("sword") and int(mp4.hero_override().get("atk", -1)) == 3) + mp4.forge_select("weapon", "sword") and int(mp4.hero_override().get("atk", -1)) == 3) # V7 三层 AC:重甲(基础 12)+路线 Lv1 加值+局内 0——经参数面起冒险断 runner 生效值 var sv5 = _meta_new_save(_META_SMOKE_PATHS[17]) var mp5 = load("res://core/meta_progression.gd").new(_db, sv5) - sv5.add_keystone(1000) + sv5.meta_state._add_keystone(1000) Kit.check("重甲直购解锁+首购+选中(三层 AC 夹具)", - mp5.forge_buy_unlock("heavy") and mp5.forge_buy_level("heavy") and mp5.forge_select_armor("heavy")) + mp5.forge_buy_unlock("heavy") and mp5.forge_buy_level("heavy") and mp5.forge_select("armor", "heavy")) var adr5 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(77)) var p5: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv5, mp5) Kit.check("注入参数冒险启动(两轴+override 同面)", adr5.start_adventure(77, p5)) @@ -515,7 +515,7 @@ func _smoke_forge_axes() -> void: adr5.stats().attack_type == "normal" and adr5.stats().armor_type == "heavy") # V6 锁死:开冒险后改存档选中→参数面/注入值不变(快照冻结;light=初始持有已解锁可切) Kit.check("冒险开始后锁死:改存档选中不影响参数面快照与生效值(契约 8)〔锻造工坊批 V6〕", - mp5.forge_select_armor("light") and adr5.stats().armor_type == "heavy" + mp5.forge_select("armor", "light") and adr5.stats().armor_type == "heavy" and int(r5.effective_ac()) == 12 + 1) # V5/V7 末端:enc 两轴直达 spawn 英雄快照(hero_attack 行携带选中类型键) var run_ax = _run_battle(1, 0, {"attack_type": "pierce", "armor_type": "ward"}) @@ -528,12 +528,12 @@ func _smoke_forge_axes() -> void: # spawn 显式注入面(三参旧形态零注入已在上方覆盖——此处钉显式值) var uc2 = load("res://core/unit_container.gd").new(_db) Kit.check("spawn 显式两轴注入(magic/heavy 直入英雄快照)〔锻造工坊批 V5〕", - uc2.spawn(1, 0, int(_db.hero.max_hp), [], -1, -1, [], "magic", "heavy") + uc2.spawn({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp), "hero_attack_type": "magic", "hero_armor_type": "heavy"}) and String(uc2.hero_unit().get("attack_type", "")) == "magic" and String(uc2.hero_unit().get("armor_type", "")) == "heavy") # V10 复核:树表 12 行 effects 键集无 atk/armor_class(契约 1——树零攻防来源) var tree_atk_ac := false - for trow in _db.tree_rows(): + for trow in _db.crossrun.meta_tree.rows: var eff: Dictionary = trow.get("effects", {}) if eff.has("atk") or eff.has("armor_class"): tree_atk_ac = true @@ -547,11 +547,11 @@ func _smoke_tree() -> void: print("--- 天赋树(12 节点 · 购买面 · Fork 双态 · 彩蛋觉醒)---") # 〔V1〕结构:12 行 + schema 零违例 Kit.check("meta_tree 已加载(12 节点=脊柱 2〔生命/命中淬炼一线一节点 cap4〕+锻造 2〔钥匙+彩蛋——彩蛋含在锻造 2 内非第 13 行〕+钥石 2〔秘宝匣/刷新升级〕+同调 6〔socket 3+Fork 3 双态〕——v0.8 终态)〔天赋树批 V1〕", - _db.meta_tree != null and (_db.meta_tree.rows as Array).size() == 12) + _db.crossrun.meta_tree != null and (_db.crossrun.meta_tree.rows as Array).size() == 12) Kit.check("MetaTreeTable.validate 零违例(脉/类型/币词表出域即拒/Fork 三组每组恰一且持 a/b 双效果键/彩蛋 ≤3/effects 键封闭/零 archetype)", - _db.meta_tree.validate().is_empty()) + _db.crossrun.meta_tree.validate().is_empty()) # 彩蛋行硬要求(契约 9 红线落点):effects 键集 ⊆ {max_hp, hit} - var ley: Dictionary = _db.meta_tree.row_by_id("easter_ley") + var ley: Dictionary = _db.crossrun.meta_tree.row_by_id("easter_ley") var ley_ok: bool = not ley.is_empty() for k in (ley.get("effects", {}) as Dictionary).keys(): if not (String(k) == "max_hp" or String(k) == "hit"): @@ -560,66 +560,66 @@ func _smoke_tree() -> void: ley_ok) # V1/V4 反例:出域行逐类构造被拒(复制行副本塞词表外值) var t2 = load("res://data/schemas/meta_tree_table.gd").new() - var bad_vein: Dictionary = (_db.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true) + var bad_vein: Dictionary = (_db.crossrun.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true) bad_vein["node_id"] = "bad_vein" bad_vein["vein"] = "underworld" t2.rows = [bad_vein] Kit.check("schema 反例:vein 出域被拒", not t2.validate().is_empty()) - var bad_fork: Dictionary = (_db.meta_tree.row_by_id("fork_posture") as Dictionary).duplicate(true) + var bad_fork: Dictionary = (_db.crossrun.meta_tree.row_by_id("fork_posture") as Dictionary).duplicate(true) bad_fork["node_id"] = "bad_fork" bad_fork["fork_group"] = "fg_mastery" t2.rows = [bad_fork] Kit.check("schema 反例:fork_group 出域(fg_mastery 已退役组)被拒", not t2.validate().is_empty()) - var bad_arch: Dictionary = (_db.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true) + var bad_arch: Dictionary = (_db.crossrun.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true) bad_arch["node_id"] = "bad_arch" bad_arch["effects"] = {"archetype": 1} t2.rows = [bad_arch] Kit.check("schema 反例:effects.archetype 加载致命(🔴 层级纪律护栏面)〔天赋树批 V4〕", not t2.validate().is_empty()) - var bad_dangling: Dictionary = (_db.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true) + var bad_dangling: Dictionary = (_db.crossrun.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true) bad_dangling["requires"] = ["ghost_node"] t2.rows = [bad_dangling] Kit.check("schema 反例:requires 悬空被拒", not t2.validate().is_empty()) # 购买面(V2 主干) var sv = _meta_new_save(_META_SMOKE_PATHS[8]) var mp = load("res://core/meta_progression.gd").new(_db, sv) - sv.add_keystone(1000) + sv.meta_state._add_keystone(1000) Kit.check("树购买受理(脊柱生命 L1——树全钥石单币)", - mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1) + mp.tree_buy("sp_hp") and sv.meta_state.tree_state().nodes.get("sp_hp", 0) == 1) Kit.check("行门槛:第 2 级不可购(总等级 1 < 4——一线一节点主语,买第 N 级需全树总投入 ≥4×(N−1))〔天赋树批 V2〕", not mp.tree_can_buy("sp_hp")) Kit.check("命中 L1/锻造钥匙(free 无前置=初始零购零币可点亮)/同调位 1(根节点无前置)受理(总等级 1→4)", mp.tree_buy("sp_hit") and mp.tree_buy("forge_workshop") and mp.tree_buy("att_socket1")) - Kit.check("锻造工坊钥匙零币(free)", int(_db.tree_node("forge_workshop").get("price", -1)) == 0) + Kit.check("锻造工坊钥匙零币(free)", int(_db.crossrun.meta_tree.row_by_id("forge_workshop").get("price", -1)) == 0) Kit.check("行门槛恰达开放(总等级 4 ≥ 4×1)", mp.tree_can_buy("sp_hp")) Kit.check("第 2 级购买受理(门槛恰达当刻开放)", - mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 2) + mp.tree_buy("sp_hp") and sv.meta_state.tree_state().nodes.get("sp_hp", 0) == 2) # Fork 一节点双态(V9) - sv.add_keystone(100) + sv.meta_state._add_keystone(100) Kit.check("Fork 购买=解锁+默认 A 态(一节点双态——单行承载)", - mp.tree_buy_fork("fork_posture") and sv.tree_level("fork_posture") == 1 and sv.fork_side("fg_posture") == "a") + mp.tree_buy_fork("fork_posture") and sv.meta_state.tree_state().nodes.get("fork_posture", 0) == 1 and sv.meta_state.tree_state().fork_sides.get("fg_posture", "") == "a") Kit.check("已解锁 Fork 重复购买拒绝(cap 1)", not mp.tree_can_buy("fork_posture")) Kit.check("同态重复切换零操作(false)", not mp.tree_toggle_fork("fg_posture", "a")) Kit.check("未解锁 Fork 切换拒绝(解锁唯一入口=购买面)", not mp.tree_toggle_fork("fg_sense", "b")) - var bal_pre_toggle: int = sv.keystone_balance() + var bal_pre_toggle: int = sv.meta_state.keystone_balance() Kit.check("零币切态 b(购买解锁后 A/B 可切)", - mp.tree_toggle_fork("fg_posture", "b") and sv.fork_side("fg_posture") == "b") - Kit.check("切态零消费(钥石余额差分不动)", int(sv.keystone_balance()) == bal_pre_toggle) + mp.tree_toggle_fork("fg_posture", "b") and sv.meta_state.tree_state().fork_sides.get("fg_posture", "") == "b") + Kit.check("切态零消费(钥石余额差分不动)", int(sv.meta_state.keystone_balance()) == bal_pre_toggle) Kit.check("规则键投影=激活态效果键(B 态稳健拓荒——tree_rule_keys 随切态换键集)", (mp.tree_rule_keys() as Array).has("posture_bulwark") and not (mp.tree_rule_keys() as Array).has("posture_first_strike")) Kit.check("切回 A 态键集随换(六键词表在册——V11 校验面归单元段)", mp.tree_toggle_fork("fg_posture", "a") and (mp.tree_rule_keys() as Array).has("posture_first_strike")) # 彩蛋特化觉醒(V8) Kit.check("彩蛋不经购买(手动购亮=红——觉醒唯一入口=特化判定)", not mp.tree_can_buy("easter_ley")) - Kit.check("觉醒前置未满足不亮(两线未拉满)", sv.tree_level("easter_ley") == 0) - sv.set_tree_level("sp_hp", 4) - sv.set_tree_level("sp_hit", 4) - sv.set_tree_level("att_socket2", 1) + Kit.check("觉醒前置未满足不亮(两线未拉满)", sv.meta_state.tree_state().nodes.get("easter_ley", 0) == 0) + sv.meta_state.set_tree_level("sp_hp", 4) + sv.meta_state.set_tree_level("sp_hit", 4) + sv.meta_state.set_tree_level("att_socket2", 1) mp.tree_buy("att_socket3") # 经购买口触发 _check_easter_egg - Kit.check("两线拉满+同调位全解锁 → 地脉共鸣自动点亮(零币特化判定)", sv.tree_level("easter_ley") == 1) - var bal_now: int = sv.keystone_balance() + Kit.check("两线拉满+同调位全解锁 → 地脉共鸣自动点亮(零币特化判定)", sv.meta_state.tree_state().nodes.get("easter_ley", 0) == 1) + var bal_now: int = sv.meta_state.keystone_balance() mp.tree_buy("sp_hp") - Kit.check("觉醒零消费(钥石余额不动)", int(sv.keystone_balance()) == bal_now) + Kit.check("觉醒零消费(钥石余额不动)", int(sv.meta_state.keystone_balance()) == bal_now) _meta_smoke_cleanup() @@ -732,12 +732,12 @@ func _smoke_tree_rules() -> void: var lr = load("res://core/level_runner.gd").new(_db, _fresh_rng(777004), {"tree_rules": []}) # 构造注入(拆件批 T3——setter 群退役) Kit.check("散未激活:商品标价原价(无 economy_spend 键)", - int(lr.current_price({"base_price": 100, "price_increment": 0})) == 100 + int(_db.shop_price_level_bonus(0))) + int(lr.current_price({"base_price": 100, "price_increment": 0})) == 100 + int(_db.run.shop.price_level_bonus_at(0))) lr = load("res://core/level_runner.gd").new(_db, _fresh_rng(777005), {"tree_rules": ["economy_spend"]}) # 构造注入(拆件批 T3——setter 群退役) Kit.check("散激活:商品购买价 ×80%(provisional 折率——floor 偏向玩家、下限 1;刷新价不折=另一计价点零触碰)", - int(lr.current_price({"base_price": 100, "price_increment": 0})) == maxi(1, int(floor(float(100 + int(_db.shop_price_level_bonus(0))) * float(_db.shop.spend_discount)))) - and int(lr.current_price({"base_price": 1, "price_increment": 0})) == maxi(1, int(floor(float(1 + int(_db.shop_price_level_bonus(0))) * float(_db.shop.spend_discount))))) + int(lr.current_price({"base_price": 100, "price_increment": 0})) == maxi(1, int(floor(float(100 + int(_db.run.shop.price_level_bonus_at(0))) * float(_db.run.shop.spend_discount)))) + and int(lr.current_price({"base_price": 1, "price_increment": 0})) == maxi(1, int(floor(float(1 + int(_db.run.shop.price_level_bonus_at(0))) * float(_db.run.shop.spend_discount))))) # 财富之道·聚:战斗掉落乘算·入账前(编排 _roll_gp_drop——同种子配对对拍 1.5×) var orch3 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777005)) Kit.check("聚 rig:基线编排启动(无增益侧)", diff --git a/game/tests/unit/core/node_map_generator_test.gd b/game/tests/unit/core/node_map_generator_test.gd index 01c8c767..57054f1f 100644 --- a/game/tests/unit/core/node_map_generator_test.gd +++ b/game/tests/unit/core/node_map_generator_test.gd @@ -35,7 +35,7 @@ func _init() -> void: func _params(L: int) -> Dictionary: - return _db.level_gen_params_row(L) + return _db.run.level_gen_params.row_by_level_index(L) func _graph(L: int, seed_val: int) -> Dictionary: diff --git a/game/tests/unit/core/node_map_growth_test.gd b/game/tests/unit/core/node_map_growth_test.gd index ef8f7608..86d3227f 100644 --- a/game/tests/unit/core/node_map_growth_test.gd +++ b/game/tests/unit/core/node_map_growth_test.gd @@ -28,7 +28,7 @@ func _init() -> void: func _reading(gen, db, L: int) -> Dictionary: - var g: Dictionary = gen.generate(db.level_gen_params_row(L), 42) + var g: Dictionary = gen.generate(db.run.level_gen_params.row_by_level_index(L), 42) var maxd := 0 for n in g["nodes"]: maxd = maxi(maxd, int(n["depth"])) @@ -62,8 +62,8 @@ func _test_growth(gen, db) -> void: func _test_fail_fast_static(gen, db) -> void: Kit.check("生产三档参数档可行性判定零错误〔fv4〕", - NodeMapGenerator.check_params(db.level_gen_params_row(1)).is_empty() - and NodeMapGenerator.check_params(db.level_gen_params_row(3)).is_empty()) + NodeMapGenerator.check_params(db.run.level_gen_params.row_by_level_index(1)).is_empty() + and NodeMapGenerator.check_params(db.run.level_gen_params.row_by_level_index(3)).is_empty()) var unsolvable := {"layers": 6, "branch_density": 0.0, "battle_ratio": 0.5, "reward_ratio": 0.2, "rest_ratio": 0.2, "shop_ratio": 0.1, "entry_count": 1, "exit_count": 3, "battle_floor": 4, "fork_ways_min": 2, "fork_ways_max": 3} diff --git a/game/tests/unit/core/pool_case_d_test.gd b/game/tests/unit/core/pool_case_d_test.gd index 3319f765..a00b593a 100644 --- a/game/tests/unit/core/pool_case_d_test.gd +++ b/game/tests/unit/core/pool_case_d_test.gd @@ -78,7 +78,7 @@ func check_v7(a, db) -> void: continue var hi := 0 for t in cands: - if db.adventure.tier_rank(String(t.get("tier", ""))) >= db.adventure.tier_rank(db.adventure.TIER_EXCELLENT): + if db.run.adventure.tier_rank(String(t.get("tier", ""))) >= db.run.adventure.tier_rank(db.run.adventure.TIER_EXCELLENT): hi += 1 if hi >= 1: hi_seen = true @@ -161,10 +161,10 @@ func check_v8(c, db) -> void: Kit.check_quiet(false, "V8 前置:冒险启动") return # 概率位与 cap 两属性独立:改 cap 不动牌库(概率位)、改牌库不动 cap 字段 - var cap_field_before := int(db.adventure.curio_cap_gp) - db.adventure.curio_cap_gp = 1 # 独立设 cap(概率位=gp 令牌数不动) + var cap_field_before := int(db.run.adventure.cap_segment.curio_cap_gp) + db.run.adventure.cap_segment.curio_cap_gp = 1 # 独立设 cap(概率位=gp 令牌数不动) var gp_tokens: int = c.deck().pool_stock("gp") - Kit.check_quiet(gp_tokens > 0 and int(db.adventure.curio_value("gp", "common")) == 20, + Kit.check_quiet(gp_tokens > 0 and int(db.run.adventure.curio_value("gp", "common")) == 20, "改 cap 不动概率位:牌库 gp 令牌 %d 枚在库、件值字段不受影响〔专长批 V8〕" % gp_tokens) # cap 只在拿了才计:刷出不计 c._curio.deck = [_tok("gp", "common"), _tok("attack", "common"), _tok("max_hp", "common")] @@ -187,4 +187,4 @@ func check_v8(c, db) -> void: gp_cand = true Kit.check_quiet(gp_in_deck >= 0 and not gp_cand, "提前退役:cap 满后候选视图零 gp(牌库本体张数不动=%d——概率位独立存活)〔专长批 V8〕" % gp_in_deck) - db.adventure.curio_cap_gp = cap_field_before # 还原表(同进程后续套件防御) + db.run.adventure.cap_segment.curio_cap_gp = cap_field_before # 还原表(同进程后续套件防御) diff --git a/game/tests/unit/core/save_boot_no_activate_test.gd b/game/tests/unit/core/save_boot_no_activate_test.gd index 2c0725f7..7345c8e5 100644 --- a/game/tests/unit/core/save_boot_no_activate_test.gd +++ b/game/tests/unit/core/save_boot_no_activate_test.gd @@ -23,14 +23,14 @@ func _init() -> void: # ① 无参实例(autoload 实例化形态)=无激活态+默认合法空态(sv2 类级) func _test_no_arg_instance_has_no_activation() -> void: var sm := SaveManagerScript.new() - Kit.check_quiet(sm.active_slot() == 0 and not sm.has_write_target(), + Kit.check_quiet(sm.slots.active_slot() == 0 and not sm.slots.has_write_target(), "sv2 无参实例无激活态(路径空/激活号 0/写盘目标空)〔卡① sv2〕") - Kit.check_quiet(sm.keystone_balance() == 0 and not sm.has_breakpoint() - and sm.adventure_total() == 0 and sm.adventure_cleared() == 0, + Kit.check_quiet(sm.meta_state.keystone_balance() == 0 and not sm.has_breakpoint() + and sm.meta_state.career_state().adventure_total == 0 and sm.meta_state.career_state().adventure_cleared == 0, "sv2 内存=默认合法空态(钥石/断点/总局数/冒险成功数全零值——访问器 fail-safe)〔卡① sv2〕") # attach_path 注入任意隔离路径=注入即激活(契约 7 单路径语义——隔离面行为零变化) - sm.attach_path("user://smoke_boot_noact_iso.json") - Kit.check_quiet(sm.has_write_target() and sm.keystone_balance() == 0, + sm.slots.attach_path("user://smoke_boot_noact_iso.json") + Kit.check_quiet(sm.slots.has_write_target() and sm.meta_state.keystone_balance() == 0, "sv2 attach_path 注入即激活(写盘目标在位、数据回默认空态)〔卡① sv2/契约7〕") if FileAccess.file_exists("user://smoke_boot_noact_iso.json"): DirAccess.remove_absolute("user://smoke_boot_noact_iso.json") @@ -43,7 +43,7 @@ func _test_write_rejected_without_target() -> void: var sm := SaveManagerScript.new() var before := {} for i in range(1, 4): - var p: String = sm.slot_path(i) + var p: String = sm.slots.slot_path(i) before[i] = [FileAccess.file_exists(p), FileAccess.get_file_as_bytes(p) if FileAccess.file_exists(p) else PackedByteArray(), FileAccess.get_modified_time(p) if FileAccess.file_exists(p) else 0] @@ -55,7 +55,7 @@ func _test_write_rejected_without_target() -> void: "sv3 无激活写盘拒绝(write_to_disk=false)〔卡① sv3〕") var unchanged := true for i in range(1, 4): - var p: String = sm.slot_path(i) + var p: String = sm.slots.slot_path(i) var now_state: Array = [FileAccess.file_exists(p), FileAccess.get_file_as_bytes(p) if FileAccess.file_exists(p) else PackedByteArray(), FileAccess.get_modified_time(p) if FileAccess.file_exists(p) else 0] @@ -88,11 +88,11 @@ func _test_settle_chain_write_rejected() -> void: for sp in ["res://.tmp", "res://.tmp.tmp"]: if FileAccess.file_exists(sp): DirAccess.remove_absolute(sp) - Kit.check_quiet(not sm.has_write_target() and not sm.write_to_disk(), + Kit.check_quiet(not sm.slots.has_write_target() and not sm.write_to_disk(), "sv3 链前置:无激活态在位且直写被拒〔卡① sv3〕") meta.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, "stopped_at": 2, "keystone_total": 0, "master_seed": 3}) - Kit.check_quiet(not sm.has_write_target() and sm.adventure_total() == 1, + Kit.check_quiet(not sm.slots.has_write_target() and sm.meta_state.career_state().adventure_total == 1, "sv3 生产链:settle_adventure 落账内存(adventure_total=1)而写盘仍无目标(被拒不落盘)〔卡① sv3〕") Kit.check_quiet(not FileAccess.file_exists("res://.tmp") and not FileAccess.file_exists("res://.tmp.tmp"), @@ -102,11 +102,11 @@ func _test_settle_chain_write_rejected() -> void: # ③ 越界槽号三原语拒绝+窥视越界返回不可读(原语边界 fail-safe) func _test_out_of_range_slots_rejected() -> void: var sm := SaveManagerScript.new() - sm.attach_slot_template("user://smoke_boot_noact_%d.json") - Kit.check_quiet(not sm.activate_slot(0) and not sm.activate_slot(4) - and not sm.create_slot(0) and not sm.create_slot(4) - and not sm.delete_slot(0) and not sm.delete_slot(4), + sm.slots.attach_slot_template("user://smoke_boot_noact_%d.json") + Kit.check_quiet(not sm.slots.activate_slot(0) and not sm.slots.activate_slot(4) + and not sm.slots.create_slot(0) and not sm.slots.create_slot(4) + and not sm.slots.delete_slot(0) and not sm.slots.delete_slot(4), "原语边界:槽号 0/4 全拒绝(位数写死 3——契约 1)〔卡① sv1〕") - var out: Dictionary = sm.peek_slot_summary(4) + var out: Dictionary = sm.slots.peek_slot_summary(4) Kit.check_quiet(not bool(out.get("readable", true)), "原语边界:越界窥视返回不可读标记〔卡① sv7〕") diff --git a/game/tests/unit/core/save_manager_domain_test.gd b/game/tests/unit/core/save_manager_domain_test.gd index 8e23fd32..70ea1100 100644 --- a/game/tests/unit/core/save_manager_domain_test.gd +++ b/game/tests/unit/core/save_manager_domain_test.gd @@ -67,55 +67,55 @@ func _smoke_save() -> void: # ① 往返:全访问器写非默认值 → write → 新实例 load → 逐域逐值相等 var w = sm_script.new(paths[0]) - w.add_keystone(7) - w.set_tree_level("sp_hp", 2) - w.set_tree_level("sp_hit", 1) - w.set_fork_side("fg_posture", "b") # 树域写入 - w.set_forge_route_level("spear", 3) # 锻造工坊域写入(等级+直购解锁+选中标记) - w.add_forge_unlock("spear") - w.set_forge_weapon("spear") - w.set_forge_armor("ward") - w.unlock_item("probe_compass") - w.apply_adventure_summary({"result": "failed", "cleared": false, "levels_cleared": 2, + w.meta_state._add_keystone(7) + w.meta_state.set_tree_level("sp_hp", 2) + w.meta_state.set_tree_level("sp_hit", 1) + w.meta_state.set_fork_side("fg_posture", "b") # 树域写入 + w.meta_state.set_forge_route_level("spear", 3) # 锻造工坊域写入(等级+直购解锁+选中标记) + w.meta_state.add_forge_unlock("spear") + w.meta_state.set_forge_weapon("spear") + w.meta_state.set_forge_armor("ward") + w.meta_state.unlock_item("probe_compass") + w.meta_state.apply_adventure_summary({"result": "failed", "cleared": false, "levels_cleared": 2, "stopped_at": 3, "keystone_total": 3, "master_seed": 99}) Kit.check("apply 原语入账(钥石 7+3、关卡完成次数 1/1/0)", - w.keystone_balance() == 10 and w.clear_count(1) == 1 and w.clear_count(2) == 1 and w.clear_count(3) == 0) + w.meta_state.keystone_balance() == 10 and w.meta_state.career_state().clear_counts.get(str(1), 0) == 1 and w.meta_state.career_state().clear_counts.get(str(2), 0) == 1 and w.meta_state.career_state().clear_counts.get(str(3), 0) == 0) Kit.check("首杀标记随关卡完成置位(1/2 有、3 无)", - w.boss_first_kill_marked(1) and w.boss_first_kill_marked(2) and not w.boss_first_kill_marked(3)) + w.meta_state.career_state().boss_first_kill.has(str(1)) and w.meta_state.career_state().boss_first_kill.has(str(2)) and not w.meta_state.career_state().boss_first_kill.has(str(3))) Kit.check("生涯终身计数随 apply 自增恰一(中止局:总局数 1、冒险成功局数 0——迭代 18 卡④)", - w.adventure_total() == 1 and w.adventure_cleared() == 0) + w.meta_state.career_state().adventure_total == 1 and w.meta_state.career_state().adventure_cleared == 0) Kit.check("写盘成功", w.write_to_disk()) var r = sm_script.new(paths[0]) Kit.check("读回成功", r.load_from_disk()) - Kit.check("钥石余额往返相等(10)", r.keystone_balance() == 10) + Kit.check("钥石余额往返相等(10)", r.meta_state.keystone_balance() == 10) Kit.check("树域往返相等(tree_nodes/fork_sides——迭代 21 换代承载)", - r.tree_level("sp_hp") == 2 and r.tree_level("sp_hit") == 1 and r.fork_side("fg_posture") == "b") + r.meta_state.tree_state().nodes.get("sp_hp", 0) == 2 and r.meta_state.tree_state().nodes.get("sp_hit", 0) == 1 and r.meta_state.tree_state().fork_sides.get("fg_posture", "") == "b") Kit.check("锻造工坊域往返相等(levels/unlocked/weapon/armor——迭代 23 T5)〔锻造工坊批 V8〕", - r.forge_route_level("spear") == 3 and r.forge_route_level("sword") == 0 - and r.forge_unlocked_purchases() == ["spear"] - and r.forge_weapon() == "spear" and r.forge_armor() == "ward") - Kit.check("道具解锁往返相等", r.item_unlocked("probe_compass") and not r.item_unlocked("rope")) - Kit.check("关卡完成次数表往返相等", r.clear_count(1) == 1 and r.clear_count(2) == 1 and r.clear_count(3) == 0) + r.meta_state.forge_state().levels.get("spear", 0) == 3 and r.meta_state.forge_state().levels.get("sword", 0) == 0 + and r.meta_state.forge_state().unlocked == ["spear"] + and r.meta_state.forge_state().weapon == "spear" and r.meta_state.forge_state().armor == "ward") + Kit.check("道具解锁往返相等", r.meta_state.item_unlocked("probe_compass") and not r.meta_state.item_unlocked("rope")) + Kit.check("关卡完成次数表往返相等", r.meta_state.career_state().clear_counts.get(str(1), 0) == 1 and r.meta_state.career_state().clear_counts.get(str(2), 0) == 1 and r.meta_state.career_state().clear_counts.get(str(3), 0) == 0) Kit.check("首杀标记往返相等", - r.boss_first_kill_marked(1) and r.boss_first_kill_marked(2) and not r.boss_first_kill_marked(3)) - Kit.check("生涯终身计数往返相等(1/0)", r.adventure_total() == 1 and r.adventure_cleared() == 0) - var s0: Dictionary = r.recent_summaries()[0] + r.meta_state.career_state().boss_first_kill.has(str(1)) and r.meta_state.career_state().boss_first_kill.has(str(2)) and not r.meta_state.career_state().boss_first_kill.has(str(3))) + Kit.check("生涯终身计数往返相等(1/0)", r.meta_state.career_state().adventure_total == 1 and r.meta_state.career_state().adventure_cleared == 0) + var s0: Dictionary = r.meta_state.career_state().recent_summaries[0] Kit.check("冒险摘要往返相等(1 条,seed 99)", - r.recent_summaries().size() == 1 and int(s0.get("master_seed", -1)) == 99 and int(s0.get("keystone_total", -1)) == 3) + r.meta_state.career_state().recent_summaries.size() == 1 and int(s0.get("master_seed", -1)) == 99 and int(s0.get("keystone_total", -1)) == 3) # ② 新档合法:不存在路径 load → true + 全零默认值 var f2 = sm_script.new(paths[1]) Kit.check("新档合法态(load true + 全零默认)", - f2.load_from_disk() and f2.keystone_balance() == 0 and f2.tree_level("sp_hp") == 0 - and not f2.item_unlocked("anything") and f2.clear_count(1) == 0 and f2.recent_summaries().is_empty()) + f2.load_from_disk() and f2.meta_state.keystone_balance() == 0 and f2.meta_state.tree_state().nodes.get("sp_hp", 0) == 0 + and not f2.meta_state.item_unlocked("anything") and f2.meta_state.career_state().clear_counts.get(str(1), 0) == 0 and f2.meta_state.career_state().recent_summaries.is_empty()) Kit.check("新档生涯终身计数缺省全零(迭代 18 卡④两新域——白板存档完整可玩)", - f2.adventure_total() == 0 and f2.adventure_cleared() == 0) + f2.meta_state.career_state().adventure_total == 0 and f2.meta_state.career_state().adventure_cleared == 0) # ③ spend 拒绝:余额不足 false 且零副作用(钥石只增不减——消费除外) var f3 = sm_script.new(paths[2]) - f3.add_keystone(5) - Kit.check("余额 5 花 8 拒绝且余额不动", not f3.spend_keystone(8) and f3.keystone_balance() == 5) - Kit.check("余额内消费成功(5→2)", f3.spend_keystone(3) and f3.keystone_balance() == 2) + f3.meta_state._add_keystone(5) + Kit.check("余额 5 花 8 拒绝且余额不动", not f3.meta_state.spend_keystone(8) and f3.meta_state.keystone_balance() == 5) + Kit.check("余额内消费成功(5→2)", f3.meta_state.spend_keystone(3) and f3.meta_state.keystone_balance() == 2) # ⑤ 高版本拒读:load false、原文件字节不动、后续写盘拒绝且文件仍字节不动(契约 4) var hv_text := "{\"version\": 99, \"meta\": {\"keystone_balance\": 1}}" @@ -135,7 +135,7 @@ func _smoke_save() -> void: fj.close() var f6 = sm_script.new(paths[4]) Kit.check("损坏档兜底(默认数据继续)", - f6.load_from_disk() and f6.keystone_balance() == 0 and f6.recent_summaries().is_empty()) + f6.load_from_disk() and f6.meta_state.keystone_balance() == 0 and f6.meta_state.career_state().recent_summaries.is_empty()) Kit.check("坏档备份 .corrupt.bak 存在", FileAccess.file_exists(paths[4] + ".corrupt.bak")) Kit.check("备份内容 = 原坏档字节", FileAccess.open(paths[4] + ".corrupt.bak", FileAccess.READ).get_as_text() == junk) @@ -149,7 +149,7 @@ func _smoke_save() -> void: fm.store_string(JSON.stringify({"version": 0, "keystone_old": 12})) fm.close() Kit.check("v0 旧档经迁移链入账(keystone_old 12 → 余额 12)", - f7.load_from_disk() and f7.keystone_balance() == 12) + f7.load_from_disk() and f7.meta_state.keystone_balance() == 12) Kit.check("迁移链 0→1(测试钩)→7(生产) 写盘 version == 7(v7=锻造工坊域,迭代 23 T5——v6=生涯终身计数域迭代 18 卡④)", f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 7) @@ -167,9 +167,9 @@ func _smoke_save() -> void: # ⑩ 摘要 cap:12 份 → 保留 10、最新在前 var f10 = sm_script.new(paths[7]) for i in range(12): - f10.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 0, + f10.meta_state.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 0, "stopped_at": 0, "keystone_total": 0, "master_seed": i}) - var arr: Array = f10.recent_summaries() + var arr: Array = f10.meta_state.career_state().recent_summaries Kit.check("摘要 cap 10(12 份 → 10 条)", arr.size() == 10) Kit.check("最新在前(头 seed 11 / 尾 seed 2)", int(arr[0].get("master_seed", -1)) == 11 and int(arr[9].get("master_seed", -1)) == 2) @@ -227,7 +227,7 @@ func _smoke_save() -> void: fold.store_string("{\"version\": 1, \"meta\": {\"keystone_balance\": 4}}") fold.close() Kit.check("v1 旧档迁移 v7:断点域补空(合法空态,无断点域不炸)", - fo.load_from_disk() and fo.keystone_balance() == 4 and not fo.has_breakpoint()) + fo.load_from_disk() and fo.meta_state.keystone_balance() == 4 and not fo.has_breakpoint()) Kit.check("v1 旧档迁移后写盘 version == 7(v1→v7 链式——锻造工坊域 2026-10-12)", fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 7) @@ -241,7 +241,7 @@ func _smoke_save() -> void: "visited_ids": []}})) fv2f.close() Kit.check("v2 旧档迁移 v3:断点域与 meta 域原样保留(缺新附随件=合法空态)", - fv2.load_from_disk() and fv2.has_breakpoint() and fv2.keystone_balance() == 7) + fv2.load_from_disk() and fv2.has_breakpoint() and fv2.meta_state.keystone_balance() == 7) Kit.check("v2 旧断点字段零丢失(向后兼容——breakpoint 内容不因 schema 升版重写)", int((fv2.breakpoint_snapshot() as Dictionary).get("master_seed", 0)) == 777) var adr_v2 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(777)) @@ -255,22 +255,22 @@ func _smoke_save() -> void: fv5u.store_string(JSON.stringify({"version": 5, "meta": {"keystone_balance": 9}})) fv5u.close() Kit.check("v5 旧档升 v7:计数域缺省补零且既有余额原样(迁移只增字段不改值——v5→v6→v7 链式)", - f13.load_from_disk() and f13.adventure_total() == 0 and f13.adventure_cleared() == 0 - and f13.keystone_balance() == 9) + f13.load_from_disk() and f13.meta_state.career_state().adventure_total == 0 and f13.meta_state.career_state().adventure_cleared == 0 + and f13.meta_state.keystone_balance() == 9) Kit.check("升档幂等:v7 档写盘再载零漂移(重复迁移零副作用——锻造工坊域同批补齐)", f13.write_to_disk() and f13.load_from_disk() and int(JSON.parse_string(FileAccess.open(paths[11], FileAccess.READ).get_as_text()).get("version", -1)) == 7 - and f13.adventure_total() == 0 and f13.adventure_cleared() == 0) + and f13.meta_state.career_state().adventure_total == 0 and f13.meta_state.career_state().adventure_cleared == 0) var f13b = sm_script.new(paths[12]) f13b.load_from_disk() - f13b.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 3, + f13b.meta_state.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 3, "stopped_at": 0, "keystone_total": 0, "master_seed": 1}) Kit.check("收束自增恰一·冒险成功局(三关全通 cleared=true:总局数+1 且冒险成功局数+1)", - f13b.adventure_total() == 1 and f13b.adventure_cleared() == 1) - f13b.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1, + f13b.meta_state.career_state().adventure_total == 1 and f13b.meta_state.career_state().adventure_cleared == 1) + f13b.meta_state.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1, "stopped_at": 2, "keystone_total": 0, "master_seed": 2}) Kit.check("收束自增恰一·中止局(放弃局:仅总局数+1,冒险成功局数不动)", - f13b.adventure_total() == 2 and f13b.adventure_cleared() == 1) + f13b.meta_state.career_state().adventure_total == 2 and f13b.meta_state.career_state().adventure_cleared == 1) # ⑮ 旧档破坏性废弃(契约 4:换代前结构检测=树域废弃重置,禁迁移禁折算)〔V16〕 var f15 = sm_script.new(paths[13]) @@ -281,12 +281,12 @@ func _smoke_save() -> void: "fork_sides": {"fg_posture": "a", "fg_mastery": "b"}}})) fv16.close() Kit.check("换代前结构存档:树域废弃重置(旧节点 id/fork 侧/forge_levels 旧域残留清退)、钥石余额零折算零变动", - f15.load_from_disk() and f15.keystone_balance() == 33 - and f15.tree_nodes().is_empty() and f15.fork_sides().is_empty()) + f15.load_from_disk() and f15.meta_state.keystone_balance() == 33 + and f15.meta_state.tree_state().nodes.is_empty() and f15.meta_state.tree_state().fork_sides.is_empty()) var mp16 = load("res://core/meta_progression.gd").new(_db, f15) - f15.add_keystone(50) + f15.meta_state._add_keystone(50) Kit.check("废弃重置后后续购买正常受理(无残留脏节点)", - mp16.tree_buy("sp_hp") and f15.tree_level("sp_hp") == 1) + mp16.tree_buy("sp_hp") and f15.meta_state.tree_state().nodes.get("sp_hp", 0) == 1) # ⑯ 锻造工坊域 forge_routes(V8):v6 旧档缺键补齐=初始持有态+无特效域+域界干净 var f16 = sm_script.new(paths[14]) @@ -295,26 +295,26 @@ func _smoke_save() -> void: "tree_nodes": {"sp_hp": 1}, "tree_struct": 2, "adventure_total": 4, "adventure_cleared": 1}})) fv6.close() Kit.check("v6 旧档升 v7:既有域逐位不动(钥石/树/生涯计数零折算——迁移只增不改)〔锻造工坊批 V8〕", - f16.load_from_disk() and f16.keystone_balance() == 21 - and f16.tree_level("sp_hp") == 1 and f16.adventure_total() == 4 and f16.adventure_cleared() == 1) + f16.load_from_disk() and f16.meta_state.keystone_balance() == 21 + and f16.meta_state.tree_state().nodes.get("sp_hp", 0) == 1 and f16.meta_state.career_state().adventure_total == 4 and f16.meta_state.career_state().adventure_cleared == 1) Kit.check("v6 旧档缺键补齐=初始持有态(六路线 0 级/直购清单空/选中=sword+light)〔锻造工坊批 V8〕", - f16.forge_route_level("sword") == 0 and f16.forge_route_level("spear") == 0 - and f16.forge_route_level("light") == 0 and f16.forge_route_level("ward") == 0 - and f16.forge_unlocked_purchases().is_empty() - and f16.forge_weapon() == "sword" and f16.forge_armor() == "light") - var fr_keys: Array = (f16.forge_routes_snapshot() as Dictionary).keys() + f16.meta_state.forge_state().levels.get("sword", 0) == 0 and f16.meta_state.forge_state().levels.get("spear", 0) == 0 + and f16.meta_state.forge_state().levels.get("light", 0) == 0 and f16.meta_state.forge_state().levels.get("ward", 0) == 0 + and f16.meta_state.forge_state().unlocked.is_empty() + and f16.meta_state.forge_state().weapon == "sword" and f16.meta_state.forge_state().armor == "light") + var fr_keys: Array = (f16.meta_state.forge_state() as Dictionary).keys() fr_keys.sort() Kit.check("forge_routes 域键集恰四项(无特效域——满级特效本期不做,契约 7)〔锻造工坊批 V8/V11〕", fr_keys == ["armor", "levels", "unlocked", "weapon"]) - var lv_keys: Array = ((f16.forge_routes_snapshot() as Dictionary).get("levels", {}) as Dictionary).keys() + var lv_keys: Array = ((f16.meta_state.forge_state() as Dictionary).get("levels", {}) as Dictionary).keys() lv_keys.sort() Kit.check("levels 六路线键集恰全(sword/spear/staff/light/heavy/ward)〔锻造工坊批 V8〕", lv_keys == ["heavy", "light", "spear", "staff", "sword", "ward"]) # 值拷贝出户(改快照不渗档——本类契约) - var snap: Dictionary = f16.forge_routes_snapshot() + var snap: Dictionary = f16.meta_state.forge_state() (snap.get("levels", {}) as Dictionary)["sword"] = 99 Kit.check("forge_routes_snapshot 值拷贝出户(改快照不渗档)〔锻造工坊批 V8〕", - f16.forge_route_level("sword") == 0) + f16.meta_state.forge_state().levels.get("sword", 0) == 0) # ⑭ 收束链入账(真实收束挂点全链恰一:主流程 adventure_finished → # MetaProgression.settle_adventure → apply_adventure_summary 单口;两夹具经同链入账) @@ -322,11 +322,11 @@ func _smoke_save() -> void: mp13.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 2, "stopped_at": 3, "keystone_total": 0, "master_seed": 3}) Kit.check("收束链自增·中止局(settle_adventure 真实挂点:总局数+1、冒险成功局数不动)", - f13b.adventure_total() == 3 and f13b.adventure_cleared() == 1) + f13b.meta_state.career_state().adventure_total == 3 and f13b.meta_state.career_state().adventure_cleared == 1) mp13.settle_adventure({"result": "completed", "cleared": true, "levels_cleared": 3, "stopped_at": 0, "keystone_total": 0, "master_seed": 4}) Kit.check("收束链自增·冒险成功局(三关全通:总局数+1 且冒险成功局数+1——全链恰一)", - f13b.adventure_total() == 4 and f13b.adventure_cleared() == 2) + f13b.meta_state.career_state().adventure_total == 4 and f13b.meta_state.career_state().adventure_cleared == 2) # 段尾清理(测试文件绝不残留) for p in stale: @@ -352,39 +352,39 @@ func _smoke_save_slots() -> void: DirAccess.remove_absolute(p) var t = sm_script.new() - t.attach_slot_template(slot_tpl) + t.slots.attach_slot_template(slot_tpl) # ① 三槽往返×3+位间隔离+进档(sv1/sv5/sv10) for i in range(1, 4): - Kit.check("sv5 进档受理·槽 %d(激活+读盘)〔卡① sv5〕" % i, t.activate_slot(i)) - t.apply_adventure_summary({"result": "completed", "cleared": i == 1, "levels_cleared": i, + Kit.check("sv5 进档受理·槽 %d(激活+读盘)〔卡① sv5〕" % i, t.slots.activate_slot(i)) + t.meta_state.apply_adventure_summary({"result": "completed", "cleared": i == 1, "levels_cleared": i, "stopped_at": 0, "keystone_total": 100 * i, "master_seed": i}) Kit.check("sv3 激活态写盘受理·槽 %d〔卡① sv1〕" % i, t.write_to_disk()) var t2 = sm_script.new() - t2.attach_slot_template(slot_tpl) + t2.slots.attach_slot_template(slot_tpl) for i in range(1, 4): Kit.check("sv1 三槽往返一致·槽 %d(异值按位读回)〔卡① sv1〕" % i, - t2.activate_slot(i) and t2.keystone_balance() == 100 * i - and t2.adventure_total() == 1 and t2.adventure_cleared() == (1 if i == 1 else 0)) + t2.slots.activate_slot(i) and t2.meta_state.keystone_balance() == 100 * i + and t2.meta_state.career_state().adventure_total == 1 and t2.meta_state.career_state().adventure_cleared == (1 if i == 1 else 0)) var slot2_before := FileAccess.get_file_as_bytes(slot_tpl % 2) - t.activate_slot(1) - t.add_keystone(7) + t.slots.activate_slot(1) + t.meta_state._add_keystone(7) Kit.check("sv3 写盘受理·槽 1 加值后〔卡① sv1〕", t.write_to_disk()) Kit.check("sv1 位间隔离:改槽 1 槽 2 字节不动〔卡① sv1〕", FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_before) # ② 删档原语(sv6):删激活槽(指针连清除)→ 删非激活槽(指针保持+他槽字节不动) - Kit.check("sv5 切激活至槽 3(删激活槽夹具前置)〔卡① sv5〕", t.activate_slot(3)) - Kit.check("sv6 删档受理·激活槽 3〔卡① sv6〕", t.delete_slot(3)) + Kit.check("sv5 切激活至槽 3(删激活槽夹具前置)〔卡① sv5〕", t.slots.activate_slot(3)) + Kit.check("sv6 删档受理·激活槽 3〔卡① sv6〕", t.slots.delete_slot(3)) Kit.check("sv6 删后文件不在盘+无 .tmp 残留〔卡① sv6〕", not FileAccess.file_exists(slot_tpl % 3) and not FileAccess.file_exists(slot_tpl % 3 + ".tmp")) Kit.check("sv6 删激活槽激活指针连清除(该槽不再可写)〔卡① sv6〕", - t.active_slot() == 0 and not t.has_write_target()) + t.slots.active_slot() == 0 and not t.slots.has_write_target()) var slot1_before := FileAccess.get_file_as_bytes(slot_tpl % 1) - t.activate_slot(1) - Kit.check("sv6 删非激活槽受理·槽 2〔卡① sv6〕", t.delete_slot(2)) + t.slots.activate_slot(1) + Kit.check("sv6 删非激活槽受理·槽 2〔卡① sv6〕", t.slots.delete_slot(2)) Kit.check("sv6 删非激活槽:文件不在盘+激活指针保持〔卡① sv6〕", - not FileAccess.file_exists(slot_tpl % 2) and t.active_slot() == 1) + not FileAccess.file_exists(slot_tpl % 2) and t.slots.active_slot() == 1) Kit.check("sv6 删槽 2 槽 1 字节不动(位间互隔离)〔卡① sv6〕", FileAccess.get_file_as_bytes(slot_tpl % 1) == slot1_before) @@ -394,10 +394,10 @@ func _smoke_save_slots() -> void: fold.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 999, "adventure_total": 5, "adventure_cleared": 2, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) fold.close() - Kit.check("sv4 新档受理·槽 3(创建即激活)〔卡① sv4〕", t.create_slot(3)) + Kit.check("sv4 新档受理·槽 3(创建即激活)〔卡① sv4〕", t.slots.create_slot(3)) Kit.check("sv4 新档=全零可玩合法白板态(旧内容 999/5 零带入——钥石/总局数/断点全零)〔卡① sv4〕", - t.active_slot() == 3 and t.keystone_balance() == 0 and not t.has_breakpoint() - and t.adventure_total() == 0) + t.slots.active_slot() == 3 and t.meta_state.keystone_balance() == 0 and not t.has_breakpoint() + and t.meta_state.career_state().adventure_total == 0) var created: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(slot_tpl % 3)) Kit.check("sv4 新档落盘=白板内容(盘面核验非 999 旧值)〔卡① sv4〕", int((created.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 0) @@ -407,14 +407,14 @@ func _smoke_save_slots() -> void: fhv.store_string(JSON.stringify({"version": 99, "meta": {}})) fhv.close() var hv = sm_script.new() - hv.attach_slot_template(slot_tpl) + hv.slots.attach_slot_template(slot_tpl) Kit.check("sv5 进档槽 1 读回(107)〔卡① sv10〕", - hv.activate_slot(1) and hv.keystone_balance() == 107) + hv.slots.activate_slot(1) and hv.meta_state.keystone_balance() == 107) var slot2_hv := FileAccess.get_file_as_bytes(slot_tpl % 2) - Kit.check("sv9 高版本槽进档拒绝(拒读禁写链按槽)〔卡① sv9〕", not hv.activate_slot(2)) + Kit.check("sv9 高版本槽进档拒绝(拒读禁写链按槽)〔卡① sv9〕", not hv.slots.activate_slot(2)) Kit.check("sv10 激活失败恢复切换前三态(路径/激活号/数据归槽 1)〔卡① sv10〕", - hv.active_slot() == 1 and hv.keystone_balance() == 107) - hv.add_keystone(1) + hv.slots.active_slot() == 1 and hv.meta_state.keystone_balance() == 107) + hv.meta_state._add_keystone(1) Kit.check("sv10 恢复后写盘落激活槽 1(高版本槽 2 零误写)〔卡① sv10〕", hv.write_to_disk() and FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_hv) @@ -425,14 +425,14 @@ func _smoke_save_slots() -> void: fm.close() for i in range(1, 4): var mi = sm_script.new() - mi.attach_slot_template(slot_tpl) + mi.slots.attach_slot_template(slot_tpl) Kit.check("sv9 迁移链按槽成立·槽 %d(v5→v7 各槽独立+写盘 version=7)〔卡① sv9〕" % i, - mi.activate_slot(i) and mi.keystone_balance() == i * 11 + mi.slots.activate_slot(i) and mi.meta_state.keystone_balance() == i * 11 and mi.write_to_disk()) # ⑥ 窥视三态零副作用(sv7/sv8):异值三槽逐槽三项+空槽零值+坏/高版本字节 mtime 双比对 var pv = sm_script.new() - pv.attach_slot_template(slot_tpl) + pv.slots.attach_slot_template(slot_tpl) for i in range(1, 4): var fp := FileAccess.open(slot_tpl % i, FileAccess.WRITE) fp.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 200 * i, @@ -440,12 +440,12 @@ func _smoke_save_slots() -> void: "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) fp.close() for i in range(1, 4): - var ps: Dictionary = pv.peek_slot_summary(i) + var ps: Dictionary = pv.slots.peek_slot_summary(i) Kit.check("sv7 窥视三项逐槽正确·槽 %d〔卡① sv7〕" % i, bool(ps.get("readable", false)) and int(ps.get("keystone", -1)) == 200 * i and int(ps.get("cleared", -1)) == i and int(ps.get("total", -1)) == 5 * i) - Kit.check("sv6 删槽 3 备空槽窥视〔卡① sv6〕", pv.delete_slot(3)) - var pe: Dictionary = pv.peek_slot_summary(3) + Kit.check("sv6 删槽 3 备空槽窥视〔卡① sv6〕", pv.slots.delete_slot(3)) + var pe: Dictionary = pv.slots.peek_slot_summary(3) Kit.check("sv7 空槽窥视=零值合法态〔卡① sv7〕", bool(pe.get("readable", false)) and int(pe.get("keystone", -1)) == 0 and int(pe.get("cleared", -1)) == 0 and int(pe.get("total", -1)) == 0) @@ -454,7 +454,7 @@ func _smoke_save_slots() -> void: fbad.close() var bad_bytes := FileAccess.get_file_as_bytes(slot_tpl % 1) var bad_mtime := FileAccess.get_modified_time(slot_tpl % 1) - var pb: Dictionary = pv.peek_slot_summary(1) + var pb: Dictionary = pv.slots.peek_slot_summary(1) Kit.check("sv8 坏槽窥视=显式损坏标记〔卡① sv8〕", not bool(pb.get("readable", true))) Kit.check("sv8 坏槽窥视零副作用(字节+mtime 双比对不变+零 .corrupt.bak 改名)〔卡① sv8〕", FileAccess.get_file_as_bytes(slot_tpl % 1) == bad_bytes @@ -465,7 +465,7 @@ func _smoke_save_slots() -> void: fhv2.close() var hv_bytes := FileAccess.get_file_as_bytes(slot_tpl % 2) var hv_mtime := FileAccess.get_modified_time(slot_tpl % 2) - var ph: Dictionary = pv.peek_slot_summary(2) + var ph: Dictionary = pv.slots.peek_slot_summary(2) Kit.check("sv8 高版本槽窥视=不可读标记〔卡① sv8〕", not bool(ph.get("readable", true))) Kit.check("sv8 高版本窥视零副作用(字节+mtime 不变+零改名)〔卡① sv8〕", FileAccess.get_file_as_bytes(slot_tpl % 2) == hv_bytes @@ -473,7 +473,7 @@ func _smoke_save_slots() -> void: # ⑦ 旧单档迁移(sv12):字节搬运+源改名留痕+幂等+他槽零触碰 var ml = sm_script.new() - ml.attach_slot_template(slot_tpl) + ml.slots.attach_slot_template(slot_tpl) var fsrc := FileAccess.open(legacy_src, FileAccess.WRITE) fsrc.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 1234}})) fsrc.close() diff --git a/game/tests/unit/core/save_slot_isolation_test.gd b/game/tests/unit/core/save_slot_isolation_test.gd index 20cb7260..9d7070c8 100644 --- a/game/tests/unit/core/save_slot_isolation_test.gd +++ b/game/tests/unit/core/save_slot_isolation_test.gd @@ -39,46 +39,46 @@ func _write_v7_keystone(path: String, keystone: int) -> void: # ① 三槽往返:各槽写异值 → 写盘 → 新实例按位读回逐域相等;改槽 1 槽 2 字节不动(sv1) func _test_three_slot_roundtrip_and_isolation() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) for i in range(1, 4): - Kit.check_quiet(t.activate_slot(i), "sv5 进档受理·槽 %d〔卡① sv5〕" % i) - t.add_keystone(100 * i) - t.apply_adventure_summary({"result": "completed", "cleared": i == 1, "levels_cleared": i, + Kit.check_quiet(t.slots.activate_slot(i), "sv5 进档受理·槽 %d〔卡① sv5〕" % i) + t.meta_state._add_keystone(100 * i) + t.meta_state.apply_adventure_summary({"result": "completed", "cleared": i == 1, "levels_cleared": i, "stopped_at": 0, "keystone_total": 0, "master_seed": i}) Kit.check_quiet(t.write_to_disk(), "sv3 激活态写盘受理·槽 %d〔卡① sv1〕" % i) var t2 := SaveManagerScript.new() - t2.attach_slot_template(TPL) + t2.slots.attach_slot_template(TPL) for i in range(1, 4): - Kit.check_quiet(t2.activate_slot(i) and t2.keystone_balance() == 100 * i - and t2.adventure_total() == 1 and t2.adventure_cleared() == (1 if i == 1 else 0), + Kit.check_quiet(t2.slots.activate_slot(i) and t2.meta_state.keystone_balance() == 100 * i + and t2.meta_state.career_state().adventure_total == 1 and t2.meta_state.career_state().adventure_cleared == (1 if i == 1 else 0), "sv1 三槽往返一致·槽 %d(异值按位读回)〔卡① sv1〕" % i) var slot2_before := FileAccess.get_file_as_bytes(TPL % 2) - t.activate_slot(1) - t.add_keystone(7) + t.slots.activate_slot(1) + t.meta_state._add_keystone(7) Kit.check_quiet(t.write_to_disk(), "sv3 写盘受理·槽 1 加值后〔卡① sv1〕") Kit.check_quiet(FileAccess.get_file_as_bytes(TPL % 2) == slot2_before, "sv1 位间隔离:改槽 1 槽 2 字节不动〔卡① sv1〕") # 激活号记账:activate 槽派生名后 active_slot 同步;attach_path 注入槽名亦然 - Kit.check_quiet(t.active_slot() == 1 and t.has_write_target(), + Kit.check_quiet(t.slots.active_slot() == 1 and t.slots.has_write_target(), "sv1 activate 后激活号同步(active_slot=1 写盘目标在位)〔卡① sv1〕") # ② 高版本槽进档失败:拒绝+恢复切换前三态(路径/激活号/数据)+拒读槽零误写(sv9/sv10) func _test_activate_failure_restores_previous_slot() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) _write_v7_keystone(TPL % 1, 311) _write_v7_keystone(TPL % 2, 422) var fhv := FileAccess.open(TPL % 3, FileAccess.WRITE) fhv.store_string(JSON.stringify({"version": 99, "meta": {}})) fhv.close() - Kit.check_quiet(t.activate_slot(1) and t.keystone_balance() == 311, + Kit.check_quiet(t.slots.activate_slot(1) and t.meta_state.keystone_balance() == 311, "sv5 进档槽 1 读回(311)〔卡① sv10〕") var slot2_before := FileAccess.get_file_as_bytes(TPL % 2) - Kit.check_quiet(not t.activate_slot(3), "sv9 高版本槽进档拒绝(拒读链按槽)〔卡① sv9〕") - Kit.check_quiet(t.active_slot() == 1 and t.has_write_target() and t.keystone_balance() == 311, + Kit.check_quiet(not t.slots.activate_slot(3), "sv9 高版本槽进档拒绝(拒读链按槽)〔卡① sv9〕") + Kit.check_quiet(t.slots.active_slot() == 1 and t.slots.has_write_target() and t.meta_state.keystone_balance() == 311, "sv10 激活失败恢复切换前三态(路径/激活号/数据归槽 1)〔卡① sv10〕") - t.add_keystone(1) + t.meta_state._add_keystone(1) Kit.check_quiet(t.write_to_disk(), "sv3 恢复后写盘受理〔卡① sv10〕") Kit.check_quiet(FileAccess.get_file_as_bytes(TPL % 2) == slot2_before, "sv10 拒读槽零误写(写盘落激活槽 1,槽 2/3 字节不动)〔卡① sv10〕") @@ -87,17 +87,17 @@ func _test_activate_failure_restores_previous_slot() -> void: # ③ MetaProgression 宿主链切槽:引用同一性保持+写盘落当前槽(sv10——禁换实例先例) func _test_metaprogression_reference_identity() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) _write_v7_keystone(TPL % 1, 533) _write_v7_keystone(TPL % 2, 644) - Kit.check_quiet(t.activate_slot(1), "sv5 进档槽 1〔卡① sv10〕") + Kit.check_quiet(t.slots.activate_slot(1), "sv5 进档槽 1〔卡① sv10〕") var meta := MetaProgressionScript.new(null, t) - Kit.check_quiet(meta.save == t and meta.keystone_balance() == 533, + Kit.check_quiet(meta.save == t and meta.meta_state.keystone_balance() == 533, "sv10 宿主链取数走动态分发(meta.save 同一实例、读槽 1)〔卡① sv10〕") - Kit.check_quiet(t.activate_slot(2) and meta.save == t and meta.keystone_balance() == 644, + Kit.check_quiet(t.slots.activate_slot(2) and meta.save == t and meta.meta_state.keystone_balance() == 644, "sv10 切槽后引用同一性保持(零换实例)且读数随槽(644)〔卡① sv10〕") - meta.save.add_keystone(6) - Kit.check_quiet(t.write_to_disk() and t.keystone_balance() == 650, + meta.save.meta_state._add_keystone(6) + Kit.check_quiet(t.write_to_disk() and t.meta_state.keystone_balance() == 650, "sv10 宿主链写盘落当前槽 2(650=644+6)〔卡① sv10〕") Kit.check_quiet(int((JSON.parse_string(FileAccess.get_file_as_string(TPL % 2)) as Dictionary)\ .get("meta", {}).get("keystone_balance", -1)) == 650, diff --git a/game/tests/unit/core/save_slot_summary_test.gd b/game/tests/unit/core/save_slot_summary_test.gd index ae0be20e..468045c9 100644 --- a/game/tests/unit/core/save_slot_summary_test.gd +++ b/game/tests/unit/core/save_slot_summary_test.gd @@ -37,20 +37,20 @@ func _write_slot_json(path: String, content: String) -> void: # ① 三槽异值逐槽三项正确+不激活不写内存(sv7) func _test_peek_three_slots_distinct_values() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) for i in range(1, 4): _write_slot_json(TPL % i, JSON.stringify({"version": 7, "meta": { "keystone_balance": 200 * i, "adventure_total": 5 * i, "adventure_cleared": i, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) for i in range(1, 4): - var ps: Dictionary = t.peek_slot_summary(i) + var ps: Dictionary = t.slots.peek_slot_summary(i) Kit.check_quiet(bool(ps.get("readable", false)) and int(ps.get("keystone", -1)) == 200 * i and int(ps.get("cleared", -1)) == i and int(ps.get("total", -1)) == 5 * i, "sv7 窥视三项逐槽正确·槽 %d(钥石/冒险成功/总局)〔卡① sv7〕" % i) - Kit.check_quiet(t.active_slot() == 0 and not t.has_write_target() and t.keystone_balance() == 0, + Kit.check_quiet(t.slots.active_slot() == 0 and not t.slots.has_write_target() and t.meta_state.keystone_balance() == 0, "sv7 窥视不激活不写内存(激活号 0/无写盘目标/内存余额零)〔卡① sv7〕") # 路径注入口同语义(契约 7 注入面) - var pinj: Dictionary = t.peek_summary_at(TPL % 2) + var pinj: Dictionary = t.slots.peek_summary_at(TPL % 2) Kit.check_quiet(bool(pinj.get("readable", false)) and int(pinj.get("keystone", -1)) == 400, "sv7 peek_summary_at 注入口同语义(槽 2 钥石 400)〔卡① sv7〕") @@ -58,32 +58,32 @@ func _test_peek_three_slots_distinct_values() -> void: # ② 空槽零值合法态+不可读三态显式标记(sv7/契约 9) func _test_peek_empty_and_unreadable_states() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) DirAccess.remove_absolute(TPL % 3) # 清①段残留——空槽夹具 - var pe: Dictionary = t.peek_slot_summary(3) # 槽 3 无文件=空槽 + var pe: Dictionary = t.slots.peek_slot_summary(3) # 槽 3 无文件=空槽 Kit.check_quiet(bool(pe.get("readable", false)) and int(pe.get("keystone", -1)) == 0 and int(pe.get("cleared", -1)) == 0 and int(pe.get("total", -1)) == 0, "sv7 空槽窥视=零值合法态(可读+三项全零)〔卡① sv7〕") _write_slot_json(TPL % 1, "this is not json{{{") - Kit.check_quiet(not bool(t.peek_slot_summary(1).get("readable", true)), + Kit.check_quiet(not bool(t.slots.peek_slot_summary(1).get("readable", true)), "sv7 坏槽窥视=显式损坏标记〔卡① sv7〕") _write_slot_json(TPL % 2, JSON.stringify({"version": 99, "meta": {}})) - Kit.check_quiet(not bool(t.peek_slot_summary(2).get("readable", true)), + Kit.check_quiet(not bool(t.slots.peek_slot_summary(2).get("readable", true)), "sv7 高版本槽窥视=不可读标记〔卡① sv7〕") _write_slot_json(TPL % 3, JSON.stringify({"meta": {"keystone_balance": 5}})) # 缺版本字段 - Kit.check_quiet(not bool(t.peek_slot_summary(3).get("readable", true)), + Kit.check_quiet(not bool(t.slots.peek_slot_summary(3).get("readable", true)), "sv7 缺版本字段槽窥视=不可读标记〔卡① sv7/契约9〕") # ③ 窥视零副作用:字节+mtime 双比对不变+零 .corrupt.bak 改名(sv8——真实入口冒烟段同判据) func _test_peek_zero_side_effect() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) _write_slot_json(TPL % 1, "corrupt bytes {{{{") var bytes_before := FileAccess.get_file_as_bytes(TPL % 1) var mtime_before := FileAccess.get_modified_time(TPL % 1) - t.peek_slot_summary(1) - t.peek_summary_at(TPL % 1) + t.slots.peek_slot_summary(1) + t.slots.peek_summary_at(TPL % 1) Kit.check_quiet(FileAccess.get_file_as_bytes(TPL % 1) == bytes_before and FileAccess.get_modified_time(TPL % 1) == mtime_before and not FileAccess.file_exists(TPL % 1 + ".corrupt.bak"), @@ -91,7 +91,7 @@ func _test_peek_zero_side_effect() -> void: _write_slot_json(TPL % 2, JSON.stringify({"version": 99, "meta": {}})) var hv_bytes := FileAccess.get_file_as_bytes(TPL % 2) var hv_mtime := FileAccess.get_modified_time(TPL % 2) - t.peek_slot_summary(2) + t.slots.peek_slot_summary(2) Kit.check_quiet(FileAccess.get_file_as_bytes(TPL % 2) == hv_bytes and FileAccess.get_modified_time(TPL % 2) == hv_mtime and not FileAccess.file_exists(TPL % 2 + ".corrupt.bak"), @@ -99,7 +99,7 @@ func _test_peek_zero_side_effect() -> void: # .tmp 残留按空槽处理(契约 9) DirAccess.remove_absolute(TPL % 3) # 主文件清场——只留 .tmp 残留 _write_slot_json(TPL % 3 + ".tmp", "{}") - var pt: Dictionary = t.peek_slot_summary(3) + var pt: Dictionary = t.slots.peek_slot_summary(3) Kit.check_quiet(bool(pt.get("readable", false)) and int(pt.get("keystone", -1)) == 0, "sv8 .tmp 残留按空槽处理(主文件不在=空槽零值)〔卡① sv8〕") if FileAccess.file_exists(TPL % 3 + ".tmp"): diff --git a/game/tests/unit/core/settle_segments_test.gd b/game/tests/unit/core/settle_segments_test.gd index dd469fc8..a5fab988 100644 --- a/game/tests/unit/core/settle_segments_test.gd +++ b/game/tests/unit/core/settle_segments_test.gd @@ -34,7 +34,7 @@ func _init() -> void: var rng_c = load("res://core/rng_service.gd").new() rng_c.set_master_seed(4241) var orch_c = load("res://core/battle_orchestrator.gd").new(db, rng_c) - var orch_ok: bool = orch_c.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "feats": ["geologist"]}) + var orch_ok: bool = orch_c.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "feats": ["geologist"]}) var tgt_c := Vector2i(-9, -9) var dig_acc := false if orch_ok: @@ -71,7 +71,7 @@ func _init() -> void: settlement.step_row.connect(func(row): rows.append(row)) var result := {"r": ""} settlement.battle_finished.connect(func(payload): result.r = payload.get("result", "")) - var enc := {"level_index": 1, "node_depth": 1, "hero_hp": int(db.hero.max_hp)} + var enc := {"level_index": 1, "node_depth": 1, "hero_hp": int(db.combat.hero.max_hp)} Kit.check_quiet(settlement.begin_battle(enc), "V4 前置:begin_battle 受理") # 停手触发轮末结算(stop 经批次提交——_maybe_settle 在 WAITING 相位放行 _settle 段序) var outcomes: Array = settlement.minesweeper.settle_batch([{"action": "stop"}]) diff --git a/game/tests/unit/core/treasure_effects_test.gd b/game/tests/unit/core/treasure_effects_test.gd index 36076598..13db7148 100644 --- a/game/tests/unit/core/treasure_effects_test.gd +++ b/game/tests/unit/core/treasure_effects_test.gd @@ -40,7 +40,7 @@ func _init() -> void: # 反例=未装配零命中 var rng1b = load("res://core/rng_service.gd").new(); rng1b.set_master_seed(222) var ob1b = orch_s.new(db, rng1b) - Kit.check_quiet(ob1b.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp)}) and ob1b.attune_param_sum("light_radius_bonus") == 0, + Kit.check_quiet(ob1b.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp)}) and ob1b.attune_param_sum("light_radius_bonus") == 0, "空装配基线复用:未装配件参数读数 0") var hits_none = ob1b.fx_host().engine.dispatch(ob1b.fx_host().counters_ctx, "round_start", {"round_index": 1}) Kit.check_quiet(_ap_of(hits_none) == 0, "晨操令反例:未装配首轮零命中(订阅行随装配集装载)") @@ -48,7 +48,7 @@ func _init() -> void: # ② 双管装填(前 2 轮各 +1 / 第 3 轮零) var rng2 = load("res://core/rng_service.gd").new(); rng2.set_master_seed(231) var ob2 = orch_s.new(db, rng2) - Kit.check_quiet(ob2.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["twin_loader"]}), + Kit.check_quiet(ob2.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["twin_loader"]}), "双管装填:装配起战") Kit.check_quiet(_ap_of(ob2.fx_host().engine.dispatch(ob2.fx_host().counters_ctx, "round_start", {"round_index": 1})) == 1 and _ap_of(ob2.fx_host().engine.dispatch(ob2.fx_host().counters_ctx, "round_start", {"round_index": 2})) == 1, @@ -59,7 +59,7 @@ func _init() -> void: # ③ 阔视野带(参数读数=4 / 未装配=0——注入面消费在呈现桥 T4) var rng3 = load("res://core/rng_service.gd").new(); rng3.set_master_seed(241) var ob3 = orch_s.new(db, rng3) - Kit.check_quiet(ob3.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["wide_scout_band"]}), + Kit.check_quiet(ob3.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["wide_scout_band"]}), "阔视野带:装配起战") Kit.check_quiet(ob3.attune_param_sum("light_radius_bonus") == 4, "阔视野带:参数读数 4(8→12 格=+4 provisional;只扩参数不揭安危)") @@ -69,7 +69,7 @@ func _init() -> void: # ④ 试炼镐(积点受理闸:未达拒/达点兑付 AP+1/未装配拒) var rng4 = load("res://core/rng_service.gd").new(); rng4.set_master_seed(251) var ob4 = orch_s.new(db, rng4) - Kit.check_quiet(ob4.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["trial_pick"]}), + Kit.check_quiet(ob4.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["trial_pick"]}), "试炼镐:装配起战") # 自动兑付:无玩家入口——揭示缓冲点即时计数,满 charge_n 自动 # grant_ap(cell_revealed 先于本格扣费→AP 归零恰逢满点时先兑再扣、轮不结束)。直调揭示处理器 @@ -88,9 +88,9 @@ func _init() -> void: "试炼镐读数口:兑付后进度归零(8 取余)、阈值=charge_n、C 区浮窗数据源") var rng4b = load("res://core/rng_service.gd").new(); rng4b.set_master_seed(252) var ob4b = orch_s.new(db, rng4b) - ob4b.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp)}) + ob4b.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp)}) ob4b._on_cell_revealed({"kind": "safe", "cell": Vector2i(0, 0)}) - var ap_base := int(db.action_economy.ap_per_round) + var ap_base := int(db.run.action_economy.ap_per_round) Kit.check_quiet(int(ob4b.action_economy.ap) == ap_base and not bool(ob4b.attune_convert_state().get("assembled", true)), "试炼镐反例:未装配零兑付零计数(AP=整轮表值、读数口 assembled=false)") @@ -98,7 +98,7 @@ func _init() -> void: # 展示行=C 区悬停浮窗进度唯一承载:handle=-1 非库存句柄+note 进度;空骑库存桩直构投影链) var rng4c = load("res://core/rng_service.gd").new(); rng4c.set_master_seed(253) var ob4c = orch_s.new(db, rng4c) - ob4c.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["trial_pick"]}) + ob4c.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["trial_pick"]}) var ctrl4 = load("res://presentation/treasure_interact_controller.gd").new({ "db": db, "meta": null, "info_panel": null, "toast": null, "board_view": null, "settlement_get": func(): return ob4c, "runner_get": func(): return StubAutoInvRunner.new(), @@ -126,19 +126,19 @@ func _init() -> void: # ⑥ 储备罐(转存率 125=1.25:1 整除口径 / 100=1:1 逐位同旧) var ae6 = load("res://core/action_economy.gd").new(db) - ae6.queue_ap_delta(4 - int(db.action_economy.ap_per_round)) # 排队修正到测试口径 4 AP(notify_round_start 兑现) + ae6.queue_ap_delta(4 - int(db.run.action_economy.ap_per_round)) # 排队修正到测试口径 4 AP(notify_round_start 兑现) ae6.notify_round_start() ae6.request_stop() ae6.finish_round_stop(false, 100) Kit.check_quiet(ae6.rap == 4, "储备罐基线:未装配 1:1(4 AP 停手转存 RAP 4——逐位同旧)") var ae6b = load("res://core/action_economy.gd").new(db) - ae6b.queue_ap_delta(4 - int(db.action_economy.ap_per_round)) + ae6b.queue_ap_delta(4 - int(db.run.action_economy.ap_per_round)) ae6b.notify_round_start() ae6b.request_stop() ae6b.finish_round_stop(false, 125) Kit.check_quiet(ae6b.rap == 5, "储备罐正常:125% 转存(4 AP→RAP 5=1.25:1 整除口径;不碰 AP 上限)") var ae6c = load("res://core/action_economy.gd").new(db) - ae6c.queue_ap_delta(3 - int(db.action_economy.ap_per_round)) + ae6c.queue_ap_delta(3 - int(db.run.action_economy.ap_per_round)) ae6c.notify_round_start() ae6c.request_stop() ae6c.finish_round_stop(false, 125) @@ -147,7 +147,7 @@ func _init() -> void: # ⑦ 巢穴预告(装板后一次报数量——明细行经 step_row 信号收集) var rng7 = load("res://core/rng_service.gd").new(); rng7.set_master_seed(271) var ob7 = orch_s.new(db, rng7) - Kit.check_quiet(ob7.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["nest_forecast"]}), + Kit.check_quiet(ob7.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["nest_forecast"]}), "巢穴预告:装配起战(board_installed 发射)") var total7: int = ob7.minesweeper.mine_total() Kit.check_quiet(total7 > 0 and int(ob7.nest_preview_count) == total7, @@ -158,7 +158,7 @@ func _init() -> void: # ⑧ 线香(真视单格 cell 目标 / 每战一次战斗级限量) var rng8 = load("res://core/rng_service.gd").new(); rng8.set_master_seed(281) var ob8 = orch_s.new(db, rng8) - Kit.check_quiet(ob8.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["incense_stick"]}), + Kit.check_quiet(ob8.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["incense_stick"]}), "线香:装配起战(battle 级充能 1)") var inv8 = load("res://core/treasure_items.gd").new(db) var rec8: Dictionary = inv8.add_item("incense_stick", "attunement", Vector2i(-1, -1), 0) @@ -177,7 +177,7 @@ func _init() -> void: # ⑨ 远程镐(挖掘距离放宽 +1 / 不挖开不相邻事实) var rng9 = load("res://core/rng_service.gd").new(); rng9.set_master_seed(291) var ob9 = orch_s.new(db, rng9) - Kit.check_quiet(ob9.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["ranged_pick"]}), + Kit.check_quiet(ob9.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["ranged_pick"]}), "远程镐:装配起战(dig_range_bonus 注入)") # 夹具:初始展开稳态下出生 d2 环全开(剃雷域收窄 r=1=出生 5×5 恰为 d≤2)——把英雄语境推进到 # 「已开区前沿格」(其 d2 环含未开格最多的已开格),测试直写 _hero(夹具惯例) @@ -226,17 +226,17 @@ func _init() -> void: # ⑩ 刷新执照(商店免费刷新 +2/店——读数叠加) var lv10 = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new()) - Kit.check_quiet(int(lv10.shop_refresh_free_left()) == int(db.adventure.shop_refresh_free), - "刷新执照基线:未装配=表值(%d)" % int(db.adventure.shop_refresh_free)) + Kit.check_quiet(int(lv10.shop_refresh_free_left()) == int(db.run.adventure.shop_refresh_free), + "刷新执照基线:未装配=表值(%d)" % int(db.run.adventure.shop_refresh_free)) lv10 = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new(), {"attunement": ["refresh_license"]}) # 构造注入(拆件批 T3——setter 群退役) - Kit.check_quiet(int(lv10.shop_refresh_free_left()) == int(db.adventure.shop_refresh_free) + 2, + Kit.check_quiet(int(lv10.shop_refresh_free_left()) == int(db.run.adventure.shop_refresh_free) + 2, "刷新执照正常:免费刷新 +2/店(读数即本店可用——进店重置生命周期同 knob)") # ⑪ 天眼(3×3 真视+安全格直开/危险格不开/封印格跳过) var rng11 = load("res://core/rng_service.gd").new(); rng11.set_master_seed(301) var ob11 = orch_s.new(db, rng11) - Kit.check_quiet(ob11.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["sky_eye"]}), + Kit.check_quiet(ob11.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["sky_eye"]}), "天眼:装配起战(level 级充能 1)") var inv11 = load("res://core/treasure_items.gd").new(db) var rec11: Dictionary = inv11.add_item("sky_eye", "attunement", Vector2i(-1, -1), 0) @@ -265,7 +265,7 @@ func _init() -> void: # ⑫ 信标网络(封印不限相邻/封印价照付) var rng12 = load("res://core/rng_service.gd").new(); rng12.set_master_seed(311) var ob12 = orch_s.new(db, rng12) - Kit.check_quiet(ob12.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["beacon_network"]}), + Kit.check_quiet(ob12.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["beacon_network"]}), "信标网络:装配起战(seal_range_free 注入)") var far12: Vector2i = _cell_at(ob12, ob12.minesweeper.hero_pos() + Vector2i(4, 4)) if far12 != Vector2i(-9, -9): @@ -277,7 +277,7 @@ func _init() -> void: "信标网络正常:封印可达豁免在册(封印仍按标准动作价计费=try_spend 照走)") var rng12b = load("res://core/rng_service.gd").new(); rng12b.set_master_seed(312) var ob12b = orch_s.new(db, rng12b) - ob12b.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp)}) + ob12b.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp)}) Kit.check_quiet(ob12b.minesweeper.seal_range_free == false, "信标网络反例:未装配封印可达豁免=缺省 false(零装配零扰动)") @@ -285,7 +285,7 @@ func _init() -> void: var rng13 = load("res://core/rng_service.gd").new(); rng13.set_master_seed(321) var ob13 = orch_s.new(db, rng13) var lg13 = load("res://core/gain_journal.gd").new() - Kit.check_quiet(ob13.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["incense_stick"], "journal": lg13}), + Kit.check_quiet(ob13.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.combat.hero.max_hp), "attunement": ["incense_stick"], "journal": lg13}), "计数链:装配起战(GainJournal 携带)") var inv13 = load("res://core/treasure_items.gd").new(db) var rec13: Dictionary = inv13.add_item("incense_stick", "attunement", Vector2i(-1, -1), 0) diff --git a/game/tests/unit/core/treasure_items_test.gd b/game/tests/unit/core/treasure_items_test.gd index ffcf8e99..72219d19 100644 --- a/game/tests/unit/core/treasure_items_test.gd +++ b/game/tests/unit/core/treasure_items_test.gd @@ -32,7 +32,7 @@ func _stub_db() -> StubDB: "effect_kinds": ["reward_preview"], "target": "area_3x3", "magnitude": 0, "use_phase": "active_dig"}, } var db := StubDB.new() - db.treasure_items = t + db.crossrun.treasure_items = t return db diff --git a/game/tests/unit/core/tree_requires_level_test.gd b/game/tests/unit/core/tree_requires_level_test.gd index 46d143b5..8879e59a 100644 --- a/game/tests/unit/core/tree_requires_level_test.gd +++ b/game/tests/unit/core/tree_requires_level_test.gd @@ -53,7 +53,7 @@ func _init() -> void: var mp = load("res://core/meta_progression.gd").new(db, sv) mt.rows = [base_pre.duplicate(true), base_dep.duplicate(true)] db.set("meta_tree", mt) - sv.set_tree_level("gate_pre", 1) + sv.meta_state.set_tree_level("gate_pre", 1) if mp.tree_can_buy("gate_dep"): failures += 1 print("[r1] FAIL 前置 Lv1 < 阈值 2 应拒购") @@ -62,18 +62,18 @@ func _init() -> void: failures += 1 print("[r1] FAIL 无 requires_level 行判定不受机制位影响") # ⑦ 判定面:达标开放 - sv.set_tree_level("gate_pre", 2) + sv.meta_state.set_tree_level("gate_pre", 2) if not mp.tree_can_buy("gate_dep"): failures += 1 print("[r1] FAIL 前置 Lv2 达阈值应可购") # ⑧ 投影:gate_text 未达标拼门槛文案、达标清空 - sv.set_tree_level("gate_pre", 1) - var gate: String = mp.tree_gate_text("gate_dep") + sv.meta_state.set_tree_level("gate_pre", 1) + var gate: String = mp._tree_gate_text("gate_dep") if gate != "需 门槛前置 Lv2": failures += 1 print("[r1] FAIL 未达标 gate_text 应=「需 门槛前置 Lv2」实得「%s」" % gate) - sv.set_tree_level("gate_pre", 2) - if mp.tree_gate_text("gate_dep") != "": + sv.meta_state.set_tree_level("gate_pre", 2) + if mp._tree_gate_text("gate_dep") != "": failures += 1 print("[r1] FAIL 达标后 gate_text 应为空串") if failures == 0: diff --git a/game/tests/unit/data_config/config_db_fatal_oob_test.gd b/game/tests/unit/data_config/config_db_fatal_oob_test.gd index 31d1d348..5265be3b 100644 --- a/game/tests/unit/data_config/config_db_fatal_oob_test.gd +++ b/game/tests/unit/data_config/config_db_fatal_oob_test.gd @@ -24,7 +24,7 @@ func _init() -> void: func _trigger_oob() -> void: # 越域请求(关卡序号 0 < 合法域 ≥1)——_fatal 内部 push fatal 诊断并 quit(1)(帧末退出) - var got: Dictionary = _db.resolve_board_gen(0, 0) + var got: Dictionary = _db.combat.board_gen.resolve_row(0, 0) if _db.diagnostics_count("fatal") >= 1: print("[fatal_oob_test] fatal 通道已触发(诊断在案)") quit(1) # 与 _fatal 的 quit(1) 同码双保险 diff --git a/game/tests/unit/data_config/config_db_test.gd b/game/tests/unit/data_config/config_db_test.gd index 3d229531..46346262 100644 --- a/game/tests/unit/data_config/config_db_test.gd +++ b/game/tests/unit/data_config/config_db_test.gd @@ -30,7 +30,7 @@ func _init() -> void: # ---- 进度坐标 ceil 解析(R1:向上下取整保证偏难;越最大行 clamp 取最大值 + 警告去重)---- func _test_progress_resolution(db) -> void: - var bg_rows: Array = db.board_gen.rows + var bg_rows: Array = db.combat.board_gen.rows # 行键全集自表推导(禁写死关卡/深度数值) var level_keys: Array = [] var depth_keys_by_level: Dictionary = {} @@ -51,7 +51,7 @@ func _test_progress_resolution(db) -> void: # 精确命中:请求 = 表内现存 (L,D) → 原行返回(字段逐位相等) var probe_row: Dictionary = bg_rows[0] - var got: Dictionary = db.resolve_board_gen(int(probe_row.level_index), int(probe_row.node_depth)) + var got: Dictionary = db.combat.board_gen.resolve_row(int(probe_row.level_index), int(probe_row.node_depth)) Kit.check("进度解析精确命中返回原行(board_gen 首行)", int(got.get("level_index", -99)) == int(probe_row.level_index) and int(got.get("node_depth", -99)) == int(probe_row.node_depth) @@ -59,12 +59,12 @@ func _test_progress_resolution(db) -> void: # enemy 行集同通道:同 (L,D) 行集数组非空(关卡节点地图 前置——battle 节点双表精确行集契约; # 行集面契约:返回形态是行集数组,精确性断言随行集面) - var en_got: Array = db.resolve_enemy_rowset(int(probe_row.level_index), int(probe_row.node_depth), "battle") + var en_got: Array = db.combat.enemy.resolve_rowset(int(probe_row.level_index), int(probe_row.node_depth), "battle") Kit.check("enemy 行集同坐标解析非空(双表精确行契约——battle 行类)", not en_got.is_empty()) # 深度 clamp:请求深度超最大行 → 取该关最大深度行 + 警告诊断一次(去重) db.reset_diagnostics() - var deep: Dictionary = db.resolve_board_gen(min_l, int(min_depths[-1]) + 1) + var deep: Dictionary = db.combat.board_gen.resolve_row(min_l, int(min_depths[-1]) + 1) Kit.check("深度越最大行 clamp 取该关最大深度行", int(deep.get("level_index", -99)) == min_l and int(deep.get("node_depth", -99)) == int(min_depths[-1])) Kit.check("clamp 解析发警告诊断(resolve_depth_clamp)", db.diagnostics_count("warning") >= 1) @@ -74,19 +74,19 @@ func _test_progress_resolution(db) -> void: found_clamp = true Kit.check("警告类别 = resolve_depth_clamp", found_clamp) var warn_before: int = db.diagnostics_count("warning") - db.resolve_board_gen(min_l, int(min_depths[-1]) + 1) + db.combat.board_gen.resolve_row(min_l, int(min_depths[-1]) + 1) Kit.check("同键重复请求警告去重(四元组注册表)", db.diagnostics_count("warning") == warn_before) # 关卡 clamp:请求关卡超最大行 → 取最大关卡行 db.reset_diagnostics() - var high: Dictionary = db.resolve_board_gen(max_l + 1, 0) + var high: Dictionary = db.combat.board_gen.resolve_row(max_l + 1, 0) Kit.check("关卡越最大行 clamp 取最大关卡行", int(high.get("level_index", -99)) == max_l) # ---- 变异池门控(unlock_level 闸 + ∅ board_types 通配 + 节点身份第三维)---- func _test_variant_pool_gate(db) -> void: - var rows: Array = db.variation.rows + var rows: Array = db.combat.variation.rows var gated_row := {} var wildcard_row := {} var global_row := {} @@ -103,19 +103,19 @@ func _test_variant_pool_gate(db) -> void: if not gated_row.is_empty(): var u: int = int(gated_row.unlock_level) var tag: String = String(gated_row.board_types[0]) - var before: Array = db.variant_pool(u - 1, tag) - var after: Array = db.variant_pool(u, tag) + var before: Array = db.combat.variation.variant_pool(u - 1, tag) + var after: Array = db.combat.variation.variant_pool(u, tag) Kit.check("变异池 unlock 前不含未解锁行(level=unlock−1)", not _rows_contain(before, gated_row)) Kit.check("变异池 unlock 当级含该行", _rows_contain(after, gated_row)) if not wildcard_row.is_empty(): var u2: int = int(wildcard_row.unlock_level) - var any_pool: Array = db.variant_pool(u2, "nonexistent_board_type_tag") + var any_pool: Array = db.combat.variation.variant_pool(u2, "nonexistent_board_type_tag") Kit.check("∅ board_types 行通配(任一 board_type 命中)", _rows_contain(any_pool, wildcard_row)) if not global_row.is_empty(): var u3: int = int(global_row.unlock_level) - Kit.check("global 行 boss 节点入池(board_types ∅ 通配)", _rows_contain(db.variant_pool(u3, "nonexistent_board_type_tag", "boss"), global_row)) - Kit.check("global 行缺省双参调用跳过(既有调用口径零扰动)", not _rows_contain(db.variant_pool(u3, "nonexistent_board_type_tag"), global_row)) - Kit.check("global 行 battle 节点静默跳过(门控兜底零报错)", not _rows_contain(db.variant_pool(u3, "nonexistent_board_type_tag", "battle"), global_row)) + Kit.check("global 行 boss 节点入池(board_types ∅ 通配)", _rows_contain(db.combat.variation.variant_pool(u3, "nonexistent_board_type_tag", "boss"), global_row)) + Kit.check("global 行缺省双参调用跳过(既有调用口径零扰动)", not _rows_contain(db.combat.variation.variant_pool(u3, "nonexistent_board_type_tag"), global_row)) + Kit.check("global 行 battle 节点静默跳过(门控兜底零报错)", not _rows_contain(db.combat.variation.variant_pool(u3, "nonexistent_board_type_tag", "battle"), global_row)) func _rows_contain(pool: Array, row: Dictionary) -> bool: @@ -128,28 +128,28 @@ func _rows_contain(pool: Array, row: Dictionary) -> bool: # ---- ShopTable 消费面(行集解析单点——runner/编排禁自扫表的契约消费侧验证)---- func _test_shop_accessors(db) -> void: - var products: Array = db.shop_products() + var products: Array = db.run.shop.product_rows() if products.is_empty(): Kit.check("ShopTable products 行族非空(表已已入库前提)", false) return Kit.check("ShopTable products 行族非空", true) var first: Dictionary = products[0] - var got: Dictionary = db.shop_product_row(String(first.product_id)) + var got: Dictionary = db.run.shop.product_row(String(first.product_id)) Kit.check("shop_product_row 按 product_id 精确命中首行", got == first) - Kit.check("shop_product_row 未知 id 返回空字典(容错通道)", db.shop_product_row("no_such_product_id__").is_empty()) + Kit.check("shop_product_row 未知 id 返回空字典(容错通道)", db.run.shop.product_row("no_such_product_id__").is_empty()) var tier_rows: Array = db._shop_rows("gp_drops") if not tier_rows.is_empty(): var tier0: String = String(tier_rows[0].tier) - Kit.check("shop_gp_drop_row 按 tier 命中", not db.shop_gp_drop_row(tier0).is_empty()) + Kit.check("shop_gp_drop_row 按 tier 命中", not db.run.shop.gp_drop_row(tier0).is_empty()) var bonus_rows: Array = db._shop_rows("level_gp_bonus") if not bonus_rows.is_empty(): var blv: int = int(bonus_rows[0].level_index) - Kit.check("shop_level_gp_bonus 按关卡序号命中行值", db.shop_level_gp_bonus(blv) == int(bonus_rows[0].bonus)) - Kit.check("shop_level_gp_bonus 未登记关卡返回 0(兜底通道)", db.shop_level_gp_bonus(-1) == 0) + Kit.check("shop_level_gp_bonus 按关卡序号命中行值", db.run.shop.level_gp_bonus_at(blv) == int(bonus_rows[0].bonus)) + Kit.check("shop_level_gp_bonus 未登记关卡返回 0(兜底通道)", db.run.shop.level_gp_bonus_at(-1) == 0) var price_rows: Array = db._shop_rows("price_level_bonus") if not price_rows.is_empty(): var plv: int = int(price_rows[0].level_index) - Kit.check("shop_price_level_bonus 按关卡序号命中行值", db.shop_price_level_bonus(plv) == int(price_rows[0].bonus)) + Kit.check("shop_price_level_bonus 按关卡序号命中行值", db.run.shop.price_level_bonus_at(plv) == int(price_rows[0].bonus)) # ---- MetaTable 消费面 ---- @@ -159,11 +159,16 @@ func _test_meta_accessors(db) -> void: var key_rows: Array = db._meta_rows("boss_keystone") if not key_rows.is_empty(): var klv: int = int(key_rows[0].level_index) - Kit.check("meta_boss_keystone_row 按关卡序号命中", db.meta_boss_keystone_row(klv) == key_rows[0]) + Kit.check("meta_boss_keystone_row 按关卡序号命中", db.crossrun.meta.boss_keystone_row(klv) == key_rows[0]) var conv_rows: Array = db._meta_rows("conversion") if not conv_rows.is_empty(): var cattr: String = String(conv_rows[0].attr) - Kit.check("meta_conversion_row 按 attr 命中", db.meta_conversion_row(cattr) == conv_rows[0]) + var conv_hit := {} + for r in db.crossrun.meta.conversion: + if String(r.get("attr", "")) == cattr: + conv_hit = r + break + Kit.check("meta.conversion 行集按 attr 命中(访问器退役改直扫同判据)", conv_hit == conv_rows[0]) # ---- TABLE_MANIFEST ↔ data/tables 目录同步(各系统→D2 单点契约的登记面)---- diff --git a/game/tests/unit/data_config/forge_table_test.gd b/game/tests/unit/data_config/forge_table_test.gd index a9054b27..c1a75a4e 100644 --- a/game/tests/unit/data_config/forge_table_test.gd +++ b/game/tests/unit/data_config/forge_table_test.gd @@ -26,7 +26,7 @@ func _init() -> void: # ---- 生产表六行正向(V1 生产面 + V11 行字段闭集)---- func _test_production_rows(db) -> void: - var rows: Array = db.forge_routes() + var rows: Array = db.crossrun.forge.route_rows() Kit.check("六路线行族规模恰 6(三武器+三甲)〔锻造工坊批 V1〕", rows.size() == 6) var ids := [] for row in rows: @@ -52,39 +52,44 @@ func _test_production_rows(db) -> void: initial.sort() Kit.check("初始持有恰 sword+light〔锻造工坊批 V1〕", initial == ["light", "sword"]) # 默认两轴(契约 2 所有权移交——裸体态 normal/light) - Kit.check("默认攻击类型轴 = normal(常驻持有)〔锻造工坊批 V1〕", db.forge_default_attack_type() == "normal") - Kit.check("默认甲型轴 = light(常驻持有)〔锻造工坊批 V1〕", db.forge_default_armor_type() == "light") + Kit.check("默认攻击类型轴 = normal(常驻持有)〔锻造工坊批 V1〕", db.crossrun.forge.default_attack_type == "normal") + Kit.check("默认甲型轴 = light(常驻持有)〔锻造工坊批 V1〕", db.crossrun.forge.default_armor_type == "light") # 类型轴分族与并联双通道前置(4b-1 裁) Kit.check("长枪/法杖 ← 长剑练满(并联双通道①)〔锻造工坊批 V1〕", - String(db.forge_route("spear").get("prereq_route", "")) == "sword" - and String(db.forge_route("staff").get("prereq_route", "")) == "sword") + String(db.crossrun.forge.route("spear").get("prereq_route", "")) == "sword" + and String(db.crossrun.forge.route("staff").get("prereq_route", "")) == "sword") Kit.check("重甲/魔法护甲 ← 轻甲练满(并联双通道①)〔锻造工坊批 V1〕", - String(db.forge_route("heavy").get("prereq_route", "")) == "light" - and String(db.forge_route("ward").get("prereq_route", "")) == "light") + String(db.crossrun.forge.route("heavy").get("prereq_route", "")) == "light" + and String(db.crossrun.forge.route("ward").get("prereq_route", "")) == "light") Kit.check("四待解锁路线一次性解锁价 = 200〔锻造工坊批 V1〕", - int(db.forge_route("spear").get("unlock_price", 0)) == 200 - and int(db.forge_route("staff").get("unlock_price", 0)) == 200 - and int(db.forge_route("heavy").get("unlock_price", 0)) == 200 - and int(db.forge_route("ward").get("unlock_price", 0)) == 200) + int(db.crossrun.forge.route("spear").get("unlock_price", 0)) == 200 + and int(db.crossrun.forge.route("staff").get("unlock_price", 0)) == 200 + and int(db.crossrun.forge.route("heavy").get("unlock_price", 0)) == 200 + and int(db.crossrun.forge.route("ward").get("unlock_price", 0)) == 200) # 甲型基础 AC(provisional 现役结构值——settlement.tres 矩阵同行) Kit.check("甲型基础 AC = 轻 10 / 重 12 / 魔法护甲 9〔锻造工坊批 V1〕", - int(db.forge_route("light").get("base_ac", 0)) == 10 - and int(db.forge_route("heavy").get("base_ac", 0)) == 12 - and int(db.forge_route("ward").get("base_ac", 0)) == 9) + int(db.crossrun.forge.route("light").get("base_ac", 0)) == 10 + and int(db.crossrun.forge.route("heavy").get("base_ac", 0)) == 12 + and int(db.crossrun.forge.route("ward").get("base_ac", 0)) == 9) # 按类型轴反查(甲路线 id=甲型同值的显式化入口) Kit.check("forge_armor_route_by_type(ward) = 魔法护甲锻造〔锻造工坊批 V1〕", - String(db.forge_armor_route_by_type("ward").get("route_id", "")) == "ward") - Kit.check("forge_weapon_route_by_type(pierce) = 长枪锻造〔锻造工坊批 V1〕", - String(db.forge_weapon_route_by_type("pierce").get("route_id", "")) == "spear") + String(db.crossrun.forge.armor_route_by_type("ward").get("route_id", "")) == "ward") + var wpn_hit := {} + for r in db.crossrun.forge.route_rows(): + if String(r.get("kind", "")) == "weapon" and String(r.get("type_axis", "")) == "pierce": + wpn_hit = r + break + Kit.check("forge route_rows 按轴反查武器路线(pierce)=长枪锻造〔锻造工坊批 V1——访问器退役改直扫同判据〕", + String(wpn_hit.get("route_id", "")) == "spear") # 缺行/未知轴 = 空字典合法态 Kit.check("未知 route_id / 未知类型轴 = 空字典〔锻造工坊批 V1〕", - db.forge_route("blade").is_empty() and db.forge_armor_route_by_type("plate").is_empty()) + db.crossrun.forge.route("blade").is_empty() and db.crossrun.forge.armor_route_by_type("plate").is_empty()) # ---- 几何价格梯(V2 价格面单测半边——购买写在 T3 冒烟)---- func _test_price_ladder(db) -> void: - var row: Dictionary = db.forge_route("sword") + var row: Dictionary = db.crossrun.forge.route("sword") var prices := [] for owned in range(5): prices.append(ForgeTableScript.level_price(row, owned)) diff --git a/game/tests/unit/data_config/level_gen_params_test.gd b/game/tests/unit/data_config/level_gen_params_test.gd index dee8e2fa..28ac1aba 100644 --- a/game/tests/unit/data_config/level_gen_params_test.gd +++ b/game/tests/unit/data_config/level_gen_params_test.gd @@ -41,9 +41,9 @@ func _table(rows: Array) -> Variant: # ---- 生产三行正向(V2)---- func _test_production(db) -> void: - var r1: Dictionary = db.level_gen_params_row(1) - var r2: Dictionary = db.level_gen_params_row(2) - var r3: Dictionary = db.level_gen_params_row(3) + var r1: Dictionary = db.run.level_gen_params.row_by_level_index(1) + var r2: Dictionary = db.run.level_gen_params.row_by_level_index(2) + var r3: Dictionary = db.run.level_gen_params.row_by_level_index(3) Kit.check("访问器读回 L1 行五旋钮齐备〔fv2〕", int(r1.get("layers", 0)) == 6 and is_equal_approx(float(r1.get("branch_density", 0.0)), 0.3) and int(r1.get("exit_count", 0)) == 1 and int(r1.get("entry_count", 0)) == 1) @@ -52,7 +52,7 @@ func _test_production(db) -> void: Kit.check("三行出口 = 1/2/3、战斗保底 = 4/6/8 与 §8.1 表对上〔fv2〕", int(r1.get("battle_floor", 0)) == 4 and int(r2.get("exit_count", 0)) == 2 and int(r3.get("exit_count", 0)) == 3 and int(r3.get("battle_floor", 0)) == 8) - Kit.check("生产表 validate 零错误〔fv2〕", db.level_gen_params.validate().is_empty()) + Kit.check("生产表 validate 零错误〔fv2〕", db.run.level_gen_params.validate().is_empty()) # ---- 访问器缺行防御 ---- diff --git a/game/tests/unit/data_config/table_structure_guard_test.gd b/game/tests/unit/data_config/table_structure_guard_test.gd index a9f21456..182b5e8d 100644 --- a/game/tests/unit/data_config/table_structure_guard_test.gd +++ b/game/tests/unit/data_config/table_structure_guard_test.gd @@ -75,7 +75,7 @@ static func _errs_has_cross_field(errs: Array) -> bool: # 首遇带数据驱动定位(契约 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: @@ -89,31 +89,31 @@ func _first_encounter_band(level_index: int, enemy_id: String) -> int: # ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸;TP0 令牌池:配额结构/件值对/池总量域检) func _smoke_adventure_table() -> void: print("--- 冒险表(AdventureTable——令牌池行值单点:回血/配额/件值对/同调位)---") - Kit.check("db.adventure 已加载(adventure.tres 入 manifest)", _db.adventure != null) - if _db.adventure == null: + Kit.check("db.adventure 已加载(adventure.tres 入 manifest)", _db.run.adventure != null) + if _db.run.adventure == null: return - Kit.check("AdventureTable.validate() 零违例(全键域检+档位配额结构锚在案)", _db.adventure.validate().is_empty()) - Kit.check("clear_heal_pct ∈ [0,100](%d——关卡完成部分回血比例)" % int(_db.adventure.clear_heal_pct), - int(_db.adventure.clear_heal_pct) >= 0 and int(_db.adventure.clear_heal_pct) <= 100) - for tier in _db.adventure.TIERS: - Kit.check("档位配额结构锚(四级 70/25/7/0——迭代 31 卡② 净删两件投放):%s = %d 张" % [tier, int(_db.adventure.TIER_QUOTAS[tier])], - int(_db.adventure.tier_total(tier)) == int(_db.adventure.TIER_QUOTAS[tier])) - Kit.check("令牌池总量 = 102 张(四级 普通 70/优秀 25/稀有 7/传说 0——迭代 31 卡② 净删两件投放 104→102)〔凭证裁削批 V5〕", int(_db.adventure.deck_total()) == 102) + Kit.check("AdventureTable.validate() 零违例(全键域检+档位配额结构锚在案)", _db.run.adventure.validate().is_empty()) + Kit.check("clear_heal_pct ∈ [0,100](%d——关卡完成部分回血比例)" % int(_db.run.adventure.clear_heal_pct), + int(_db.run.adventure.clear_heal_pct) >= 0 and int(_db.run.adventure.clear_heal_pct) <= 100) + for tier in _db.run.adventure.TIERS: + Kit.check("档位配额结构锚(四级 70/25/7/0——迭代 31 卡② 净删两件投放):%s = %d 张" % [tier, int(_db.run.adventure.TIER_QUOTAS[tier])], + int(_db.run.adventure.tier_total(tier)) == int(_db.run.adventure.TIER_QUOTAS[tier])) + Kit.check("令牌池总量 = 102 张(四级 普通 70/优秀 25/稀有 7/传说 0——迭代 31 卡② 净删两件投放 104→102)〔凭证裁削批 V5〕", int(_db.run.adventure.deck_total()) == 102) Kit.check("数值对(四级换代承袭):小GP 20/大GP 50 · 小增伤 +1/大 +2 · 小生命 +5/大 +10 · 小护甲 +1/大 +2", - int(_db.adventure.curio_value("gp", "common")) == 20 and int(_db.adventure.curio_value("gp", "excellent")) == 50 - and int(_db.adventure.curio_value("attack", "common")) == 1 and int(_db.adventure.curio_value("attack", "excellent")) == 2 - and int(_db.adventure.curio_value("max_hp", "common")) == 5 and int(_db.adventure.curio_value("max_hp", "excellent")) == 10 - and int(_db.adventure.curio_value("armor", "common")) == 1 and int(_db.adventure.curio_value("armor", "excellent")) == 2) + int(_db.run.adventure.curio_value("gp", "common")) == 20 and int(_db.run.adventure.curio_value("gp", "excellent")) == 50 + and int(_db.run.adventure.curio_value("attack", "common")) == 1 and int(_db.run.adventure.curio_value("attack", "excellent")) == 2 + and int(_db.run.adventure.curio_value("max_hp", "common")) == 5 and int(_db.run.adventure.curio_value("max_hp", "excellent")) == 10 + and int(_db.run.adventure.curio_value("armor", "common")) == 1 and int(_db.run.adventure.curio_value("armor", "excellent")) == 2) Kit.check("恢复件 +6 当即回血(优秀档承袭)· AP +1(稀有档承袭)〔专长批 V14 换代〕", - int(_db.adventure.curio_value("heal", "excellent")) == 6 and int(_db.adventure.curio_value("ap", "rare")) == 1) + int(_db.run.adventure.curio_value("heal", "excellent")) == 6 and int(_db.run.adventure.curio_value("ap", "rare")) == 1) Kit.check("cap 占位在域(案②提前退役——99=实质无上限,实数归调参批)〔专长批 V8 形态面〕", - int(_db.adventure.curio_cap("gp")) == 99 and int(_db.adventure.curio_cap("attack")) == 99) + int(_db.run.adventure.curio_cap("gp")) == 99 and int(_db.run.adventure.curio_cap("attack")) == 99) Kit.check("同调位读数 null 档早退分支求值 0(迭代 21 T4 槽位收编——谓词表已退役)〔天赋树批 V14·核代码增补〕", Attunement.slots(_db, null) == 0) Kit.check("同调位读数源=树同调位节点点亮数(非空档——socket1 点亮 1 件计 1)〔天赋树批 V14·核代码增补〕", Attunement.slots(_db, _TreeSaveMock.new()) == 1) # 同调目录 12 件(V1/V2 断言落点;装配→战斗贯通与空装配基线的现行主语 = morning_order) - var trows: Array = _db.treasure_attunement_rows() + var trows: Array = _db.crossrun.treasure_items.attunement_rows() var tid_set := {} for r in trows: tid_set[String(r.get("item_id", ""))] = true @@ -160,11 +160,11 @@ func _smoke_adventure_table() -> void: alloc_ok and tier_map["common"] == 7 and tier_map["excellent"] == 3 and tier_map["rare"] == 2) var orch_l = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(191)) Kit.check("装配→战斗贯通:enc.attunement=[morning_order] 起战 attune_has=true(注入面+只读出口)〔秘宝批换主语〕", - orch_l.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "attunement": ["morning_order"]}) + orch_l.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp), "attunement": ["morning_order"]}) and orch_l.attune_has("morning_order")) var orch_e = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(192)) Kit.check("空装配基线:不带 attunement 起战 attune_has=false(off 态零扰动)〔秘宝批换主语〕", - orch_e.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) + orch_e.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)}) and not orch_e.attune_has("morning_order")) # V9 静态词表断言(棋盘事实不可变契约 3:12 行 triggers do.kind 与 effect_kinds # 全集零棋盘改写类词〔rewrite_board/remove_mines/cell_removal=改写族——现表零引用〕; @@ -223,14 +223,14 @@ func _smoke_enemy_bands() -> void: "3|boss|9|duergar_king", "3|boss|11|duergar_king", "3|boss|12|duergar_king", "3|boss|13|duergar_king", ] var got_keys: Array[String] = [] - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: got_keys.append("%d|%s|%d|%s" % [int(row.get("level_index", -1)), String(row.get("row_kind", "")), int(row.get("depth_band", -1)), String(row.get("enemy_id", ""))]) Kit.check("敌人表 28 行且键序 = 覆盖表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕", - _db.enemy.rows.size() == 28 and got_keys == expect_keys) + _db.combat.enemy.rows.size() == 28 and got_keys == expect_keys) # ② hobgoblin 缺口行留任随迁(契约 9:不删行不补立绘——缺口登记随行) var hob := {} - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: if String(row.get("enemy_id", "")) == "hobgoblin": hob = row Kit.check("hobgoblin 行在 (1,battle,3) 留任随迁(缺口行不删不补——补齐归敌种内容批)〔fv12〕〔fv25〕", @@ -242,7 +242,7 @@ func _smoke_enemy_bands() -> void: # 生成图多终点要求每终点深度解析到互异带键 ⇒ 每关 Boss 行必然多于一行—— # 迭代 35 覆盖批随动改写为带键清单,「带键互异」的运行时把关在 level_shop_economy_test〔v11〕) var boss_bands := {} - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: if String(row.get("row_kind", "")) == "boss": var lk: int = int(row.get("level_index", -1)) if not boss_bands.has(lk): @@ -269,47 +269,47 @@ func _smoke_enemy_bands() -> void: func _smoke_ac_swap_guard() -> void: print("--- 护甲等级掷点批·换装护栏 ---") # ① 三处无双轨 - Kit.check("状态表 defense_up 行已退役(无双轨)", _db.status_effect_row("defense_up").is_empty()) - var ac_row: Dictionary = _db.status_effect_row("ac_up") + Kit.check("状态表 defense_up 行已退役(无双轨)", _db.combat.status_effects.row_by_id("defense_up").is_empty()) + var ac_row: Dictionary = _db.combat.status_effects.row_by_id("ac_up") Kit.check("ac_up 行在位(hero · +1 ac · 只续时不叠层——2026-09-20 主角增益续时批)", not ac_row.is_empty() and String(ac_row.get("target_side", "")) == "hero" \ and String(ac_row.get("unit", "")) == "ac" and int(ac_row.get("magnitude", 0)) == 1 \ and String(ac_row.get("stack_policy", "")) == "refresh") var ac_reward := {} - for row in _db.reward.rows: + for row in _db.run.reward.rows: if String(row.get("cell_type", "")) == "defense" and String(row.get("polarity", "")) == "positive": ac_reward = row Kit.check("奖励表 defense 正向行 effect_id = ac_up(显示名盾牌加护,权重/tier 不变)", String(ac_reward.get("effect_id", "")) == "ac_up" and String(ac_reward.get("display_name", "")) == "盾牌加护") Kit.check("effect_pool_map 无 defense_up/ac_up 键(减伤不入池——AC 不经乘区)", - not _db.settlement.core.effect_pool_map.has("defense_up") and not _db.settlement.core.effect_pool_map.has("ac_up")) + not _db.combat.settlement.core.effect_pool_map.has("defense_up") and not _db.combat.settlement.core.effect_pool_map.has("ac_up")) # ② 免伤墙归零护栏(泛化——BUG-0003 免疫) var wall_ok := true var wall_detail := "" - 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 row.is_empty() or String(row.get("unit", "")) != "percent": continue var floor_v: int = int(row.get("magnitude", 0)) * int(row.get("max_stack", 1)) - if floor_v <= int(_db.settlement.core.pool_clamp_lo): + if floor_v <= int(_db.combat.settlement.core.pool_clamp_lo): wall_ok = false wall_detail += " %s(%d)" % [eff_id, floor_v] Kit.check("hero_defense 池 percent 行叠满不可触池底(墙最深%s > clamp_lo %d——单效果归零结构性不可达)" % [ - wall_detail if wall_detail != "" else "(池内现无负向行)", int(_db.settlement.core.pool_clamp_lo)], wall_ok) + wall_detail if wall_detail != "" else "(池内现无负向行)", int(_db.combat.settlement.core.pool_clamp_lo)], wall_ok) var hd_maps := 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": hd_maps += 1 Kit.check("hero_defense 池映射数==0(绊线钉——红=已加映射,去核上方护栏是否已同步激活)", hd_maps == 0) # ③ 单位表 armor_class:域内校验(配置推导——正式投值归怪物/难度批,改表不断锚) - Kit.check("主角 armor_class 在域内(%d——暂定基准随表走)" % int(_db.hero.armor_class), - int(_db.hero.armor_class) >= 0 and int(_db.hero.armor_class) <= 30) + Kit.check("主角 armor_class 在域内(%d——暂定基准随表走)" % int(_db.combat.hero.armor_class), + int(_db.combat.hero.armor_class) >= 0 and int(_db.combat.hero.armor_class) <= 30) var ac_band := true var ac_detail := "" var heavy_names := ["熊地精重击手", "灰矮人重装手"] # 逐行手设值 8/9,非精英编码位 - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: var ac: int = int(row.get("armor_class", -1)) var dname := String(row.get("display_name", "?")) var boss_row: bool = String(row.get("enemy_id", "")).ends_with("_chief") or \ @@ -325,7 +325,7 @@ func _smoke_ac_swap_guard() -> void: ac_detail if ac_detail != "" else "无"], ac_band) # ③b Boss 台阶断言(关卡即台阶——L1 8 < L2 10 < L3 12)+ 首遇位教学位低 AC(≤3) var boss_ac := {} - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: var eid := String(row.get("enemy_id", "")) if eid == "ogre_chief" or eid == "drider" or eid == "duergar_king": boss_ac[int(row.get("level_index", 0))] = int(row.get("armor_class", 0)) @@ -337,7 +337,7 @@ func _smoke_ac_swap_guard() -> void: # 曲线护栏 = 深度带升序相邻带「hp × 中位数量」总量比 ≤ 2.5(12/10/22/35/72 现行全绿;不用单调不回撤——会红在首带→次带) var l12_band := _first_encounter_band(1, "bugbear") var l12_row: Dictionary = {} - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: if int(row.get("level_index", -1)) == 1 and String(row.get("row_kind", "")) == "battle" \ and int(row.get("depth_band", -1)) == l12_band and String(row.get("enemy_id", "")) == "bugbear": l12_row = row @@ -350,7 +350,7 @@ func _smoke_ac_swap_guard() -> void: and int(l12_row.get("armor_class", -1)) == 2 \ and l12_pool == {"attack": 6, "charge": 2, "heavy": 2}) var l1_totals := {} - for row in _db.enemy.rows: + for row in _db.combat.enemy.rows: if int(row.get("level_index", -1)) != 1: continue var cnt_lo: int = int(row.get("unit_count", 1)) @@ -370,12 +370,12 @@ func _smoke_ac_swap_guard() -> void: Kit.check("第一关相邻深度带总量比 ≤ 2.5(12/10/22/35/72——迭代 24 N1 曲线护栏,带键升序;越带%s)" % [ curve_detail if curve_detail != "" else "无"], curve_ok) # ④ 掷点参数域(加载校验已过——此处钉形状语义) - 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("掷点参数拍板值在位(d20 / nat20 暴击 ×200%% / 下限 1 / attack_bonus 0 / armor_roll 流)", - int(_db.settlement.damage_roll.roll_faces) == 20 and int(_db.settlement.damage_roll.crit_roll) == 20 \ - and int(_db.settlement.damage_roll.crit_multiplier) == 200 and int(_db.settlement.damage_roll.damage_floor) == 1 \ - and int(_db.settlement.damage_roll.attack_bonus) == 0 and String(_db.settlement.damage_roll.armor_roll_stream) == "armor_roll") + int(_db.combat.settlement.damage_roll.roll_faces) == 20 and int(_db.combat.settlement.damage_roll.crit_roll) == 20 \ + and int(_db.combat.settlement.damage_roll.crit_multiplier) == 200 and int(_db.combat.settlement.damage_roll.damage_floor) == 1 \ + and int(_db.combat.settlement.damage_roll.attack_bonus) == 0 and String(_db.combat.settlement.damage_roll.armor_roll_stream) == "armor_roll") Kit.check("档位表拍板值在位(缺口 1-3 → ×0.75 · 4-7 → ×0.5 · 8+ → ×0.25——无零档)", bounds == [3, 7] and discounts == [75, 50, 25]) @@ -385,24 +385,24 @@ func _smoke_ac_swap_guard() -> void: func _smoke_variation_framework() -> void: print("--- 地形变异 框架面(首批四类型 + 候选池门控 + 跨字段互锁)---") Kit.check("变异清单 6 行(荆棘丛/藤蔓缠绕/岩浆/烬覆 + 落石/矿尘——首批类型定稿批 + 第三关内容批;贫矿脉 2026-10-16 用户令屏蔽删行)", - _db.variation.rows.size() == 6) + _db.combat.variation.rows.size() == 6) Kit.check("变异表校验零错误(6 行全过域与跨字段互锁——含 scope=global 三互锁负样本)", - _db.variation.validate().is_empty()) + _db.combat.variation.validate().is_empty()) var pool1_ids: Array = [] - for r in _db.variant_pool(1, "dungeon"): + for r in _db.combat.variation.variant_pool(1, "dungeon"): pool1_ids.append(String(r.variant_id)) Kit.check("L1 dungeon 候选池 = 荆棘丛+藤蔓缠绕(unlock + board_types 双闸)", pool1_ids == ["thorn", "vine"]) var pool2_ids: Array = [] - for r in _db.variant_pool(2, "underdark"): + for r in _db.combat.variation.variant_pool(2, "underdark"): pool2_ids.append(String(r.variant_id)) Kit.check("L2 underdark 候选池 = 岩浆+烬覆(第二关解锁)", pool2_ids == ["magma", "ash"]) - Kit.check("unlock 门控:L1 不进 underdark 行(岩浆/烬覆第二关解锁)", _db.variant_pool(1, "underdark").is_empty()) + Kit.check("unlock 门控:L1 不进 underdark 行(岩浆/烬覆第二关解锁)", _db.combat.variation.variant_pool(1, "underdark").is_empty()) var pool2d_ids: Array = [] - for r in _db.variant_pool(2, "dungeon"): + for r in _db.combat.variation.variant_pool(2, "dungeon"): pool2d_ids.append(String(r.variant_id)) Kit.check("board_types 门控:L2 dungeon 板池 = 荆棘丛+藤蔓缠绕(解锁累积;岩浆/烬覆被 board_types ∅ 交集排除)", pool2d_ids == ["thorn", "vine"]) - Kit.check("清单外 variant_id 返回空(读取通道在位)", _db.variation.row_by_id("nonexistent").is_empty()) + Kit.check("清单外 variant_id 返回空(读取通道在位)", _db.combat.variation.row_by_id("nonexistent").is_empty()) # 跨字段互锁负样本(加载期致命——枚举语义一致性闸) var vt = load("res://data/schemas/variation_table.gd").new() vt.rows.assign([{"variant_id": "bad1", "display_name": "坏行甲", "unlock_level": 1, "scope": "region", @@ -419,30 +419,30 @@ func _smoke_variation_framework() -> void: func _smoke_difficulty_table() -> void: print("--- 难度曲线与Boss·DifficultyTable 结构位 ---") - Kit.check("DifficultyTable MVP 空表加载(结构位)", _db.difficulty != null and _db.difficulty.rows.is_empty()) + Kit.check("DifficultyTable MVP 空表加载(结构位)", _db.run.difficulty != null and _db.run.difficulty.rows.is_empty()) # ① 空表零行为:敌人系统.spawn 敌值 = EnemyTable 行值原样 var enemy_system = load("res://core/unit_container.gd").new(_db) - Kit.check("spawn 受理", enemy_system.spawn(1, 0, int(_db.hero.max_hp))) + Kit.check("spawn 受理", enemy_system.spawn({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)})) var unit = enemy_system.all_enemies()[0] - var d0: Dictionary = _db.resolve_enemy_rowset(1, 0, "battle")[0] # 行集首行(带键寻址:战斗节点查 battle 键域) + var d0: Dictionary = _db.combat.enemy.resolve_rowset(1, 0, "battle")[0] # 行集首行(带键寻址:战斗节点查 battle 键域) Kit.check("空表零修饰(hp=%d atk=%d = 行值 %d/%d)" % [int(unit.hp), int(unit.atk), int(d0.hp), int(d0.atk)], int(unit.hp) == int(d0.hp) and int(unit.atk) == int(d0.atk)) # ② 修饰行运行时注入(debug 通道——R3 override 同族):套乘生效于 hp/atk - _db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 1.5, "atk_mult": 2.0}) + _db.run.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 1.5, "atk_mult": 2.0}) var enemy_system_b = load("res://core/unit_container.gd").new(_db) - Kit.check("修饰后 spawn 受理", enemy_system_b.spawn(1, 0, int(_db.hero.max_hp))) + Kit.check("修饰后 spawn 受理", enemy_system_b.spawn({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)})) var g2 = enemy_system_b.all_enemies()[0] Kit.check("hp 修饰 ×1.5(%d→%d,max_hp 同步)" % [int(d0.hp), int(g2.hp)], int(g2.hp) == int(round(float(d0.hp) * 1.5)) and int(g2.max_hp) == int(g2.hp)) Kit.check("atk 修饰 ×2.0(%d→%d)" % [int(d0.atk), int(g2.atk)], int(g2.atk) == int(d0.atk) * 2) # ③ 越域 clamp:乘数 50 → clamp 10 - _db.difficulty.rows.clear() - _db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 50.0, "atk_mult": 1.0}) + _db.run.difficulty.rows.clear() + _db.run.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 50.0, "atk_mult": 1.0}) var enemy_system_c = load("res://core/unit_container.gd").new(_db) - Kit.check("越域修饰 spawn 受理", enemy_system_c.spawn(1, 0, int(_db.hero.max_hp))) + Kit.check("越域修饰 spawn 受理", enemy_system_c.spawn({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.combat.hero.max_hp)})) Kit.check("越域乘数 clamp 至 10(%d→%d)" % [int(d0.hp), int(enemy_system_c.all_enemies()[0].hp)], int(enemy_system_c.all_enemies()[0].hp) == int(d0.hp) * 10) - _db.difficulty.rows.clear() # 还原空表(后续冒烟/关卡断言基线) + _db.run.difficulty.rows.clear() # 还原空表(后续冒烟/关卡断言基线) # ---- ⑤ 格面资源九型 + gp_drop 流(黄金向量)---- @@ -455,7 +455,7 @@ func _smoke_nine_types_gp_stream() -> void: var pos_weight := 0 var gp_pos := -1 var gp_neg := -1 - for row in _db.reward.rows: + for row in _db.run.reward.rows: if String(row.get("pool_tag", "")) == "cell": cell_rows += 1 if String(row.get("polarity", "")) == "positive": @@ -468,7 +468,7 @@ func _smoke_nine_types_gp_stream() -> void: cell_rows == 18) # 危险格负向直伤当量(负向≈正向 2 倍定价——真人反馈「三雷 3/4 血无后果感」的投值口径) var mine_power := {"heal_backlash": 6, "pierce_snare": 8, "magic_recoil": 8} # 危险格负向直伤当量(抬档值;依据见 numfix 报告) - for row in _db.reward.rows: + for row in _db.run.reward.rows: var rid := String(row.get("reward_id", "")) if mine_power.has(rid): Kit.check("危险格负向直伤 %s power=%d(卡⑧投值)" % [rid, int(mine_power[rid])], @@ -477,7 +477,7 @@ func _smoke_nine_types_gp_stream() -> void: # 合计即配置自身的和(改表自动跟随,不再硬编码合计值)。 var pos_all_in_pool := true var neg_all_zero := true - for row in _db.reward.rows: + for row in _db.run.reward.rows: if String(row.get("pool_tag", "")) != "cell": continue if String(row.get("polarity", "")) == "positive" and int(row.get("weight", 0)) < 1: @@ -498,7 +498,7 @@ func _smoke_nine_types_gp_stream() -> void: # conversion 覆盖恰三,Boss 两档 first ≥ repeat)---- func _smoke_meta() -> void: print("--- 元进度·MetaTable 表结构 ---") - var meta_tbl = _db.meta + var meta_tbl = _db.crossrun.meta Kit.check("MetaTable 非空 + validate 零错(三行族词表封闭/唯一/连续/域界/first≥repeat/ratio 全过)", meta_tbl != null and meta_tbl.validate().is_empty()) Kit.check("MetaTable 行族规模 3/3/2(Boss/换算恰三/Y 升级两档——forge_products 迭代 21 退役、workshop_items 迭代 22 退役)", @@ -506,11 +506,11 @@ func _smoke_meta() -> void: and meta_tbl.conversion.size() == 3 and meta_tbl.attune_refresh_upgrade.size() == 2) var attrs_ok := true for a in ["max_hp", "atk", "armor_class"]: - attrs_ok = attrs_ok and not _db.meta_conversion_row(a).is_empty() + attrs_ok = attrs_ok and not _conversion_row_of(_db.crossrun.meta.conversion, a).is_empty() Kit.check("conversion attr 词表封闭恰全(三属性轴——meta_* 访问器可达)", attrs_ok) var boss_ok := true for li in [1, 2, 3]: - var brow: Dictionary = _db.meta_boss_keystone_row(li) + var brow: Dictionary = _db.crossrun.meta.boss_keystone_row(li) boss_ok = boss_ok and not brow.is_empty() and int(brow.get("first_kill", 0)) >= int(brow.get("repeat_kill", -1)) Kit.check("boss_keystone L1–L3 行连续可达且 first_kill ≥ repeat_kill 全行(首杀全量/重复小额)", boss_ok) @@ -519,3 +519,10 @@ class _TreeSaveMock: # 核代码增补:最小 save 形态——attunement.slots 只读 tree_level("att_socketN") func tree_level(n: String) -> int: return 1 if n == "att_socket1" else 0 + + +func _conversion_row_of(rows: Array, attr: String) -> Dictionary: + for r in rows: + if String(r.get("attr", "")) == attr: + return r + return {} diff --git a/game/tests/unit/presentation/battle_narrator_test.gd b/game/tests/unit/presentation/battle_narrator_test.gd index e58a8085..13362dd7 100644 --- a/game/tests/unit/presentation/battle_narrator_test.gd +++ b/game/tests/unit/presentation/battle_narrator_test.gd @@ -64,7 +64,7 @@ func _test_boss_name_uses_boss_rowset() -> void: if not loaded: return var boss_row := {} - for row in db.enemy.rows: + for row in db.combat.enemy.rows: if int(row.level_index) == 1 and String(row.get("row_kind", "")) == "boss": boss_row = row break @@ -72,8 +72,8 @@ func _test_boss_name_uses_boss_rowset() -> void: if boss_row.is_empty(): return var boss_depth: int = int(boss_row.depth_band) - var boss_rows: Array = db.resolve_enemy_rowset(1, boss_depth, "boss") - var battle_rows: Array = db.resolve_enemy_rowset(1, boss_depth, "battle") + var boss_rows: Array = db.combat.enemy.resolve_rowset(1, boss_depth, "boss") + var battle_rows: Array = db.combat.enemy.resolve_rowset(1, boss_depth, "battle") var boss_expect := String(boss_rows[0].get("display_name", "")) var battle_name := String(battle_rows[0].get("display_name", "")) var runner := _BossRunner.new() diff --git a/game/tests/unit/presentation/forge_workbench_test.gd b/game/tests/unit/presentation/forge_workbench_test.gd index a41256dd..df860ece 100644 --- a/game/tests/unit/presentation/forge_workbench_test.gd +++ b/game/tests/unit/presentation/forge_workbench_test.gd @@ -83,7 +83,7 @@ func _run() -> void: rows.sword.buy_btn.disabled and rows.spear.unlock_btn.disabled) # ⑥ 满级行:无购买钮、状态=已满级(直置存档域构造——两分法调试口径) - sv.set_forge_route_level("sword", 5) + sv.meta_state.set_forge_route_level("sword", 5) ov.refresh_camp({"balance": 500, "forge": mp.forge_shop_status(), "tree": [{"node_id": "forge_workshop", "level": 1}]}) rows = _row_by_id(ov) diff --git a/game/tests/unit/presentation/level_map_info_fog_test.gd b/game/tests/unit/presentation/level_map_info_fog_test.gd index c0cce8bf..f3b2d0fe 100644 --- a/game/tests/unit/presentation/level_map_info_fog_test.gd +++ b/game/tests/unit/presentation/level_map_info_fog_test.gd @@ -35,7 +35,7 @@ func _runner(db, nodes: Array, candidates: Array, n: int = 2): var t := LevelTableScript.new() t.info_visible_layers = n t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}] - db.level = t + db.run.level = t var r = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new()) r.level_row.assign({"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}) r.node_index = -1 diff --git a/game/tests/unit/presentation/pacing_queue_test.gd b/game/tests/unit/presentation/pacing_queue_test.gd index 0b4fbdff..de6afc4a 100644 --- a/game/tests/unit/presentation/pacing_queue_test.gd +++ b/game/tests/unit/presentation/pacing_queue_test.gd @@ -51,8 +51,8 @@ func _init() -> void: if FileAccess.file_exists(p): DirAccess.remove_absolute(p) var sm = main.get_node("/root/SaveManager") - sm.attach_path("user://test_pacing_queue_save.json") - sm.mark_first_adventure_done() + sm.slots.attach_path("user://test_pacing_queue_save.json") + sm.meta_state.mark_first_adventure_done() # 行信号计数器(真实信号路径旁挂计数——零触碰既有接线) var rows_seen: Array = [] main._start_adventure_from_menu() diff --git a/game/tests/unit/presentation/player_copy_sweep_test.gd b/game/tests/unit/presentation/player_copy_sweep_test.gd index 9013be8b..04e389b9 100644 --- a/game/tests/unit/presentation/player_copy_sweep_test.gd +++ b/game/tests/unit/presentation/player_copy_sweep_test.gd @@ -94,7 +94,7 @@ func _sweep_source_names() -> void: row_view._tip_label.text.contains("来源:危险格")) row_view._hide_tip() Kit.check("结算表 source_label_map[mine_hit]==「危险格」(u6 换代——ConfigDB 真装载)〔wa7〕", - String(_db.settlement.core.source_label_map.get("mine_hit", "")) == "危险格") + String(_db.combat.settlement.core.source_label_map.get("mine_hit", "")) == "危险格") var log_view = load("res://presentation/views/battle_log_view.gd").new(_db) var row_txt := String(log_view._format_row({"type": "effect", "source_class": "mine_hit", "display_name": "挖掘反噬", "value": -5, "pool_value": -5, "round_index": 1})) diff --git a/game/tests/unit/presentation/terrain_tile_sets_test.gd b/game/tests/unit/presentation/terrain_tile_sets_test.gd index 94599bc5..a7f295e4 100644 --- a/game/tests/unit/presentation/terrain_tile_sets_test.gd +++ b/game/tests/unit/presentation/terrain_tile_sets_test.gd @@ -27,7 +27,7 @@ func _init() -> void: return # board_type 词表自 board_gen 行集推导(禁测试侧写死词表) var types: Array = [] - for row in db.board_gen.rows: + for row in db.combat.board_gen.rows: var bt := String(row.get("board_type", "")) if bt != "" and not types.has(bt): types.append(bt) diff --git a/game/tests/unit/presentation/theme_projection_test.gd b/game/tests/unit/presentation/theme_projection_test.gd index f7b37176..b17fd50a 100644 --- a/game/tests/unit/presentation/theme_projection_test.gd +++ b/game/tests/unit/presentation/theme_projection_test.gd @@ -624,7 +624,7 @@ func _smoke_texture_filter() -> void: Kit.check("V3 白名单在位:暗角 vignette 显式 LINEAR", vg.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR) vg.free() - var bm = uikit_s.beams_rect() + var bm = uikit_s._beams_rect() Kit.check("V3 白名单在位:光柱 beams 显式 LINEAR", bm.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR) # T4 光柱连续 alpha 结构断言(观感归目检):带心峰值 0.05/带缘连续过渡/纵向衰减上亮下暗 @@ -649,7 +649,7 @@ func _smoke_texture_filter() -> void: Kit.check("T4 光柱纵向衰减生效(上半亮度 %.0f > 下半 %.0f 的 2 倍——上亮下暗)" % [top_sum, bot_sum], top_sum > bot_sum * 2.0) bm.free() - var gb = uikit_s.gradient_base(Color.BLACK, Color.WHITE) + var gb = uikit_s._gradient_base(Color.BLACK, Color.WHITE) Kit.check("V3 白名单在位:地图渐变 gradient_base 显式 LINEAR", gb.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR) gb.free() @@ -819,21 +819,21 @@ func _career_overlay_projection() -> void: var mp = load("res://core/meta_progression.gd").new(_db, sv) # 已玩态夹具:两局收束(一通一止)+ 钥石非零——投影可辨非全零白板 # (生涯页不统计天赋树与账号成长态,只统计终身计数) - sv.add_keystone(45) - sv.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 3, + sv.meta_state._add_keystone(45) + sv.meta_state.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 3, "stopped_at": 0, "keystone_total": 0, "master_seed": 21}) - sv.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1, + sv.meta_state.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1, "stopped_at": 2, "keystone_total": 0, "master_seed": 22}) sv.write_to_disk() # 投影拼装(HudPresenter.career_data 同构)+ 视图喂入 - var data := {"total": sv.adventure_total(), "cleared": sv.adventure_cleared(), - "clears": [], "boss_first_kills": 0, "boss_first_total": _db.level_count(), - "boss_kills": sv.boss_kill_count(), "keystone": mp.keystone_balance()} - for i in _db.level_count(): + var data := {"total": sv.meta_state.career_state().adventure_total, "cleared": sv.meta_state.career_state().adventure_cleared, + "clears": [], "boss_first_kills": 0, "boss_first_total": _db.run.level.level_count(), + "boss_kills": sv.meta_state.career_state().boss_kill_count, "keystone": mp.meta_state.keystone_balance()} + for i in _db.run.level.level_count(): var li: int = i + 1 - data["clears"].append({"display_name": String(_db.level_row(li).get("display_name", "")), - "count": sv.clear_count(li)}) - if sv.boss_first_kill_marked(li): + data["clears"].append({"display_name": String(_db.run.level.row_by_level_index(li).get("display_name", "")), + "count": sv.meta_state.career_state().clear_counts.get(str(li), 0)}) + if sv.meta_state.career_state().boss_first_kill.has(str(li)): data["boss_first_kills"] += 1 var co = load("res://presentation/views/career_overlay.gd").new() co.open_career(data) @@ -850,7 +850,7 @@ func _career_overlay_projection() -> void: Kit.check("Boss 首杀/总杀投影 = 首杀标记计数 + boss_kill_count()(%s / %s)" % [co._boss_first_label.text, co._boss_kills_label.text], String(co._boss_first_label.text) == "3/3 关" and String(co._boss_kills_label.text) == "4 次") Kit.check("钥石余额徽投影 = keystone_balance() 读数(%s)" % co._keystone_badge.value_label.text, - String(co._keystone_badge.value_label.text) == "钥石 %d" % mp.keystone_balance()) + String(co._keystone_badge.value_label.text) == "钥石 %d" % mp.meta_state.keystone_balance()) # 口径换代(2026-10-26):行标签换代钉子+生涯页零「通关」残留(负断言兜全页) var career_label_texts: Array[String] = [] for career_lbl: Label in _all_labels(co): @@ -869,14 +869,14 @@ func _career_overlay_projection() -> void: fv5c.store_string(JSON.stringify({"version": 5, "meta": {"keystone_balance": 3}})) fv5c.close() Kit.check("v5 现役档升 v6 载入成功(计数域缺省补零)", - v5.load_from_disk() and v5.adventure_total() == 0 and v5.adventure_cleared() == 0) + v5.load_from_disk() and v5.meta_state.career_state().adventure_total == 0 and v5.meta_state.career_state().adventure_cleared == 0) var mp_v5 = load("res://core/meta_progression.gd").new(_db, v5) - var data_v5 := {"total": v5.adventure_total(), "cleared": v5.adventure_cleared(), - "clears": [], "boss_first_kills": 0, "boss_first_total": _db.level_count(), - "boss_kills": v5.boss_kill_count(), "keystone": mp_v5.keystone_balance()} - for i in _db.level_count(): - data_v5["clears"].append({"display_name": String(_db.level_row(i + 1).get("display_name", "")), - "count": v5.clear_count(i + 1)}) + var data_v5 := {"total": v5.meta_state.career_state().adventure_total, "cleared": v5.meta_state.career_state().adventure_cleared, + "clears": [], "boss_first_kills": 0, "boss_first_total": _db.run.level.level_count(), + "boss_kills": v5.meta_state.career_state().boss_kill_count, "keystone": mp_v5.meta_state.keystone_balance()} + for i in _db.run.level.level_count(): + data_v5["clears"].append({"display_name": String(_db.run.level.row_by_level_index(i + 1).get("display_name", "")), + "count": v5.meta_state.career_state().clear_counts.get(str(i + 1), 0)}) var co_v5 = load("res://presentation/views/career_overlay.gd").new() co_v5.open_career(data_v5) Kit.check("旧档载入后生涯页正常显示(v5 升 v6:总局数/冒险成功局数显示 0,既有钥石余额原样呈现)", diff --git a/reports/iter41-反哺点-2026-10-28.md b/reports/iter41-反哺点-2026-10-28.md new file mode 100644 index 00000000..85f8c3b9 --- /dev/null +++ b/reports/iter41-反哺点-2026-10-28.md @@ -0,0 +1,33 @@ +# 迭代 41 反哺点清单——两卡(核心数据面结构收敛批+呈现层结构收敛批) + +> 每条=哪个文件哪一节、改成什么、依据。落文归文档批(doc-design),本清单只是申请单。 + +## A. architecture.md(v1.22)——定稿注记的现状改写(卡① §6 ③) + +依据:卡① 全清裁决四项已实现,v1.19 定稿注记的「实现批后」表述全部过期;现行行号按 v1.22 实查(2026-10-28)。 + +1. **:88 ConfigDB 行**——「分组建档于实现卡,消费方改址随该批」改现状:三容器已建(`config_domain_combat/run/crossrun.gd`,类型化成员 7/7/5),公开面收敛至 8(push_diag/is_fatal/reset_diagnostics/diagnostics/diagnostics_count/load_tables/set_reload_gate/reload),类变量 9;resolver 经 load_tables 装配段注入三表(board_gen.resolve_row/enemy.resolve_rowset/difficulty.resolve_modifiers),resolver 文件与键零搬移。红线句「唯一加载与持有单点……经其持有的表对象」原样保留。 +2. **:89 SlotStore 行**——「实现批建件」改现状:已建件,槽位族 10 公开/类变量 4;save_manager 组装持有,消费方经 `save.slots.` 直连;_rename_overwrite 留 save_manager(IO 原语单点)。 +3. **:90 MetaStateStore 行**——「实现批建件」改现状:已建件,写口原语 13+只读投影 6(tree_state/forge_state/attune_state/career_state 四投影+keystone_balance/item_unlocked)=19 公开,add_keystone 私有 _add_keystone;零 _data 副本零缓存;消费方经 `save.meta_state.` 直连。 +4. **:126 meta_progression 行**——「随件退役」改现状:公开面 20 定稿(buy_treasure_unlock/refresh_treasure_unlocks/treasure_shop_status/forge_buy_level/forge_buy_unlock/forge_select/hero_type_axes/forge_shop_status/settle_adventure/hero_override/tree_can_buy/tree_buy/tree_toggle_fork/tree_buy_fork/tree_rule_keys/tree_camp_status/toggle_attunement/attune_camp_status/attune_refresh_charges/buy_attune_refresh);纯转发读已退役(消费方直连 meta_state);hero_override 内联锻造加值与树淬炼 flat;并组三组(forge_select(kind,route_id)/hero_type_axes()→{attack,armor}/tree_camp_status()→{rows,fork_groups});补 meta_state 成员装配。 +5. **:104 minesweeper_rules 行**——消费依赖列「A3 try_spend / last_round_end / 相位」补形态注记:last_round_end 已由属性改方法(迭代 41 K5,消费方 minesweeper_rules :495 一处改方法调用)。 +6. **其余在册行零改动注记**(卡① §6 原列 :90/:92/:93/:103/:104 为 v1.19 行号,v1.22 现行=:92 action_economy/:93 variation/:94 status_container/:103 board_generator/:105 unit_container/:106 reward_economy):公开 API 语义零变化,仅内部拆分/束化/私有化——注记「迭代 41 零改动」即可,不逐行改写。 + +## B. GDD 行号随动(卡① §6 ①②,陈述本体零变化) + +7. **design/gdd/systems/战斗循环/地图战斗资源.md :83**——代码引用行行号换代:`_rollable_rows()` 「第 207-213 行」→「第 325 行起」(reward_economy 拆分后漂移);「循环第 65-67 行」→「第 59-61 行」(roll_board 身份批循环现行位置;规范化段已提 `_normalize_eligible`)。同池同权重一视同仁的陈述本体不变。 +8. **design/gdd/systems/冒险循环/冒险结构.md 调参表**——落点列改指段路径(K4′ 拆段后标量槽随段件嵌套,单 .tres 保持): + - :268「adventure_table curio_value_*」→「adventure_table value_segment.curio_value_*」; + - :270「adventure_table token_* 配额列族」→「adventure_table quota_segment.curio_quota_*」; + - :271 clear_heal_pct 不动(knob 留置宿主,:267 同理不动)。 +9. **同表 :267 疑似先行漂移发现(非本批造成,归文档批核实裁决)**:落点列「adventure_table bonus_attack_flat / bonus_max_hp_flat / bonus_armor_flat」——全库 grep 零命中(.gd 与 .tres 均无),疑指已退役槽名;现行承载疑为 value_segment.curio_value_{attack,max_hp,armor}_{common,excellent} 六槽。本批未改该行(不属两卡清单),提请文档批核现行值源后换代。 + +## C. 卡② 同步面核对记录(architecture 预期零修订) + +10. **architecture.md 零改动核对**:两豁免段在位(:183 组合根句柄面豁免、:311 测量面豁免);模块归属表零行变化(零新件、ui_kit 无独立行、flow context/状态件均为宿主内形态);组合根句柄面条款不受影响。**落文动作=无,仅登记核对结论。** +11. **待办清单状态核对注记(卡① §6 ④)**:来源条目「gdstyle 存量债全量清偿——216 键按病根分波整改」两卡对应键账已清(卡① 85/85+卡② 67 清 2 豁免 2 改善,见实现报告 §2);条目状态推进与残余键(三宿主 12 键归迭代 39 主卡)的去向,归主会话/batch-closure 统一回写,本批未触碰该清单。 + +## D. game/ 内文档(批内机械同步,非反哺) + +12. game/tests/验证集清单.md——已机器重生成对账(68 源文件 1587 调用点;断言数与 master 侧一致,地址形态随改址换代),随批入库,无落文义务。 +13. game/tests/golden/README.md——未动(乙组建锚与甲组判读属测试动作,随特许移交;让步声明见实现报告 §八.2)。 diff --git a/reports/iter41-实现报告-2026-10-28.md b/reports/iter41-实现报告-2026-10-28.md new file mode 100644 index 00000000..6c5eb276 --- /dev/null +++ b/reports/iter41-实现报告-2026-10-28.md @@ -0,0 +1,91 @@ +# 迭代 41 实现报告——gdstyle 存量债清偿两卡(核心数据面 85 键+呈现层 71 键) + +- 批次:迭代 41(卡① 需求卡片-核心数据面结构收敛批-2026-10-27 v1.2;卡② 需求卡片-呈现层结构收敛批-2026-10-27 v1.2) +- 工作区:worktree `D:\code\g-plan2-iter41`(分支 iter41,基 7f49a8c2);卡① 提交 c56b816f→070a5340 九笔,卡② 提交 98ac923f→9bef58f9 十笔(含 T2a 修与 u14 补做),并 master 38c9a0eb +- **测试执行声明(2026-10-28 用户特许)**:「不用做任何测试……测试相关动作全部交给我来运行」——本批全部套件停跑、闸停跑(gdstyle 结构闸除外,为开发期读数反馈在跑);验收断言、golden 对拍、撤销证明复现、独立验收审计全部移交用户执行,清单见 §7。因此本报告的「验收」栏记的是**移交状态**,不是已验证状态。 + +## 一、各卡状态 + +| 卡 | 状态 | 读数 | +|---|---|---| +| 卡① 核心数据面结构收敛批 | T0–T9 完成(T10=本报告+反哺点清单) | 在册 85/85 键全消失;8 新件键账逐件在阈(§3);契约 1 行为零变化六款按拆分逐字执行(提交信息逐笔留痕) | +| 卡② 呈现层结构收敛批 | T0–T5 完成(u1–u25 全落地;u14 补做) | 71 键=67 清+2 豁免在册+2 在案改善未消(§2);u23/u25 豁免申报见 §4 | + +偏差两笔(都在 §6 立案):①卡② u14 在 T2b(525529ad)提交信息有记、实际漏改——已补做(9bef58f9);②卡①完成定义写「下降 86 键」,波 0 刷新基线中卡①范围在册实为 85 键(config_db_cross_validator `validate_skill_pool_links 局部` 键在刷新时已不在册)——按在册口径 85/85 清零。 + +## 二、S13 键账终局对拍 + +**开工(波 0 刷新基线 d219dd3d)168 键 → 终局 16 键在册(全部基线内,NEW=0、WORSENED=0)→ 消失 152 键。** + +基线 168 键构成与终局去向(逐键归类实测,脚本对拍 2026-10-28): + +| 范围 | 基线在册 | 终局去向 | +|---|---|---| +| 卡① 26 文件 | 85 | **85 全消失** | +| 卡② 25 文件 | 71 | 67 消失;main 类变量 49、tune_sim await 2 键豁免在册;board_cell_unit 类变量 31→30、standee_body 类变量 29→19 两键**改善未消**(成因见 §6-B) | +| 两卡之外(board_view 6/level_runner 3/battle_orchestrator 2/adventure_runner 1) | 12 | 12 全在册(禁触面——board_view/level_runner/adventure_runner 结构拆件归迭代 39 主卡,battle_orchestrator 类变量为 v1.22 豁免第三条;battle_orchestrator 文件长度 1037→1035 随卡① spawn 调用点改址顺带改善) | + +卡② 71 键逐单元对账(卡面 §8.4 账目 vs 实绩):69 清承诺中 66 键消失;u3 类变量键、u4 类变量键两键未消(展平计数,§6-B);u14 一键补做后消失。卡① 85 键逐 T 对账:T1 17+T2 23+T3 17+T4 8=65 拆分清偿;T5 存档拆件(save 52→7+_peek_summary_at 7→6+load_from_disk 8→6)+T6 meta 31→20 与 status/treasure+T7 config_db 44→8、25→9+T8 adventure 44→12+T9 action_economy 18→15、spawn 参数 10→1 等结构收敛清偿。 + +## 三、新件键账表(卡① 8 新件,实测 vs 卡面预演值) + +| 新件 | 公开方法 | 类变量 | 卡面预演 | 判定 | +|---|---|---|---|---| +| `core/slot_store.gd` | 10 | 4 | 10/4 | 在阈 | +| `core/meta_state_store.gd` | 19(写口 13+读投影 6,_add_keystone 私有) | 1 | 19/1 | 在阈 | +| `core/config_domain_combat.gd` | 0 | 7 | 7 | 在阈 | +| `core/config_domain_run.gd` | 0 | 7 | 7 | 在阈 | +| `core/config_domain_crossrun.gd` | 0 | 5 | 5 | 在阈 | +| `schemas/adventure_quota_segment.gd` | 0 | 13 | 13 | 在阈 | +| `schemas/adventure_value_segment.gd` | 0 | 13 | 13 | 在阈 | +| `schemas/adventure_cap_segment.gd` | 0 | 9 | 9 | 在阈 | + +## 四、豁免申报(卡② u23/u25,零重构零键清偿动作) + +1. **u25 组合根类变量**:`game/presentation/main.gd|max-class-variables = 49`(登记 49,持平)——依据 architecture「呈现层装配与会话生命周期·三屏壳与组合根句柄面」节 S13 豁免登记在册(v1.22 :183:组合根类变量面=探针契约面,约 46 句柄被探针挂载假设钉死,键永久基线内、禁再恶化)。u25 零改动;本批 main.gd 的其他改动均为卡① 改址与 u6/u19 消费点随动,S10 句柄面零增删。 +2. **u23 测速循环 await**:`game/presentation/tune_sim_controller.gd|await-in-loop`(裸键,在册持平)——依据 architecture「测试与质量域·S13 测量面豁免登记」段在册(v1.22 :311:逐帧让出=调参测量 wall-clock 步进承载语义,禁重构)。同件 print 与 run 局部两键已清;playtest_logger print×2 已删。 + +## 五、并 master 与合并期处置(2026-10-28,合并提交 38c9a0eb) + +迭代期间 master 前进(断言归位专项批 94 处、断言精简 A+B 档 19 处、单元闸挂死守护、undo_proof 退役)。合并冲突 6 文件,处置原则=**master 的断言改钉/删并+本批的容器/拆件改址,两者语义正交逐处并案**(adventure_feats×2/config_db_resolver/level_shop_economy/save_boot_no_activate/roll_multiplier);另发现两批改址的系统性盲区互补——master 新写行带旧表地址 5 处、**本批 T7 扫荡漏「无点形态」(空值守卫/赋值/传参位的裸表引用)17 处+访问器迁移漏网 3 处**,合并期全数修复(action_economy._cost、effect_engine feats 守卫、hud_presenter 两守卫、测试 14 处;终扫 PCRE 负向前瞻零残留,冻结标签字面量未动)。验证集清单机器重生成对账(68 源文件 1587 调用点,与 master 侧计数一致——本批改址零断言数变化)。 + +## 六、实现决定记录 + +**A. 事故与补做** +1. 卡① T7 首轮改址扫荡漏容器表名(`db.combat.rows` 形)→ 整树 `git checkout -- game/` 回滚、修正替换规则后重跑,净效果零(a9a163f9 提交信息在案)。撤销方式:无需(事故已消解于当卡)。 +2. 卡② u14 漏做(T2b 提交信息有记、diff 未含该文件)→ 合并前补做(9bef58f9),装配序逐行原序保持,S7 源锚句逐字在位。撤销方式:回退 9bef58f9 即回键在册态。 + +**B. 键以「在案改善」收口(卡面承诺键消失不可达)** +- u3 board_cell_unit 类变量(31→30)、u4 standee_body 类变量(29→19):gdstyle 0.4.0 对宿主内 inner class 状态件成员按宿主**展平计数**(开工探针实测),键值含状态件成员后必 >15;卡面契约 6 定死 inner class 单形态(零新文件),独立状态类形态被禁。处置=按闸口径收口(改善绿非恶化红),键留基线。代价:两键继续在册。撤销方式:后续批若改 gdstyle 计数口径(类级键排除 inner class)可自然消失;否则随键主文件的下一次结构批裁决。 +- 与卡面「71=69 清+2 豁免」的差额即此两键:实绩 67 清+2 豁免+2 改善。 + +**C. 白名单内等价微调(一行备案)** +1. legend_overlay 循环提函数时尾段成孤儿代码(不可达 NEW 键)→ 尾段归还原调用方。 +2. camp_shop_presenter 束读落地改构造期成员拷贝形态(ctx 键集不变)。 +3. main.gd 战末账行束提 `_end_victory_opts` 装配单点(u6 opts 形随动,键构成不变)。 +4. board_fx 循环按实际 `for i in 2` 形态提函数;battle_log `_fmt_damage` 宿主成员 `_db` 不进状态件。 +5. spawn 束化扫荡加引号奇偶守卫(@@SPAWN_SKIP@@),断言字符串字面量零触碰。 + +## 七、测试移交清单(用户运行——特许移交,全部未跑) + +**交付前验收唯一入口**:worktree 内跑 `python tools/run_all_gates.sh`(本批含 game/ 代码面+reports/ 文档面 → 不带参数全量档;worktree 冷检出注意素材齐盘预检——art 素材与 *.import 边车不入 git)。 + +按卡重点套件(闸绿之外的建议抽查面): +- 卡①:data_config 八套件+core 直引;board_generator/board_gen_reward+node_map 三件(同种子逐位一致);效果面六套件;save 断言面全套(save_manager_domain/save_slot_isolation/save_slot_summary/save_boot_no_activate/breakpoint_resume/save_rng_roundtrip+integration);tree_requires_level/meta_progression_forge;seal_polarity/roll_multiplier/damage_controller/system_contracts/treasure_items_test;minesweeper_rules_test/round_flow_skill_pool;level_shop_economy;adventure_feats/pool_case_d/feat_vouchers/table_structure_guard(**deck_total()=102 结构锚**)。 +- 卡②:probe_gate 全段+system_test;theme_projection/ui_struct/ui_contrast/ui_tune/ui_click/ui_probe;player_copy_sweep/standee_fx_sweep/reward_overlay/battle_narrator/level_map_info_fog/hud_presenter;render_gate(甲组在库五张 golden 改后对拍——**乙组三帧与丙组四态改前锚因码先行动未建,此义务让步**,见 §8)。 +- 撤销证明:两卡 §8.1 末列手法(读数型/迁移型/grep 型)——移交抽查执行。 +- 独立验收审计:两卡 §8.3 抽查重点(卡① ⑪ 项含契约 7 职责语句抽检;卡② ⑨ 项含 u1 子域分组与 board_view 随动改址抽 5 行)——未过不得合并。 +- 批后可裁事项:`tools/gdstyle_baseline.txt --refresh`(152 死键清账,只降不升纪律,人工专用)——用户裁决时机。 + +## 八、实机目检总清单(登记不阻塞,判读口径全部=「与改前一致」) + +1. 启动冒烟实机一屏(卡① 行为零变化批总入口)。 +2. 乙组三屏开合观感:营地/设置/战斗。 +3. 丙组四态开合观感:战末结算/三选一/图例/调试浮窗。 +4. 营地三柜台(树淬炼/锻造工坊/秘宝匣)与同调交互回归。 + +## 九、遗留读数义务 + +1. MC/对拍锚:本批零数值零 RNG 改动 → 零 MC 零对拍补跑(卡① §0 纪律),无遗留。 +2. golden 乙组三帧(camp/settings/battle)与丙组四态改前锚未建(特许时序:码先行动)——改后无法补建改前帧,唯一在库对拍面=甲组五张;呈现等价的其余证据承载=现役探针/单测断言面。 +3. gdstyle 基线 152 死键在册(闸绿不受影响)——refresh 时机归用户(§7 末条)。