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") ## Boss rooms have no door that locks, so the only thing keeping a boss fight in ## the boss room is the boss itself. Bosses are stationary today, which makes ## this the right moment to pin the invariant -- before movement arrives and ## quietly breaks it. func test_a_boss_cannot_leave_its_room() -> void: _step(SimConfig.DUNGEON_FORMING_TICKS + 5) var boss := inst.world.boss var room: Rect2 = boss.room # Shove it far outside, the way a future chase behaviour might. boss.pos = room.position + room.size + Vector2(2000.0, 2000.0) inst.step() assert_true(room.grow(1.0).has_point(boss.pos), "a boss that can be pushed out of its arena could chase a player who " + "walked away, and walking away is always allowed") 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(inst.dungeon_id)) 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_portals_and_no_hostiles() -> void: var lobby := _lobby() assert_eq(lobby.world.portals.size(), Dungeons.ORDER.size(), "one entrance per dungeon, taken from the hub stamp") for portal in lobby.world.portals: assert_ne(portal.pos, Vector2.ZERO, "the position comes from the map") assert_not_null(Dungeons.get_def(portal.dungeon)) 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.portals[0].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.portals[0].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) # --- Tunnel groups ---------------------------------------------------------- func _tunnel_enemies(run: Instance) -> Array[SimEnemy]: var out: Array[SimEnemy] = [] for e in run.world.enemies.values(): var t := run.world.map.to_tile(e.pos) if run.boss_room.has_point(t): continue var in_room := false for r in run.rooms: if r.grow(1).has_point(t): in_room = true if not in_room: out.append(e) return out func test_tunnels_are_occasionally_occupied_and_usually_not() -> void: # "Rarely" in numbers: across many depth-1 runs, some have a group in a # tunnel and most do not. var occupied := 0 var runs := 80 for s in range(1, runs + 1): if not _tunnel_enemies(Instance.make_dungeon(2, s * 7919, 1)).is_empty(): occupied += 1 assert_gt(occupied, runs / 10, "tunnel groups exist (%d of %d runs)" % [occupied, runs]) assert_lt(occupied, runs / 2, "but a run with one is the exception (%d of %d)" % [occupied, runs]) func test_a_tunnel_group_is_never_awake_when_you_arrive() -> void: var widest := Instance._widest_aggro() for s in range(1, 120): var run := Instance.make_dungeon(2, s * 7919, 1 + (s % 3)) for e in _tunnel_enemies(run): assert_gt(e.pos.distance_to(run.world.spawn_point), widest, "seed %d: a tunnel %s stands within aggro range of the entrance" % [s * 7919, e.def.id]) func test_a_tunnel_group_is_a_group() -> void: # Never a lone straggler, and never spread down the whole tunnel: every # member has another within a few tiles. var near := MapGrid.TILE * (Instance.TUNNEL_GROUP_SPREAD * 2 + 1) var seen := 0 for s in range(1, 120): var found := _tunnel_enemies(Instance.make_dungeon(2, s * 7919, 1 + (s % 3))) for e in found: seen += 1 var has_company := false for other in found: if other != e and other.pos.distance_to(e.pos) <= near: has_company = true assert_true(has_company, "seed %d: a tunnel %s on its own" % [s * 7919, e.def.id]) assert_gt(seen, 0, "the sample produced at least one group to check") func test_a_tunnel_group_is_scaled_like_everything_else() -> void: # The Proving Grounds scales every enemy it spawns; a tunnel group must not # be the one thing that arrives at full health in the easy harness. for s in range(1, 120): var run := Instance.make_dungeon(2, s * 7919, 2, Dungeons.PROVING) for e in _tunnel_enemies(run): assert_lt(e.hp, Content.enemy(e.def.id).max_hp, "seed %d: a tunnel %s skipped the dungeon's scaling" % [s * 7919, e.def.id]) func test_boss_rush_leaves_the_tunnels_empty_too() -> void: GameOpts.boss_rush = true var run := Instance.make_dungeon(2, 4242, 3) GameOpts.boss_rush = false assert_eq(_tunnel_enemies(run).size(), 0)