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
+62 -7
View File
@@ -9,17 +9,26 @@ prediction and reconciliation for the local player.
client server (60 Hz)
| |
|-- InputFrame (60 Hz, x3) ---->| queue_input(): validate, drop, sort
| move, aim, 3 button bits | SimWorld.step(): move, AI, emitters,
| | integrate bullets, resolve hits
|<-- events (reliable, ch 3) ---| bullet spawns/despawns, hits, deaths
|<-- snapshot (unrel., ch 2) ---| players, enemies, boss @ 20 Hz
| move, aim, 5 button bits, | SimWorld.step(): move, AI, emitters,
| inventory slot | integrate bullets, resolve hits, items
|<-- events (reliable, ch 3) ---| bullet spawns/despawns, hits, deaths,
| | item pickups/uses/drops
|<-- snapshot (unrel., ch 2) ---| players, enemies, boss, ground loot,
| | your own inventory @ 20 Hz
| |
predict locally, reconcile authoritative for everything
```
The client has no message that expresses a position, a hit, damage taken, or a
completed escape. This is deliberate and stronger than validating such messages
after the fact: there is no code path to exploit, only intent to interpret.
The client has no message that expresses a position, a hit, damage taken, a
completed escape, or an item it now owns. This is deliberate and stronger than
validating such messages after the fact: there is no code path to exploit, only
intent to interpret.
Item actions are worth noting as the newest thing to resist becoming a message
of its own. Using or dropping an item is a request that reaches the server as
two button bits and a slot number on the ordinary input frame, and the server
decides what — if anything — happened. See *Item actions ride the input frame*
below.
## Why bullets are not replicated as state
@@ -187,6 +196,50 @@ is invisible damage. The floor is *longest bullet travel + fog radius* — 1500
from the live content, so adding a faster or longer-lived bullet fails a test
rather than producing bullets that wink into existence.
## Loot has two visibilities, and one of them is an interest rule
Ground loot rides the snapshot rather than an event stream: items do not move,
so re-sending them 20 times a second costs almost nothing and a lost packet
costs nothing at all — which a spawn-once event could not claim.
Each item is either **world-shared** (`SimLoot.owner_peer == 0`) or
**player-instanced** (owned by exactly one peer). The instanced kind is filtered
in `NetCodec.encode_snapshot`, next to the actor interest radius and for the
same reason: a peer is never *told* that another player's copy exists, so a
modified client has nothing to reveal. Hiding it in the UI would have been the
same class of mistake as relying on fog to hide enemies.
Two consequences:
- **An instanced item leaves with its owner.** `SimWorld.remove_player` deletes
loot owned by the departing peer. Nobody else can see or take it, so leaving
it behind would be an invisible entity the instance carries until it closes.
- **Anything dropped becomes world-shared**, whatever it was before. That is
what makes dropping worth having.
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.
## Item actions ride the input frame
`InputFrame` carries `BTN_USE`, `BTN_DROP` and a slot byte (10 bytes total, up
from 9). The alternative — a reliable `c_use_item(slot)` RPC — would have needed
its own replay guard, its own rate limit, and its own ordering story against the
movement on the same tick. The input stream already has all three.
The one thing it does not give for free is edge detection. The client repeats
its last few frames every tick (that redundancy is what covers a dropped packet)
and a starved server coasts on the last frame it holds, so a level-triggered
read would spend four items in four ticks. `SimPlayer.prev_buttons` and
`prev_slot` hold the edge; the slot is part of it, so tapping 2 while 1 is still
held is a second action rather than a swallowed one. Movement and fire stay
level-triggered — holding them is exactly what you mean.
Pickup shares `BTN_INTERACT` with the dungeon portal. Loot wins when both are in
reach, but only on a tick where something was actually taken, so a full bag
cannot leave a player standing on the portal unable to use it.
## No contact damage
Nothing hurts you by touching it. Every threat is a bullet you can see and
@@ -222,6 +275,8 @@ transition, not a combat mechanic.
| Input claiming the future | `f.tick > tick + INPUT_MAX_LEAD` |
| Flood | queue capped at `INPUT_MAX_AGE`, oldest dropped |
| Leaving the hub early after death | `RESPAWN_LOCKOUT_TICKS`, server-side |
| Item action repeated by a held key | edge-triggered against `prev_buttons` / `prev_slot` |
| Inventory slot index out of range | `SimPlayer.take_slot` answers "nothing" |
The respawn lockout is worth calling out: the HUD disables its button for the
same three seconds, but that is presentation. A client that ignores its own UI