Files
aetherbound-guild/runtime/domain/battle_simulation.gd
T

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