650 lines
24 KiB
GDScript
650 lines
24 KiB
GDScript
class_name ABGBattleSimulation
|
|
extends RefCounted
|
|
|
|
## M80-B1 deterministic battle core.
|
|
##
|
|
## Implements the authoritative timing, attribute, targeting and damage rules of
|
|
## `docs/02_GAMEPLAY_AND_BALANCE.md`:
|
|
## * 100 ms ticks with the section 8.1 priority order (simplified where later
|
|
## stages add casts, channels and summons);
|
|
## * section 9 derived statistics;
|
|
## * section 10 readiness: `Readiness = min(150, Readiness + Tempo*0.1)`, act at
|
|
## >= 100 and keep the overflow;
|
|
## * section 11 target construction order (side, reach, protection, policy);
|
|
## * section 15 damage with labeled critical rolls and the documented floors;
|
|
## * section 18 Fell -> line compaction, and section 19 outcome evaluation.
|
|
##
|
|
## The simulation is pure: no engine singletons, no time source beyond its own
|
|
## tick counter, and every stochastic step draws from a labelled hash so adding
|
|
## a proc cannot shift another stream.
|
|
|
|
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")
|
|
const TraitHooks = preload("res://domain/trait_hooks.gd")
|
|
|
|
enum Side { ALLY = 0, ENEMY = 1 }
|
|
|
|
var root_seed := ""
|
|
var tick := 0
|
|
var allies: Array[Dictionary] = []
|
|
var enemies: Array[Dictionary] = []
|
|
var journal: Array[Dictionary] = []
|
|
var draw_indices: Dictionary = {}
|
|
var outcome := ""
|
|
var outcome_tick := -1
|
|
var fallen_count := 0
|
|
|
|
|
|
func setup(next_root_seed: String, ally_rows: Array, enemy_rows: Array) -> void:
|
|
root_seed = next_root_seed
|
|
allies = []
|
|
enemies = []
|
|
for row in ally_rows:
|
|
allies.append(_actor(Dictionary(row), Side.ALLY))
|
|
for row in enemy_rows:
|
|
enemies.append(_actor(Dictionary(row), Side.ENEMY))
|
|
journal = []
|
|
draw_indices = {}
|
|
outcome = ""
|
|
outcome_tick = -1
|
|
fallen_count = 0
|
|
tick = 0
|
|
|
|
|
|
func run() -> Dictionary:
|
|
while outcome.is_empty() and tick < MAX_TICKS:
|
|
tick += 1
|
|
_step()
|
|
if outcome.is_empty():
|
|
outcome = "timeout"
|
|
outcome_tick = tick
|
|
return {"outcome": outcome, "tick": outcome_tick, "journal": journal.duplicate(true), "allies": _snapshot(allies), "enemies": _snapshot(enemies)}
|
|
|
|
|
|
func roll_bp(label: String, actor_id: String, sequence: int, target_id: String) -> int:
|
|
# Labelled determinism (section 8.2): independent streams per label, drawn in
|
|
# a stable order, so content procs never shift another stream's results.
|
|
var key := "%s|%s|%s" % [label, actor_id, target_id]
|
|
var index := int(draw_indices.get(key, 0))
|
|
draw_indices[key] = index + 1
|
|
var digest := ("%s|%s|%d|%s|%d" % [root_seed, label, sequence, actor_id, index]).sha256_text()
|
|
return int(digest.substr(0, 8).hex_to_int()) % 10000
|
|
|
|
|
|
static func derived(row: Dictionary) -> Dictionary:
|
|
# Section 9 derived statistics. Values round down to 1/100 after the full
|
|
# expression, so callers see the authored two decimals.
|
|
var level := int(row.get("level", 1))
|
|
var might := float(row.get("might", 8))
|
|
var finesse := float(row.get("finesse", 8))
|
|
var insight := float(row.get("insight", 8))
|
|
var vigor := float(row.get("vigor", 8))
|
|
var resolve := float(row.get("resolve", 8))
|
|
return {
|
|
"physical_power": _floor2(float(row.get("weapon_power", 0)) + 2.00 * might + 0.50 * finesse),
|
|
"arcane_power": _floor2(float(row.get("focus_power", 0)) + 2.00 * insight + 0.50 * resolve),
|
|
"healing_power": _floor2(float(row.get("focus_power", 0)) + 1.20 * insight + 1.20 * resolve),
|
|
"max_hp": _floor2(float(row.get("profession_base_hp", 200)) + 8.0 * level + 10.0 * vigor),
|
|
"guard": _floor2(float(row.get("armor_guard", 0)) + 1.20 * vigor + 0.60 * might),
|
|
"ward": _floor2(float(row.get("armor_ward", 0)) + 1.20 * resolve + 0.60 * insight),
|
|
"potency": _floor2(insight + 0.50 * finesse + float(row.get("flat_potency", 0))),
|
|
"tenacity": _floor2(resolve + 0.50 * vigor + float(row.get("flat_tenacity", 0))),
|
|
"tempo": _floor2(clampf(float(row.get("profession_base_tempo", 30)) + 0.40 * finesse + float(row.get("flat_tempo", 0)), 20.0, 100.0)),
|
|
"initiative": _floor2(clampf(float(row.get("profession_base_initiative", 10)) + 0.25 * finesse + float(row.get("flat_initiative", 0)), 0.0, 95.0)),
|
|
}
|
|
|
|
|
|
static func _floor2(value: float) -> float:
|
|
return floorf(value * 100.0) / 100.0
|
|
|
|
|
|
func damage_preview(source: Dictionary, target: Dictionary, action: Dictionary, crit := false) -> Dictionary:
|
|
# Section 15.1 damage for one already-selected source/target/action.
|
|
var stats_source: Dictionary = source.stats
|
|
var stats_target: Dictionary = target.stats
|
|
var power := float(stats_source.get(str(action.get("power_source", "physical_power")), 0.0))
|
|
var raw := maxf(0.0, float(action.get("base", 0)) + float(action.get("coefficient", 1.0)) * power)
|
|
var defense := clampf(float(stats_target.get("guard", 0.0)) - float(action.get("pierce", 0.0)), -75.0, 500.0)
|
|
var defense_multiplier := 100.0 / (100.0 + defense) if defense >= 0.0 else 2.0 - 100.0 / (100.0 - defense)
|
|
var crit_chance := clampi(int(action.get("crit_bp", 0)) + int(20.0 * (float(stats_source.get("finesse", 0.0)) - float(stats_target.get("tenacity", 0.0)))), 0, 5000)
|
|
var crit_multiplier := clampf(float(action.get("crit_multiplier", 1.5)), 1.25, 2.00) if crit else 1.0
|
|
var mitigated := raw * defense_multiplier * crit_multiplier
|
|
var protection_stacks := int(target.get("protection_stacks", 0))
|
|
var screened := mitigated * maxf(0.70, 1.0 - 0.15 * float(protection_stacks))
|
|
if str(action.get("protection_mode", "screened")) == "direct":
|
|
screened = mitigated
|
|
var damage_floor := ceili(0.04 * raw)
|
|
return {
|
|
"final": maxi(1, maxi(damage_floor, int(floorf(screened)))),
|
|
"raw": raw,
|
|
"crit_chance_bp": crit_chance,
|
|
"defense_multiplier": defense_multiplier,
|
|
"crit_multiplier": crit_multiplier,
|
|
"protection_stacks": protection_stacks,
|
|
}
|
|
|
|
|
|
func _actor(row: Dictionary, side: int) -> Dictionary:
|
|
var stats := derived(row)
|
|
return {
|
|
"id": str(row.get("id", "")),
|
|
"side": int(side),
|
|
"position": int(row.get("position", 0)),
|
|
"reach": int(row.get("reach", 1)),
|
|
"stats": stats,
|
|
"hp": int(row.get("hp", int(stats.max_hp))),
|
|
"guard": int(row.get("guard", int(stats.guard))),
|
|
"readiness": float(stats.initiative),
|
|
"fallen": false,
|
|
"action_sequence": 0,
|
|
"profession_id": str(row.get("profession_id", "")),
|
|
"footprint": int(row.get("footprint", 1)),
|
|
"immovable": bool(row.get("immovable", false)),
|
|
"rooted": bool(row.get("rooted", false)),
|
|
"traits": _hook_rows(row),
|
|
"equipment_hooks": Array(row.get("equipment_hooks", [])),
|
|
"profession_table": _profession_table(row),
|
|
"resource": 0,
|
|
"last_target": "",
|
|
"action": Dictionary(row.get("action", {
|
|
"id": "basic",
|
|
"base": 8,
|
|
"coefficient": 1.0,
|
|
"power_source": "physical_power",
|
|
"reach": 1,
|
|
"protection_mode": "screened",
|
|
"pierce": 0,
|
|
"crit_bp": 500,
|
|
"crit_multiplier": 1.5,
|
|
})),
|
|
}
|
|
|
|
|
|
func _step() -> void:
|
|
# 1-4: statuses, casts and projectiles arrive in later stages; the core only
|
|
# needs expiry-free actors.
|
|
# 5: actions for ready and legal actors.
|
|
var ready_actors: Array[Dictionary] = []
|
|
for actor in _living(allies) + _living(enemies):
|
|
actor.readiness = minf(READINESS_CAP, float(actor.readiness) + float(actor.stats.tempo) * 0.1)
|
|
if float(actor.readiness) >= READINESS_THRESHOLD:
|
|
ready_actors.append(actor)
|
|
ready_actors.sort_custom(func(a, b): return float(a.readiness) > float(b.readiness))
|
|
# 7: batch the resolved actions against one pre-batch state.
|
|
var batch: Array[Dictionary] = []
|
|
for actor in ready_actors:
|
|
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, chosen, action))
|
|
for record in batch:
|
|
_apply_damage(record)
|
|
# 8-9: mark Fallen, compact lines and evaluate the outcome.
|
|
_mark_fallen()
|
|
_compact()
|
|
_evaluate_outcome()
|
|
|
|
|
|
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 bonuses := _trait_effect(actor, "before_damage")
|
|
if int(bonuses.get("damage_bonus", 0)) > 0:
|
|
action = action.duplicate(true)
|
|
action["base"] = float(action.get("base", 0)) + float(bonuses.damage_bonus)
|
|
var guard_bonus := int(_trait_effect(actor, "after_damage").get("guard_bonus", 0))
|
|
if guard_bonus > 0:
|
|
actor.guard = int(actor.guard) + guard_bonus
|
|
if int(bonuses.get("resource_gain", 0)) > 0:
|
|
actor.resource = int(actor.resource) + int(bonuses.resource_gain)
|
|
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
|
|
var preview: Dictionary = damage_preview(actor, target, action, crit)
|
|
return {
|
|
"tick": tick,
|
|
"actor": str(actor.id),
|
|
"side": int(actor.side),
|
|
"target": str(target.id),
|
|
"action": str(action.get("id", "basic")),
|
|
"kind": str(action.get("kind", "damage")),
|
|
"crit": crit,
|
|
"crit_roll": roll,
|
|
"amount": int(preview.final),
|
|
"push": int(action.get("push", 0)),
|
|
"pull": int(action.get("pull", 0)),
|
|
"protection_stacks": int(preview.protection_stacks),
|
|
"source": actor,
|
|
"victim": target,
|
|
}
|
|
|
|
|
|
func _apply_damage(record: Dictionary) -> void:
|
|
var victim: Dictionary = record.victim
|
|
if bool(victim.fallen):
|
|
return
|
|
var amount := int(record.amount)
|
|
if str(record.get("kind", "damage")) == "guard":
|
|
# A guard-kind action raises the target's Guard pool instead of dealing
|
|
# damage. Without this branch the row's amount was applied as self-damage
|
|
# while the pool stayed empty, so `self_guard_below_half` never cleared
|
|
# and the actor looped on its refresh row for the rest of the battle.
|
|
var cap := int(victim.stats.guard) * 2
|
|
victim.guard = mini(cap, int(victim.guard) + amount)
|
|
record.guard_after = int(victim.guard)
|
|
record.hp_after = int(victim.hp)
|
|
record.erase("source")
|
|
record.erase("victim")
|
|
journal.append(record)
|
|
return
|
|
if str(record.get("kind", "damage")) == "healing":
|
|
var max_hp := int(victim.stats.max_hp)
|
|
var heal_bonus := int(_trait_effect(victim, "after_heal").get("heal_bonus", 0))
|
|
amount += heal_bonus
|
|
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
|
|
var to_hp := amount - absorbed
|
|
victim.hp = maxi(0, int(victim.hp) - to_hp)
|
|
record.absorbed = absorbed
|
|
record.hp_after = int(victim.hp)
|
|
record.guard_after = int(victim.guard)
|
|
var source: Dictionary = record.get("source", {})
|
|
var push := int(record.get("push", 0))
|
|
var pull := int(record.get("pull", 0))
|
|
record.erase("source")
|
|
record.erase("victim")
|
|
journal.append(record)
|
|
if int(victim.hp) > 0 and (push > 0 or pull > 0) and not source.is_empty():
|
|
_apply_forced_movement(victim, source, push, pull)
|
|
|
|
|
|
func _apply_forced_movement(victim: Dictionary, source: Dictionary, push: int, pull: int) -> void:
|
|
# Section 14.1: `push N` walks the target toward its rear through up to N
|
|
# adjacent block swaps, `pull N` toward its front. A step blocked by an
|
|
# Immovable actor or the line boundary deals one collision event:
|
|
# max(1, floor(0.08 * source PhysicalPower)).
|
|
var direction := 1 if push > 0 else -1
|
|
var steps := maxi(push, pull)
|
|
var from_position := int(victim.position)
|
|
var moved := 0
|
|
var blocked := false
|
|
for _step in range(steps):
|
|
if not _forced_swap(victim, direction):
|
|
blocked = true
|
|
break
|
|
moved += 1
|
|
if moved > 0:
|
|
journal.append({
|
|
"tick": tick,
|
|
"actor": str(source.get("id", "")),
|
|
"target": str(victim.id),
|
|
"kind": "movement",
|
|
"steps": moved,
|
|
"from": from_position,
|
|
"to": int(victim.position),
|
|
"hp_after": int(victim.hp),
|
|
})
|
|
if blocked:
|
|
var power := float(Dictionary(source.get("stats", {})).get("physical_power", 0.0))
|
|
var collision := maxi(1, int(floor(0.08 * power)))
|
|
victim.hp = maxi(0, int(victim.hp) - collision)
|
|
journal.append({
|
|
"tick": tick,
|
|
"actor": str(source.get("id", "")),
|
|
"target": str(victim.id),
|
|
"kind": "collision",
|
|
"amount": collision,
|
|
"hp_after": int(victim.hp),
|
|
})
|
|
|
|
|
|
func _forced_swap(actor: Dictionary, direction: int) -> bool:
|
|
# Footprint blocks move indivisibly; this core swaps single-footprint actors
|
|
# and refuses the step when either side is Immovable or the boundary is hit.
|
|
var line: Array[Dictionary] = allies if int(actor.side) == Side.ALLY else enemies
|
|
var index := -1
|
|
for candidate in range(line.size()):
|
|
if str(line[candidate].id) == str(actor.id):
|
|
index = candidate
|
|
break
|
|
if index == -1:
|
|
return false
|
|
var neighbor_index := index + direction
|
|
if neighbor_index < 0 or neighbor_index >= line.size():
|
|
return false
|
|
var neighbor: Dictionary = line[neighbor_index]
|
|
if bool(actor.immovable) or bool(neighbor.immovable) or bool(neighbor.fallen):
|
|
return false
|
|
var actor_position := int(actor.position)
|
|
var neighbor_position := int(neighbor.position)
|
|
actor.position = neighbor_position
|
|
neighbor.position = actor_position
|
|
line[index] = neighbor
|
|
line[neighbor_index] = actor
|
|
return true
|
|
|
|
|
|
static func _hook_rows(row: Dictionary) -> Array:
|
|
# Trait rows and equipment hooks share one shape so `_trait_effect` applies
|
|
# both through the same section-17 hook vocabulary.
|
|
var rows := TraitHooks.for_actor(Array(row.get("trait_ids", [])))
|
|
for hook in Array(row.get("equipment_hooks", [])):
|
|
rows.append(Dictionary(hook))
|
|
return rows
|
|
|
|
|
|
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 _trait_effect(actor: Dictionary, hook: String) -> Dictionary:
|
|
# Applies the implemented hook rows for this actor and returns the summed
|
|
# declarative effect. Unimplemented rows are skipped until their system lands.
|
|
var total := {"reach_bonus": 0, "damage_bonus": 0, "guard_bonus": 0, "heal_bonus": 0, "resource_gain": 0, "haste": 0}
|
|
for row in Array(actor.traits):
|
|
var entry: Dictionary = Dictionary(row)
|
|
if not bool(entry.get("implemented", false)) or String(entry.get("hook", "")) != hook:
|
|
continue
|
|
var effect: Dictionary = Dictionary(entry.get("effect", {}))
|
|
var kind := String(effect.get("kind", ""))
|
|
if total.has(kind):
|
|
total[kind] = int(total[kind]) + int(effect.get("value", 0))
|
|
return total
|
|
|
|
|
|
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
|
|
var condition := str(candidate.get("condition", "always"))
|
|
if condition.contains("_ready"):
|
|
# A ready token is spent by the row it unlocks; the core does not yet
|
|
# model the declared per-resource refill rules beyond the gain flags.
|
|
actor.resource = maxi(0, int(actor.resource) - 1)
|
|
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
|
|
"new_target_available":
|
|
return _has_new_target(actor, living_enemies)
|
|
"front_gap_open":
|
|
return int(actor.position) > 0
|
|
"ally_lethal_forecast":
|
|
return _has_ally_below(living_allies, 0.35)
|
|
"poise_ready":
|
|
return _resource_ready(actor)
|
|
"edge_ready_and_low_target":
|
|
return _resource_ready(actor) and _has_enemy_below(living_enemies, 0.35)
|
|
"charge_ready_and_clustered_pair":
|
|
return _resource_ready(actor) and _has_clustered_pair(living_enemies)
|
|
"aim_ready_and_rear_caster":
|
|
return _resource_ready(actor) and _has_rear_caster(actor, living_enemies)
|
|
"verse_ready_and_slower_ally":
|
|
return _resource_ready(actor) and _has_slower_ally(actor, living_allies)
|
|
"bond_ready_and_ally_below_threshold":
|
|
return _resource_ready(actor) and _has_ally_below(living_allies, 0.50)
|
|
"supply_ready_and_urgent_item":
|
|
return _resource_ready(actor) and _has_ally_below(living_allies, 0.50)
|
|
"rear_neighbor_endangered":
|
|
return _rear_neighbor_endangered(actor, living_allies)
|
|
# Conditions whose systems are not in the core yet: statuses (marked,
|
|
# remedy, omen), summons and devices, links, windups. They stay false so
|
|
# those rows are skipped rather than firing on a guess.
|
|
"self_marked_and_neighbor_open", "marked_target_visible", "summon_present", "remedy_ready_and_ally_status", "link_broken", "device_destroyed_and_parts_ready", "device_ready", "enemy_major_windup", "omen_ready_and_enemy_status":
|
|
return false
|
|
return false
|
|
|
|
|
|
func _resource_ready(actor: Dictionary) -> bool:
|
|
return int(actor.resource) >= 1
|
|
|
|
|
|
func _has_new_target(actor: Dictionary, living_enemies: Array[Dictionary]) -> bool:
|
|
var last := str(actor.last_target)
|
|
for enemy in living_enemies:
|
|
if str(enemy.id) != last:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _has_ally_below(living_allies: Array[Dictionary], ratio: float) -> bool:
|
|
for ally in living_allies:
|
|
if int(ally.hp) <= int(ally.stats.max_hp) * ratio:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _has_enemy_below(living_enemies: Array[Dictionary], ratio: float) -> bool:
|
|
for enemy in living_enemies:
|
|
if int(enemy.hp) <= int(enemy.stats.max_hp) * ratio:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _has_clustered_pair(living_enemies: Array[Dictionary]) -> bool:
|
|
var depth := -2
|
|
for enemy in living_enemies:
|
|
var position := int(enemy.position)
|
|
if position == depth + 1:
|
|
return true
|
|
depth = position
|
|
return false
|
|
|
|
|
|
func _has_rear_caster(actor: Dictionary, living_enemies: Array[Dictionary]) -> bool:
|
|
var rear_from := int(ceil(float(living_enemies.size()) / 2.0))
|
|
for enemy in living_enemies:
|
|
var kind := str(Dictionary(enemy.action).get("kind", "damage"))
|
|
if int(enemy.position) >= rear_from and kind in ["ranged", "magic"]:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _has_slower_ally(actor: Dictionary, living_allies: Array[Dictionary]) -> bool:
|
|
var tempo := float(actor.stats.tempo)
|
|
for ally in living_allies:
|
|
if str(ally.id) != str(actor.id) and float(ally.stats.tempo) < tempo:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _rear_neighbor_endangered(actor: Dictionary, living_allies: Array[Dictionary]) -> bool:
|
|
var index := -1
|
|
for candidate in range(living_allies.size()):
|
|
if str(living_allies[candidate].id) == str(actor.id):
|
|
index = candidate
|
|
break
|
|
if index == -1:
|
|
return false
|
|
var rear := -1
|
|
var best := 1 << 30
|
|
for candidate in range(living_allies.size()):
|
|
var position := int(living_allies[candidate].position)
|
|
if position > int(actor.position) and position < best:
|
|
best = position
|
|
rear = candidate
|
|
if rear == -1:
|
|
return false
|
|
var neighbor: Dictionary = living_allies[rear]
|
|
return int(neighbor.hp) <= int(neighbor.stats.max_hp) * 0.35
|
|
|
|
|
|
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"))
|
|
var reach_bonus := int(_trait_effect(actor, "before_target").get("reach_bonus", 0))
|
|
if reach_bonus > 0:
|
|
action = action.duplicate(true)
|
|
var reach_value = action.get("reach", 1)
|
|
if reach_value is int:
|
|
action["reach"] = int(reach_value) + reach_bonus
|
|
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.
|
|
var opposing: Array[Dictionary] = enemies if int(actor.side) == Side.ALLY else allies
|
|
var reach := int(actor.action.get("reach", actor.reach))
|
|
var candidates := _reachable(actor, opposing, int(reach))
|
|
if candidates.is_empty():
|
|
return {}
|
|
var best: Dictionary = candidates[0]
|
|
var best_depth := int(best.position)
|
|
for candidate in candidates:
|
|
if int(candidate.position) < best_depth:
|
|
best = candidate
|
|
best_depth = int(candidate.position)
|
|
var ties: Array[Dictionary] = []
|
|
for candidate in candidates:
|
|
if int(candidate.position) == best_depth:
|
|
ties.append(candidate)
|
|
if ties.size() > 1:
|
|
var roll := roll_bp("target_tie", str(actor.id), int(actor.action_sequence) + 1, str(best.id))
|
|
best = ties[roll % ties.size()]
|
|
return best
|
|
|
|
|
|
func _reachable(actor: Dictionary, opposing: Array[Dictionary], reach: int) -> Array[Dictionary]:
|
|
# Reach N may select through at most N-1 intervening living units; `line`
|
|
# reaches any legal unit (section 11).
|
|
var living: Array[Dictionary] = []
|
|
for candidate in opposing:
|
|
if not bool(candidate.fallen):
|
|
living.append(candidate)
|
|
living.sort_custom(func(a, b): return int(a.position) < int(b.position))
|
|
if str(actor.action.get("reach", reach)) == "line" or reach >= living.size():
|
|
return living
|
|
var allowed := mini(living.size(), maxi(1, reach))
|
|
return living.slice(0, allowed)
|
|
|
|
|
|
func _living(line: Array[Dictionary]) -> Array[Dictionary]:
|
|
var result: Array[Dictionary] = []
|
|
for actor in line:
|
|
if not bool(actor.fallen):
|
|
result.append(actor)
|
|
return result
|
|
|
|
|
|
func _mark_fallen() -> void:
|
|
for actor in allies + enemies:
|
|
if not bool(actor.fallen) and int(actor.hp) <= 0:
|
|
actor.fallen = true
|
|
fallen_count += 1
|
|
|
|
|
|
func _compact() -> void:
|
|
# Section 18: living units close vacancies preserving relative order.
|
|
_compact_line(allies)
|
|
_compact_line(enemies)
|
|
|
|
|
|
func _compact_line(line: Array[Dictionary]) -> void:
|
|
var living: Array[Dictionary] = []
|
|
for actor in line:
|
|
if not bool(actor.fallen):
|
|
living.append(actor)
|
|
living.sort_custom(func(a, b): return int(a.position) < int(b.position))
|
|
for index in range(living.size()):
|
|
living[index].position = index
|
|
if living.is_empty():
|
|
return
|
|
|
|
|
|
func _evaluate_outcome() -> void:
|
|
# Section 19: breaking the enemy line wins; losing every ally loses. A
|
|
# timeout is decided by the caller after MAX_TICKS.
|
|
if _living(enemies).is_empty():
|
|
outcome = "victory"
|
|
outcome_tick = tick
|
|
elif _living(allies).is_empty():
|
|
outcome = "defeat"
|
|
outcome_tick = tick
|
|
|
|
|
|
func _snapshot(line: Array[Dictionary]) -> Array[Dictionary]:
|
|
var rows: Array[Dictionary] = []
|
|
for actor in line:
|
|
rows.append({
|
|
"id": str(actor.id),
|
|
"position": int(actor.position),
|
|
"hp": int(actor.hp),
|
|
"guard": int(actor.guard),
|
|
"fallen": bool(actor.fallen),
|
|
"readiness": int(float(actor.readiness)),
|
|
"max_hp": int(Dictionary(actor.stats).max_hp),
|
|
})
|
|
return rows
|