Add a second dungeon: the Proving Grounds, a test harness you walk into
ci / verify (push) Successful in 47s

Two labelled portals now stand side by side in the hub. The Proving Grounds
runs the same generator, the same rooms, the same enemies and the same
four-phase Warden -- enemies at a fifth health, the boss at 288 instead of
3600, and trash dropping potions 80% of the time instead of 8%. A manual pass
over loot, the inventory, dropping and every boss phase takes a couple of
minutes rather than a quarter of an hour.

It is multipliers over the shared content rather than a parallel copy: a
duplicated Content would drift the first time anything was tuned, and
"identical but easier" would quietly stop being true. And it is a portal
rather than a launch flag, so the two can be compared back to back without
restarting the server -- which is most of the point.

Which dungeon you enter is resolved from the player's server-side position,
and PORTAL_USED carries the answer. There is deliberately no client message
that names a dungeon: one would let any client ask for the generous loot table
and bring the results back to the hub. Instance matching compares dungeon ids
too, so walking into one entrance can never drop you into the other's run on
timing alone.

SimWorld.portals replaces portal_pos/portal_enabled, enter_instance carries
the portal list and the dungeon id (the client needs the latter to scale the
boss bar's ceiling the way the server scaled the boss), and Protocol.VERSION
goes to 7.

Also pins what happens when two players reach for one item on the same tick:
exactly one gets it -- the loop is sequential and the pickup erases the entity
before the next player looks. The tie-break is join order rather than distance,
which is arbitrary rather than designed, so it is recorded as such.

Stale doc fixed while here: MapGen.build() still claimed the client rebuilds
the map from the seed, which has not been true since map streaming landed and
is the opposite of the rule.

check.sh clean, 288 tests, SMOKE PASS (18 assertions, both dungeon kinds
opened over a real socket), all three diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 21:34:41 +02:00
parent 050b8251a7
commit a943aa19f6
28 changed files with 733 additions and 74 deletions
+31
View File
@@ -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.
+17
View File
@@ -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
+36 -2
View File
@@ -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