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
+86 -2
View File
@@ -15,6 +15,11 @@ signal local_hit(damage: int)
signal shot_fired
signal enemy_died
signal boss_died
## Item transactions, straight from server events. Carry the item id so the
## view can name what happened without guessing from the inventory diff.
signal item_picked_up(item: StringName)
signal item_used(item: StringName)
signal item_dropped(item: StringName)
## The account's character roster changed: created, selected, levelled or died.
signal characters_changed
signal select_failed(reason: String)
@@ -47,6 +52,10 @@ var my_max_hp: int = SimConfig.PLAYER_MAX_HP
## Lifetime experience, straight from the snapshot so the bar moves per kill
## rather than per roster message.
var my_total_xp: int = 0
## Carried items as wire indices (0 = empty slot). Replaced wholesale by every
## snapshot, so it can never drift from what the server thinks you have --
## there is deliberately no local "I picked that up" optimism here.
var my_inventory: Array[int] = []
var my_alive: bool = true
var my_escape: float = 0.0
var my_escaping: bool = false
@@ -183,7 +192,23 @@ func _sample_input() -> InputFrame:
buttons |= InputFrame.BTN_ESCAPE
if Input.is_action_pressed("interact"):
buttons |= InputFrame.BTN_INTERACT
return InputFrame.make(input_tick, move, aim, buttons)
# Number keys use a slot; shift-number drops it. The bit is sent for as long
# as the key is held and the server takes the leading edge, so a stuck or
# repeated packet cannot spend more than one item.
var slot := held_slot()
if slot >= 0:
buttons |= InputFrame.BTN_DROP if Input.is_key_pressed(KEY_SHIFT) \
else InputFrame.BTN_USE
return InputFrame.make(input_tick, move, aim, buttons, maxi(slot, 0))
## Which inventory slot key is down, or -1. Lowest wins, so pressing 1 while 2
## is held reads as "now slot 1" rather than as nothing.
func held_slot() -> int:
for i in SimConfig.INVENTORY_SLOTS:
if Input.is_action_pressed("use_slot_%d" % (i + 1)):
return i
return -1
## Scripted input so `tools/smoke.sh` can play the game with no display: orbit
@@ -202,9 +227,20 @@ func _bot_input() -> InputFrame:
buttons |= InputFrame.BTN_INTERACT
# Walk onto the portal instead of orbiting, or interact never lands.
move = (portal_pos - predicted_pos).normalized()
var slot := 0
if instance_kind == Protocol.InstanceKind.DUNGEON:
# Grab at whatever is underfoot and occasionally drink, so the item
# messages are exercised over a real socket by the smoke test. Bots are
# poor shots and rarely produce loot, so this is coverage of the wire
# format rather than of the drop rules -- tools/diag_loot.tscn covers
# those.
if _bot_tick % 90 < 6:
buttons |= InputFrame.BTN_INTERACT
if _bot_tick % 150 == 0:
buttons |= InputFrame.BTN_USE
if instance_kind == Protocol.InstanceKind.DUNGEON and _bot_tick > 900:
buttons |= InputFrame.BTN_ESCAPE
return InputFrame.make(input_tick, move, aim, buttons)
return InputFrame.make(input_tick, move, aim, buttons, slot)
# --- Server messages --------------------------------------------------------
@@ -325,6 +361,7 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
request_escape = false
request_respawn = false
my_respawn_wait = 0.0
my_inventory = []
cleared_countdown = Protocol.COUNTDOWN_NONE
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
instance_changed.emit()
@@ -336,6 +373,7 @@ func on_snapshot(data: PackedByteArray) -> void:
if not snap_curr.is_empty() and int(snap["tick"]) <= int(snap_curr["tick"]):
return # stale or duplicate; unreliable channel, newest wins
cleared_countdown = int(snap["cleared_countdown"])
my_inventory = snap["inventory"]
snap_prev = snap_curr
snap_curr = snap
_interp = 0.0
@@ -435,6 +473,18 @@ func on_events(data: PackedByteArray) -> void:
enemy_died.emit()
SimEvent.Type.BOSS_DIED:
boss_died.emit()
SimEvent.Type.ITEM_PICKED_UP:
if int(ev["peer"]) == my_peer:
item_picked_up.emit(ev["item"])
hud_dirty.emit()
SimEvent.Type.ITEM_USED:
if int(ev["peer"]) == my_peer:
item_used.emit(ev["item"])
hud_dirty.emit()
SimEvent.Type.ITEM_DROPPED:
if int(ev["peer"]) == my_peer:
item_dropped.emit(ev["item"])
hud_dirty.emit()
_:
pass
@@ -451,6 +501,40 @@ func enemies() -> Array[Dictionary]:
return _interpolated("enemies", "id", [])
## Ground loot the server has told us about. Not interpolated -- items do not
## move -- and never filtered here: what arrives is already exactly what this
## player is allowed to see.
func ground_loot() -> Array:
if snap_curr.is_empty():
return []
return snap_curr["loot"]
## The item that pressing interact would pick up, or an empty dictionary. Purely
## for the prompt: the server does this same search for itself and does not care
## what the client concluded.
func loot_in_reach() -> Dictionary:
var best := {}
var best_d := SimConfig.LOOT_PICKUP_RADIUS * SimConfig.LOOT_PICKUP_RADIUS
for l: Dictionary in ground_loot():
var d: float = predicted_pos.distance_squared_to(l["pos"])
if d <= best_d:
best_d = d
best = l
return best
## True when every slot is taken, so the HUD can explain why a pickup did
## nothing rather than looking broken.
func inventory_full() -> bool:
if my_inventory.is_empty():
return false
for index in my_inventory:
if index == 0:
return false
return true
func boss_state() -> Dictionary:
if snap_curr.is_empty() or snap_curr.get("boss") == null:
return {}
+61 -2
View File
@@ -103,6 +103,32 @@ static func encode_snapshot(world: SimWorld,
b.put_float(world.boss.pos.y)
b.put_u32(maxi(world.boss.hp, 0))
b.put_u8(clampi(world.boss.phase_index, 0, 255))
# Only the observer's own bag. Nobody needs to see what a party member is
# carrying, and not sending it means there is nothing to leak.
for i in SimConfig.INVENTORY_SLOTS:
var carried := Items.NONE
if observer != null and i < observer.inventory.size():
carried = observer.inventory[i]
b.put_u8(Items.index_of(carried))
# Ground loot. Player-instanced items are filtered here rather than hidden
# in the client: a peer is never told that another player's copy exists, so
# a modified client has nothing to reveal.
var visible_loot: Array[SimLoot] = []
for l in world.loot.values():
if observer != null:
if not l.visible_to(for_peer):
continue
if eye.distance_squared_to(l.pos) > cull_sq:
continue
visible_loot.append(l)
b.put_u16(mini(visible_loot.size(), 65535))
for l in visible_loot:
b.put_u32(l.id)
b.put_float(l.pos.x)
b.put_float(l.pos.y)
b.put_u8(Items.index_of(l.item))
return b.data_array
@@ -114,6 +140,7 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
"tick": b.get_u32(),
"cleared_countdown": b.get_u8(),
"players": [], "enemies": [], "boss": null,
"loot": [], "inventory": [],
}
var pcount := b.get_u8()
@@ -149,6 +176,19 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
"hp": b.get_u32(),
"phase": b.get_u8(),
}
var inventory: Array[int] = []
for _i in SimConfig.INVENTORY_SLOTS:
inventory.append(b.get_u8())
snap["inventory"] = inventory
var lcount := b.get_u16()
for _i in lcount:
snap["loot"].append({
"id": b.get_u32(),
"pos": Vector2(b.get_float(), b.get_float()),
"item": b.get_u8(),
})
return snap
@@ -201,6 +241,11 @@ static func encode_events(server_tick: int, events: Array[Dictionary]) -> Packed
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED:
body.put_u32(ev["peer"])
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
SimEvent.Type.ITEM_DROPPED:
body.put_u32(ev["peer"])
# By index, like everywhere else on the wire. See Items.ORDER.
body.put_u8(Items.index_of(ev["item"]))
SimEvent.Type.PLAYER_RESPAWNED:
body.put_u32(ev["peer"])
body.put_float(ev["pos"].x)
@@ -252,6 +297,10 @@ static func decode_events(data: PackedByteArray) -> Dictionary:
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED:
ev["peer"] = b.get_u32()
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
SimEvent.Type.ITEM_DROPPED:
ev["peer"] = b.get_u32()
ev["item"] = Items.by_index(b.get_u8())
SimEvent.Type.PLAYER_RESPAWNED:
ev["peer"] = b.get_u32()
ev["pos"] = Vector2(b.get_float(), b.get_float())
@@ -393,6 +442,8 @@ static func encode_characters(chars: Array[Character], selected: String) -> Pack
b.put_u16(clampi(c.max_hp(), 1, 65535))
b.put_u8(1 if c.active else 0)
b.put_u32(c.colour.to_rgba32())
for i in SimConfig.INVENTORY_SLOTS:
b.put_u8(Items.index_of(c.inventory[i] if i < c.inventory.size() else Items.NONE))
return b.data_array
@@ -427,8 +478,8 @@ static func decode_characters(data: PackedByteArray) -> Dictionary:
return {"selected": selected, "characters": out}
var count := b.get_u8()
# Bytes each entry needs after its two strings: level, xp, progress,
# max_hp, active, colour.
var fixed := 1 + 4 + 1 + 2 + 1 + 4
# max_hp, active, colour, then one byte per inventory slot.
var fixed := 1 + 4 + 1 + 2 + 1 + 4 + SimConfig.INVENTORY_SLOTS
for _i in count:
var id := _safe_utf8(b)
var display := _safe_utf8(b)
@@ -443,5 +494,13 @@ static func decode_characters(data: PackedByteArray) -> Dictionary:
"max_hp": b.get_u16(),
"active": b.get_u8() == 1,
"colour": Color.hex(b.get_u32()),
"inventory": _read_inventory(b),
})
return {"selected": selected, "characters": out}
static func _read_inventory(b: StreamPeerBuffer) -> Array[int]:
var out: Array[int] = []
for _i in SimConfig.INVENTORY_SLOTS:
out.append(b.get_u8())
return out
+5 -1
View File
@@ -13,7 +13,11 @@ extends RefCounted
## 5: handshake carries an auth ticket instead of a bare name; added character
## list/select/create messages, per-player max health and colour in the
## snapshot.
const VERSION := 5
## 6: inventory and loot. The snapshot gained the observer's own inventory and
## 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
## message both ends parse positionally.
const VERSION := 6
const DEFAULT_PORT := 27015
const MAX_CLIENTS := 32
+23
View File
@@ -97,6 +97,12 @@ func _dispatch_events(inst: Instance) -> void:
_award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", ""))))
SimEvent.Type.BOSS_DIED:
_award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", ""))))
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
SimEvent.Type.ITEM_DROPPED:
# The simulation moved items between the ground and a bag; the
# store is what makes that survive a restart. Same division of
# labour as experience: the world decides, this layer banks it.
_persist_inventory(inst, int(ev["peer"]))
SimEvent.Type.PLAYER_DIED:
# Deferred like the transfers below: the payload has not been
# sent yet, and a player must still receive news of its own
@@ -436,6 +442,23 @@ func _award_kill(inst: Instance, amount: int) -> void:
_grant_xp(peer, amount)
## Copy a player's bag back onto the character record it belongs to.
##
## Called on every item transaction rather than on a timer, because the whole
## point of persisting an inventory is that a crash between "picked it up" and
## "wrote it down" must not be a way to lose an item -- or, far worse, a way to
## duplicate one.
func _persist_inventory(inst: Instance, peer_id: int) -> void:
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
var character_id: String = peer_characters.get(peer_id, "")
if account == AuthProvider.NO_ACCOUNT or character_id.is_empty():
return
var p: SimPlayer = inst.world.players.get(peer_id)
if p == null:
return
store.set_inventory(account, character_id, p.inventory)
func _grant_xp(peer_id: int, amount: int) -> void:
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
var character_id: String = peer_characters.get(peer_id, "")