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:
@@ -89,7 +89,7 @@ and `tests/integration/test_replica_parity.gd` pin this down.
|
|||||||
| Path | What lives there |
|
| Path | What lives there |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `src/sim/` | The whole game as plain RefCounted objects. No nodes, no physics server, no rendering. |
|
| `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/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_grid.gd` | Tile grid: collision, line of sight, chunk streaming. |
|
||||||
| `src/sim/map_gen.gd` | Dungeon generation; `build()` is the only entry point. |
|
| `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/meta/` | Accounts, characters, persistence, XP curve. Server-owned. |
|
||||||
| `src/content/content.gd` | All enemies and bosses, defined in code. Source of truth. |
|
| `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/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/net/` | Codec, `ServerRuntime`, `ClientRuntime`. |
|
||||||
| `src/instances/` | Lobby hub and dungeon runs. |
|
| `src/instances/` | Lobby hub and dungeon runs. |
|
||||||
| `src/view/`, `src/ui/` | Read-only rendering. Never decides anything. |
|
| `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
|
- **`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
|
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
|
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.**
|
- **Loot has two visibilities, and the instanced one is enforced in the codec.**
|
||||||
`NetCodec.encode_snapshot` filters items owned by another peer, exactly like
|
`NetCodec.encode_snapshot` filters items owned by another peer, exactly like
|
||||||
the actor interest radius. Never move that check into the client: hiding an
|
the actor interest radius. Never move that check into the client: hiding an
|
||||||
|
|||||||
@@ -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
|
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
|
hub — which never closes and where players can drop things — can realistically
|
||||||
reach the cap.
|
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.
|
||||||
|
|||||||
@@ -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
|
every boss kill, so the filter runs in every real fight rather than only in
|
||||||
tests.
|
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
|
## Item actions ride the input frame
|
||||||
|
|
||||||
`InputFrame` carries `BTN_USE`, `BTN_DROP` and a slot byte (10 bytes total, up
|
`InputFrame` carries `BTN_USE`, `BTN_DROP` and a slot byte (10 bytes total, up
|
||||||
|
|||||||
+36
-2
@@ -20,8 +20,8 @@ What "everything passes" currently means. Numbers move; the shape does not.
|
|||||||
| Gate | Covers | Runtime |
|
| Gate | Covers | Runtime |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `tools/check.sh` | every script parses and type-checks | ~5s |
|
| `tools/check.sh` | every script parses and type-checks | ~5s |
|
||||||
| `tools/test.sh` | 266 GUT tests, no SceneTree | ~3s |
|
| `tools/test.sh` | 288 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/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_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s |
|
||||||
| `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~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 |
|
| `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*
|
## Art and audio · *first pass done*
|
||||||
|
|
||||||
Placeholders are gone: terrain, actors and bullets are sprites, and four sounds
|
Placeholders are gone: terrain, actors and bullets are sprites, and four sounds
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cqert4nr662vl
|
||||||
+8
-5
@@ -169,12 +169,13 @@ func send_welcome(peer_id: int) -> void:
|
|||||||
## hack with no work required; tiles are streamed instead (send_map_chunks).
|
## 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,
|
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,
|
boss_id: String, spawn: Vector2, map_w: int, map_h: int,
|
||||||
portal: Vector2) -> void:
|
portals: PackedByteArray, dungeon: String) -> void:
|
||||||
if _is_local(peer_id):
|
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:
|
else:
|
||||||
s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn,
|
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:
|
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)
|
@rpc("authority", "call_remote", "reliable", 1)
|
||||||
func s_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
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:
|
if client == null:
|
||||||
return
|
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)
|
@rpc("authority", "call_remote", "reliable", 1)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dw7j78sn8obvw
|
||||||
@@ -96,16 +96,21 @@ static func stamp(grid: MapGrid, s: PackedStringArray, origin: Vector2i) -> Dict
|
|||||||
markers[ch].append(Vector2i(tx, ty))
|
markers[ch].append(Vector2i(tx, ty))
|
||||||
return markers
|
return markers
|
||||||
|
|
||||||
## The hub. Hand-authored like the boss arenas, and reproduced identically on
|
## The hub. Hand-authored like the boss arenas.
|
||||||
## the client from this same function -- see MapGen.build().
|
##
|
||||||
## P portal to the dungeons S player spawn T practice target
|
## 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:
|
static func lobby() -> PackedStringArray:
|
||||||
return PackedStringArray([
|
return PackedStringArray([
|
||||||
"#########################################",
|
"#########################################",
|
||||||
"#.......................................#",
|
"#.......................................#",
|
||||||
"#.......................................#",
|
"#.......................................#",
|
||||||
"#.......................................#",
|
"#.......................................#",
|
||||||
"#...................P...................#",
|
"#..............P.........P..............#",
|
||||||
"#.......................................#",
|
"#.......................................#",
|
||||||
"#.....o...........................o.....#",
|
"#.....o...........................o.....#",
|
||||||
"#.......................................#",
|
"#.......................................#",
|
||||||
|
|||||||
@@ -174,5 +174,7 @@ const MAP_STREAM_RADIUS := 900.0
|
|||||||
const MAP_CHUNKS_PER_TICK := 6
|
const MAP_CHUNKS_PER_TICK := 6
|
||||||
|
|
||||||
# --- Portal -----------------------------------------------------------------
|
# --- 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
|
const PORTAL_RADIUS := 60.0
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ var age: int = 0
|
|||||||
var seed_value: int = 0
|
var seed_value: int = 0
|
||||||
## Drives dungeon size and difficulty. The hub is always depth 0.
|
## Drives dungeon size and difficulty. The hub is always depth 0.
|
||||||
var depth: int = 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.
|
## Dungeon progression. -1 is the pre-fight breather.
|
||||||
var stage: int = -1
|
var stage: int = -1
|
||||||
@@ -41,8 +43,7 @@ static func make_lobby(instance_id: int) -> Instance:
|
|||||||
inst.world = SimWorld.new(inst.seed_value)
|
inst.world = SimWorld.new(inst.seed_value)
|
||||||
var built := MapGen.build(Protocol.InstanceKind.LOBBY, inst.seed_value, 0)
|
var built := MapGen.build(Protocol.InstanceKind.LOBBY, inst.seed_value, 0)
|
||||||
inst.world.set_map(built["grid"])
|
inst.world.set_map(built["grid"])
|
||||||
inst.world.portal_enabled = true
|
inst.world.portals = built["portals"]
|
||||||
inst.world.portal_pos = built["portal"]
|
|
||||||
inst.world.spawn_point = built["spawn"]
|
inst.world.spawn_point = built["spawn"]
|
||||||
inst.state = State.ACTIVE
|
inst.state = State.ACTIVE
|
||||||
# A single inert practice target so players can feel out the gun before
|
# 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
|
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()
|
var inst := Instance.new()
|
||||||
inst.id = instance_id
|
inst.id = instance_id
|
||||||
inst.kind = Protocol.InstanceKind.DUNGEON
|
inst.kind = Protocol.InstanceKind.DUNGEON
|
||||||
inst.seed_value = dungeon_seed
|
inst.seed_value = dungeon_seed
|
||||||
inst.depth = maxi(dungeon_depth, 1)
|
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)
|
inst.world = SimWorld.new(dungeon_seed)
|
||||||
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed, inst.depth)
|
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed, inst.depth)
|
||||||
inst.world.set_map(built["grid"])
|
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:
|
func _populate() -> void:
|
||||||
var rng := RandomNumberGenerator.new()
|
var rng := RandomNumberGenerator.new()
|
||||||
rng.seed = seed_value ^ 0x5eed
|
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.pos = boss_spawn
|
||||||
boss.room = _room_rect_world(boss_room)
|
boss.room = _room_rect_world(boss_room)
|
||||||
GameLog.info("instance", "BOSS_SPAWNED %s in instance %d" % [boss_id, id])
|
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 r: Rect2i = rooms[i]
|
||||||
var count := rng.randi_range(1, 3 + depth / 2)
|
var count := rng.randi_range(1, 3 + depth / 2)
|
||||||
for _n in count:
|
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 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 ty := rng.randi_range(r.position.y + 1, r.end.y - 2)
|
||||||
var at := world.map.tile_centre(tx, ty)
|
var at := world.map.tile_centre(tx, ty)
|
||||||
@@ -166,8 +173,12 @@ func exit_countdown_seconds() -> int:
|
|||||||
Protocol.COUNTDOWN_NONE - 1)
|
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 \
|
return kind == Protocol.InstanceKind.DUNGEON \
|
||||||
|
and dungeon_id == dungeon \
|
||||||
and state == State.FORMING \
|
and state == State.FORMING \
|
||||||
and peers.size() < SimConfig.DUNGEON_PARTY_MAX
|
and peers.size() < SimConfig.DUNGEON_PARTY_MAX
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,13 @@ var characters_known: bool = false
|
|||||||
## Whole seconds until a cleared dungeon returns the party, or
|
## Whole seconds until a cleared dungeon returns the party, or
|
||||||
## Protocol.COUNTDOWN_NONE outside that state.
|
## Protocol.COUNTDOWN_NONE outside that state.
|
||||||
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
|
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
|
||||||
## Where this world's dungeon portal is. Per-map now, so it has to be told.
|
## Dungeon entrances in this world, as [{ "pos": Vector2, "dungeon": StringName }].
|
||||||
var portal_pos := Vector2.ZERO
|
## 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.
|
## Offset the view applies when drawing the world: screen = world + this.
|
||||||
## Published by the game scene every frame, rather than assumed, so aiming
|
## 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
|
## 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 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:
|
func _bot_input() -> InputFrame:
|
||||||
_bot_tick += 1
|
_bot_tick += 1
|
||||||
var t := float(_bot_tick) * SimConfig.TICK_DELTA
|
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)
|
return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT)
|
||||||
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
|
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
|
||||||
buttons |= InputFrame.BTN_INTERACT
|
buttons |= InputFrame.BTN_INTERACT
|
||||||
# Walk onto the portal instead of orbiting, or interact never lands.
|
# Walk onto a portal instead of orbiting, or interact never lands.
|
||||||
move = (portal_pos - predicted_pos).normalized()
|
# 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
|
var slot := 0
|
||||||
if instance_kind == Protocol.InstanceKind.DUNGEON:
|
if instance_kind == Protocol.InstanceKind.DUNGEON:
|
||||||
# Grab at whatever is underfoot and occasionally drink, so the item
|
# 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,
|
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_id = id
|
||||||
instance_kind = kind as Protocol.InstanceKind
|
instance_kind = kind as Protocol.InstanceKind
|
||||||
portal_pos = portal
|
portals = NetCodec.decode_portals(portals_data)
|
||||||
boss_def = Content.boss(StringName(boss_id)) if not boss_id.is_empty() else null
|
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
|
# 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
|
# 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
|
# holds terrain it has not been near -- there is no seed here to regenerate
|
||||||
|
|||||||
@@ -424,6 +424,44 @@ static func decode_map_chunks_into(map: MapGrid, data: PackedByteArray) -> int:
|
|||||||
return applied
|
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 -------------------------------------------------------
|
# --- Character roster -------------------------------------------------------
|
||||||
# Sent once at login and after any change. Low frequency and carries strings,
|
# 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.
|
# like the online roster, so it is the same fixed-header-then-utf8 shape.
|
||||||
|
|||||||
+3
-1
@@ -17,7 +17,9 @@ extends RefCounted
|
|||||||
## the ground-loot list, the input frame gained a slot byte, and three item
|
## 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
|
## events were appended. Every one of those changes the byte layout of a
|
||||||
## message both ends parse positionally.
|
## 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 DEFAULT_PORT := 27015
|
||||||
const MAX_CLIENTS := 32
|
const MAX_CLIENTS := 32
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ func _dispatch_events(inst: Instance) -> void:
|
|||||||
# Collected and applied after the send below, because a transfer mutates
|
# Collected and applied after the send below, because a transfer mutates
|
||||||
# inst.peers and would otherwise change the list mid-broadcast.
|
# inst.peers and would otherwise change the list mid-broadcast.
|
||||||
var to_lobby: Array[int] = []
|
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] = []
|
var died: Array[int] = []
|
||||||
for ev in events:
|
for ev in events:
|
||||||
match int(ev["t"]):
|
match int(ev["t"]):
|
||||||
@@ -114,8 +115,11 @@ func _dispatch_events(inst: Instance) -> void:
|
|||||||
to_lobby.append(peer)
|
to_lobby.append(peer)
|
||||||
SimEvent.Type.PORTAL_USED:
|
SimEvent.Type.PORTAL_USED:
|
||||||
var peer := int(ev["peer"])
|
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):
|
if not to_dungeon.has(peer):
|
||||||
to_dungeon.append(peer)
|
to_dungeon[peer] = StringName(ev.get("dungeon", ""))
|
||||||
_:
|
_:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -144,7 +148,7 @@ func _dispatch_events(inst: Instance) -> void:
|
|||||||
for peer in to_lobby:
|
for peer in to_lobby:
|
||||||
_send_to_lobby(peer)
|
_send_to_lobby(peer)
|
||||||
for peer in to_dungeon:
|
for peer in to_dungeon:
|
||||||
_send_to_dungeon(peer)
|
_send_to_dungeon(peer, to_dungeon[peer])
|
||||||
|
|
||||||
|
|
||||||
# --- Peer lifecycle ---------------------------------------------------------
|
# --- Peer lifecycle ---------------------------------------------------------
|
||||||
@@ -327,7 +331,8 @@ func _place(peer_id: int, inst: Instance) -> void:
|
|||||||
peer_chunks[peer_id] = {}
|
peer_chunks[peer_id] = {}
|
||||||
Net.send_enter_instance(peer_id, inst.id, int(inst.kind), inst.world.tick,
|
Net.send_enter_instance(peer_id, inst.id, int(inst.kind), inst.world.tick,
|
||||||
String(inst.boss_id), inst.world.spawn_point,
|
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
|
# Seed the area around the spawn before anything else, so the player is not
|
||||||
# briefly standing in an unrendered void on arrival.
|
# briefly standing in an unrendered void on arrival.
|
||||||
_stream_map(peer_id, inst)
|
_stream_map(peer_id, inst)
|
||||||
@@ -379,16 +384,23 @@ func _send_to_lobby(peer_id: int) -> void:
|
|||||||
_transfer(peer_id, lobby)
|
_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
|
var target: Instance = null
|
||||||
for inst in instances.values():
|
for inst in instances.values():
|
||||||
if inst.accepts_new_party_member():
|
if inst.accepts_new_party_member(wanted):
|
||||||
target = inst
|
target = inst
|
||||||
break
|
break
|
||||||
if target == null:
|
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
|
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)
|
_transfer(peer_id, target)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+29
-14
@@ -3,16 +3,22 @@ extends RefCounted
|
|||||||
## Builds a dungeon: generated rooms and corridors around a hand-authored boss
|
## Builds a dungeon: generated rooms and corridors around a hand-authored boss
|
||||||
## arena (see [Rooms]).
|
## arena (see [Rooms]).
|
||||||
##
|
##
|
||||||
## Deterministic from (seed, depth) alone, so the server can hand a client the
|
## Deterministic from (seed, depth) alone, which is what makes a failing run
|
||||||
## same two numbers instead of a map, and a failing run can be reproduced from
|
## reproducible from its log line. It does NOT mean the client can rebuild the
|
||||||
## its log line.
|
## 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
|
## This used to be described as "both sides call it with the same three
|
||||||
## (kind, seed, depth), so the server ships three integers and the client
|
## integers" -- that is no longer true and has not been since map streaming
|
||||||
## rebuilds the identical grid. tests/unit/test_map_gen.gd pins the determinism
|
## landed. Handing a client the seed would let it regenerate the entire floor
|
||||||
## that makes that safe, and it keeps a big dungeon free on the wire.
|
## 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:
|
static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int) -> Dictionary:
|
||||||
if kind == Protocol.InstanceKind.LOBBY:
|
if kind == Protocol.InstanceKind.LOBBY:
|
||||||
return _build_lobby()
|
return _build_lobby()
|
||||||
@@ -29,10 +35,19 @@ static func _build_lobby() -> Dictionary:
|
|||||||
if not markers["S"].is_empty():
|
if not markers["S"].is_empty():
|
||||||
var m: Vector2i = markers["S"][0]
|
var m: Vector2i = markers["S"][0]
|
||||||
spawn = grid.tile_centre(m.x, m.y)
|
spawn = grid.tile_centre(m.x, m.y)
|
||||||
var portal := grid.tile_centre(size.x / 2, 3)
|
# One portal per marker, in reading order, matched against Dungeons.ORDER.
|
||||||
if not markers["P"].is_empty():
|
# A stamp with fewer markers than dungeons simply makes the extra ones
|
||||||
var m: Vector2i = markers["P"][0]
|
# unreachable rather than crashing -- an unreachable dungeon is a content
|
||||||
portal = grid.tile_centre(m.x, m.y)
|
# 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)
|
var target := grid.tile_centre(size.x / 4, size.y / 2)
|
||||||
if not markers["T"].is_empty():
|
if not markers["T"].is_empty():
|
||||||
var m: Vector2i = markers["T"][0]
|
var m: Vector2i = markers["T"][0]
|
||||||
@@ -41,7 +56,7 @@ static func _build_lobby() -> Dictionary:
|
|||||||
"grid": grid,
|
"grid": grid,
|
||||||
"rooms": [] as Array[Rect2i],
|
"rooms": [] as Array[Rect2i],
|
||||||
"spawn": spawn,
|
"spawn": spawn,
|
||||||
"portal": portal,
|
"portals": portals,
|
||||||
"dummy": target,
|
"dummy": target,
|
||||||
"boss_pos": Vector2.ZERO,
|
"boss_pos": Vector2.ZERO,
|
||||||
"boss_room": Rect2i(),
|
"boss_room": Rect2i(),
|
||||||
@@ -131,7 +146,7 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
|
|||||||
"grid": grid,
|
"grid": grid,
|
||||||
"rooms": rooms,
|
"rooms": rooms,
|
||||||
"spawn": spawn,
|
"spawn": spawn,
|
||||||
"portal": Vector2.ZERO,
|
"portals": [] as Array[SimPortal],
|
||||||
"dummy": Vector2.ZERO,
|
"dummy": Vector2.ZERO,
|
||||||
"boss_pos": boss_pos,
|
"boss_pos": boss_pos,
|
||||||
"boss_room": boss_room,
|
"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.
|
## unit test build a world without thinking about terrain.
|
||||||
var map: MapGrid = null
|
var map: MapGrid = null
|
||||||
|
|
||||||
## Set on a lobby world so the interact button can open a dungeon.
|
## Dungeon entrances standing in this world. Empty everywhere but the hub.
|
||||||
var portal_enabled: bool = false
|
## A list rather than a single position because which dungeon an entrance opens
|
||||||
## Where the hub's dungeon portal sits. Per-world now that maps vary in size.
|
## is the whole reason there is more than one.
|
||||||
var portal_pos := Vector2.ZERO
|
var portals: Array[SimPortal] = []
|
||||||
var spawn_point := Vector2(0.0, 240.0)
|
var spawn_point := Vector2(0.0, 240.0)
|
||||||
## Arrival protection granted to players entering this world. 0 in the hub,
|
## Arrival protection granted to players entering this world. 0 in the hub,
|
||||||
## SimConfig.SPAWN_GRACE_TICKS in a dungeon.
|
## SimConfig.SPAWN_GRACE_TICKS in a dungeon.
|
||||||
@@ -117,6 +117,20 @@ func spawn_boss(def: BossDef) -> SimBoss:
|
|||||||
return b
|
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:
|
func alive_player_count() -> int:
|
||||||
var n := 0
|
var n := 0
|
||||||
for p in players.values():
|
for p in players.values():
|
||||||
@@ -226,9 +240,11 @@ func _step_players() -> void:
|
|||||||
if edge & InputFrame.BTN_INTERACT:
|
if edge & InputFrame.BTN_INTERACT:
|
||||||
took_item = _try_pickup(p)
|
took_item = _try_pickup(p)
|
||||||
|
|
||||||
if portal_enabled and not took_item and frame.pressed(InputFrame.BTN_INTERACT):
|
if not took_item and frame.pressed(InputFrame.BTN_INTERACT):
|
||||||
if p.pos.distance_to(portal_pos) <= SimConfig.PORTAL_RADIUS:
|
var portal := portal_at(p.pos)
|
||||||
events.append({"t": SimEvent.Type.PORTAL_USED, "peer": p.peer_id})
|
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
|
## Pull the next input for this player, or coast on the last one. Coasting is
|
||||||
|
|||||||
+6
-2
@@ -95,7 +95,11 @@ func _update_respawn_button() -> void:
|
|||||||
func _status_text() -> String:
|
func _status_text() -> String:
|
||||||
if client == null:
|
if client == null:
|
||||||
return "connecting..."
|
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 who := client.current_character()
|
||||||
var name_part := ""
|
var name_part := ""
|
||||||
if not who.is_empty():
|
if not who.is_empty():
|
||||||
@@ -112,7 +116,7 @@ func _hint_text() -> String:
|
|||||||
if not client.my_alive:
|
if not client.my_alive:
|
||||||
return "DOWN"
|
return "DOWN"
|
||||||
if client.instance_kind == Protocol.InstanceKind.LOBBY:
|
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"
|
return "WASD move mouse aim LMB fire hold F to return to the hub Esc menu F1 hitboxes"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+24
-7
@@ -54,8 +54,7 @@ func _draw() -> void:
|
|||||||
if client == null:
|
if client == null:
|
||||||
return
|
return
|
||||||
_draw_terrain()
|
_draw_terrain()
|
||||||
if client.instance_kind == Protocol.InstanceKind.LOBBY:
|
_draw_portals()
|
||||||
_draw_portal()
|
|
||||||
for l in client.ground_loot():
|
for l in client.ground_loot():
|
||||||
if _visible(l["pos"]):
|
if _visible(l["pos"]):
|
||||||
_draw_loot(l)
|
_draw_loot(l)
|
||||||
@@ -170,12 +169,30 @@ func _visible(at: Vector2) -> bool:
|
|||||||
return map.has_line_of_sight(eye, at)
|
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)
|
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,
|
for portal in client.portals:
|
||||||
Color(COL_PORTAL, 0.4 + 0.4 * pulse), 3.0)
|
var at: Vector2 = portal["pos"]
|
||||||
draw_circle(client.portal_pos, SimConfig.PORTAL_RADIUS * 0.25,
|
var def := Dungeons.get_or_default(portal["dungeon"])
|
||||||
Color(COL_PORTAL, 0.25 + 0.25 * pulse))
|
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
|
## An item on the floor. The server has already decided this player may see it
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func test_a_full_party_locks_the_dungeon_immediately() -> void:
|
|||||||
inst.add_peer(peer, "p%d" % peer)
|
inst.add_peer(peer, "p%d" % peer)
|
||||||
_step(2)
|
_step(2)
|
||||||
assert_eq(inst.state, Instance.State.ACTIVE)
|
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:
|
func test_killing_the_boss_clears_the_instance() -> void:
|
||||||
@@ -102,10 +102,13 @@ func _lobby() -> Instance:
|
|||||||
return l
|
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()
|
var lobby := _lobby()
|
||||||
assert_true(lobby.world.portal_enabled)
|
assert_eq(lobby.world.portals.size(), Dungeons.ORDER.size(),
|
||||||
assert_ne(lobby.world.portal_pos, Vector2.ZERO, "the portal comes from the map")
|
"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:
|
for _i in 600:
|
||||||
lobby.step()
|
lobby.step()
|
||||||
assert_eq(lobby.world.pool.live_count, 0, "nothing in the hub may shoot at you")
|
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:
|
func test_interacting_on_the_portal_asks_for_a_dungeon() -> void:
|
||||||
var lobby := _lobby()
|
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] = [
|
var frames: Array[InputFrame] = [
|
||||||
InputFrame.make(lobby.world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
|
InputFrame.make(lobby.world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
|
||||||
lobby.world.queue_input(1, frames)
|
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:
|
func test_interacting_away_from_the_portal_does_nothing() -> void:
|
||||||
var lobby := _lobby()
|
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)
|
+ Vector2(0.0, SimConfig.PORTAL_RADIUS + 80.0)
|
||||||
var frames: Array[InputFrame] = [
|
var frames: Array[InputFrame] = [
|
||||||
InputFrame.make(lobby.world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
|
InputFrame.make(lobby.world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://x5hi6p68krjs
|
||||||
+65
-2
@@ -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
|
## The portal and pickup share the interact key. A full bag must not leave a
|
||||||
## player standing on the portal unable to use it.
|
## player standing on the portal unable to use it.
|
||||||
func test_a_failed_pickup_does_not_block_the_portal() -> void:
|
func test_a_failed_pickup_does_not_block_the_portal() -> void:
|
||||||
world.portal_enabled = true
|
world.portals = [SimPortal.make(Vector2.ZERO, Dungeons.default_id())]
|
||||||
world.portal_pos = Vector2.ZERO
|
|
||||||
var me := world.players[ME]
|
var me := world.players[ME]
|
||||||
for _i in SimConfig.INVENTORY_SLOTS:
|
for _i in SimConfig.INVENTORY_SLOTS:
|
||||||
me.add_item(Items.WARDENS_RATION)
|
me.add_item(Items.WARDENS_RATION)
|
||||||
@@ -355,3 +354,67 @@ func test_a_replica_never_creates_loot_of_its_own() -> void:
|
|||||||
replica.step()
|
replica.step()
|
||||||
assert_eq(replica.loot.size(), 0,
|
assert_eq(replica.loot.size(), 0,
|
||||||
"a client must never invent an item for itself to pick up")
|
"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)
|
||||||
|
|||||||
@@ -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_true(out[0].pressed(InputFrame.BTN_USE))
|
||||||
assert_eq(out[1].slot, 1)
|
assert_eq(out[1].slot, 1)
|
||||||
assert_true(out[1].pressed(InputFrame.BTN_DROP))
|
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)
|
||||||
|
|||||||
@@ -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.
|
# 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 "inventories reach the save file" "$OUT/characters.json" "\"inventory\""
|
||||||
check "a dungeon instance opened" "$OUT/server.log" "opened dungeon instance"
|
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 "emergency escape completed" "$OUT/server.log" "escaped to lobby"
|
||||||
check "bot1 reached a dungeon" "$OUT/bot1.log" "entered instance .*DUNGEON"
|
check "bot1 reached a dungeon" "$OUT/bot1.log" "entered instance .*DUNGEON"
|
||||||
check "bot1 returned to the lobby" "$OUT/bot1.log" "entered instance .*LOBBY"
|
check "bot1 returned to the lobby" "$OUT/bot1.log" "entered instance .*LOBBY"
|
||||||
|
|||||||
Reference in New Issue
Block a user