Room 0 is left empty so that arriving is not an ambush, but that only ever covered room 0. An enemy in a neighbouring room within aggro range and in sight of the entrance woke the moment you arrived -- in one run in eight before the tunnels were widened, and in three runs in eight after, because wider tunnels mean more open ground and more lines of sight. Measured over 200 runs: 25 before, 75 after. A screenshot of an arrival with enemies visibly in range is what prompted counting. Room enemies that would be awake on arrival -- within their aggro range of the entrance with nothing in the way, the same two questions aggro asks -- are now skipped. That is about one enemy in sixteen. Clearing everything within aggro range regardless of sight would have removed nearly a third, which is a different dungeon rather than a fixed one. The skipped enemy's dice have already been rolled, so every other enemy stands exactly where that seed always put it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
8.8 KiB
GDScript
237 lines
8.8 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")
|
|
|
|
|
|
## 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)
|
|
|
|
|
|
func test_nothing_is_awake_and_watching_when_the_party_arrives() -> void:
|
|
# Room 0 is left empty so that arriving is not an ambush, but that only ever
|
|
# covered room 0. With tunnels widened, three runs in eight had an enemy in
|
|
# a neighbouring room within aggro range and in sight of the entrance.
|
|
var watching := 0
|
|
for s in range(1, 120):
|
|
var run := Instance.make_dungeon(2, s * 7919, 1 + (s % 3))
|
|
for e in run.world.enemies.values():
|
|
if e.pos.distance_to(run.world.spawn_point) <= e.def.aggro_range \
|
|
and run.world.map.has_line_of_sight(e.pos, run.world.spawn_point):
|
|
watching += 1
|
|
assert_eq(watching, 0, "%d enemies could see the entrance at the moment of arrival" % watching)
|
|
|
|
|
|
func test_clearing_the_entrance_leaves_the_rest_of_the_dungeon_alone() -> void:
|
|
# It skips the few enemies that would be awake on arrival and nothing else:
|
|
# the dungeon keeps nearly all of its population.
|
|
var total := 0
|
|
for s in range(1, 40):
|
|
total += Instance.make_dungeon(2, s * 7919, 1).world.enemies.size()
|
|
assert_gt(float(total) / 39.0, 9.0, "a depth-1 dungeon still has a dungeon's worth of enemies")
|