Files
transcience/tests/integration/test_dungeon_flow.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

122 lines
4.2 KiB
GDScript

extends GutTest
## A dungeon run, end to end: a generated map populated at creation, explored
## rather than survived, and cleared by killing the boss.
var inst: Instance
func before_each() -> void:
inst = Instance.make_dungeon(2, 12345, 1)
inst.add_peer(1, "tester")
func _step(n: int) -> void:
for _i in n:
inst.step()
func test_a_dungeon_is_populated_when_it_is_created() -> void:
# Not spawned in waves: the map has contents before anyone walks in, which
# is what makes exploring a decision instead of a countdown.
assert_gt(inst.world.enemies.size(), 0, "the rooms should have occupants")
assert_not_null(inst.world.boss)
assert_true(inst.world.boss.alive)
func test_the_boss_starts_inside_its_own_room() -> void:
assert_true(inst.boss_room.size != Vector2i.ZERO, "a boss room must exist")
assert_true(inst.world.boss.room.has_point(inst.world.boss.pos),
"the boss has to start inside the arena it is confined to")
func test_nothing_spawns_inside_geometry() -> void:
for e in inst.world.enemies.values():
assert_false(inst.world.map.circle_blocked(e.pos, e.def.radius),
"%s spawned inside a wall" % e.def.id)
assert_false(inst.world.map.circle_blocked(inst.world.spawn_point,
SimConfig.PLAYER_RADIUS), "the party would arrive inside a wall")
func test_a_forming_dungeon_locks_after_the_window() -> void:
assert_eq(inst.state, Instance.State.FORMING)
_step(SimConfig.DUNGEON_FORMING_TICKS + 2)
assert_eq(inst.state, Instance.State.ACTIVE)
func test_a_full_party_locks_the_dungeon_immediately() -> void:
for peer in range(2, 2 + SimConfig.DUNGEON_PARTY_MAX - 1):
inst.add_peer(peer, "p%d" % peer)
_step(2)
assert_eq(inst.state, Instance.State.ACTIVE)
assert_false(inst.accepts_new_party_member())
func test_killing_the_boss_clears_the_instance() -> void:
_step(SimConfig.DUNGEON_FORMING_TICKS + 5)
inst.world.boss.alive = false
_step(5)
assert_eq(inst.state, Instance.State.CLEARED)
assert_almost_eq(inst.exit_countdown_seconds(),
SimConfig.DUNGEON_CLEARED_EXIT_TICKS / SimConfig.TICK_RATE, 1)
_step(SimConfig.DUNGEON_CLEARED_EXIT_TICKS + 10)
assert_eq(inst.stage_delay, 0)
assert_eq(inst.exit_countdown_seconds(), 0)
## Trash left alive must not keep the run open -- you clear a dungeon by
## beating the boss, not by sweeping every corner of the map.
func test_leftover_enemies_do_not_block_clearing() -> void:
_step(SimConfig.DUNGEON_FORMING_TICKS + 5)
assert_gt(inst.world.enemies.size(), 0, "setup: enemies should remain")
inst.world.boss.alive = false
_step(5)
assert_eq(inst.state, Instance.State.CLEARED)
func test_deeper_dungeons_are_larger() -> void:
var deep := Instance.make_dungeon(3, 12345, 5)
assert_gt(deep.world.map.width, inst.world.map.width)
# --- The hub ----------------------------------------------------------------
func _lobby() -> Instance:
var l := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
l.add_peer(1, "tester")
return l
func test_the_lobby_has_a_portal_and_no_hostiles() -> void:
var lobby := _lobby()
assert_true(lobby.world.portal_enabled)
assert_ne(lobby.world.portal_pos, Vector2.ZERO, "the portal comes from the map")
for _i in 600:
lobby.step()
assert_eq(lobby.world.pool.live_count, 0, "nothing in the hub may shoot at you")
assert_eq(lobby.world.players[1].hp, SimConfig.PLAYER_MAX_HP)
func test_interacting_on_the_portal_asks_for_a_dungeon() -> void:
var lobby := _lobby()
lobby.world.players[1].pos = lobby.world.portal_pos
var frames: Array[InputFrame] = [
InputFrame.make(lobby.world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
lobby.world.queue_input(1, frames)
lobby.step()
var used := lobby.world.events.filter(
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.PORTAL_USED)
assert_eq(used.size(), 1)
func test_interacting_away_from_the_portal_does_nothing() -> void:
var lobby := _lobby()
lobby.world.players[1].pos = lobby.world.portal_pos \
+ Vector2(0.0, SimConfig.PORTAL_RADIUS + 80.0)
var frames: Array[InputFrame] = [
InputFrame.make(lobby.world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
lobby.world.queue_input(1, frames)
lobby.step()
var used := lobby.world.events.filter(
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.PORTAL_USED)
assert_eq(used.size(), 0)