diff --git a/CLAUDE.md b/CLAUDE.md index ede4e3a..e4e57f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ and `tests/integration/test_replica_parity.gd` pin this down. | Path | What lives there | | --- | --- | | `src/sim/` | The whole game as plain RefCounted objects. No nodes, no physics server, no rendering. | -| `src/actors/` | Data-only `Resource` definitions: `EnemyDef`, `BossDef`, `ItemDef`, `LootDrop`. Shapes, not instances. | +| `src/actors/` | Data-only `Resource` definitions: `EnemyDef`, `BossDef`, `ItemDef`, `LootDrop`, `DungeonDef`. Shapes, not instances. | | `src/sim/patterns/` | Bullet emitters — the authoring surface for every enemy and boss. | | `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunk streaming. | | `src/sim/map_gen.gd` | Dungeon generation; `build()` is the only entry point. | @@ -97,6 +97,7 @@ and `tests/integration/test_replica_parity.gd` pin this down. | `src/meta/` | Accounts, characters, persistence, XP curve. Server-owned. | | `src/content/content.gd` | All enemies and bosses, defined in code. Source of truth. | | `src/content/items.gd` | All items, same idea. `Items.ORDER` is the wire format — append only. | +| `src/content/dungeons.gd` | The kinds of run. `Dungeons.ORDER` is both a wire format and the hub's portal order. | | `src/net/` | Codec, `ServerRuntime`, `ClientRuntime`. | | `src/instances/` | Lobby hub and dungeon runs. | | `src/view/`, `src/ui/` | Read-only rendering. Never decides anything. | @@ -168,7 +169,14 @@ ticks in milliseconds with no SceneTree. - **`Items.ORDER` is a wire format.** An item's index in it is the byte that rides the snapshot and every item event. Append, never reorder — reordering makes every existing client decode a potion as a ration, so it needs a - `Protocol.VERSION` bump. + `Protocol.VERSION` bump. `Dungeons.ORDER` is the same, and additionally + decides which hub portal opens which dungeon (Nth `P` marker in the lobby + stamp → Nth entry). +- **Which dungeon you enter comes from where you are standing, never from the + client.** `SimWorld.portal_at()` resolves the player's server-side position + to a portal, and the `PORTAL_USED` event carries the answer. There is no + message that names a dungeon, and adding one would let any client pick the + easy variant's loot rate. - **Loot has two visibilities, and the instanced one is enforced in the codec.** `NetCodec.encode_snapshot` filters items owned by another peer, exactly like the actor interest radius. Never move that check into the client: hiding an diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c52232a..f9347a5 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -249,3 +249,34 @@ are all simpler for it. Add a count when something actually needs one. oldest evicted.** Dungeons close and take their litter with them, so only the hub — which never closes and where players can drop things — can realistically reach the cap. + +--- + +## More than one dungeon + +**A second dungeon is a set of multipliers over the shared content, not a +parallel copy of it.** `DungeonDef` scales enemy health, boss health and loot +chance; the generator, the rooms, the enemy mix and the boss are the same +objects the real run uses. A duplicated `Content` would drift from the original +the first time anything was tuned, and the whole value of the Proving Grounds is +that it is *identical apart from the numbers*. + +**It is reachable from the hub rather than hidden behind a launch flag.** A flag +would need a server restart to switch, which makes comparing the two a chore +and makes "does this behave the same in the real run?" a question nobody +bothers to ask. Two portals a few metres apart makes it a five-second check. + +**Which dungeon you enter is decided by where you are standing.** The portal is +resolved server-side from the player's own position, and the `PORTAL_USED` event +carries the answer. There is deliberately no client message that names a +dungeon: one would let any client ask for the Proving Grounds' loot rate and +walk out with it. + +**The instance matcher compares dungeon ids.** A forming run only accepts party +members who asked for that kind. Without it, walking into one entrance could +drop you into the other's run purely on timing. + +**Scaling clamps at both ends.** Health never scales below 1 — a creature with +zero health is a crash waiting for a divide — and a boosted drop chance never +exceeds certain, or the roll becomes dead code and "chance" stops meaning +anything. diff --git a/docs/NETCODE.md b/docs/NETCODE.md index d449522..20372a2 100644 --- a/docs/NETCODE.md +++ b/docs/NETCODE.md @@ -221,6 +221,23 @@ The Warden's Ration exists to keep this path honest: it is dropped instanced on every boss kill, so the filter runs in every real fight rather than only in tests. +## Which dungeon you enter is a position, not a request + +The hub holds a list of portals, each bound to a dungeon id. Pressing interact +resolves the player's **server-side** position through `SimWorld.portal_at()`, +and the resulting `PORTAL_USED` event carries which dungeon that entrance opens. + +There is no client message that names a dungeon, and there must not be: one +would let any client ask for the Proving Grounds' loot rate — ten times the drop +chance — and bring the results back to the hub. The same reasoning as everywhere +else here; the difference between "the server checks your request" and "there is +no request" is the whole model. + +`enter_instance` carries the portal list (position plus a dungeon index, via +`NetCodec.encode_portals`) and the id of the dungeon you have arrived in. The +client needs the latter to scale the boss's health ceiling the same way the +server did, or the boss bar would sit near empty for an entire easy fight. + ## Item actions ride the input frame `InputFrame` carries `BTN_USE`, `BTN_DROP` and a slot byte (10 bytes total, up diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 75a34ad..d74ae8a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,8 +20,8 @@ What "everything passes" currently means. Numbers move; the shape does not. | Gate | Covers | Runtime | | --- | --- | --- | | `tools/check.sh` | every script parses and type-checks | ~5s | -| `tools/test.sh` | 266 GUT tests, no SceneTree | ~3s | -| `tools/smoke.sh` | 16 assertions over a real ENet socket: handshake, auth, character creation and persistence, portal, escape, hard kill, polite disconnect | ~40s | +| `tools/test.sh` | 288 GUT tests, no SceneTree | ~3s | +| `tools/smoke.sh` | 18 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s | | `diag_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s | | `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~10s | | `diag_loot.tscn` | drop → snapshot → pick up → persist → use → drop, and both loot visibilities on the wire | ~10s | @@ -70,6 +70,40 @@ draws whatever it holds. The defence is what the server declines to send. --- +## Dungeon kinds · *done* + +Two entrances stand side by side in the hub, labelled, and open different runs. + +| Dungeon | `Dungeons` id | Enemy HP | Boss HP | Loot chance | +| --- | --- | --- | --- | --- | +| Warden's Descent | `warden_descent` | ×1 | ×1 (3600) | ×1 (trash 8%) | +| Proving Grounds | `proving_grounds` | ×0.2 | ×0.08 (288) | ×10 (trash 80%, clamped) | + +The Proving Grounds is a **test harness you can walk into**: same generator, +same rooms, same enemies, the same four-phase Warden — everything simply dies +faster and drops more. A manual pass over loot, the inventory, dropping and all +four boss phases takes a couple of minutes instead of a quarter of an hour, and +because it is a portal rather than a launch flag you can compare the two back to +back without restarting the server. + +Adding a third dungeon is one entry in `Dungeons.ORDER` plus one more `P` marker +in the lobby stamp — the Nth marker, in reading order, opens the Nth entry. + +Three things worth knowing: + +- **It is multipliers over the shared content, not a copy of it.** A duplicated + `Content` would drift the moment anything was tuned, and "identical but + easier" would quietly stop being true. +- **Which dungeon you enter comes from where you stand.** `SimWorld.portal_at()` + resolves the server-side position; the `PORTAL_USED` event carries the answer. + No client message names a dungeon, which is what stops anyone picking the + generous loot table from the real run. +- **`accepts_new_party_member` takes the dungeon id.** Without that, walking + into the Proving Grounds would drop you into whatever standard run happened + to still be forming. + +--- + ## Art and audio · *first pass done* Placeholders are gone: terrain, actors and bullets are sprites, and four sounds diff --git a/src/actors/dungeons/dungeon_def.gd b/src/actors/dungeons/dungeon_def.gd new file mode 100644 index 0000000..f4728d2 --- /dev/null +++ b/src/actors/dungeons/dungeon_def.gd @@ -0,0 +1,45 @@ +class_name DungeonDef +extends Resource +## A kind of dungeon run. Same generator, same rooms, same enemies -- what +## differs is how tough its contents are and how freely they drop. +## +## Deliberately a set of multipliers over the shared content rather than a +## parallel copy of it: a second dungeon that duplicated `Content` would drift +## from the first the moment anything was tuned, and then "identical but +## easier" would quietly stop being true. + +@export var id: StringName = &"dungeon" +@export var display_name: String = "Dungeon" +## One line for the portal label, so a player standing in the hub can tell the +## entrances apart without reading a wiki. +@export var subtitle: String = "" +@export var enemy_hp_mult: float = 1.0 +@export var boss_hp_mult: float = 1.0 +## Multiplies every loot chance, clamped at certain. Guaranteed drops stay +## guaranteed; there is nothing above 1.0 to reach for. +@export var loot_chance_mult: float = 1.0 +## Portal colour, and the tint of the dungeon's name on the HUD. +@export var tint := Color(0.5, 0.9, 1.0) + + +## Scale a freshly built [EnemyDef] in place. +## +## Safe to mutate because every caller of `Content.enemy()` gets a new object -- +## the content functions construct one per call. If that ever changes, this has +## to duplicate first, or one easy dungeon would nerf every hard one. +func apply_to_enemy(def: EnemyDef) -> EnemyDef: + def.max_hp = maxi(1, roundi(float(def.max_hp) * enemy_hp_mult)) + _scale_loot(def.loot) + return def + + +func apply_to_boss(def: BossDef) -> BossDef: + def.max_hp = maxi(1, roundi(float(def.max_hp) * boss_hp_mult)) + _scale_loot(def.loot) + return def + + +func _scale_loot(table: Array[LootDrop]) -> void: + for entry in table: + if entry != null: + entry.chance = clampf(entry.chance * loot_chance_mult, 0.0, 1.0) diff --git a/src/actors/dungeons/dungeon_def.gd.uid b/src/actors/dungeons/dungeon_def.gd.uid new file mode 100644 index 0000000..1045628 --- /dev/null +++ b/src/actors/dungeons/dungeon_def.gd.uid @@ -0,0 +1 @@ +uid://cqert4nr662vl diff --git a/src/autoload/net.gd b/src/autoload/net.gd index 62ac9fa..03f8189 100644 --- a/src/autoload/net.gd +++ b/src/autoload/net.gd @@ -169,12 +169,13 @@ func send_welcome(peer_id: int) -> void: ## hack with no work required; tiles are streamed instead (send_map_chunks). func send_enter_instance(peer_id: int, id: int, kind: int, server_tick: int, boss_id: String, spawn: Vector2, map_w: int, map_h: int, - portal: Vector2) -> void: + portals: PackedByteArray, dungeon: String) -> void: if _is_local(peer_id): - client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w, map_h, portal) + client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w, + map_h, portals, dungeon) else: s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn, - map_w, map_h, portal) + map_w, map_h, portals, dungeon) func send_map_chunks(peer_id: int, instance_id: int, data: PackedByteArray) -> void: @@ -289,10 +290,12 @@ func s_welcome(peer_id: int, _version: int) -> void: @rpc("authority", "call_remote", "reliable", 1) func s_enter_instance(id: int, kind: int, server_tick: int, boss_id: String, - spawn: Vector2, map_w: int, map_h: int, portal: Vector2) -> void: + spawn: Vector2, map_w: int, map_h: int, portals: PackedByteArray, + dungeon: String) -> void: if client == null: return - client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w, map_h, portal) + client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w, + map_h, portals, dungeon) @rpc("authority", "call_remote", "reliable", 1) diff --git a/src/content/dungeons.gd b/src/content/dungeons.gd new file mode 100644 index 0000000..c068e7a --- /dev/null +++ b/src/content/dungeons.gd @@ -0,0 +1,80 @@ +class_name Dungeons +extends RefCounted +## The kinds of dungeon run, defined in code like everything else in +## `src/content/`. +## +## [constant ORDER] does double duty: it is the wire order for the dungeon id, +## and it is the order the hub's portals are assigned. The Nth `P` marker in the +## lobby stamp (reading order) opens the Nth entry here, so adding a dungeon is +## one entry plus one marker. + +const STANDARD := &"warden_descent" +const PROVING := &"proving_grounds" + +const ORDER: Array[StringName] = [ + STANDARD, + PROVING, +] + + +static func default_id() -> StringName: + return STANDARD + + +static func get_def(id: StringName) -> DungeonDef: + match id: + STANDARD: return standard() + PROVING: return proving_grounds() + return null + + +## Falls back to the standard run rather than to null. An id off the wire has to +## resolve to something playable, and "the normal dungeon" is the safe answer. +static func get_or_default(id: StringName) -> DungeonDef: + var d := get_def(id) + return d if d != null else standard() + + +static func index_of(id: StringName) -> int: + return maxi(ORDER.find(id), 0) + + +static func by_index(index: int) -> StringName: + if index < 0 or index >= ORDER.size(): + return default_id() + return ORDER[index] + + +# --- The dungeons ----------------------------------------------------------- + +## The real thing. Every multiplier is 1.0, which is the point: this is the +## baseline the other definitions are described against. +static func standard() -> DungeonDef: + var d := DungeonDef.new() + d.id = STANDARD + d.display_name = "Warden's Descent" + d.subtitle = "the real run" + d.tint = Color(0.5, 0.9, 1.0) + return d + + +## A test harness you can walk into. +## +## Same generator, same rooms, same enemies and the same Warden -- everything +## dies far faster and drops far more often, so a manual pass over the loot, +## the inventory and all four boss phases takes a couple of minutes instead of a +## quarter of an hour. Being reachable from the hub rather than hidden behind a +## launch flag is most of the value: you can compare the two back to back in one +## session without restarting the server. +static func proving_grounds() -> DungeonDef: + var d := DungeonDef.new() + d.id = PROVING + d.display_name = "Proving Grounds" + d.subtitle = "for testing -- fragile, generous" + d.enemy_hp_mult = 0.2 + d.boss_hp_mult = 0.08 + # 0.08 -> 0.8 for trash. High enough that a handful of kills fills a bag, + # which is what makes the four-slot limit and dropping testable at all. + d.loot_chance_mult = 10.0 + d.tint = Color(1.0, 0.75, 0.35) + return d diff --git a/src/content/dungeons.gd.uid b/src/content/dungeons.gd.uid new file mode 100644 index 0000000..3e6917e --- /dev/null +++ b/src/content/dungeons.gd.uid @@ -0,0 +1 @@ +uid://dw7j78sn8obvw diff --git a/src/content/rooms.gd b/src/content/rooms.gd index 987d27a..c55adaa 100644 --- a/src/content/rooms.gd +++ b/src/content/rooms.gd @@ -96,16 +96,21 @@ static func stamp(grid: MapGrid, s: PackedStringArray, origin: Vector2i) -> Dict markers[ch].append(Vector2i(tx, ty)) return markers -## The hub. Hand-authored like the boss arenas, and reproduced identically on -## the client from this same function -- see MapGen.build(). -## P portal to the dungeons S player spawn T practice target +## The hub. Hand-authored like the boss arenas. +## +## P a dungeon portal S player spawn T practice target +## +## Each `P`, in reading order, opens the matching entry in Dungeons.ORDER. Two +## of them now: the real run and the Proving Grounds, side by side so they can +## be compared without restarting anything. Adding a third dungeon means adding +## a third marker here. static func lobby() -> PackedStringArray: return PackedStringArray([ "#########################################", "#.......................................#", "#.......................................#", "#.......................................#", - "#...................P...................#", + "#..............P.........P..............#", "#.......................................#", "#.....o...........................o.....#", "#.......................................#", diff --git a/src/core/sim_config.gd b/src/core/sim_config.gd index 0b7b402..e2a8536 100644 --- a/src/core/sim_config.gd +++ b/src/core/sim_config.gd @@ -174,5 +174,7 @@ const MAP_STREAM_RADIUS := 900.0 const MAP_CHUNKS_PER_TICK := 6 # --- Portal ----------------------------------------------------------------- -## Position is per-world now (SimWorld.portal_pos), taken from the hub's map. +## Positions are per-world (SimWorld.portals), taken from the hub's map. Two +## entrances must be placed further apart than twice this, or their catchment +## areas overlap and which one you get stops being obvious from where you stand. const PORTAL_RADIUS := 60.0 diff --git a/src/instances/instance.gd b/src/instances/instance.gd index c8c3cbe..e17fea8 100644 --- a/src/instances/instance.gd +++ b/src/instances/instance.gd @@ -22,6 +22,8 @@ var age: int = 0 var seed_value: int = 0 ## Drives dungeon size and difficulty. The hub is always depth 0. var depth: int = 0 +## Which kind of run this is -- see [Dungeons]. Empty for the hub. +var dungeon_id: StringName = &"" ## Dungeon progression. -1 is the pre-fight breather. var stage: int = -1 @@ -41,8 +43,7 @@ static func make_lobby(instance_id: int) -> Instance: inst.world = SimWorld.new(inst.seed_value) var built := MapGen.build(Protocol.InstanceKind.LOBBY, inst.seed_value, 0) inst.world.set_map(built["grid"]) - inst.world.portal_enabled = true - inst.world.portal_pos = built["portal"] + inst.world.portals = built["portals"] inst.world.spawn_point = built["spawn"] inst.state = State.ACTIVE # A single inert practice target so players can feel out the gun before @@ -51,12 +52,17 @@ static func make_lobby(instance_id: int) -> Instance: return inst -static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int = 1) -> Instance: +## [param dungeon] picks the flavour of run. The map, the rooms, the enemies and +## the boss are the same whichever is chosen; only how much health they have and +## how freely they drop differs. See [Dungeons]. +static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int = 1, + dungeon: StringName = &"") -> Instance: var inst := Instance.new() inst.id = instance_id inst.kind = Protocol.InstanceKind.DUNGEON inst.seed_value = dungeon_seed inst.depth = maxi(dungeon_depth, 1) + inst.dungeon_id = dungeon if not dungeon.is_empty() else Dungeons.default_id() inst.world = SimWorld.new(dungeon_seed) var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed, inst.depth) inst.world.set_map(built["grid"]) @@ -80,7 +86,8 @@ static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int func _populate() -> void: var rng := RandomNumberGenerator.new() rng.seed = seed_value ^ 0x5eed - var boss := world.spawn_boss(Content.boss(boss_id)) + var flavour := Dungeons.get_or_default(dungeon_id) + var boss := world.spawn_boss(flavour.apply_to_boss(Content.boss(boss_id))) boss.pos = boss_spawn boss.room = _room_rect_world(boss_room) GameLog.info("instance", "BOSS_SPAWNED %s in instance %d" % [boss_id, id]) @@ -94,7 +101,7 @@ func _populate() -> void: var r: Rect2i = rooms[i] var count := rng.randi_range(1, 3 + depth / 2) for _n in count: - var def := _pick_enemy(rng) + var def := flavour.apply_to_enemy(_pick_enemy(rng)) var tx := rng.randi_range(r.position.x + 1, r.end.x - 2) var ty := rng.randi_range(r.position.y + 1, r.end.y - 2) var at := world.map.tile_centre(tx, ty) @@ -166,8 +173,12 @@ func exit_countdown_seconds() -> int: Protocol.COUNTDOWN_NONE - 1) -func accepts_new_party_member() -> bool: +## [param dungeon] has to match: walking into the Proving Grounds portal must +## never drop you into a standard run that happens to still be forming, however +## conveniently timed. +func accepts_new_party_member(dungeon: StringName) -> bool: return kind == Protocol.InstanceKind.DUNGEON \ + and dungeon_id == dungeon \ and state == State.FORMING \ and peers.size() < SimConfig.DUNGEON_PARTY_MAX diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index 7b80ce0..8d496b0 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -88,8 +88,13 @@ var characters_known: bool = false ## Whole seconds until a cleared dungeon returns the party, or ## Protocol.COUNTDOWN_NONE outside that state. var cleared_countdown: int = Protocol.COUNTDOWN_NONE -## Where this world's dungeon portal is. Per-map now, so it has to be told. -var portal_pos := Vector2.ZERO +## Dungeon entrances in this world, as [{ "pos": Vector2, "dungeon": StringName }]. +## Empty outside the hub. +var portals: Array[Dictionary] = [] +## Which kind of run this instance is, or empty in the hub. Drives the HUD label +## and the boss's health ceiling -- an easier dungeon's boss has less of it, and +## a bar computed from the unscaled definition would sit near empty all fight. +var dungeon_id: StringName = &"" ## Offset the view applies when drawing the world: screen = world + this. ## Published by the game scene every frame, rather than assumed, so aiming @@ -213,6 +218,15 @@ func held_slot() -> int: ## Scripted input so `tools/smoke.sh` can play the game with no display: orbit ## the arena, fire constantly, take the portal, then punch out with the escape. +## The portal this bot heads for. Spread across the available entrances by +## account so the smoke test opens one of each kind. +func bot_portal() -> Vector2: + if portals.is_empty(): + return predicted_pos + var pick: int = absi(GameOpts.account_override) % portals.size() + return portals[pick]["pos"] + + func _bot_input() -> InputFrame: _bot_tick += 1 var t := float(_bot_tick) * SimConfig.TICK_DELTA @@ -225,8 +239,10 @@ func _bot_input() -> InputFrame: return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT) if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30: buttons |= InputFrame.BTN_INTERACT - # Walk onto the portal instead of orbiting, or interact never lands. - move = (portal_pos - predicted_pos).normalized() + # Walk onto a portal instead of orbiting, or interact never lands. + # Which one is picked from the account id, so a smoke run with several + # bots exercises every dungeon rather than only the first. + move = (bot_portal() - predicted_pos).normalized() var slot := 0 if instance_kind == Protocol.InstanceKind.DUNGEON: # Grab at whatever is underfoot and occasionally drink, so the item @@ -329,11 +345,18 @@ func on_roster(data: PackedByteArray) -> void: func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String, - spawn: Vector2, map_w: int, map_h: int, portal: Vector2) -> void: + spawn: Vector2, map_w: int, map_h: int, portals_data: PackedByteArray, + dungeon: String) -> void: instance_id = id instance_kind = kind as Protocol.InstanceKind - portal_pos = portal - boss_def = Content.boss(StringName(boss_id)) if not boss_id.is_empty() else null + portals = NetCodec.decode_portals(portals_data) + dungeon_id = StringName(dungeon) + # Scaled the same way the server scaled it, so the boss bar reads as a + # fraction of the health this particular run's boss actually has. + boss_def = null + if not boss_id.is_empty(): + boss_def = Dungeons.get_or_default(dungeon_id).apply_to_boss( + Content.boss(StringName(boss_id))) # We are told how big the map is and nothing else. Every tile starts UNKNOWN # and is filled in by streaming as the player moves, so the client never # holds terrain it has not been near -- there is no seed here to regenerate diff --git a/src/net/net_codec.gd b/src/net/net_codec.gd index b6879ab..f66b938 100644 --- a/src/net/net_codec.gd +++ b/src/net/net_codec.gd @@ -424,6 +424,44 @@ static func decode_map_chunks_into(map: MapGrid, data: PackedByteArray) -> int: return applied +# --- Portals ---------------------------------------------------------------- +# Sent once, with enter_instance. There are two of them and they never move, so +# this is about as cold as a message gets -- it is a codec only because the +# count is variable and RPC arguments are not. + +static func encode_portals(portals: Array[SimPortal]) -> PackedByteArray: + var b := StreamPeerBuffer.new() + b.big_endian = false + b.put_u8(mini(portals.size(), 255)) + for portal in portals: + b.put_float(portal.pos.x) + b.put_float(portal.pos.y) + # By index, like item ids. See Dungeons.ORDER. + b.put_u8(Dungeons.index_of(portal.dungeon)) + return b.data_array + + +## Returns [{ "pos": Vector2, "dungeon": StringName }]. +static func decode_portals(data: PackedByteArray) -> Array[Dictionary]: + var out: Array[Dictionary] = [] + if data.size() < 1: + return out + var b := StreamPeerBuffer.new() + b.big_endian = false + b.data_array = data + var count := b.get_u8() + for _i in count: + # 4 + 4 + 1. A truncated packet gives back fewer portals rather than + # reading past the end and inventing one at a garbage position. + if b.get_available_bytes() < 9: + break + out.append({ + "pos": Vector2(b.get_float(), b.get_float()), + "dungeon": Dungeons.by_index(b.get_u8()), + }) + return out + + # --- Character roster ------------------------------------------------------- # Sent once at login and after any change. Low frequency and carries strings, # like the online roster, so it is the same fixed-header-then-utf8 shape. diff --git a/src/net/protocol.gd b/src/net/protocol.gd index ed5ed4a..ce93310 100644 --- a/src/net/protocol.gd +++ b/src/net/protocol.gd @@ -17,7 +17,9 @@ extends RefCounted ## the ground-loot list, the input frame gained a slot byte, and three item ## events were appended. Every one of those changes the byte layout of a ## message both ends parse positionally. -const VERSION := 6 +## 7: more than one dungeon. enter_instance carries a portal LIST and the id of +## the dungeon you are standing in, replacing the single portal position. +const VERSION := 7 const DEFAULT_PORT := 27015 const MAX_CLIENTS := 32 diff --git a/src/net/server_runtime.gd b/src/net/server_runtime.gd index 2e9e795..c50f8e7 100644 --- a/src/net/server_runtime.gd +++ b/src/net/server_runtime.gd @@ -89,7 +89,8 @@ func _dispatch_events(inst: Instance) -> void: # Collected and applied after the send below, because a transfer mutates # inst.peers and would otherwise change the list mid-broadcast. var to_lobby: Array[int] = [] - var to_dungeon: Array[int] = [] + # peer -> which dungeon their portal opens. + var to_dungeon: Dictionary[int, StringName] = {} var died: Array[int] = [] for ev in events: match int(ev["t"]): @@ -114,8 +115,11 @@ func _dispatch_events(inst: Instance) -> void: to_lobby.append(peer) SimEvent.Type.PORTAL_USED: var peer := int(ev["peer"]) + # Keyed by peer, so a player brushing both entrances in one tick + # still only enters one dungeon -- the first portal that + # answered. if not to_dungeon.has(peer): - to_dungeon.append(peer) + to_dungeon[peer] = StringName(ev.get("dungeon", "")) _: pass @@ -144,7 +148,7 @@ func _dispatch_events(inst: Instance) -> void: for peer in to_lobby: _send_to_lobby(peer) for peer in to_dungeon: - _send_to_dungeon(peer) + _send_to_dungeon(peer, to_dungeon[peer]) # --- Peer lifecycle --------------------------------------------------------- @@ -327,7 +331,8 @@ func _place(peer_id: int, inst: Instance) -> void: peer_chunks[peer_id] = {} Net.send_enter_instance(peer_id, inst.id, int(inst.kind), inst.world.tick, String(inst.boss_id), inst.world.spawn_point, - inst.world.map.width, inst.world.map.height, inst.world.portal_pos) + inst.world.map.width, inst.world.map.height, + NetCodec.encode_portals(inst.world.portals), String(inst.dungeon_id)) # Seed the area around the spawn before anything else, so the player is not # briefly standing in an unrendered void on arrival. _stream_map(peer_id, inst) @@ -379,16 +384,23 @@ func _send_to_lobby(peer_id: int) -> void: _transfer(peer_id, lobby) -func _send_to_dungeon(peer_id: int) -> void: +## Put a peer into a forming run of the kind they asked for, opening one if +## there is none. The dungeon id comes from the portal they used, which the +## simulation reported -- never from anything the client said. +func _send_to_dungeon(peer_id: int, dungeon_id: StringName = &"") -> void: + var wanted := dungeon_id if Dungeons.get_def(dungeon_id) != null \ + else Dungeons.default_id() var target: Instance = null for inst in instances.values(): - if inst.accepts_new_party_member(): + if inst.accepts_new_party_member(wanted): target = inst break if target == null: - target = Instance.make_dungeon(_take_instance_id(), randi(), GameOpts.dungeon_depth) + target = Instance.make_dungeon(_take_instance_id(), randi(), + GameOpts.dungeon_depth, wanted) instances[target.id] = target - GameLog.info("server", "opened dungeon instance %d" % target.id) + GameLog.info("server", "opened dungeon instance %d (%s)" + % [target.id, wanted]) _transfer(peer_id, target) diff --git a/src/sim/map_gen.gd b/src/sim/map_gen.gd index 3d59871..3721432 100644 --- a/src/sim/map_gen.gd +++ b/src/sim/map_gen.gd @@ -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, diff --git a/src/sim/sim_portal.gd b/src/sim/sim_portal.gd new file mode 100644 index 0000000..b088882 --- /dev/null +++ b/src/sim/sim_portal.gd @@ -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 diff --git a/src/sim/sim_portal.gd.uid b/src/sim/sim_portal.gd.uid new file mode 100644 index 0000000..2986d2b --- /dev/null +++ b/src/sim/sim_portal.gd.uid @@ -0,0 +1 @@ +uid://fnxk8yj4shad diff --git a/src/sim/sim_world.gd b/src/sim/sim_world.gd index ab0533c..bf49e85 100644 --- a/src/sim/sim_world.gd +++ b/src/sim/sim_world.gd @@ -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 diff --git a/src/ui/hud.gd b/src/ui/hud.gd index a0ac5ff..d0d93ce 100644 --- a/src/ui/hud.gd +++ b/src/ui/hud.gd @@ -95,7 +95,11 @@ func _update_respawn_button() -> void: func _status_text() -> String: if client == null: return "connecting..." - var where := "LOBBY" if client.instance_kind == Protocol.InstanceKind.LOBBY else "DUNGEON" + # Named rather than just "DUNGEON": there is more than one kind now, and + # knowing which one you are standing in is the whole point of having two. + var where := "LOBBY" + if client.instance_kind != Protocol.InstanceKind.LOBBY: + where = Dungeons.get_or_default(client.dungeon_id).display_name.to_upper() var who := client.current_character() var name_part := "" if not who.is_empty(): @@ -112,7 +116,7 @@ func _hint_text() -> String: if not client.my_alive: return "DOWN" if client.instance_kind == Protocol.InstanceKind.LOBBY: - return "WASD move mouse aim LMB fire E on the ring to enter a dungeon Esc menu F1 hitboxes" + return "WASD move mouse aim LMB fire E on a ring to enter that dungeon Esc menu F1 hitboxes" return "WASD move mouse aim LMB fire hold F to return to the hub Esc menu F1 hitboxes" diff --git a/src/view/world_view.gd b/src/view/world_view.gd index e247f47..df4865a 100644 --- a/src/view/world_view.gd +++ b/src/view/world_view.gd @@ -54,8 +54,7 @@ func _draw() -> void: if client == null: return _draw_terrain() - if client.instance_kind == Protocol.InstanceKind.LOBBY: - _draw_portal() + _draw_portals() for l in client.ground_loot(): if _visible(l["pos"]): _draw_loot(l) @@ -170,12 +169,30 @@ func _visible(at: Vector2) -> bool: return map.has_line_of_sight(eye, at) -func _draw_portal() -> void: +## One ring per entrance, coloured and labelled by the dungeon it opens. The +## label is not decoration: with two entrances a few metres apart, a player has +## to be able to tell which is the real run and which is the test harness while +## standing between them. +func _draw_portals() -> void: var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.003) - draw_arc(client.portal_pos, SimConfig.PORTAL_RADIUS, 0.0, TAU, 48, - Color(COL_PORTAL, 0.4 + 0.4 * pulse), 3.0) - draw_circle(client.portal_pos, SimConfig.PORTAL_RADIUS * 0.25, - Color(COL_PORTAL, 0.25 + 0.25 * pulse)) + for portal in client.portals: + var at: Vector2 = portal["pos"] + var def := Dungeons.get_or_default(portal["dungeon"]) + draw_arc(at, SimConfig.PORTAL_RADIUS, 0.0, TAU, 48, + Color(def.tint, 0.4 + 0.4 * pulse), 3.0) + draw_circle(at, SimConfig.PORTAL_RADIUS * 0.25, + Color(def.tint, 0.25 + 0.25 * pulse)) + _draw_portal_label(at, def) + + +func _draw_portal_label(at: Vector2, def: DungeonDef) -> void: + var font := ThemeDB.fallback_font + var top := at - Vector2(0.0, SimConfig.PORTAL_RADIUS + 26.0) + draw_string(font, top - Vector2(110.0, 0.0), def.display_name, + HORIZONTAL_ALIGNMENT_CENTER, 220.0, 16, def.tint) + if not def.subtitle.is_empty(): + draw_string(font, top - Vector2(110.0, -16.0), def.subtitle, + HORIZONTAL_ALIGNMENT_CENTER, 220.0, 12, Color(def.tint, 0.65)) ## An item on the floor. The server has already decided this player may see it diff --git a/tests/integration/test_dungeon_flow.gd b/tests/integration/test_dungeon_flow.gd index 39e3c64..67ce115 100644 --- a/tests/integration/test_dungeon_flow.gd +++ b/tests/integration/test_dungeon_flow.gd @@ -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)] diff --git a/tests/unit/test_dungeons.gd b/tests/unit/test_dungeons.gd new file mode 100644 index 0000000..fb1ade2 --- /dev/null +++ b/tests/unit/test_dungeons.gd @@ -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") diff --git a/tests/unit/test_dungeons.gd.uid b/tests/unit/test_dungeons.gd.uid new file mode 100644 index 0000000..fb609fa --- /dev/null +++ b/tests/unit/test_dungeons.gd.uid @@ -0,0 +1 @@ +uid://x5hi6p68krjs diff --git a/tests/unit/test_loot.gd b/tests/unit/test_loot.gd index f62cf5c..b1e252b 100644 --- a/tests/unit/test_loot.gd +++ b/tests/unit/test_loot.gd @@ -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) diff --git a/tests/unit/test_net_codec.gd b/tests/unit/test_net_codec.gd index e228fe1..9b56a16 100644 --- a/tests/unit/test_net_codec.gd +++ b/tests/unit/test_net_codec.gd @@ -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) diff --git a/tools/smoke.sh b/tools/smoke.sh index 82d6b0c..f9b3d7b 100755 --- a/tools/smoke.sh +++ b/tools/smoke.sh @@ -120,6 +120,11 @@ check "and are played" "$OUT/server.log" "playing 'bot1'" # do is produce a drop to act on -- that end of it is tools/diag_loot.tscn. check "inventories reach the save file" "$OUT/characters.json" "\"inventory\"" check "a dungeon instance opened" "$OUT/server.log" "opened dungeon instance" +# Bots pick their entrance from their account id, so a run with several of them +# opens one of each kind. This is what catches a portal wired to the wrong +# dungeon, or an instance matcher that ignores which dungeon was asked for. +check "the real run opened" "$OUT/server.log" "opened dungeon instance [0-9]+ \(warden_descent\)" +check "the proving grounds opened" "$OUT/server.log" "opened dungeon instance [0-9]+ \(proving_grounds\)" check "emergency escape completed" "$OUT/server.log" "escaped to lobby" check "bot1 reached a dungeon" "$OUT/bot1.log" "entered instance .*DUNGEON" check "bot1 returned to the lobby" "$OUT/bot1.log" "entered instance .*LOBBY"