feat(m80-b2): twelve base profession action tables driven by the priority-row scan
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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 后用模拟批量跑数(经济/时长)复核 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user