feat(combat): Implement D20-based combat resolution with roll generation, modifiers, and resolution logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-11 06:15:12 -07:00
parent 8096ecaa35
commit ede8a5b969
2 changed files with 39 additions and 16 deletions

View file

@ -97,30 +97,48 @@ func preview(
## Apply the Rust resolve result to live unit/city objects and emit follow-on events.
##
## GdCombatResolver.resolve() (see api-gdext/src/lib.rs) returns these keys:
## attacker_hp, defender_hp, attacker_damage, defender_damage,
## attacker_killed, defender_killed, attacker_xp, defender_xp,
## city_damage, city_hp_remaining, life_drain_heal
## This used to read `attacker_alive` / `defender_alive` / `damage_dealt`
## which never existed in the Rust dict, so kill detection silently
## defaulted to "everyone lives" and no combat death ever propagated to
## EventBus. Read the Rust keys directly now.
func _apply_resolve_results(
attacker: RefCounted,
defender: RefCounted,
result: Dictionary,
all_units: Array,
) -> void:
var attacker_hp: int = result.get("attacker_hp", attacker.hp)
var defender_hp: int = result.get("defender_hp", defender.hp if defender is UnitScript else 0)
var attacker_hp: int = int(result.get("attacker_hp", attacker.hp))
var defender_hp_default: int = defender.hp if defender is UnitScript else 0
var defender_hp: int = int(result.get("defender_hp", defender_hp_default))
attacker.hp = attacker_hp
if defender is UnitScript:
defender.hp = defender_hp
elif defender is CityScript:
defender.city_hp = result.get("city_hp_remaining", defender.city_hp)
defender.city_hp = int(result.get("city_hp_remaining", defender.city_hp))
var attacker_alive: bool = result.get("attacker_alive", attacker.hp > 0)
var defender_alive: bool = result.get("defender_alive", true)
var attacker_killed: bool = bool(
result.get("attacker_killed", attacker_hp <= 0)
)
var defender_is_unit: bool = defender is UnitScript
var defender_killed: bool
if defender_is_unit:
defender_killed = bool(
result.get("defender_killed", defender_hp <= 0)
)
else:
defender_killed = int(result.get("city_hp_remaining", 1)) <= 0
if not defender_alive:
if defender is UnitScript:
CombatUtilsScript.handle_unit_death(defender, attacker, all_units)
if infusion_system != null:
infusion_system.on_unit_killed(attacker)
if not attacker_alive:
if defender_killed and defender_is_unit:
CombatUtilsScript.handle_unit_death(defender, attacker, all_units)
if infusion_system != null:
infusion_system.on_unit_killed(attacker)
if attacker_killed:
CombatUtilsScript.handle_unit_death(attacker, defender, all_units)
if infusion_system != null:
infusion_system.on_unit_killed(defender)
@ -128,13 +146,13 @@ func _apply_resolve_results(
if result.get("captured", false) and defender is CityScript:
CombatUtilsScript.capture_city(defender, attacker, defender.owner, all_units)
var attack_xp: int = result.get("attacker_xp", 0)
var defend_xp: int = result.get("defender_xp", 0)
var attack_xp: int = int(result.get("attacker_xp", 0))
var defend_xp: int = int(result.get("defender_xp", 0))
if attacker_alive and attacker is UnitScript:
if not attacker_killed and attacker is UnitScript:
attacker.gain_xp(attack_xp)
_check_promotion(attacker)
if defender_alive and defender is UnitScript:
if not defender_killed and defender_is_unit:
defender.gain_xp(defend_xp)
_check_promotion(defender)

View file

@ -75,11 +75,16 @@ static func handle_unit_death(unit: RefCounted, killer: RefCounted, all_units: A
return
# Drop equipped items as ground loot before removing the unit.
# ItemSystem.drop_all_loot wants the dying unit's tile, not the whole
# map — passing the map used to Array-type-error the FFI and abort the
# death handler before it could emit unit_destroyed.
var primary_layer: Dictionary = GameState.get_primary_layer()
if not primary_layer.is_empty():
var game_map: RefCounted = primary_layer.get("map")
if game_map != null:
ItemSystemScript.drop_all_loot(unit, game_map)
var tile: Resource = game_map.get_tile(unit.position) as Resource
if tile != null:
ItemSystemScript.drop_all_loot(unit, tile)
all_units.erase(unit)