feat(game-engine): Add D20 attribute system and movement domain mechanics to Unit class

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-11 06:15:11 -07:00
parent 035a07b069
commit 47c59b2a05

View file

@ -27,6 +27,9 @@ var type_id: String = ""
var display_name: String = ""
## Combat type string from JSON data, e.g. "melee", "civilian", "ranged".
var unit_type: String = ""
## Movement domain string from JSON data, e.g. "land", "flying", "naval".
## Consumed by Pathfinder._is_passable to decide terrain passability.
var domain: String = "land"
# ── Capability flags ─────────────────────────────────────────────────
## True if this unit can found cities (derived from "found_city" keyword).
@ -64,10 +67,30 @@ var fortified_turns: int = 0
var xp: int = 0
var level: int = 1
# ── D20 attribute stubs ───────────────────────────────────────────────
## These fields are probed by CombatResolver._has_d20_attributes to decide
## whether to send D20-derived stats or flat JSON stats to Rust mc-combat.
## Arena units have no D20 profile, so all stubs default to 0 and combat
## takes the flat-stats path. Real D20 units will populate these later.
var base_str: int = 0
var base_dex: int = 0
var base_con: int = 0
var base_int: int = 0
var bonus_str: int = 0
var bonus_dex: int = 0
var bonus_con: int = 0
var bonus_int: int = 0
var bonus_attack: int = 0
var bonus_defense: int = 0
var veteran_level: int = 0
var formation_count: int = 1
var stimulant_penalty: int = 0
# ── Promotions, items, magic ──────────────────────────────────────────
var promo_ids: Array[String] = []
## Equipped items: Array[Dictionary] {item_id: String, charges: int}.
var equipped_items: Array = []
## Equipped items: each entry is {item_id: String, charges_remaining: int}.
## Must stay strongly typed — GdItemSystem's FFI rejects untyped arrays.
var equipped_items: Array[Dictionary] = []
## Currently active enchantment IDs (non-channeled).
var infusions: Array[String] = []
## Single channeled enchantment (mage-anchored) — empty if none active.
@ -104,7 +127,20 @@ func _populate_from_data() -> void:
max_movement = data.get("movement", 2)
movement_remaining = max_movement
vision = data.get("vision", 2)
unit_type = data.get("combat_type", "")
# JSON data authored for Age of Dwarves uses `unit_type` for the role
# ("military"/"civilian"/"support") and `combat_type` for the weapon
# profile ("melee"/"ranged"/"siege"). Earlier iterations read
# `combat_type` into this field, which silently produced an empty
# string for every dwarf unit and broke downstream consumers like
# the pathfinder's unit_type gate. Read both fields now — prefer
# `combat_type` when present (some reference units still use it) and
# fall back to `unit_type`, so arena units always report a non-empty
# role string. `domain` carries the pathfinder-facing passability tag.
var combat_type: String = String(data.get("combat_type", ""))
if combat_type.is_empty():
combat_type = String(data.get("unit_type", ""))
unit_type = combat_type
domain = String(data.get("domain", "land"))
if display_name.is_empty():
display_name = data.get("name", unit_id.capitalize())
var is_founder: bool = (
@ -188,11 +224,27 @@ func get_attack() -> int:
func get_defense() -> int:
var total: int = defense
total += UnitStatHelpers.get_promotion_stat(promo_ids, "defense_bonus")
if is_fortified:
total += mini(fortified_turns, 2) * 2
total += get_fortification_bonus()
return total
## Defensive bonus earned by fortifying in place. Capped at 2 turns of
## accumulation (same formula that was inlined in `get_defense`). Read by
## CombatResolver._build_unit_dict when packing the attacker/defender
## dict for the Rust combat FFI.
func get_fortification_bonus() -> int:
if not is_fortified:
return 0
return mini(fortified_turns, 2) * 2
## Alias for `max_hp` used by CombatUtils.handle_unit_death's soul gem
## resurrection path. Kept as a method so the death code doesn't depend on
## the exact field name.
func get_max_hp() -> int:
return max_hp
func fortify() -> void:
if is_civilian():
return