383 lines
14 KiB
Python
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
|