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:
+61
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user