a943aa19f6
ci / verify (push) Successful in 47s
Two labelled portals now stand side by side in the hub. The Proving Grounds runs the same generator, the same rooms, the same enemies and the same four-phase Warden -- enemies at a fifth health, the boss at 288 instead of 3600, and trash dropping potions 80% of the time instead of 8%. A manual pass over loot, the inventory, dropping and every boss phase takes a couple of minutes rather than a quarter of an hour. It is multipliers over the shared content rather than a parallel copy: a duplicated Content would drift the first time anything was tuned, and "identical but easier" would quietly stop being true. And it is a portal rather than a launch flag, so the two can be compared back to back without restarting the server -- which is most of the point. Which dungeon you enter is resolved from the player's server-side position, and PORTAL_USED carries the answer. There is deliberately no client message that names a dungeon: one would let any client ask for the generous loot table and bring the results back to the hub. Instance matching compares dungeon ids too, so walking into one entrance can never drop you into the other's run on timing alone. SimWorld.portals replaces portal_pos/portal_enabled, enter_instance carries the portal list and the dungeon id (the client needs the latter to scale the boss bar's ceiling the way the server scaled the boss), and Protocol.VERSION goes to 7. Also pins what happens when two players reach for one item on the same tick: exactly one gets it -- the loop is sequential and the pickup erases the entity before the next player looks. The tie-break is join order rather than distance, which is arbitrary rather than designed, so it is recorded as such. Stale doc fixed while here: MapGen.build() still claimed the client rebuilds the map from the seed, which has not been true since map streaming landed and is the opposite of the rule. check.sh clean, 288 tests, SMOKE PASS (18 assertions, both dungeon kinds opened over a real socket), all three diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
141 lines
5.1 KiB
GDScript
141 lines
5.1 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)
|