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
+66 -1
View File
@@ -129,7 +129,7 @@ and troubleshooting — and the player picks another character or creates one.
---
## Characters and progression *(this session)*
## Characters and progression
**Level 1 is base health; each level adds 10.** So level 15 is
`PLAYER_MAX_HP + 14 * 10` = 240. Level is *derived* from lifetime experience
@@ -184,3 +184,68 @@ choice that cannot be taken.
re-sent when the *set* of characters changes, so a bar fed from it moved only on
level-up or a swap. The live total is four bytes on a message that already goes
out at 20 Hz.
---
## Inventory and loot
**Four slots, permanently on screen.** An inventory you have to open is a menu,
and a menu is a death in a game where the floor is bullets. `INVENTORY_SLOTS` is
one constant that the wire format, the save record and the HUD all read, so
growing it is a one-line change — but not into a paged or scrolling UI.
**Item actions ride the input frame rather than becoming new messages.**
`InputFrame` gained `BTN_USE`, `BTN_DROP` and a slot byte. Using an item happens
*during* a fight, so it has to be ordered against movement on the same tick and
be as cheap to reject as a movement vector. Riding the existing stream gets the
redundancy that covers a dropped packet, the replay guard on `last_input_tick`,
and a rate limit of one action per tick for free. A separate reliable RPC would
have needed every one of those bolted back on.
**Item actions are edge-triggered; movement and fire are not.** The client
resends its last few frames every tick and a starved server coasts on the last
one it holds, so a level-triggered read would empty the whole inventory in four
ticks. The *slot* is part of the edge as well — tapping 2 while 1 is still held
is a second, distinct action rather than a swallowed one.
**Loot has two visibilities, and the instanced one is enforced on the wire.**
World-shared loot is one entity the first player to reach it takes.
Player-instanced loot is one entity per eligible player, and a peer is never
told the other copies exist — the filter lives in `NetCodec.encode_snapshot`
beside the actor interest radius, not in the client. It is an
interest-management rule, not a UI convention.
**The Warden's Ration is useless on purpose.** It is dropped by every boss, one
per player who was alive for the kill, and does nothing when used. Its job is to
make sure the player-instanced path runs on every single boss kill instead of
being a code path nothing exercises. If it ever gains an effect, that job needs
a new holder.
**Anything dropped becomes world-shared, whatever it was before.** An instanced
trophy you do not want should be able to reach someone who does — otherwise
"droppable" means nothing for half the items in the game.
**A potion used at full health is refused, not spent.** Nobody drinks one on
purpose at full health, so a mistimed keypress must not do it for them. The
useless ration, by contrast, *is* consumed: "does nothing" has to mean a
completed transaction or it proves nothing about the path it exists to test.
**A full bag leaves the item on the floor.** Nothing is destroyed by a failed
pickup, and the failure does not block the portal, which shares the interact
key.
**Inventories live on the character and are written on every transaction.**
Not on a timer: a crash between "picked it up" and "wrote it down" must not be a
way to lose an item, or — far worse — to duplicate one. They are stored as item
*ids* rather than wire indices, so a save survives `Items.ORDER` being appended
to, and an id this build does not know decays to an empty slot rather than to
the wrong item.
**Items do not stack.** One id per slot, no count, no charges. Everything the
game currently needs fits that, and the wire format, the save record and the UI
are all simpler for it. Add a count when something actually needs one.
**Ground loot never expires; each world caps at `MAX_LOOT_PER_INSTANCE`,
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.