7ee6c8e761
ci / verify (push) Successful in 46s
The gap Stage 1 left open: snapshots were encoded once and broadcast to every peer in an instance, so clients were handed enemies the fog then hid. Fog is a rendering rule -- a modified client draws whatever it holds -- so that was no defence at all. Snapshots are now encoded per peer, and actors beyond ACTOR_INTEREST_RADIUS (800u) are never sent. Measured cost of four filtered encodes against one shared: 95.6us vs 24.9us, or 0.032 ms/tick amortised over the snapshot interval. Two asymmetries worth keeping: - The observer's own player record is never filtered, however far out the arithmetic puts it. The client reconciles its prediction against that record, so dropping it breaks the player's own movement rather than hiding someone. - Only bullet SPAWNS are filtered, never despawns. A client told about a bullet must always be told it died, or it keeps a phantom. Bullet spawns use a much wider radius (2200u) than actors, deliberately. An enemy appearing at the edge of sight is cosmetic; a bullet withheld at spawn that later flies into view is invisible damage. The floor is longest bullet travel + fog radius -- 1500 for the Warden's Collapse snipe -- and test_interest.gd recomputes that from live content, so adding a faster or longer-lived bullet fails a test instead of producing bullets that wink into existence. Also clamped bosses to their room. A no-op today since every boss is stationary, which is exactly when the invariant is cheap to establish: boss rooms have no door that locks, so the only thing keeping a fight in the boss room is the boss, and Stage 5's movement work would otherwise break it quietly. 146 tests (was 137). check.sh, test.sh and smoke.sh pass. Stage 1 has no partials left; docs/ROADMAP.md now records the three interest radii and the floor each must respect.
138 lines
4.9 KiB
GDScript
138 lines
4.9 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())
|
|
|
|
|
|
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)
|