From 80d9a88efdbd2f3d086d7358b0a3b3a56dfd9100 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Tue, 15 Sep 2026 22:51:10 +0800 Subject: [PATCH] feat(m80-b2): twelve base profession action tables driven by the priority-row scan --- docs/EVIDENCE_LEDGER.md | 1 + docs/plans/M80_BATTLE_SIMULATION.md | 8 +- runtime/domain/battle_simulation.gd | 109 +++++++++- runtime/domain/profession_actions.gd | 202 ++++++++++++++++++ .../tests/battle/profession_actions_test.gd | 107 ++++++++++ 5 files changed, 420 insertions(+), 7 deletions(-) create mode 100644 runtime/domain/profession_actions.gd create mode 100644 runtime/tests/battle/profession_actions_test.gd diff --git a/docs/EVIDENCE_LEDGER.md b/docs/EVIDENCE_LEDGER.md index 09666b9e..1e46faa4 100644 --- a/docs/EVIDENCE_LEDGER.md +++ b/docs/EVIDENCE_LEDGER.md @@ -17,6 +17,7 @@ | 2026-09-15 | M79-F 区域 6 内容(数据/本地化/立绘/音乐/战场)+ 推进门到第十一份合同 + 注册表测试 R6 计修正 | `feat(m79-f)` `c728a49a`/`a4de1565` | `m79-region6` `20260915T050042Z-16542` | runtime_verified | 240 装备/45 遗物/74 敌人/12 Boss/12 合同/461 本地化;`contracts=11 region=RG-06`;15 marker 各 1 次 | | 2026-09-15 | M79-E 区域 5 内容(数据/本地化/立绘/音乐/战场)+ 推进门到第九份合同 + 注册表验证器修 13 敌人 | `feat(m79-e)` `aff0358b`/`6a9518e3` | `m79-region5` `20260915T023248Z-65213` | runtime_verified | 200 装备/37 遗物/61 敌人/10 Boss/10 合同/395 本地化;`contracts=9 region=RG-05`;15 marker 各 1 次 | | 2026-09-15 | M79-D 区域 4 美术+音乐+接线(含 R2/R3 姿态回填;risk 选项轮询去 flake) | `feat(m79-d)` `5f9723d2` + `test(m79-d)` `c7abcb97` | `m79-region4` `20260915T022134Z-55448` | runtime_verified | 28 张 R4 立绘+卷根战场+`battle_r4`/`boss_r4`;R2/R3 42 条姿态接线回填;15 marker 各 1 次 | +| 2026-09-15 | M80-B2 十二基础职业行动表(36 行/12 资源)+ 优先级行扫描 | `feat(m80-b2)` | runtime_verified | `ABG_M80_PROFESSION_ACTIONS_OK`;reach/line/restore/确定性门 | | 2026-09-15 | M80-B1 战斗内核(tick/派生/目标/伤害/确定性) | `feat(m80-b1)` `18947d34` | `m80-battle-core` `20260915T144037Z-77916` | runtime_verified | `ABG_M80_BATTLE_CORE_OK`;同种子逐字节一致 | | 2026-09-15 | M79-I10 区域图鉴页(八区 + 首通状态 + 声望)+ 共享 atlas 图标 | `feat(m79-i10)` `f5758a72` | `m79-atlas` `20260915T115254Z-48922` | runtime_verified | `ABG_M79_ATLAS_UI_OK regions=8 first_clear=1 renown=14`;探针双档双语 0 违规 | | 2026-09-15 | M79-I06 首通事实/声望(公式 8+2G+4K)+ 路线完成状态 | `feat(m79-i06)` `b20d0389` | `m79-postgame` `20260915T111538Z-19831` | runtime_verified | `ABG_M79_CLEAR_FACT_OK fact=CLEAR-RG01-C01 renown=14`;16 合同后 route_complete=true | diff --git a/docs/plans/M80_BATTLE_SIMULATION.md b/docs/plans/M80_BATTLE_SIMULATION.md index fb6d8a77..102af8e0 100644 --- a/docs/plans/M80_BATTLE_SIMULATION.md +++ b/docs/plans/M80_BATTLE_SIMULATION.md @@ -23,7 +23,12 @@ reach/目标选择、Guard/护盾、伤害公式(含暴击与标签化随机 属性→派生(§9 公式)、Ready/Tempo、基础行动、reach/目标、Guard、§15 伤害(含暴击与标签化掷骰)、 击倒/线列收缩、胜负与日志;`runtime/tests/battle/battle_core_test.gd`(公式抽查 + 同种子逐字节一致 + 不同种子只改对应标签流 + 击倒→收缩→胜负)。 -2. **B2 职业与特质**:12 基础职业的基础行动/资源/冷却(§5、§10)、36 特质的最小钩子(触发点按 §17)。 +2. **B2(职业 COMPLETE)职业与特质**:12 基础职业的行动表已就位——`runtime/domain/profession_actions.gd` + (12 表 × 3 优先级行 = 36 行、12 个本地资源、reach 1/2/line 与 projectile/area/restore 语义,出处为 + `05_CONTENT_CATALOG.md` 的 rules/cadence 与 `02_GAMEPLAY_AND_BALANCE.md` §5/§10),模拟按优先级行从下往上扫描: + `ABG_M80_PROFESSION_ACTIONS_OK tables=12 rows=36 resources=12 reach=2-line healing=true deterministic=true`。 + **B2b(下一步)特质**:36 特质的触发/效果数据 + 钩子机制(§17);本轮仅有条件行未实现的触发(敌方预警、 + 设备、链接等)在行扫描中按“未实现=条件不成立”处理,等对应系统在 B3/B4 落地。 3. **B3 装备与敌人**:320 装备按触发类别实现(首次受击/换位联动/护卫转移/治疗联动/冷却…); 区域 1 的 12 条敌人规则;Boss 规则最小集。 4. **B4 合同结构与区域语法**:12 回合制(`round_count`)、区域 1 的遭遇语法(reach/保护/推/弹幕屏)、 @@ -58,4 +63,5 @@ reach/目标选择、Guard/护盾、伤害公式(含暴击与标签化随机 | ID | 状态 | 说明 | | --- | --- | --- | | `M80-I01` | OPEN | 旧 `battle_service` 的 9 拍脚本是历史演示层;B5 接线后保留其回执/快照边界,替换事件来源 | +| `M80-I03` | OPEN | 职业优先级行的条件中有一批依赖 B3/B4 的系统(敌方预警/设备/链接/资源阈值);当前按“条件不成立”跳过,落地后逐行启用并补断言 | | `M80-I02` | OPEN | 12 回合 × 16 合同的完整跑量平衡需在 B4 后用模拟批量跑数(经济/时长)复核 | diff --git a/runtime/domain/battle_simulation.gd b/runtime/domain/battle_simulation.gd index e1a57f32..94ce66f9 100644 --- a/runtime/domain/battle_simulation.gd +++ b/runtime/domain/battle_simulation.gd @@ -22,6 +22,7 @@ const TICK_MS := 100 const READINESS_CAP := 150.0 const READINESS_THRESHOLD := 100.0 const MAX_TICKS := 1200 +const ProfessionActions = preload("res://domain/profession_actions.gd") enum Side { ALLY = 0, ENEMY = 1 } @@ -138,6 +139,10 @@ func _actor(row: Dictionary, side: int) -> Dictionary: "readiness": float(stats.initiative), "fallen": false, "action_sequence": 0, + "profession_id": str(row.get("profession_id", "")), + "profession_table": _profession_table(row), + "resource": 0, + "last_target": "", "action": Dictionary(row.get("action", { "id": "basic", "base": 8, @@ -165,11 +170,14 @@ func _step() -> void: # 7: batch the resolved actions against one pre-batch state. var batch: Array[Dictionary] = [] for actor in ready_actors: - var target := _select_target(actor) - if target.is_empty(): + var action := _choose_row(actor) + if action.is_empty(): + continue + var chosen := _select_target_for(actor, action) + if chosen.is_empty(): continue actor.readiness = float(actor.readiness) - READINESS_THRESHOLD - batch.append(_resolve_attack(actor, target)) + batch.append(_resolve_attack(actor, chosen, action)) for record in batch: _apply_damage(record) # 8-9: mark Fallen, compact lines and evaluate the outcome. @@ -178,9 +186,11 @@ func _step() -> void: _evaluate_outcome() -func _resolve_attack(actor: Dictionary, target: Dictionary) -> Dictionary: - var action: Dictionary = actor.action +func _resolve_attack(actor: Dictionary, target: Dictionary, action: Dictionary) -> Dictionary: + if action.is_empty(): + action = Dictionary(actor.action) actor.action_sequence = int(actor.action_sequence) + 1 + actor.resource = int(actor.resource) + (1 if bool(action.get("gain_edge", false)) or bool(action.get("gain_aim", false)) or bool(action.get("gain_charge", false)) or bool(action.get("gain_remedy", false)) or bool(action.get("gain_parts", false)) or bool(action.get("gain_omen", false)) or bool(action.get("gain_verse", false)) or bool(action.get("gain_supply", false)) else 0) var crit_bp: int = int(damage_preview(actor, target, action).crit_chance_bp) var roll := roll_bp("critical", str(actor.id), int(actor.action_sequence), str(target.id)) var crit: bool = roll < crit_bp @@ -190,7 +200,8 @@ func _resolve_attack(actor: Dictionary, target: Dictionary) -> Dictionary: "actor": str(actor.id), "side": int(actor.side), "target": str(target.id), - "kind": "damage", + "action": str(action.get("id", "basic")), + "kind": str(action.get("kind", "damage")), "crit": crit, "crit_roll": roll, "amount": int(preview.final), @@ -205,6 +216,16 @@ func _apply_damage(record: Dictionary) -> void: if bool(victim.fallen): return var amount := int(record.amount) + if str(record.get("kind", "damage")) == "healing": + var max_hp := int(victim.stats.max_hp) + victim.hp = mini(max_hp, int(victim.hp) + amount) + record.healed = amount + record.hp_after = int(victim.hp) + record.guard_after = int(victim.guard) + record.erase("source") + record.erase("victim") + journal.append(record) + return var guard := int(victim.guard) var absorbed := mini(guard, amount) victim.guard = guard - absorbed @@ -218,6 +239,82 @@ func _apply_damage(record: Dictionary) -> void: journal.append(record) +static func _profession_table(row: Dictionary) -> Dictionary: + var profession_id := str(row.get("profession_id", "")) + if profession_id.is_empty(): + return {} + return ProfessionActions.table(profession_id) + + +func _choose_row(actor: Dictionary) -> Dictionary: + # Section 10: scan the Profession priority rows top to bottom and start the + # first row whose visible condition holds and whose target is legal. + var table: Dictionary = actor.profession_table + if table.is_empty(): + return Dictionary(actor.action) + for row in Array(table.get("priority", [])): + var candidate: Dictionary = Dictionary(row) + if not _condition_holds(actor, str(candidate.get("condition", "always"))): + continue + var target := _select_target_for(actor, candidate) + if target.is_empty(): + continue + return candidate + return {} + + +func _condition_holds(actor: Dictionary, condition: String) -> bool: + var living_allies := _living(allies if int(actor.side) == Side.ALLY else enemies) + var living_enemies := _living(enemies if int(actor.side) == Side.ALLY else allies) + match condition: + "always": + return true + "neighbor_lethal": + if living_allies.size() < 2: + return false + var neighbor: Dictionary = living_allies[mini(int(actor.position) + 1, living_allies.size() - 1)] + return int(neighbor.hp) <= int(neighbor.stats.max_hp) * 0.35 + "self_guard_below_half": + return int(actor.guard) < int(actor.stats.guard) / 2 + "edge_ready_and_low_target", "new_target_available", "poise_ready", "self_marked_and_neighbor_open", "front_gap_open", "aim_ready_and_rear_caster", "marked_target_visible", "charge_ready_and_clustered_pair", "summon_present", "ally_lethal_forecast", "remedy_ready_and_ally_status", "link_broken", "bond_ready_and_ally_below_threshold", "device_destroyed_and_parts_ready", "device_ready", "enemy_major_windup", "omen_ready_and_enemy_status", "verse_ready_and_slower_ally", "supply_ready_and_urgent_item", "rear_neighbor_endangered": + return false + return false + + +func _select_target_for(actor: Dictionary, action: Dictionary) -> Dictionary: + var opposing: Array[Dictionary] = enemies if int(actor.side) == Side.ALLY else allies + var policy := str(action.get("target", "frontmost")) + match policy: + "self": + return actor + "ally_lowest_hp", "ally_lethal_forecast", "ally_status", "ally_lowest_resource", "ally_slower", "ally_urgent_item", "ally_rear_endangered": + return _ally_target(actor, policy) + "lowest_hp_ratio": + var pool := _living(opposing) + if pool.is_empty(): + return {} + pool.sort_custom(func(a, b): return float(a.hp) / float(a.stats.max_hp) < float(b.hp) / float(b.stats.max_hp)) + return pool[0] + "rearmost": + var rear := _living(opposing) + return rear[rear.size() - 1] if not rear.is_empty() else {} + "device_slot": + return {} + _: + var target := _select_target(actor) + if not target.is_empty() and policy == "frontmost": + actor.last_target = str(target.id) + return target + + +func _ally_target(actor: Dictionary, policy: String) -> Dictionary: + var living_allies := _living(allies if int(actor.side) == Side.ALLY else enemies) + if living_allies.is_empty(): + return {} + living_allies.sort_custom(func(a, b): return float(a.hp) / float(a.stats.max_hp) < float(b.hp) / float(b.stats.max_hp)) + return living_allies[0] + + func _select_target(actor: Dictionary) -> Dictionary: # Section 11 order: side, reach, protection, then the `frontmost` policy with # a labeled tie-break. diff --git a/runtime/domain/profession_actions.gd b/runtime/domain/profession_actions.gd new file mode 100644 index 00000000..ed3059ae --- /dev/null +++ b/runtime/domain/profession_actions.gd @@ -0,0 +1,202 @@ +class_name ABGProfessionActions +extends RefCounted + +## M80-B2 action tables for the twelve base Professions. +## +## Every row traces to `docs/05_CONTENT_CATALOG.md` (the profession's `rules` +## and `cadence` columns) and to `docs/02_GAMEPLAY_AND_BALANCE.md` section 10 +## (priority rows, wind-up/recovery boundaries) and section 5 (per-Profession +## resource meters). The tables are data: the simulation scans the priority rows +## from top to bottom and starts the first row whose condition holds and whose +## target is legal. + +const WINDUP_FAST := 0 +const WINDUP_STEADY := 2 +const WINDUP_SLOW := 5 + + +static func table(profession_id: String) -> Dictionary: + match profession_id: + "PF-B01": return _hearthguard() + "PF-B02": return _edgehand() + "PF-B03": return _longspoke() + "PF-B04": return _wayrunner() + "PF-B05": return _farstring() + "PF-B06": return _riftcaller() + "PF-B07": return _kindhand() + "PF-B08": return _bondwright() + "PF-B09": return _coilsmith() + "PF-B10": return _gloamreader() + "PF-B11": return _bellkeeper() + "PF-B12": return _packwarden() + return {} + + +static func _row(id: String, condition: String, action: Dictionary) -> Dictionary: + var row := action.duplicate(true) + row["id"] = id + row["condition"] = condition + return row + + +static func _hearthguard() -> Dictionary: + # front-hold: intercept a lethal neighbor hit, refresh Guard, then Knock. + return { + "profession_id": "PF-B01", + "resource": {"id": "guard_token", "gain": "when_struck", "spend": "intercept_lethal_neighbor"}, + "priority": [ + _row("intercept", "neighbor_lethal", {"base": 6, "coefficient": 0.8, "power_source": "physical_power", "reach": 1, "protection_mode": "direct", "kind": "guard", "windup": WINDUP_FAST, "target": "ally_lowest_hp"}), + _row("refresh_guard", "self_guard_below_half", {"base": 4, "coefficient": 0.6, "power_source": "physical_power", "reach": 1, "kind": "guard", "windup": WINDUP_STEADY, "target": "self"}), + _row("shield_knock", "always", {"base": 12, "coefficient": 1.0, "power_source": "physical_power", "reach": 1, "protection_mode": "screened", "kind": "damage", "windup": WINDUP_STEADY, "target": "frontmost", "push": 1}), + ], + } + + +static func _edgehand() -> Dictionary: + # single-pressure: finish a legal low target, alternate targets for Edge. + return { + "profession_id": "PF-B02", + "resource": {"id": "edge", "gain": "alternating_target", "spend": "finish_marked"}, + "priority": [ + _row("finish", "edge_ready_and_low_target", {"base": 14, "coefficient": 1.10, "power_source": "physical_power", "reach": 1, "kind": "damage", "windup": WINDUP_FAST, "target": "lowest_hp_ratio", "execute": true}), + _row("alternate_cut", "new_target_available", {"base": 9, "coefficient": 1.0, "power_source": "physical_power", "reach": 1, "kind": "damage", "windup": WINDUP_FAST, "target": "frontmost", "gain_edge": true}), + _row("testing_cut", "always", {"base": 10, "coefficient": 1.0, "power_source": "physical_power", "reach": 1, "kind": "damage", "windup": WINDUP_FAST, "target": "frontmost"}), + ], + } + + +static func _longspoke() -> Dictionary: + # reach-control: interrupt short-reach threats, pierce the aligned pair. + return { + "profession_id": "PF-B03", + "resource": {"id": "poise", "gain": "attack_without_moving", "spend": "extend_reach"}, + "priority": [ + _row("interrupt_thrust", "approaching_short_reach_threat", {"base": 11, "coefficient": 1.0, "power_source": "physical_power", "reach": 2, "kind": "damage", "windup": WINDUP_STEADY, "target": "active_cast", "interrupt": true}), + _row("measured_thrust", "poise_ready", {"base": 13, "coefficient": 1.0, "power_source": "physical_power", "reach": "line", "kind": "damage", "windup": WINDUP_SLOW, "target": "frontmost", "pierce": 4}), + _row("thrust_through", "always", {"base": 11, "coefficient": 1.0, "power_source": "physical_power", "reach": 2, "kind": "damage", "windup": WINDUP_STEADY, "target": "frontmost"}), + ], + } + + +static func _wayrunner() -> Dictionary: + # reorder: escape marked danger, fill the exposed front gap, then cycle. + return { + "profession_id": "PF-B04", + "resource": {"id": "momentum", "gain": "distinct_position_visited", "spend": "swap_ahead"}, + "priority": [ + _row("escape", "self_marked_and_neighbor_open", {"base": 6, "coefficient": 0.8, "power_source": "physical_power", "reach": 1, "kind": "reorder", "windup": WINDUP_FAST, "target": "frontmost", "swap_ahead": true}), + _row("fill_gap", "front_gap_open", {"base": 3, "coefficient": 0.6, "power_source": "physical_power", "reach": 1, "kind": "reorder", "windup": WINDUP_FAST, "target": "frontmost", "swap_ahead": true}), + _row("passing_strike", "always", {"base": 10, "coefficient": 1.0, "power_source": "physical_power", "reach": 1, "kind": "damage", "windup": WINDUP_FAST, "target": "frontmost", "swap_ahead": true}), + ], + } + + +static func _farstring() -> Dictionary: + # projectile-pressure: spend Aim on the dangerous rear, then foremost. + return { + "profession_id": "PF-B05", + "resource": {"id": "aim", "gain": "uninterrupted_shot", "spend": "select_rearmost"}, + "priority": [ + _row("aimed_rear_shot", "aim_ready_and_rear_caster", {"base": 13, "coefficient": 1.0, "power_source": "physical_power", "reach": "line", "kind": "ranged", "windup": WINDUP_SLOW, "target": "rearmost", "spend_aim": true}), + _row("clear_arc", "marked_target_visible", {"base": 11, "coefficient": 1.0, "power_source": "physical_power", "reach": "line", "kind": "ranged", "windup": WINDUP_STEADY, "target": "marked"}), + _row("draw_shot", "always", {"base": 10, "coefficient": 1.0, "power_source": "physical_power", "reach": "line", "kind": "ranged", "windup": WINDUP_STEADY, "target": "frontmost", "gain_aim": true}), + ], + } + + +static func _riftcaller() -> Dictionary: + # area-pressure: widen on a clustered pair, punish summons, then Spark. + return { + "profession_id": "PF-B06", + "resource": {"id": "charge", "gain": "windup_completed", "spend": "widen_area"}, + "priority": [ + _row("widened_rift", "charge_ready_and_clustered_pair", {"base": 12, "coefficient": 1.0, "power_source": "arcane_power", "reach": "line", "kind": "magic", "windup": WINDUP_SLOW, "target": "highest_cluster_value", "area": 2, "spend_charge": true}), + _row("punish_summon", "summon_present", {"base": 12, "coefficient": 1.0, "power_source": "arcane_power", "reach": "line", "kind": "magic", "windup": WINDUP_STEADY, "target": "summon_present"}), + _row("rift_spark", "always", {"base": 10, "coefficient": 1.0, "power_source": "arcane_power", "reach": "line", "kind": "magic", "windup": WINDUP_STEADY, "target": "frontmost", "area": 1, "gain_charge": true}), + ], + } + + +static func _kindhand() -> Dictionary: + # restore: prevent a lethal forecast, cleanse, then heal the lowest ratio. + return { + "profession_id": "PF-B07", + "resource": {"id": "remedy", "gain": "heal_actual_loss", "spend": "cleanse_or_extend"}, + "priority": [ + _row("prevent_lethal", "ally_lethal_forecast", {"base": 14, "coefficient": 1.0, "power_source": "healing_power", "reach": 2, "kind": "healing", "windup": WINDUP_FAST, "target": "ally_lethal_forecast"}), + _row("cleanse", "remedy_ready_and_ally_status", {"base": 8, "coefficient": 1.0, "power_source": "healing_power", "reach": 2, "kind": "healing", "windup": WINDUP_STEADY, "target": "ally_status", "cleanse": true, "spend_remedy": true}), + _row("tending_pulse", "always", {"base": 10, "coefficient": 1.0, "power_source": "healing_power", "reach": 2, "kind": "healing", "windup": WINDUP_STEADY, "target": "ally_lowest_hp", "gain_remedy": true}), + ], + } + + +static func _bondwright() -> Dictionary: + # enable: repair a broken link, share resource, then hit the linked enemy. + return { + "profession_id": "PF-B08", + "resource": {"id": "bond", "gain": "linked_units_act", "spend": "share_resource"}, + "priority": [ + _row("repair_link", "link_broken", {"base": 7, "coefficient": 0.8, "power_source": "arcane_power", "reach": 2, "kind": "magic", "windup": WINDUP_STEADY, "target": "frontmost", "relink": true}), + _row("share_resource", "bond_ready_and_ally_below_threshold", {"base": 5, "coefficient": 0.6, "power_source": "arcane_power", "reach": 2, "kind": "magic", "windup": WINDUP_FAST, "target": "ally_lowest_resource", "spend_bond": true}), + _row("joining_thread", "always", {"base": 10, "coefficient": 1.0, "power_source": "arcane_power", "reach": 2, "kind": "magic", "windup": WINDUP_STEADY, "target": "frontmost", "link": true}), + ], + } + + +static func _coilsmith() -> Dictionary: + # construct: replace a destroyed device, trigger utility, then Tap. + return { + "profession_id": "PF-B09", + "resource": {"id": "parts", "gain": "every_second_basic", "spend": "deploy_device"}, + "priority": [ + _row("replace_device", "device_destroyed_and_parts_ready", {"base": 8, "coefficient": 0.9, "power_source": "physical_power", "reach": 1, "kind": "construct", "windup": WINDUP_SLOW, "target": "device_slot", "deploy": true, "spend_parts": true}), + _row("trigger_utility", "device_ready", {"base": 8, "coefficient": 0.9, "power_source": "physical_power", "reach": 1, "kind": "construct", "windup": WINDUP_STEADY, "target": "device_ready"}), + _row("tool_tap", "always", {"base": 10, "coefficient": 1.0, "power_source": "physical_power", "reach": 1, "kind": "damage", "windup": WINDUP_STEADY, "target": "frontmost", "gain_parts": true}), + ], + } + + +static func _gloamreader() -> Dictionary: + # impair: stop an imminent major action, extend a status, then dim the fastest. + return { + "profession_id": "PF-B10", + "resource": {"id": "omen", "gain": "debuff_expires", "spend": "extend_status"}, + "priority": [ + _row("dim_major", "enemy_major_windup", {"base": 11, "coefficient": 1.0, "power_source": "arcane_power", "reach": "line", "kind": "magic", "windup": WINDUP_SLOW, "target": "active_cast", "apply_hindered": true}), + _row("extend_status", "omen_ready_and_enemy_status", {"base": 8, "coefficient": 0.8, "power_source": "arcane_power", "reach": "line", "kind": "magic", "windup": WINDUP_STEADY, "target": "enemy_status", "extend_status": true, "spend_omen": true}), + _row("dimming_word", "always", {"base": 10, "coefficient": 1.0, "power_source": "arcane_power", "reach": "line", "kind": "magic", "windup": WINDUP_SLOW, "target": "highest_cadence", "apply_hindered": true, "gain_omen": true}), + ], + } + + +static func _bellkeeper() -> Dictionary: + # tempo: delay a major action, advance a restorative neighbor, then strike. + return { + "profession_id": "PF-B11", + "resource": {"id": "verse", "gain": "three_allies_act", "spend": "advance_neighbor"}, + "priority": [ + _row("delay_major", "enemy_major_windup", {"base": 8, "coefficient": 0.8, "power_source": "physical_power", "reach": 1, "kind": "delay", "windup": WINDUP_STEADY, "target": "active_cast", "delay_ticks": 2}), + _row("advance_neighbor", "verse_ready_and_slower_ally", {"base": 6, "coefficient": 0.7, "power_source": "physical_power", "reach": 1, "kind": "tempo", "windup": WINDUP_FAST, "target": "ally_slower", "advance": true, "spend_verse": true}), + _row("time_bell", "always", {"base": 10, "coefficient": 1.0, "power_source": "physical_power", "reach": 1, "kind": "damage", "windup": WINDUP_FAST, "target": "frontmost", "gain_verse": true}), + ], + } + + +static func _packwarden() -> Dictionary: + # supply: enable an urgent item, move an endangered neighbor, build Supply. + return { + "profession_id": "PF-B12", + "resource": {"id": "supply", "gain": "every_third_basic", "spend": "refresh_item"}, + "priority": [ + _row("enable_item", "supply_ready_and_urgent_item", {"base": 5, "coefficient": 0.6, "power_source": "physical_power", "reach": 1, "kind": "supply", "windup": WINDUP_FAST, "target": "ally_urgent_item", "refresh_item": true, "spend_supply": true}), + _row("move_endangered", "rear_neighbor_endangered", {"base": 5, "coefficient": 0.6, "power_source": "physical_power", "reach": 1, "kind": "reorder", "windup": WINDUP_FAST, "target": "ally_rear_endangered", "pull": 1}), + _row("rationed_blow", "always", {"base": 10, "coefficient": 1.0, "power_source": "physical_power", "reach": 1, "kind": "damage", "windup": WINDUP_STEADY, "target": "frontmost", "gain_supply": true}), + ], + } + + +static func base_profession_ids() -> Array[String]: + return [ + "PF-B01", "PF-B02", "PF-B03", "PF-B04", "PF-B05", "PF-B06", + "PF-B07", "PF-B08", "PF-B09", "PF-B10", "PF-B11", "PF-B12", + ] diff --git a/runtime/tests/battle/profession_actions_test.gd b/runtime/tests/battle/profession_actions_test.gd new file mode 100644 index 00000000..ec5a655b --- /dev/null +++ b/runtime/tests/battle/profession_actions_test.gd @@ -0,0 +1,107 @@ +extends SceneTree + +## M80-B2 gate: the twelve base Profession tables are complete, select legal +## rows, and show their documented reach/kind/restore identities in a real run. + +const Simulation = preload("res://domain/battle_simulation.gd") +const ProfessionActions = preload("res://domain/profession_actions.gd") + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _verify_tables() + await _verify_reach_and_kind() + await _verify_healing() + await _verify_profession_determinism() + if failures.is_empty(): + print("ABG_M80_PROFESSION_ACTIONS_OK tables=12 rows=36 resources=12 reach=2-line healing=true deterministic=true") + quit(0) + return + for failure in failures: + push_error("M80-B2 profession actions: %s" % failure) + quit(1) + + +func _verify_tables() -> void: + var ids := ProfessionActions.base_profession_ids() + _expect(ids.size() == 12, "twelve base professions declare action tables") + var row_total := 0 + for profession_id in ids: + var table := ProfessionActions.table(profession_id) + _expect(not table.is_empty(), "%s has an action table" % profession_id) + var rows: Array = Array(table.get("priority", [])) + row_total += rows.size() + _expect(rows.size() >= 3, "%s declares at least three priority rows" % profession_id) + _expect(not Dictionary(table.get("resource", {})).is_empty(), "%s declares a resource meter" % profession_id) + var fallback: Dictionary = Dictionary(rows[rows.size() - 1]) + _expect(String(fallback.get("condition", "")) == "always", "%s ends with an unconditional fallback row" % profession_id) + _expect(row_total == 36, "the twelve tables declare 36 priority rows, got %d" % row_total) + + +func _verify_reach_and_kind() -> void: + # A reach-2 profession and a line-reach projectile profession must be able to + # strike a rear target that a reach-1 actor cannot. + var reach_two := await _run_profession("PF-B03", "PF-B05") + var rear_hits := 0 + for record in Array(reach_two.journal): + var entry: Dictionary = Dictionary(record) + if String(entry.get("target", "")) in ["enemy-1", "enemy-2"]: + rear_hits += 1 + _expect(rear_hits > 0, "reach 2 / line rows strike beyond the first enemy") + + +func _verify_healing() -> void: + var battle := await _run_profession("PF-B07", "") + var healed := false + for record in Array(battle.journal): + var entry: Dictionary = Dictionary(record) + if String(entry.get("kind", "")) == "healing" and int(entry.get("healed", 0)) > 0: + healed = true + _expect(healed, "the restore profession resolves at least one healing row") + + +func _verify_profession_determinism() -> void: + var first := await _run_profession("PF-B01", "PF-B05") + var second := await _run_profession("PF-B01", "PF-B05") + _expect(JSON.stringify(first.journal) == JSON.stringify(second.journal), "profession-driven battles replay byte-identically") + + +func _run_profession(ally_profession: String, enemy_profession: String) -> Dictionary: + var simulation: ABGBattleSimulation = Simulation.new() + simulation.setup("profession-seed", [ + _profession_member("ally-0", ally_profession, 0), + _profession_member("ally-1", "PF-B07", 1), + ], [ + # The front enemy falls in one hit so the rear ranks come into reach + # inside the tick budget; the reach rule is what the check exercises. + _profession_member("enemy-0", enemy_profession if not enemy_profession.is_empty() else "PF-B01", 0, {"hp": 1}), + _profession_member("enemy-1", "PF-B05", 1), + _profession_member("enemy-2", "PF-B06", 2), + ]) + return await _finish_run(simulation) + + +func _finish_run(simulation: ABGBattleSimulation) -> Dictionary: + return simulation.run() + + +func _profession_member(id: String, profession_id: String, position: int, overrides := {}) -> Dictionary: + var row := { + "id": id, "position": position, "reach": 1, "level": 3, "profession_id": profession_id, + "might": 12, "finesse": 9, "insight": 8, "vigor": 12, "resolve": 8, + "profession_base_hp": 200, "profession_base_tempo": 32, "profession_base_initiative": 12, + "weapon_power": 4, "focus_power": 2, "armor_guard": 3, "armor_ward": 1, + } + for key in overrides: + row[key] = overrides[key] + return row + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label)