Add a second dungeon: the Proving Grounds, a test harness you walk into
ci / verify (push) Successful in 47s
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:
+29
-14
@@ -3,16 +3,22 @@ extends RefCounted
|
||||
## Builds a dungeon: generated rooms and corridors around a hand-authored boss
|
||||
## arena (see [Rooms]).
|
||||
##
|
||||
## Deterministic from (seed, depth) alone, so the server can hand a client the
|
||||
## same two numbers instead of a map, and a failing run can be reproduced from
|
||||
## its log line.
|
||||
## Deterministic from (seed, depth) alone, which is what makes a failing run
|
||||
## reproducible from its log line. It does NOT mean the client can rebuild the
|
||||
## map -- see the note on build().
|
||||
|
||||
## The single entry point both server and client use.
|
||||
## The single entry point. SERVER-SIDE ONLY.
|
||||
##
|
||||
## Maps are never sent as tile data: they are a pure function of
|
||||
## (kind, seed, depth), so the server ships three integers and the client
|
||||
## rebuilds the identical grid. tests/unit/test_map_gen.gd pins the determinism
|
||||
## that makes that safe, and it keeps a big dungeon free on the wire.
|
||||
## This used to be described as "both sides call it with the same three
|
||||
## integers" -- that is no longer true and has not been since map streaming
|
||||
## landed. Handing a client the seed would let it regenerate the entire floor
|
||||
## plan, which is a map hack with no work required, so the client is given the
|
||||
## map's SIZE and nothing else and fills tiles in from streamed chunks as it
|
||||
## walks. See ServerRuntime._stream_map.
|
||||
##
|
||||
## Generation is still deterministic from (kind, seed, depth), which is what
|
||||
## makes a bad run reproducible from its log line; tests/unit/test_map_gen.gd
|
||||
## pins that.
|
||||
static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int) -> Dictionary:
|
||||
if kind == Protocol.InstanceKind.LOBBY:
|
||||
return _build_lobby()
|
||||
@@ -29,10 +35,19 @@ static func _build_lobby() -> Dictionary:
|
||||
if not markers["S"].is_empty():
|
||||
var m: Vector2i = markers["S"][0]
|
||||
spawn = grid.tile_centre(m.x, m.y)
|
||||
var portal := grid.tile_centre(size.x / 2, 3)
|
||||
if not markers["P"].is_empty():
|
||||
var m: Vector2i = markers["P"][0]
|
||||
portal = grid.tile_centre(m.x, m.y)
|
||||
# One portal per marker, in reading order, matched against Dungeons.ORDER.
|
||||
# A stamp with fewer markers than dungeons simply makes the extra ones
|
||||
# unreachable rather than crashing -- an unreachable dungeon is a content
|
||||
# bug, not a runtime one.
|
||||
var portals: Array[SimPortal] = []
|
||||
for i in (markers["P"] as Array).size():
|
||||
if i >= Dungeons.ORDER.size():
|
||||
break
|
||||
var m: Vector2i = markers["P"][i]
|
||||
portals.append(SimPortal.make(grid.tile_centre(m.x, m.y), Dungeons.ORDER[i]))
|
||||
if portals.is_empty():
|
||||
portals.append(SimPortal.make(
|
||||
grid.tile_centre(size.x / 2, 3), Dungeons.default_id()))
|
||||
var target := grid.tile_centre(size.x / 4, size.y / 2)
|
||||
if not markers["T"].is_empty():
|
||||
var m: Vector2i = markers["T"][0]
|
||||
@@ -41,7 +56,7 @@ static func _build_lobby() -> Dictionary:
|
||||
"grid": grid,
|
||||
"rooms": [] as Array[Rect2i],
|
||||
"spawn": spawn,
|
||||
"portal": portal,
|
||||
"portals": portals,
|
||||
"dummy": target,
|
||||
"boss_pos": Vector2.ZERO,
|
||||
"boss_room": Rect2i(),
|
||||
@@ -131,7 +146,7 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
|
||||
"grid": grid,
|
||||
"rooms": rooms,
|
||||
"spawn": spawn,
|
||||
"portal": Vector2.ZERO,
|
||||
"portals": [] as Array[SimPortal],
|
||||
"dummy": Vector2.ZERO,
|
||||
"boss_pos": boss_pos,
|
||||
"boss_room": boss_room,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
class_name SimPortal
|
||||
extends RefCounted
|
||||
## A dungeon entrance standing in the hub.
|
||||
##
|
||||
## The hub holds a list of these rather than a single position, because "which
|
||||
## dungeon does this one open" is the whole reason there is more than one.
|
||||
|
||||
var pos := Vector2.ZERO
|
||||
var dungeon: StringName = &""
|
||||
|
||||
|
||||
static func make(at: Vector2, dungeon_id: StringName) -> SimPortal:
|
||||
var p := SimPortal.new()
|
||||
p.pos = at
|
||||
p.dungeon = dungeon_id
|
||||
return p
|
||||
@@ -0,0 +1 @@
|
||||
uid://fnxk8yj4shad
|
||||
+23
-7
@@ -33,10 +33,10 @@ var events: Array[Dictionary] = []
|
||||
## unit test build a world without thinking about terrain.
|
||||
var map: MapGrid = null
|
||||
|
||||
## Set on a lobby world so the interact button can open a dungeon.
|
||||
var portal_enabled: bool = false
|
||||
## Where the hub's dungeon portal sits. Per-world now that maps vary in size.
|
||||
var portal_pos := Vector2.ZERO
|
||||
## Dungeon entrances standing in this world. Empty everywhere but the hub.
|
||||
## A list rather than a single position because which dungeon an entrance opens
|
||||
## is the whole reason there is more than one.
|
||||
var portals: Array[SimPortal] = []
|
||||
var spawn_point := Vector2(0.0, 240.0)
|
||||
## Arrival protection granted to players entering this world. 0 in the hub,
|
||||
## SimConfig.SPAWN_GRACE_TICKS in a dungeon.
|
||||
@@ -117,6 +117,20 @@ func spawn_boss(def: BossDef) -> SimBoss:
|
||||
return b
|
||||
|
||||
|
||||
## The portal [param at] is standing on, or null. Nearest wins, so two
|
||||
## entrances placed close enough to overlap still resolve to one answer instead
|
||||
## of to whichever happens to be first in the list.
|
||||
func portal_at(at: Vector2) -> SimPortal:
|
||||
var best: SimPortal = null
|
||||
var best_d := SimConfig.PORTAL_RADIUS * SimConfig.PORTAL_RADIUS
|
||||
for portal in portals:
|
||||
var d := at.distance_squared_to(portal.pos)
|
||||
if d <= best_d:
|
||||
best_d = d
|
||||
best = portal
|
||||
return best
|
||||
|
||||
|
||||
func alive_player_count() -> int:
|
||||
var n := 0
|
||||
for p in players.values():
|
||||
@@ -226,9 +240,11 @@ func _step_players() -> void:
|
||||
if edge & InputFrame.BTN_INTERACT:
|
||||
took_item = _try_pickup(p)
|
||||
|
||||
if portal_enabled and not took_item and frame.pressed(InputFrame.BTN_INTERACT):
|
||||
if p.pos.distance_to(portal_pos) <= SimConfig.PORTAL_RADIUS:
|
||||
events.append({"t": SimEvent.Type.PORTAL_USED, "peer": p.peer_id})
|
||||
if not took_item and frame.pressed(InputFrame.BTN_INTERACT):
|
||||
var portal := portal_at(p.pos)
|
||||
if portal != null:
|
||||
events.append({"t": SimEvent.Type.PORTAL_USED, "peer": p.peer_id,
|
||||
"dungeon": String(portal.dungeon)})
|
||||
|
||||
|
||||
## Pull the next input for this player, or coast on the last one. Coasting is
|
||||
|
||||
Reference in New Issue
Block a user