From 070a5340b39b9d563de1ded9e93e2ce236f935de Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Wed, 28 Oct 2026 08:46:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=A1=E2=91=A0T9(=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E9=9D=A2=E7=BB=93=E6=9E=84=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E6=89=B9):=20=E6=95=A3=E6=9D=A1=E7=BB=84=E2=80=94=E2=80=94K5?= =?UTF-8?q?=20action=5Feconomy(=E5=88=A0=5Fnext=5Fround=5Fmode=E6=AD=BB?= =?UTF-8?q?=E5=AD=98=E5=82=A8+=5Fap=5Fexhausted=5Flast=E6=B4=BE=E7=94=9F?= =?UTF-8?q?=E5=90=88=E5=B9=B6(ap=5Fexhausted=5Flast=5Fround=E6=94=B9=5Flas?= =?UTF-8?q?t=5Fround=5Fend.path=5Ftag=E6=B4=BE=E7=94=9F=E8=AF=BB,=E5=90=8C?= =?UTF-8?q?=E6=BA=90=E5=90=8C=E5=88=BB=E7=AD=89=E5=80=BC)+last=5Fround=5Fe?= =?UTF-8?q?nd=E5=B1=9E=E6=80=A7=E2=86=92=E6=96=B9=E6=B3=95,=E6=B6=88?= =?UTF-8?q?=E8=B4=B9=E6=96=B9minesweeper=5Frules:495=E6=94=B9=E5=9D=80)/re?= =?UTF-8?q?ward=5Feconomy=20roll=5Fboard=E8=A7=84=E8=8C=83=E5=8C=96?= =?UTF-8?q?=E5=9F=9F=E6=8F=90=E5=8F=96(=5Fnormalize=5Feligible=E5=BC=80?= =?UTF-8?q?=E6=B5=81=E5=89=8D=E9=9B=B6RNG)+=5Fskewed=5Fcell=5Frows?= =?UTF-8?q?=E4=B8=A4=E5=9F=9F(=5Fskew=5Ffamilies=E5=88=86=E6=97=8F+=5Fappl?= =?UTF-8?q?y=5Fskew=E6=9D=83=E9=87=8D=E8=A3=85=E9=85=8D,=E5=AE=88=E6=81=92?= =?UTF-8?q?assert=E9=9A=8F=E6=AE=B5=E8=BF=81=E7=A7=BB)/minesweeper=5Frules?= =?UTF-8?q?=20=5Fdo=5Fdig=E6=8B=86=5Freveal=5Fchain(cell=5Frevealed?= =?UTF-8?q?=E5=8F=91=E5=B0=84=E5=BA=8F=E5=8D=B3=E9=93=BE=E5=BA=8F=E6=95=B4?= =?UTF-8?q?=E4=BD=93=E9=9A=8F=E6=AE=B5=E6=90=AC=E8=BF=81)/K6=E2=80=B2=20un?= =?UTF-8?q?it=5Fcontainer=20spawn=E7=AD=BE=E5=90=8D=E6=9D=9F=E5=8C=96(10?= =?UTF-8?q?=E2=86=921=20req=20Dictionary,=E6=9D=9F=E9=94=AE=E5=B0=81?= =?UTF-8?q?=E9=97=AD=E8=AF=8D=E8=A1=A8=E7=BC=BA=E7=9C=81=E9=80=90=E5=AD=97?= =?UTF-8?q?)+=E4=BD=93=E5=86=85=E6=8B=86=5Fspawn=5Fhero=5Funit/=5Fspawn=5F?= =?UTF-8?q?rowset/=5Frow=5Finstance=5Fcounts+=E8=B0=83=E7=94=A8=E7=82=B924?= =?UTF-8?q?=E5=A4=849=E6=96=87=E4=BB=B6=E6=94=B9=E6=9D=9F=E5=BD=A2(orchest?= =?UTF-8?q?rator+=E6=B5=8B=E8=AF=958,=E6=96=AD=E8=A8=80=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E5=AD=97=E9=9D=A2=E9=87=8F=E9=9B=B6=E8=A7=A6=E7=A2=B0);gdstyle?= =?UTF-8?q?=2092=E2=86=9283=E9=94=AENEW=3D0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/core/action_economy.gd | 17 ++-- game/core/battle_orchestrator.gd | 4 +- game/core/minesweeper_rules.gd | 22 +++--- game/core/reward_economy.gd | 56 ++++++++----- game/core/unit_container.gd | 79 +++++++++++++------ .../damage_controller_test.gd | 2 +- .../battle_settlement/roll_multiplier_test.gd | 6 +- .../round_flow_skill_pool_test.gd | 4 +- .../battle_settlement/skill_unlock_test.gd | 6 +- .../battle_settlement/trigger_cond_test.gd | 12 +-- .../unit/core/level_shop_economy_test.gd | 6 +- .../unit/core/meta_progression_forge_test.gd | 4 +- .../data_config/table_structure_guard_test.gd | 6 +- 13 files changed, 136 insertions(+), 88 deletions(-) diff --git a/game/core/action_economy.gd b/game/core/action_economy.gd index 979b7e5f..a90a66ff 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: @@ -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 @@ -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/battle_orchestrator.gd b/game/core/battle_orchestrator.gd index 2efd161f..c47203a3 100644 --- a/game/core/battle_orchestrator.gd +++ b/game/core/battle_orchestrator.gd @@ -302,9 +302,7 @@ func _begin_battle_accept(enc: Dictionary) -> bool: return false # E4 收口:加成经 encounter 显式携带(hero_atk/hero_max_hp;缺省 = 表值——冒烟/工具直呼路径) _hero_atk = int(enc.get("hero_atk", int(db.combat.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) + 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 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/reward_economy.gd b/game/core/reward_economy.gd index eed41036..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 下划线字段) @@ -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 diff --git a/game/core/unit_container.gd b/game/core/unit_container.gd index 6031509a..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.combat.enemy.resolve_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 + 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.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} # 护甲/攻击类型轴快照(读注入值,空串回退锻造工坊配置默认值 normal/light;甲效矩阵消费面) - _enemies.clear() # 难度曲线与Boss DifficultyTable 修饰:施加于行值、行值快照之前——MVP 空表 = 恒 1.0 无行为(AC-10);混编行集内全部敌种同乘((L,D) 精确匹配一次取值——F9 同乘不改变异,敌人系统 v0.15) - var mod: Dictionary[Variant, Variant] = db.run.difficulty.resolve_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=无条件技) diff --git a/game/tests/unit/battle_settlement/damage_controller_test.gd b/game/tests/unit/battle_settlement/damage_controller_test.gd index 31e53d14..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.combat.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} diff --git a/game/tests/unit/battle_settlement/roll_multiplier_test.gd b/game/tests/unit/battle_settlement/roll_multiplier_test.gd index 65240185..b65ce03a 100644 --- a/game/tests/unit/battle_settlement/roll_multiplier_test.gd +++ b/game/tests/unit/battle_settlement/roll_multiplier_test.gd @@ -343,13 +343,13 @@ 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.combat.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.combat.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.combat.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.combat.settlement.damage_roll.armor_effectiveness Kit.check("普通行:轻甲 0 / 魔法护甲 +1 / 重甲 −1(加法核)", 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 31a9b214..865c9775 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 @@ -661,13 +661,13 @@ 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.combat.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.combat.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 diff --git a/game/tests/unit/battle_settlement/skill_unlock_test.gd b/game/tests/unit/battle_settlement/skill_unlock_test.gd index 6793d808..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.combat.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.combat.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.combat.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 e75be3fb..6c6ff144 100644 --- a/game/tests/unit/battle_settlement/trigger_cond_test.gd +++ b/game/tests/unit/battle_settlement/trigger_cond_test.gd @@ -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}, @@ -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, @@ -160,7 +160,7 @@ func _init() -> void: # ---- 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/core/level_shop_economy_test.gd b/game/tests/unit/core/level_shop_economy_test.gd index def51633..0263c22b 100644 --- a/game/tests/unit/core/level_shop_economy_test.gd +++ b/game/tests/unit/core/level_shop_economy_test.gd @@ -264,7 +264,7 @@ func _smoke_table_migration() -> void: 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.combat.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.combat.hero.display_name)) @@ -703,7 +703,7 @@ func _smoke_level_graph() -> void: _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.combat.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") @@ -711,7 +711,7 @@ func _smoke_level_graph() -> void: _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.combat.hero.max_hp))) + 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) diff --git a/game/tests/unit/core/meta_progression_forge_test.gd b/game/tests/unit/core/meta_progression_forge_test.gd index b82e85c9..d6efa4fb 100644 --- a/game/tests/unit/core/meta_progression_forge_test.gd +++ b/game/tests/unit/core/meta_progression_forge_test.gd @@ -396,7 +396,7 @@ func _smoke_forge_axes() -> void: _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.combat.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") @@ -527,7 +527,7 @@ 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.combat.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——树零攻防来源) 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 ad020e0e..f6fabaa5 100644 --- a/game/tests/unit/data_config/table_structure_guard_test.gd +++ b/game/tests/unit/data_config/table_structure_guard_test.gd @@ -422,7 +422,7 @@ func _smoke_difficulty_table() -> void: Kit.check("DifficultyTable MVP 空表加载(结构位)", _db.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.combat.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.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)], @@ -430,7 +430,7 @@ func _smoke_difficulty_table() -> void: # ② 修饰行运行时注入(debug 通道——R3 override 同族):套乘生效于 hp/atk _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.combat.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)) @@ -439,7 +439,7 @@ func _smoke_difficulty_table() -> void: _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.combat.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.run.difficulty.rows.clear() # 还原空表(后续冒烟/关卡断言基线)