Stage 3: inventory, ground loot, and two loot visibilities
ci / verify (push) Successful in 48s

Four always-on-screen slots, items as data, and loot tables on enemies and
bosses. Health potions drop rarely from trash and always from the Warden;
the Warden also drops a Warden's Ration, one per living player, which does
nothing at all.

The ration is not filler. Player-instanced loot is a separate code path from
shared loot -- a distinct entity per owner, filtered per peer in the snapshot
encoder -- and the cheapest way to keep that path honest is to have something
in the game that exercises it on every boss kill.

Item actions ride the input frame rather than becoming new client messages.
InputFrame gained BTN_USE, BTN_DROP and a slot byte, which buys the packet-loss
redundancy, the replay guard on last_input_tick, ordering against movement on
the same tick, and a rate limit of one action per tick -- all of which a
separate RPC would have needed bolted back on. The cost is that anything in
the frame which must not repeat has to be edge-triggered, since frames are
resent and a starved server coasts on the last one it holds.

Instanced loot is enforced in NetCodec.encode_snapshot, beside the actor
interest radius: a peer is never told another player's copy exists. Hiding it
client-side would have been the same mistake as relying on fog to hide enemies.

Inventories live on the character and are written to the store on every
transaction, so a crash between "picked it up" and "wrote it down" cannot lose
or duplicate an item. Anything dropped becomes world-shared whatever it was
before, and a potion used at full health is refused rather than spent.

tools/diag_loot.tscn covers drop -> snapshot -> pick up -> persist -> use ->
drop plus both visibilities on the wire, for the same reason diag_progression
exists: bots are poor shots and almost never produce a drop. It asserts each
input frame was actually consumed, after an early version silently dropped its
first press and every later check passed for the wrong reason.

check.sh clean, 266 tests, SMOKE PASS, all three diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 21:16:15 +02:00
parent ded7bf96d5
commit 050b8251a7
50 changed files with 2159 additions and 125 deletions
+31 -9
View File
@@ -25,12 +25,14 @@ non-zero on failure, so they gate like tests:
```bash
godot --headless --path . res://tools/diag_prediction.tscn # prediction gap; injects clock drift
godot --headless --path . res://tools/diag_progression.tscn # kill -> xp -> level -> death -> roster
godot --headless --path . res://tools/diag_loot.tscn # drop -> pick up -> persist -> use -> drop
godot --headless --path . --script tools/bench.gd # sim cost per tick
python3 tools/build_local_assets.py # rebuild the local-only bullet atlas
```
`diag_progression` exists because the bot smoke test cannot cover progression:
bots are poor shots and rarely kill anything.
`diag_progression` and `diag_loot` exist because the bot smoke test cannot cover
either: bots are poor shots, so they rarely kill anything, which means they
neither earn levels nor produce drops.
Everything after `--` goes to `GameOpts.parse()`:
@@ -65,10 +67,17 @@ push (~35s, skip deliberately with `SKIP_SMOKE_HOOK=1 git push`).
**The server decides everything; the client only sends intent.**
A client can send exactly two things: an [InputFrame] (move vector, aim angle,
three button bits) and a handshake. There is no message for "I moved here", "I
hit that", "I took damage" or "my escape finished". Adding one would collapse
the whole security model, so don't — validate-after-the-fact is strictly weaker
than having no code path at all.
five button bits, an inventory slot) and a handshake — plus the two low-rate
character-roster requests, which are also pure intent. There is no message for
"I moved here", "I hit that", "I took damage", "my escape finished" or "I now
own this item". Adding one would collapse the whole security model, so don't —
validate-after-the-fact is strictly weaker than having no code path at all.
When a new player action needs a message, look at whether it fits in the input
frame first. Item use and drop did, and got the redundancy, the replay guard and
the per-tick rate limit for free. The cost was one rule: anything in the input
frame that must not repeat has to be **edge-triggered** (see `prev_buttons`),
because frames are resent and a starved server coasts on the last one it holds.
`SimWorld.authoritative` is `true` on the server and `false` on the client. In
replica mode the world runs no AI, fires no emitters and resolves no hits; it
@@ -80,12 +89,14 @@ 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/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. |
| `src/content/rooms.gd` | Hand-authored room stamps (hub, boss arenas) as text. |
| `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/net/` | Codec, `ServerRuntime`, `ClientRuntime`. |
| `src/instances/` | Lobby hub and dungeon runs. |
| `src/view/`, `src/ui/` | Read-only rendering. Never decides anything. |
@@ -148,9 +159,20 @@ ticks in milliseconds with no SceneTree.
- **Bullet speed must stay under one tile per tick.** Wall collision samples
position once per tick, so anything faster tunnels. Pinned by
`test_bullet_speeds_stay_below_the_tunnelling_threshold`.
- **Only `ServerRuntime` writes progression.** The simulation reads a player's
level and max health; it never grants experience or retires a character. One
writer means a level can never disagree with the experience that earned it.
- **Only `ServerRuntime` writes progression and persistence.** The simulation
reads a player's level and max health, and moves items between the ground and
a bag; it never grants experience, retires a character, or touches the store.
It announces what happened and `ServerRuntime` banks it. One writer means a
level can never disagree with the experience that earned it, and an inventory
on disk can never disagree with the one in the world.
- **`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.
- **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
entity the client was handed defends nothing.
- **`LocalAuthProvider` is insecure on purpose.** Any client can claim any
account. It exists to have the same shape as Steamworks (opaque ticket in,
64-bit account id out) so swapping is one class. Do not ship it.