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
+179 -1
View File
@@ -21,6 +21,9 @@ var rng := RandomNumberGenerator.new()
var players: Dictionary[int, SimPlayer] = {}
var enemies: Dictionary[int, SimEnemy] = {}
var boss: SimBoss = null
## Items lying on the ground, by actor id. Shares the id space with enemies and
## the boss, so nothing has to reason about two kinds of id.
var loot: Dictionary[int, SimLoot] = {}
## Drained by the owner every tick. See [SimEvent].
var events: Array[Dictionary] = []
@@ -83,6 +86,12 @@ func add_player(peer_id: int, display_name: String) -> SimPlayer:
func remove_player(peer_id: int) -> void:
players.erase(peer_id)
# Loot instanced to this peer goes with them. Nobody else can see or take
# it, so leaving it behind would be an invisible entity the instance carries
# until it closes.
for id in loot.keys():
if loot[id].owner_peer == peer_id:
loot.erase(id)
func spawn_enemy(def: EnemyDef, at: Vector2, phase_offset: int = 0) -> SimEnemy:
@@ -179,6 +188,7 @@ func _step_players() -> void:
p.fire_cooldown -= 1
var frame := _take_input(p)
var edge := _button_edge(p, frame)
if not p.alive:
if p.respawn_lockout > 0:
@@ -199,7 +209,24 @@ func _step_players() -> void:
_step_escape(p, frame)
if portal_enabled and frame.pressed(InputFrame.BTN_INTERACT):
# Item actions are edge-triggered; movement and fire are not. Holding
# the key must spend one potion, and the buttons arrive repeated (the
# client sends the last few frames every tick, and a starved server
# coasts on the last one), so a level-triggered read would empty the
# whole inventory in four ticks.
if edge & InputFrame.BTN_USE:
_use_slot(p, frame.slot)
if edge & InputFrame.BTN_DROP:
_drop_slot(p, frame.slot)
# Pickup shares the interact button with the portal. Loot wins when both
# are in reach, and only for the tick it actually took something -- a
# full inventory must not leave you standing on the portal unable to use
# it.
var took_item := false
if edge & InputFrame.BTN_INTERACT:
took_item = _try_pickup(p)
if portal_enabled and not took_item and frame.pressed(InputFrame.BTN_INTERACT):
if p.pos.distance_to(portal_pos) <= SimConfig.PORTAL_RADIUS:
events.append({"t": SimEvent.Type.PORTAL_USED, "peer": p.peer_id})
@@ -221,6 +248,18 @@ func _take_input(p: SimPlayer) -> InputFrame:
return p.held_input
## Buttons newly pressed on this frame, for the actions that must not repeat.
## The slot counts as part of the edge: tapping slot 2 while slot 1 is still
## held is a second, distinct action, not a swallowed one.
func _button_edge(p: SimPlayer, frame: InputFrame) -> int:
var edge := frame.buttons & ~p.prev_buttons
if frame.slot != p.prev_slot:
edge |= frame.buttons & (InputFrame.BTN_USE | InputFrame.BTN_DROP)
p.prev_buttons = frame.buttons
p.prev_slot = frame.slot
return edge
func _fire_player_shot(p: SimPlayer) -> void:
p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN
events.append({"t": SimEvent.Type.PLAYER_FIRED, "peer": p.peer_id})
@@ -235,6 +274,143 @@ func _fire_player_shot(p: SimPlayer) -> void:
SimConfig.KIND_PLAYER_SHOT)
# --- Items and loot ---------------------------------------------------------
## Radius used to check that a dropped item is not inside a wall. Smaller than
## anything that walks, because loot only has to be reachable, not roomy.
const LOOT_CLEARANCE := 6.0
## Put [param item] on the ground. [param owner_peer] of 0 is world-shared;
## anything else is visible and takeable only by that peer.
func spawn_loot(item: StringName, at: Vector2, owner_peer: int = 0) -> SimLoot:
if Items.get_def(item) == null:
return null
_make_room_for_loot()
var l := SimLoot.new()
l.id = next_actor_id()
l.item = item
l.pos = at
l.owner_peer = owner_peer
l.born_tick = tick
loot[l.id] = l
return l
## Keep ground loot bounded. Only the hub can realistically reach the cap --
## dungeons close and take their litter with them -- so the oldest item is the
## right thing to lose: it is the one that has been ignored the longest.
func _make_room_for_loot() -> void:
while loot.size() >= SimConfig.MAX_LOOT_PER_INSTANCE:
var oldest := -1
for id in loot:
if oldest < 0 or loot[id].born_tick < loot[oldest].born_tick:
oldest = id
if oldest < 0:
return
loot.erase(oldest)
## Roll a loot table and put what it produced on the floor. Called on death, so
## it runs inside hit resolution and uses the world's own RNG -- loot is part of
## the simulation, not something the instance layer sprinkles on afterwards.
func _drop_loot(table: Array[LootDrop], at: Vector2) -> void:
for entry in table:
if entry == null or entry.item == Items.NONE:
continue
# Rolled unconditionally, including for guaranteed drops. Skipping the
# roll at chance 1.0 would make the RNG stream depend on the loot
# table's contents, so editing a number in content.gd would silently
# change every later roll in the world.
if rng.randf() > entry.chance:
continue
if not entry.instanced:
_place_loot(entry.item, at, Vector2.ZERO, 0)
continue
# One copy per player who was alive for the kill. Laid out on a ring so
# that a debug view of every copy at once is legible; in play each
# player is only ever sent their own, so they all appear in the middle.
var owners := _living_peers()
for i in owners.size():
var angle := TAU * float(i) / float(owners.size())
_place_loot(entry.item, at,
Vector2.RIGHT.rotated(angle) * SimConfig.LOOT_INSTANCED_SPREAD,
owners[i])
## Spawn at [param at] + [param offset], falling back to [param at] when the
## offset would put the item inside geometry -- unreachable loot is worse than
## two items in the same place.
func _place_loot(item: StringName, at: Vector2, offset: Vector2, owner_peer: int) -> void:
var want := at + offset
if offset != Vector2.ZERO and map.circle_blocked(want, LOOT_CLEARANCE):
want = at
spawn_loot(item, want, owner_peer)
func _living_peers() -> Array[int]:
var out: Array[int] = []
for p in players.values():
if p.alive:
out.append(p.peer_id)
out.sort() # stable ordering, so the ring layout is not dictionary order
return out
## Take the nearest item this player is allowed to have. Returns whether one
## was actually picked up.
func _try_pickup(p: SimPlayer) -> bool:
var best: SimLoot = null
var best_d := SimConfig.LOOT_PICKUP_RADIUS * SimConfig.LOOT_PICKUP_RADIUS
for l in loot.values():
if not l.visible_to(p.peer_id):
continue
var d := p.pos.distance_squared_to(l.pos)
if d <= best_d:
best_d = d
best = l
if best == null:
return false
if p.add_item(best.item) < 0:
return false # bags full; the item stays exactly where it was
loot.erase(best.id)
events.append({"t": SimEvent.Type.ITEM_PICKED_UP, "peer": p.peer_id, "item": best.item})
return true
func _use_slot(p: SimPlayer, slot_index: int) -> void:
if slot_index < 0 or slot_index >= p.inventory.size():
return
var item := p.inventory[slot_index]
var def := Items.get_def(item)
if def == null:
return
match def.effect:
ItemDef.Effect.HEAL:
# Refused rather than wasted. Spending a potion at full health is
# not a decision anyone makes on purpose, so it must not be one a
# mistimed keypress can make for them.
if p.heal_percent(def.effect_value) <= 0:
return
_:
pass
p.take_slot(slot_index)
events.append({"t": SimEvent.Type.ITEM_USED, "peer": p.peer_id, "item": item})
func _drop_slot(p: SimPlayer, slot_index: int) -> void:
if slot_index < 0 or slot_index >= p.inventory.size():
return
if p.inventory[slot_index] == Items.NONE:
return
var item := p.take_slot(slot_index)
# Anything dropped becomes world-shared, even if it arrived as an instanced
# drop. That is what makes dropping worth having: an item you do not want
# should be able to reach someone who does.
_place_loot(item, p.pos, Vector2.ZERO, 0)
events.append({"t": SimEvent.Type.ITEM_DROPPED, "peer": p.peer_id, "item": item})
func _step_escape(p: SimPlayer, frame: InputFrame) -> void:
# A dropped connection is treated as holding the button down. Pulling the
# plug then costs exactly what pressing escape costs -- one second of
@@ -437,6 +613,7 @@ func _damage_enemy(e: SimEnemy, amount: int) -> void:
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
if e.hp <= 0:
e.alive = false
_drop_loot(e.def.loot, e.pos)
# The def id rides along so the instance layer can score it without
# looking up an actor that is about to stop existing.
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id, "def": String(e.def.id)})
@@ -450,6 +627,7 @@ func _damage_boss(amount: int) -> void:
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp})
if boss.hp <= 0:
boss.alive = false
_drop_loot(boss.def.loot, boss.pos)
events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id)})