Files
transcience/tests/unit/test_aggro.gd
T
claude 7af439341d
ci / verify (push) Successful in 46s
Stage 1: tile maps, walls, fog of war, aggro, scrolling camera
Replaces the fixed centred arena with per-world tile geometry, which is the
foundation the remaining features sit on.

- MapGrid: tile grid with three independent flags -- blocks movement, bullets,
  sight -- so a pit stops feet but not bullets or eyes, and a barricade stops
  feet and bullets but not eyes. Circle collision with per-axis sliding, and
  Bresenham line of sight shared by fog and aggro.
- MapGen: rooms and corridors generated from (seed, depth), with hand-authored
  boss arenas from Rooms stamped in first so a corridor can never carve through
  a designed fight. Reachability from spawn to boss is asserted over 40 seeds --
  "usually connected" is the failure mode that ruins one run in twenty.
- Dungeons are populated at creation, per room, instead of gating on waves. You
  explore and choose your fights; the run ends when the boss dies, not when the
  map is swept. Boss rooms have no lock, so walking out is always available.
- Aggro: enemies need range AND line of sight, so a dungeon stays quiet until
  engaged and cover actually protects.
- Hard fog, scrolling camera, and terrain rendering.

Maps are streamed per peer in chunks around that peer's player, and the seed is
deliberately NOT sent -- a client holding it could regenerate the whole dungeon,
which is a map hack for free. Stream radius (900u) is wider than view radius
(460u) because the client predicts movement against walls and simulates bullets
that die on them; the accepted cost is a cheater seeing a little further than
the fog, never the floor plan.

Partial map knowledge means wall deaths must be announced rather than derived.
A test caught the subtle half of that: out-of-bounds tiles read as WALL by
design, so checking geometry before bounds reported every bullet leaving the map
as a wall kill.

128 tests (was 103); check.sh, test.sh and smoke.sh pass. 0.26 ms/tick with 4
players and a boss, ~65x headroom.
2026-09-03 20:49:27 +02:00

90 lines
2.6 KiB
GDScript

extends GutTest
## Aggro is what turns a populated map into an explorable one: a dungeon has to
## be quiet until you engage it, and cover has to actually protect you.
var world: SimWorld
var map: MapGrid
func before_each() -> void:
map = MapGrid.new(40, 20, MapGrid.Kind.WALL)
map.fill_rect(Rect2i(1, 1, 38, 18), MapGrid.Kind.FLOOR)
map.centre_on_origin()
world = SimWorld.new(1)
world.set_map(map)
func _turret_at(tx: int, ty: int) -> SimEnemy:
return world.spawn_enemy(Content.turret(), map.tile_centre(tx, ty))
func _player_at(tx: int, ty: int) -> SimPlayer:
var p := world.add_player(1, "tester")
p.pos = map.tile_centre(tx, ty)
p.spawn_grace = 0
return p
func test_an_enemy_out_of_range_never_fires() -> void:
var e := _turret_at(2, 10)
_player_at(37, 10)
assert_gt(e.pos.distance_to(world.players[1].pos), Content.turret().aggro_range,
"setup: the player must actually be out of range")
for _i in 400:
world.step()
assert_eq(world.pool.live_count, 0,
"a dungeon has to stay quiet until you walk into it")
assert_null(e.target)
func test_an_enemy_in_range_and_in_sight_fires() -> void:
var e := _turret_at(10, 10)
_player_at(16, 10)
for _i in 400:
world.step()
assert_gt(world.pool.live_count, 0)
assert_not_null(e.target)
## Cover is the whole point of walls. An enemy that shoots through them makes
## every wall decorative.
func test_a_wall_between_them_breaks_aggro() -> void:
var e := _turret_at(10, 10)
_player_at(16, 10)
for ty in range(1, 19):
map.set_tile(13, ty, MapGrid.Kind.WALL)
for _i in 400:
world.step()
assert_eq(world.pool.live_count, 0, "no line of sight, no shooting")
assert_null(e.target)
func test_a_barricade_does_not_break_aggro() -> void:
# You can see over a barricade, so it does not hide you -- it only stops
# what you and it are shooting.
var e := _turret_at(10, 10)
_player_at(16, 10)
for ty in range(1, 19):
map.set_tile(13, ty, MapGrid.Kind.BARRICADE)
for _i in 200:
world.step()
assert_not_null(e.target, "a chest-high barricade is not concealment")
func test_a_chasing_enemy_stops_when_it_loses_you() -> void:
var e := world.spawn_enemy(Content.stalker(), map.tile_centre(10, 10))
var p := _player_at(14, 10)
for _i in 30:
world.step()
var closed: float = e.pos.distance_to(p.pos)
assert_lt(closed, map.tile_centre(10, 10).distance_to(p.pos),
"setup: the stalker should have closed some distance")
# Walk out of range; it must give up rather than follow across the map.
p.pos = map.tile_centre(37, 18)
var before: Vector2 = e.pos
for _i in 120:
world.step()
assert_almost_eq(e.pos.distance_to(before), 0.0, 0.001,
"an enemy that never disengages makes retreating impossible")