Mi2dRPGamEng/tests/test_turrets.py

383 lines
14 KiB
Python

import math
from pathlib import Path
import pytest
from engine.ai import AIController
from engine.character import Character
from engine.defs import DefRegistry
from engine.entity import EntityPosition
from engine.map import Map
from engine.tile import Tile, TileLayerInstance
from engine.world import World
from resources.logic.ai import AI_BEHAVIORS, run_ai_tick
from resources.logic.turrets import (
TURRET_BEHAVIOR_ID,
find_closest_hostile,
is_hostile,
rotate_toward,
spawn_turret,
turret_ai_behavior,
)
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
@pytest.fixture()
def registry():
return DefRegistry.load(DEFS_DIR)
class FixedRng:
def __init__(self, roll: int = 15):
self._roll = roll
def randint(self, _lo, _hi):
return self._roll
def choice(self, seq):
return seq[0]
def uniform(self, a, b):
return a
def make_world(registry) -> World:
field = Map("field", 20, 20, 1)
for x in range(20):
for y in range(20):
field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
world = World(registry=registry)
world.add_map(field)
return world
def make_human(registry, **kwargs) -> Character:
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
def make_turret(
registry,
position,
entity_def_id="turret_pistol",
hostile_factions=("hostile",),
allied_factions=(),
hostile_entity_ids=(),
allied_entity_ids=(),
) -> Character:
return spawn_turret(
registry,
entity_def_id,
position,
hostile_factions=hostile_factions,
allied_factions=allied_factions,
hostile_entity_ids=hostile_entity_ids,
allied_entity_ids=allied_entity_ids,
)
# --- rotate_toward --------------------------------------------------------------------------
def test_rotate_toward_snaps_directly_when_within_turn_rate():
assert rotate_toward(0.0, 10.0, 15.0) == 10.0
def test_rotate_toward_is_capped_by_turn_rate():
assert rotate_toward(0.0, 90.0, 15.0) == 15.0
def test_rotate_toward_turns_the_shorter_way_around_the_wrap():
# from 350 to 10 is a 20-degree turn forward through 0, not backward through 180
assert rotate_toward(350.0, 10.0, 30.0) == 10.0
assert rotate_toward(350.0, 10.0, 5.0) == pytest.approx(355.0)
def test_rotate_toward_turns_negative_direction_when_shorter():
assert rotate_toward(90.0, 0.0, 15.0) == 75.0
# --- is_hostile ------------------------------------------------------------------------------
def test_is_hostile_defaults_to_false_for_an_untagged_character(registry):
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
bystander = make_human(registry, position=EntityPosition("field", 1, 0, 0))
assert is_hostile(turret, bystander) is False
def test_is_hostile_true_for_a_matching_hostile_faction(registry):
turret = make_turret(registry, EntityPosition("field", 0, 0, 0), hostile_factions=("raiders",))
enemy = make_human(registry, position=EntityPosition("field", 1, 0, 0), factions=["raiders"])
assert is_hostile(turret, enemy) is True
def test_is_hostile_false_for_a_matching_allied_faction(registry):
turret = make_turret(
registry, EntityPosition("field", 0, 0, 0), hostile_factions=("raiders",), allied_factions=("crew",)
)
friendly = make_human(registry, position=EntityPosition("field", 1, 0, 0), factions=["crew"])
assert is_hostile(turret, friendly) is False
def test_individual_hostile_override_works_without_any_faction(registry):
target = make_human(registry, position=EntityPosition("field", 1, 0, 0))
turret = make_turret(
registry, EntityPosition("field", 0, 0, 0), hostile_factions=(), hostile_entity_ids=(target.entity_id,)
)
assert is_hostile(turret, target) is True
def test_individual_allied_override_beats_a_hostile_faction(registry):
target = make_human(registry, position=EntityPosition("field", 1, 0, 0), factions=["raiders"])
turret = make_turret(
registry,
EntityPosition("field", 0, 0, 0),
hostile_factions=("raiders",),
allied_entity_ids=(target.entity_id,),
)
assert is_hostile(turret, target) is False
# --- find_closest_hostile ----------------------------------------------------------------------
def test_find_closest_hostile_picks_the_nearest_one(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
far = make_human(registry, position=EntityPosition("field", 5, 0, 0), factions=["hostile"])
near = make_human(registry, position=EntityPosition("field", 2, 0, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(far)
world.add_entity(near)
assert find_closest_hostile(turret, world, registry) is near
def test_find_closest_hostile_ignores_allies(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
ally = make_human(registry, position=EntityPosition("field", 1, 0, 0), factions=["player"])
world.add_entity(turret)
world.add_entity(ally)
assert find_closest_hostile(turret, world, registry) is None
def test_find_closest_hostile_ignores_targets_out_of_weapon_range(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) # pistol_basic range = 6
far = make_human(registry, position=EntityPosition("field", 15, 0, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(far)
assert find_closest_hostile(turret, world, registry) is None
def test_find_closest_hostile_is_none_without_a_weapon_assigned(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
turret.ai.state["weapon_item_id"] = None
enemy = make_human(registry, position=EntityPosition("field", 1, 0, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(enemy)
assert find_closest_hostile(turret, world, registry) is None
# --- turret_ai_behavior ------------------------------------------------------------------------
def test_behavior_is_a_noop_with_no_power(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
turret.get_or_create_body_part_override("psu").removed = True
enemy = make_human(registry, position=EntityPosition("field", 6, 0, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(enemy)
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["facing_degrees"] == 0.0
assert turret.ai.state["target_entity_id"] is None
def test_behavior_does_not_acquire_a_target_without_a_camera(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
turret.get_or_create_body_part_override("camera").removed = True
enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(enemy)
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["target_entity_id"] is None
assert turret.ai.state["facing_degrees"] == 0.0
def test_behavior_does_not_acquire_a_target_without_a_cpu(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
turret.get_or_create_body_part_override("cpu").removed = True
enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(enemy)
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["target_entity_id"] is None
def test_behavior_tracks_but_cannot_turn_without_motors(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
turret.get_or_create_body_part_override("motors").removed = True
enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(enemy)
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["target_entity_id"] == enemy.entity_id # still sees/tracks it
assert turret.ai.state["facing_degrees"] == 0.0 # but physically can't turn
def test_behavior_tracks_and_turns_but_does_not_fire_without_a_gun(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
turret.get_or_create_body_part_override("gun").removed = True
enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"])
enemy_start_integrity = enemy.get_body_part("torso").integrity
world.add_entity(turret)
world.add_entity(enemy)
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["facing_degrees"] != 0.0 # still turns toward the target
torso = enemy.get_body_part("torso")
assert torso.integrity == enemy_start_integrity # never actually got shot at
def _total_damage_taken(character: Character) -> float:
return sum(100.0 - part.integrity for part in character.resolved_body_parts().values() if part is not None)
def test_behavior_rotates_toward_and_fires_at_a_fully_functional_turret(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
enemy = make_human(registry, position=EntityPosition("field", 6, 0, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(enemy)
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng(roll=20))
assert turret.ai.state["target_entity_id"] == enemy.entity_id
assert turret.ai.state["facing_degrees"] == 0.0 # enemy due east - already facing that way
assert _total_damage_taken(enemy) > 0.0 # a roll of 20 should land a hit somewhere
def test_behavior_turn_is_capped_by_the_configured_turn_rate(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
turret.ai.state["turn_rate_degrees_per_tick"] = 10.0
enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"]) # due "south", 90 degrees
world.add_entity(turret)
world.add_entity(enemy)
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["facing_degrees"] == pytest.approx(10.0)
def test_behavior_converges_on_target_over_several_ticks(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
turret.ai.state["turn_rate_degrees_per_tick"] = 10.0
enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(enemy)
for _ in range(20):
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["facing_degrees"] == pytest.approx(90.0)
def test_behavior_ignores_a_bystander_with_no_hostile_tag(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
bystander = make_human(registry, position=EntityPosition("field", 6, 0, 0))
world.add_entity(turret)
world.add_entity(bystander)
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["target_entity_id"] is None
assert turret.ai.state["facing_degrees"] == 0.0
# --- spawn_turret ------------------------------------------------------------------------------
def test_spawn_turret_builds_a_turret_species_character(registry):
turret = spawn_turret(registry, "turret_pistol", EntityPosition("field", 1, 1, 0))
assert turret.species_id == "turret"
assert turret.name == "Pistol Turret"
assert {p.slot for p in turret.default_body_parts} == {"psu", "cpu", "motors", "gun", "camera"}
def test_spawn_turret_fills_the_ai_slot_with_the_turret_behavior(registry):
turret = spawn_turret(registry, "turret_pistol", EntityPosition("field", 1, 1, 0))
assert turret.ai is not None
assert turret.ai.behavior_id == TURRET_BEHAVIOR_ID
assert AI_BEHAVIORS[TURRET_BEHAVIOR_ID] is turret_ai_behavior
def test_spawn_turret_reads_weapon_and_turn_rate_from_the_entity_template(registry):
pistol = spawn_turret(registry, "turret_pistol", EntityPosition("field", 0, 0, 0))
autocannon = spawn_turret(registry, "turret_autocannon", EntityPosition("field", 0, 0, 0))
assert pistol.ai.state["weapon_item_id"] == "pistol_basic"
assert pistol.ai.state["turn_rate_degrees_per_tick"] == 30
assert autocannon.ai.state["weapon_item_id"] == "turret_autocannon"
assert autocannon.ai.state["turn_rate_degrees_per_tick"] == 12
def test_spawn_turret_stores_the_given_targeting_config(registry):
turret = spawn_turret(
registry,
"turret_pistol",
EntityPosition("field", 0, 0, 0),
hostile_factions=("raiders",),
allied_factions=("crew",),
hostile_entity_ids=("npc-1",),
allied_entity_ids=("npc-2",),
)
assert turret.ai.state["hostile_factions"] == ["raiders"]
assert turret.ai.state["allied_factions"] == ["crew"]
assert turret.ai.state["hostile_entity_ids"] == ["npc-1"]
assert turret.ai.state["allied_entity_ids"] == ["npc-2"]
def test_spawn_turret_gives_independent_ai_state_per_instance(registry):
a = spawn_turret(registry, "turret_pistol", EntityPosition("field", 0, 0, 0))
b = spawn_turret(registry, "turret_pistol", EntityPosition("field", 1, 1, 0))
a.ai.state["facing_degrees"] = 45.0
assert b.ai.state["facing_degrees"] == 0.0
# --- run_ai_tick dispatch ------------------------------------------------------------------------
def test_run_ai_tick_dispatches_to_the_turret_behavior(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
enemy = make_human(registry, position=EntityPosition("field", 6, 0, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(enemy)
run_ai_tick(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["target_entity_id"] == enemy.entity_id