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
+25 -3
View File
@@ -2,26 +2,46 @@ class_name InputFrame
extends RefCounted
## One tick of player intent. This is the only thing a client is allowed to tell
## the server about its own state: no positions, no hits, no damage.
##
## Item actions live here rather than in their own reliable message, which is
## worth being explicit about. Using or dropping an item is a thing you do
## *during* a fight, so it has to be ordered against your movement on the same
## tick, and it has to be as cheap to reject as a movement vector. Riding the
## input stream gets all of that for free: the redundancy that covers a dropped
## packet, the replay guard on [member SimPlayer.last_input_tick], and a natural
## rate limit of one action per tick. A separate "use item" RPC would have
## needed every one of those bolted on again.
const BTN_FIRE := 1
const BTN_ESCAPE := 2
const BTN_INTERACT := 4
## Use the item in [member slot]. Edge-triggered by the server, so holding the
## key spends one potion and not sixty.
const BTN_USE := 8
## Drop the item in [member slot] on the ground, where anyone can take it.
const BTN_DROP := 16
## Wire size in bytes: u32 tick, i8 move x/y, u16 aim, u8 buttons.
const SIZE := 9
## Wire size in bytes: u32 tick, i8 move x/y, u16 aim, u8 buttons, u8 slot.
const SIZE := 10
var tick: int = 0
var move := Vector2.ZERO
var aim: float = 0.0
var buttons: int = 0
## Which inventory slot BTN_USE / BTN_DROP refer to. Meaningless without one of
## those bits set; the server clamps it before use, so a hostile value indexes
## nothing.
var slot: int = 0
static func make(p_tick: int, p_move: Vector2, p_aim: float, p_buttons: int) -> InputFrame:
static func make(p_tick: int, p_move: Vector2, p_aim: float, p_buttons: int,
p_slot: int = 0) -> InputFrame:
var f := InputFrame.new()
f.tick = p_tick
f.move = p_move
f.aim = p_aim
f.buttons = p_buttons
f.slot = p_slot
return f
@@ -37,6 +57,7 @@ func write(buf: StreamPeerBuffer) -> void:
buf.put_8(clampi(roundi(move.y * 100.0), -100, 100))
buf.put_u16(wrapi(roundi(aim / TAU * 65536.0), 0, 65536))
buf.put_u8(buttons & 0xFF)
buf.put_u8(slot & 0xFF)
static func read(buf: StreamPeerBuffer) -> InputFrame:
@@ -45,4 +66,5 @@ static func read(buf: StreamPeerBuffer) -> InputFrame:
f.move = Vector2(float(buf.get_8()) / 100.0, float(buf.get_8()) / 100.0)
f.aim = float(buf.get_u16()) / 65536.0 * TAU
f.buttons = buf.get_u8()
f.slot = buf.get_u8()
return f
+7
View File
@@ -27,4 +27,11 @@ enum Type {
ESCAPE_CANCELLED, ## peer
ESCAPE_COMPLETED, ## peer -- the instance layer acts on this
PORTAL_USED, ## peer -- the instance layer acts on this
## Item transactions. Appended at the end of the enum on purpose: inserting
## mid-list shifts the wire value of everything after it, which is what made
## PLAYER_FIRED a protocol break. All three carry (peer, item) and all three
## tell ServerRuntime the character's inventory needs persisting.
ITEM_PICKED_UP, ## peer, item
ITEM_USED, ## peer, item
ITEM_DROPPED, ## peer, item
}
+25
View File
@@ -0,0 +1,25 @@
class_name SimLoot
extends RefCounted
## An item lying on the ground in one [SimWorld].
##
## Ground loot does not move, so unlike bullets it needs no event stream: it
## rides the snapshot alongside enemies and is re-sent 20 times a second. That
## also means a lost packet costs nothing, which a spawn-once event could not
## claim.
var id: int = 0
var item: StringName = &""
var pos := Vector2.ZERO
## 0 means world-shared: everyone sees it, the first to reach it takes it.
## Otherwise the ONLY peer that may see or take it -- the server filters it out
## of every other snapshot, so instancing is enforced on the wire and not by
## asking the client to be polite.
var owner_peer: int = 0
## World tick it appeared, used only to decide what to evict when an instance
## somehow accumulates more loot than it should hold.
var born_tick: int = 0
## Whether [param peer_id] is allowed to see and take this.
func visible_to(peer_id: int) -> bool:
return owner_peer == 0 or owner_peer == peer_id
+1
View File
@@ -0,0 +1 @@
uid://r142v0ai64i6
+74
View File
@@ -31,6 +31,17 @@ var regen_carry: float = 0.0
## Ticks before a downed player may ask to return to the hub.
var respawn_lockout: int = 0
## Carried items, one id per slot, [constant Items.NONE] where empty. Always
## exactly SimConfig.INVENTORY_SLOTS long -- callers index it directly, so it
## must never be short.
var inventory: Array[StringName] = []
## Buttons and slot from the previous consumed input, so item actions can be
## edge-triggered. Without this, holding the "use" key would drink 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.
var prev_buttons: int = 0
var prev_slot: int = -1
## The peer's connection dropped, but the player is deliberately still in the
## world. Held here rather than deleted so a disconnect cannot be used to dodge
## a dangerous moment: a linkdead player keeps channelling the escape (and stays
@@ -50,6 +61,11 @@ var held_input: InputFrame = InputFrame.new()
var starved_ticks: int = 0
func _init() -> void:
inventory.resize(SimConfig.INVENTORY_SLOTS)
inventory.fill(Items.NONE)
func escape_progress() -> float:
return clampf(float(escape_ticks) / float(SimConfig.ESCAPE_CHANNEL_TICKS), 0.0, 1.0)
@@ -78,7 +94,16 @@ func reset_for_instance(spawn: Vector2, grace: int = 0) -> void:
regen_carry = 0.0
fire_cooldown = 0
escape_ticks = 0
# Cleared, so a button held through a transition registers as a fresh press
# on arrival. Safe rather than merely tolerable: hp is set to max on the
# line above, and a heal at full health is refused, so the only item a held
# key can spend on arrival is one that does nothing anyway.
prev_buttons = 0
prev_slot = -1
input_queue.clear()
# Inventory deliberately survives. It belongs to the character, not to the
# room: walking into a dungeon with the potions you bought is the entire
# point of carrying them.
## True once the lockout has run out and the hub is available again.
@@ -87,6 +112,8 @@ func can_request_respawn() -> bool:
## Adopt a character's stats. Called when a player picks or switches character.
## Inventory comes along: it is stored on the character, so swapping in the hub
## swaps bags too rather than handing one character another's potions.
func adopt(c: Character) -> void:
character_id = c.id
display_name = c.display_name
@@ -95,6 +122,53 @@ func adopt(c: Character) -> void:
colour = c.colour
max_hp = c.max_hp()
hp = mini(hp, max_hp)
set_inventory(c.inventory)
## Replace the whole inventory, padding or trimming to the configured slot
## count so a save file written when the game had a different number of slots
## still loads into a valid player.
func set_inventory(items: Array[StringName]) -> void:
inventory.resize(SimConfig.INVENTORY_SLOTS)
for i in SimConfig.INVENTORY_SLOTS:
inventory[i] = items[i] if i < items.size() else Items.NONE
func free_slot() -> int:
for i in inventory.size():
if inventory[i] == Items.NONE:
return i
return -1
## Put [param item] in the first free slot. Returns the slot, or -1 when full.
func add_item(item: StringName) -> int:
var at := free_slot()
if at >= 0:
inventory[at] = item
return at
## Empty [param slot] and return what was in it, or [constant Items.NONE].
## Out-of-range indices answer NONE rather than erroring: the index came off
## the wire, so it has to be safe to be nonsense.
func take_slot(slot_index: int) -> StringName:
if slot_index < 0 or slot_index >= inventory.size():
return Items.NONE
var item := inventory[slot_index]
inventory[slot_index] = Items.NONE
return item
## Heal by a percentage of MAXIMUM health, and report whether it did anything.
## Refusing a wasted heal is what stops a potion being consumed at full health.
func heal_percent(percent: float) -> int:
if not alive or hp >= max_hp:
return 0
var amount := maxi(1, roundi(float(max_hp) * percent / 100.0))
var before := hp
hp = mini(hp + amount, max_hp)
return hp - before
## One tick of passive healing. Returns true if the visible hit points changed,
+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)})