7af439341d
ci / verify (push) Successful in 46s
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.
122 lines
4.2 KiB
GDScript
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)
|