Add a second dungeon: the Proving Grounds, a test harness you walk into
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>
This commit is contained in:
2026-09-04 21:34:41 +02:00
parent 050b8251a7
commit a943aa19f6
28 changed files with 733 additions and 74 deletions
+9 -6
View File
@@ -64,7 +64,7 @@ func test_a_full_party_locks_the_dungeon_immediately() -> void:
inst.add_peer(peer, "p%d" % peer)
_step(2)
assert_eq(inst.state, Instance.State.ACTIVE)
assert_false(inst.accepts_new_party_member())
assert_false(inst.accepts_new_party_member(inst.dungeon_id))
func test_killing_the_boss_clears_the_instance() -> void:
@@ -102,10 +102,13 @@ func _lobby() -> Instance:
return l
func test_the_lobby_has_a_portal_and_no_hostiles() -> void:
func test_the_lobby_has_portals_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")
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")
@@ -114,7 +117,7 @@ func test_the_lobby_has_a_portal_and_no_hostiles() -> void:
func test_interacting_on_the_portal_asks_for_a_dungeon() -> void:
var lobby := _lobby()
lobby.world.players[1].pos = lobby.world.portal_pos
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)
@@ -126,7 +129,7 @@ func test_interacting_on_the_portal_asks_for_a_dungeon() -> void:
func test_interacting_away_from_the_portal_does_nothing() -> void:
var lobby := _lobby()
lobby.world.players[1].pos = lobby.world.portal_pos \
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)]
+171
View File
@@ -0,0 +1,171 @@
extends GutTest
## The dungeon flavours. The Proving Grounds exists so a manual pass over loot,
## inventory and all four boss phases takes minutes rather than a quarter of an
## hour -- which is only true if it stays genuinely identical to the real run
## apart from the numbers it scales.
func test_every_registered_id_has_a_definition() -> void:
for id in Dungeons.ORDER:
assert_not_null(Dungeons.get_def(id), "%s is in ORDER with no definition" % id)
func test_the_wire_index_round_trips() -> void:
for id in Dungeons.ORDER:
assert_eq(Dungeons.by_index(Dungeons.index_of(id)), id)
## An id off the wire has to resolve to something playable. Falling back to the
## standard run is the safe direction; returning null would crash a peer for
## sending a byte this build does not recognise.
func test_an_unknown_id_falls_back_to_the_real_run() -> void:
assert_null(Dungeons.get_def(&"atlantis"))
assert_eq(Dungeons.get_or_default(&"atlantis").id, Dungeons.STANDARD)
assert_eq(Dungeons.by_index(99), Dungeons.default_id())
assert_eq(Dungeons.by_index(-1), Dungeons.default_id())
## The baseline has to be exactly the content as authored, or "identical but
## easier" is measured against something that is itself already scaled.
func test_the_standard_run_scales_nothing() -> void:
var d := Dungeons.standard()
assert_eq(d.enemy_hp_mult, 1.0)
assert_eq(d.boss_hp_mult, 1.0)
assert_eq(d.loot_chance_mult, 1.0)
assert_eq(d.apply_to_enemy(Content.drifter()).max_hp, Content.drifter().max_hp)
assert_eq(d.apply_to_boss(Content.warden()).max_hp, Content.warden().max_hp)
assert_eq(d.apply_to_enemy(Content.drifter()).loot[0].chance,
Content.drifter().loot[0].chance)
func test_the_proving_grounds_is_easier_in_every_direction() -> void:
var easy := Dungeons.proving_grounds()
assert_lt(easy.apply_to_enemy(Content.drifter()).max_hp, Content.drifter().max_hp)
assert_lt(easy.apply_to_boss(Content.warden()).max_hp, Content.warden().max_hp)
assert_gt(easy.apply_to_enemy(Content.drifter()).loot[0].chance,
Content.drifter().loot[0].chance)
## Nothing may be scaled out of existence: a one-shot enemy is still a fight,
## an enemy with zero health is a crash waiting for a divide.
func test_scaling_never_produces_a_creature_with_no_health() -> void:
var brutal := DungeonDef.new()
brutal.enemy_hp_mult = 0.0
brutal.boss_hp_mult = 0.0
assert_gte(brutal.apply_to_enemy(Content.drifter()).max_hp, 1)
assert_gte(brutal.apply_to_boss(Content.warden()).max_hp, 1)
## A generous multiplier must not push a chance past certain, or a "chance"
## stops being one and the roll becomes dead code.
func test_boosted_drop_chances_are_clamped_at_certain() -> void:
var generous := DungeonDef.new()
generous.loot_chance_mult = 500.0
for entry in generous.apply_to_enemy(Content.drifter()).loot:
assert_lte(entry.chance, 1.0)
# And a guaranteed drop stays exactly guaranteed rather than overflowing.
for entry in generous.apply_to_boss(Content.warden()).loot:
assert_eq(entry.chance, 1.0)
## Scaling mutates the def it is given. That is only safe because every
## Content.* call constructs a fresh one -- if that ever stops being true, one
## easy dungeon would permanently nerf every hard one in the process.
func test_scaling_one_dungeon_does_not_leak_into_the_next() -> void:
Dungeons.proving_grounds().apply_to_boss(Content.warden())
assert_eq(Dungeons.standard().apply_to_boss(Content.warden()).max_hp,
Content.warden().max_hp,
"a standard run built afterwards must still have a full-health boss")
# --- Instances --------------------------------------------------------------
func test_a_proving_run_is_built_weaker_than_a_standard_one() -> void:
var hard := Instance.make_dungeon(2, 4242, 1, Dungeons.STANDARD)
var easy := Instance.make_dungeon(3, 4242, 1, Dungeons.PROVING)
assert_lt(easy.world.boss.hp, hard.world.boss.hp)
assert_eq(easy.world.map.width, hard.world.map.width,
"same seed and depth must still give the same size of map")
assert_eq(easy.world.enemies.size(), hard.world.enemies.size(),
"and the same number of enemies in the same rooms")
var easy_hp := 0
var hard_hp := 0
for e in easy.world.enemies.values():
easy_hp += e.hp
for e in hard.world.enemies.values():
hard_hp += e.hp
assert_lt(easy_hp, hard_hp)
func test_a_dungeon_defaults_to_the_real_run() -> void:
assert_eq(Instance.make_dungeon(2, 1, 1).dungeon_id, Dungeons.default_id())
## Walking into the Proving Grounds must never drop you into a standard run that
## happens to still be forming, however conveniently timed.
func test_a_forming_run_only_accepts_its_own_kind() -> void:
var inst := Instance.make_dungeon(2, 1, 1, Dungeons.PROVING)
assert_true(inst.accepts_new_party_member(Dungeons.PROVING))
assert_false(inst.accepts_new_party_member(Dungeons.STANDARD))
func test_the_hub_has_one_entrance_per_dungeon() -> void:
var lobby := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
assert_eq(lobby.world.portals.size(), Dungeons.ORDER.size())
var seen := {}
for portal in lobby.world.portals:
seen[portal.dungeon] = true
for id in Dungeons.ORDER:
assert_true(seen.has(id), "%s has no way in" % id)
## Two entrances whose catchment areas overlap would make which dungeon you
## enter depend on list order rather than on where you are standing.
func test_the_entrances_are_far_enough_apart_to_be_unambiguous() -> void:
var portals := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID).world.portals
for i in portals.size():
for j in range(i + 1, portals.size()):
assert_gt(portals[i].pos.distance_to(portals[j].pos),
SimConfig.PORTAL_RADIUS * 2.0,
"portals %d and %d overlap" % [i, j])
func test_standing_on_an_entrance_resolves_to_that_dungeon() -> void:
var world := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID).world
for portal in world.portals:
var found := world.portal_at(portal.pos)
assert_not_null(found)
assert_eq(found.dungeon, portal.dungeon)
func test_standing_between_them_is_still_out_of_reach_of_both() -> void:
var world := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID).world
if world.portals.size() < 2:
pass_test("only one entrance; nothing to stand between")
return
var midpoint := (world.portals[0].pos + world.portals[1].pos) * 0.5
assert_null(world.portal_at(midpoint),
"the gap between entrances has to be a place where nothing happens")
## The event carries which dungeon, because the server must never take that from
## anything the client says.
func test_using_an_entrance_reports_which_dungeon_it_opens() -> void:
var lobby := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
lobby.add_peer(1, "tester")
var target: SimPortal = lobby.world.portals[Dungeons.ORDER.size() - 1]
lobby.world.players[1].pos = target.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)
assert_eq(StringName(used[0]["dungeon"]), target.dungeon)
func test_a_dungeon_world_has_no_entrances_of_its_own() -> void:
var inst := Instance.make_dungeon(2, 1, 1, Dungeons.PROVING)
assert_eq(inst.world.portals.size(), 0,
"there is no way further down yet, and no way to re-enter from inside")
+1
View File
@@ -0,0 +1 @@
uid://x5hi6p68krjs
+65 -2
View File
@@ -95,8 +95,7 @@ func test_a_full_inventory_leaves_the_item_on_the_floor() -> void:
## The portal and pickup share the interact key. A full bag must not leave a
## player standing on the portal unable to use it.
func test_a_failed_pickup_does_not_block_the_portal() -> void:
world.portal_enabled = true
world.portal_pos = Vector2.ZERO
world.portals = [SimPortal.make(Vector2.ZERO, Dungeons.default_id())]
var me := world.players[ME]
for _i in SimConfig.INVENTORY_SLOTS:
me.add_item(Items.WARDENS_RATION)
@@ -355,3 +354,67 @@ func test_a_replica_never_creates_loot_of_its_own() -> void:
replica.step()
assert_eq(replica.loot.size(), 0,
"a client must never invent an item for itself to pick up")
# --- Two players, one item ---------------------------------------------------
## There is no race here to lose. The simulation is single-threaded and
## `_step_players` walks the player list one at a time within a tick, and
## `_try_pickup` erases the item from `world.loot` the instant it succeeds --
## so the second player's search that same tick finds nothing.
func test_two_players_reaching_for_the_same_item_on_one_tick() -> void:
var them := world.add_player(THEM, "them")
them.pos = Vector2(6.0, 0.0)
them.spawn_grace = 0
world.players[ME].pos = Vector2(-6.0, 0.0)
world.spawn_loot(Items.HEALTH_POTION, Vector2.ZERO)
var at := world.tick + 1
var mine: Array[InputFrame] = [
InputFrame.make(at, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
var theirs: Array[InputFrame] = [
InputFrame.make(at, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
world.queue_input(ME, mine)
world.queue_input(THEM, theirs)
world.step()
var holders := 0
for p in world.players.values():
if p.inventory[0] == Items.HEALTH_POTION:
holders += 1
assert_eq(holders, 1, "exactly one player ends up with it")
assert_eq(world.loot.size(), 0, "and it is gone from the floor")
assert_eq(_events_of(SimEvent.Type.ITEM_PICKED_UP).size(), 1,
"one pickup happened, so one pickup is announced")
## The tie-break, recorded because it is arbitrary rather than designed: the
## player list is a Dictionary, Godot dictionaries iterate in insertion order,
## and players are inserted as they join. So on a genuine same-tick tie the
## player who has been in the instance longer wins, every time -- distance to
## the item does not enter into it.
##
## Worth knowing before it is mistaken for a fairness rule. If it ever needs to
## be one, resolving contested pickups by distance after the player loop is the
## natural change.
func test_the_same_tick_tie_break_is_join_order_not_distance() -> void:
var them := world.add_player(THEM, "them")
them.spawn_grace = 0
# The later joiner stands almost on top of it; the earlier one is further
# away but still in reach.
them.pos = Vector2(1.0, 0.0)
world.players[ME].pos = Vector2(SimConfig.LOOT_PICKUP_RADIUS - 2.0, 0.0)
world.spawn_loot(Items.HEALTH_POTION, Vector2.ZERO)
var at := world.tick + 1
var mine: Array[InputFrame] = [
InputFrame.make(at, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
var theirs: Array[InputFrame] = [
InputFrame.make(at, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
world.queue_input(ME, mine)
world.queue_input(THEM, theirs)
world.step()
assert_eq(world.players[ME].inventory[0], Items.HEALTH_POTION,
"the player who joined first wins, despite standing further away")
assert_eq(them.inventory[0], Items.NONE)
+34
View File
@@ -402,3 +402,37 @@ func test_an_input_frame_carries_its_slot_over_the_wire() -> void:
assert_true(out[0].pressed(InputFrame.BTN_USE))
assert_eq(out[1].slot, 1)
assert_true(out[1].pressed(InputFrame.BTN_DROP))
# --- Portals ----------------------------------------------------------------
func test_portals_round_trip() -> void:
var portals: Array[SimPortal] = [
SimPortal.make(Vector2(-160.0, 48.0), Dungeons.STANDARD),
SimPortal.make(Vector2(160.0, 48.0), Dungeons.PROVING),
]
var out := NetCodec.decode_portals(NetCodec.encode_portals(portals))
assert_eq(out.size(), 2)
assert_almost_eq((out[0]["pos"] as Vector2).x, -160.0, 0.01)
assert_eq(out[0]["dungeon"], Dungeons.STANDARD)
assert_almost_eq((out[1]["pos"] as Vector2).x, 160.0, 0.01)
assert_eq(out[1]["dungeon"], Dungeons.PROVING)
func test_a_truncated_portal_packet_does_not_invent_an_entrance() -> void:
var portals: Array[SimPortal] = [
SimPortal.make(Vector2(-160.0, 48.0), Dungeons.STANDARD),
SimPortal.make(Vector2(160.0, 48.0), Dungeons.PROVING),
]
var full := NetCodec.encode_portals(portals)
for cut in range(1, full.size()):
var out := NetCodec.decode_portals(full.slice(0, cut))
assert_lte(out.size(), 2,
"a portal list cut at %d bytes must degrade, not grow" % cut)
assert_eq(NetCodec.decode_portals(full).size(), 2)
func test_an_empty_portal_list_is_safe() -> void:
assert_eq(NetCodec.decode_portals(
NetCodec.encode_portals([] as Array[SimPortal])).size(), 0)
assert_eq(NetCodec.decode_portals(PackedByteArray()).size(), 0)