diff --git a/src/game/engine/tests/unit/test_keyword_handler.gd b/src/game/engine/tests/unit/test_keyword_handler.gd new file mode 100644 index 00000000..82e6c245 --- /dev/null +++ b/src/game/engine/tests/unit/test_keyword_handler.gd @@ -0,0 +1,217 @@ +extends GutTest +## Keyword handler unit tests. +## Covers: poison, ZOC, web, regeneration, skeleton spawns, tactical analysis. +## Note: indestructible, battle_rage, arcane_shield, war_cry, can_attack_flying +## are processed entirely inside Rust (GdCombatResolver). Only the GDScript-layer +## state tracked in keyword_handler.gd is tested here. + +const KeywordHandlerScript: GDScript = preload( + "res://engine/src/modules/combat/keyword_handler.gd" +) +const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") + +var _player_a: PlayerScript = null +var _player_b: PlayerScript = null + + +func before_all() -> void: + DataLoader.load_theme("age-of-dwarves") + + +func before_each() -> void: + _player_a = PlayerScript.new() + _player_a.index = 0 + _player_a.race_id = "dwarf" + _player_a.units = [] + _player_a.cities = [] + + _player_b = PlayerScript.new() + _player_b.index = 1 + _player_b.race_id = "terrans" + _player_b.units = [] + _player_b.cities = [] + + GameState.players = [_player_a, _player_b] + + ## Clear static state between tests + KeywordHandlerScript._poison_states.clear() + KeywordHandlerScript._web_states.clear() + KeywordHandlerScript._tactical_memory.clear() + KeywordHandlerScript._pending_skeleton_spawns.clear() + + +func _make_unit( + owner_idx: int, + pos: Vector2i, + atk: int = 10, + dr: int = 2, + hp_val: int = 30, +) -> UnitScript: + var unit: UnitScript = UnitScript.new() + unit.id = "u_%d_%d_%d" % [owner_idx, pos.x, pos.y] + unit.type_id = "spearmen" + unit.owner = owner_idx + unit.position = pos + unit.hp = hp_val + unit.max_hp = hp_val + unit.bonus_attack = atk + unit.bonus_defense = dr + unit.unit_type = "military" + unit.movement_remaining = 2 + return unit + + +# --------------------------------------------------------------------------- +# Poison +# --------------------------------------------------------------------------- + +func test_poison_ticks_damage_per_turn() -> void: + var defender: UnitScript = _make_unit(1, Vector2i(2, 0), 5, 2, 50) + KeywordHandlerScript._poison_states[defender.id] = { + "damage_per_turn": 3, + "turns_remaining": 2, + } + + var hp_before: int = defender.hp + KeywordHandlerScript.process_turn_effects([defender]) + assert_eq(defender.hp, hp_before - 3, "Poison must deal 3 damage per turn") + + +func test_poison_expires_after_turns() -> void: + var defender: UnitScript = _make_unit(1, Vector2i(2, 0), 5, 2, 50) + KeywordHandlerScript._poison_states[defender.id] = { + "damage_per_turn": 3, + "turns_remaining": 1, + } + + KeywordHandlerScript.process_turn_effects([defender]) + assert_false( + KeywordHandlerScript._poison_states.has(defender.id), + "Poison must be removed after turns expire" + ) + + +func test_poison_persists_with_turns_remaining() -> void: + var defender: UnitScript = _make_unit(1, Vector2i(2, 0), 5, 2, 50) + KeywordHandlerScript._poison_states[defender.id] = { + "damage_per_turn": 3, + "turns_remaining": 3, + } + + KeywordHandlerScript.process_turn_effects([defender]) + assert_true( + KeywordHandlerScript._poison_states.has(defender.id), + "Poison must persist when turns remain" + ) + assert_eq( + KeywordHandlerScript._poison_states[defender.id]["turns_remaining"], + 2, + "Turns remaining must decrement by 1" + ) + + +# --------------------------------------------------------------------------- +# ZOC entry cost +# --------------------------------------------------------------------------- + +func test_zoc_entry_cost_from_adjacent_enemy() -> void: + var unit: UnitScript = _make_unit(0, Vector2i(3, 3), 5, 2, 30) + var enemy: UnitScript = _make_unit(1, Vector2i(5, 3), 5, 2, 30) + + var cost: int = KeywordHandlerScript.get_zoc_entry_cost( + unit, Vector2i(4, 3), [enemy] + ) + assert_eq(cost, 1, "Entering tile adjacent to enemy must cost +1 movement") + + +func test_zoc_entry_cost_zero_without_enemy() -> void: + var unit: UnitScript = _make_unit(0, Vector2i(3, 3), 5, 2, 30) + + var cost: int = KeywordHandlerScript.get_zoc_entry_cost( + unit, Vector2i(4, 3), [] + ) + assert_eq(cost, 0, "No enemies means no ZOC cost") + + +func test_flying_unit_bypasses_zoc_blocked() -> void: + var flyer: UnitScript = _make_unit(0, Vector2i(3, 3), 5, 2, 30) + flyer.type_id = "wyvern_rider" + flyer.unit_type = "flying" + var enemy: UnitScript = _make_unit(1, Vector2i(4, 3), 5, 2, 30) + + var blocked: bool = KeywordHandlerScript.is_zoc_blocked( + flyer, Vector2i(3, 3), Vector2i(4, 3), [enemy] + ) + assert_false(blocked, "Flying unit must not be blocked by ZOC") + + +# --------------------------------------------------------------------------- +# Web state +# --------------------------------------------------------------------------- + +func test_web_immobilizes_for_one_turn() -> void: + var unit: UnitScript = _make_unit(0, Vector2i(1, 0), 10, 2, 50) + KeywordHandlerScript._web_states[unit.id] = 1 + + assert_true( + KeywordHandlerScript.is_webbed(unit), + "Webbed unit must report as webbed" + ) + + KeywordHandlerScript.process_turn_effects([unit]) + assert_false( + KeywordHandlerScript.is_webbed(unit), + "Web must expire after 1 turn" + ) + + +func test_non_webbed_unit_not_immobilized() -> void: + var unit: UnitScript = _make_unit(0, Vector2i(1, 0), 10, 2, 50) + assert_false( + KeywordHandlerScript.is_webbed(unit), + "Non-webbed unit must not be immobilized" + ) + + +# --------------------------------------------------------------------------- +# Skeleton spawn flush +# --------------------------------------------------------------------------- + +func test_flush_skeleton_spawns_returns_and_clears() -> void: + KeywordHandlerScript._pending_skeleton_spawns.append( + {"position": Vector2i(5, 5), "owner": 0} + ) + + var spawns: Array = KeywordHandlerScript.flush_skeleton_spawns() + assert_eq(spawns.size(), 1, "Must return 1 pending spawn") + assert_eq( + KeywordHandlerScript._pending_skeleton_spawns.size(), 0, + "Flush must clear pending spawns" + ) + + +# --------------------------------------------------------------------------- +# Tactical analysis bonus +# --------------------------------------------------------------------------- + +func test_tactical_analysis_no_bonus_without_keyword() -> void: + var attacker: UnitScript = _make_unit(0, Vector2i(1, 0), 10, 2, 50) + var defender: UnitScript = _make_unit(1, Vector2i(2, 0), 10, 2, 50) + + var bonus: int = KeywordHandlerScript.get_tactical_analysis_bonus( + attacker, defender + ) + assert_eq(bonus, 0, "Unit without tactical_analysis must get 0 bonus") + + +# --------------------------------------------------------------------------- +# Regeneration +# --------------------------------------------------------------------------- + +func test_non_regen_unit_not_healed() -> void: + var unit: UnitScript = _make_unit(0, Vector2i(1, 0), 10, 2, 50) + unit.hp = 30 + + KeywordHandlerScript.process_turn_effects([unit]) + assert_eq(unit.hp, 30, "Non-regenerating unit must not heal from turn effects") diff --git a/src/game/engine/tests/unit/test_keyword_handler.gd.uid b/src/game/engine/tests/unit/test_keyword_handler.gd.uid new file mode 100644 index 00000000..2365338f --- /dev/null +++ b/src/game/engine/tests/unit/test_keyword_handler.gd.uid @@ -0,0 +1 @@ +uid://k54furn1h7ab