feat(m80-b2): twelve base profession action tables driven by the priority-row scan

This commit is contained in:
2026-09-15 22:51:10 +08:00
parent 0e174a6fcd
commit 80d9a88efd
5 changed files with 420 additions and 7 deletions
+1
View File
@@ -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 |
+7 -1
View File
@@ -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 后用模拟批量跑数(经济/时长)复核 |
+103 -6
View File
@@ -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.
+202
View File
@@ -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",
]
@@ -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)