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:
@@ -271,3 +271,134 @@ func test_a_truncated_roster_packet_does_not_read_past_the_end() -> void:
|
||||
assert_lte(out.size(), 2,
|
||||
"a roster cut at %d bytes must degrade, not invent entries" % cut)
|
||||
assert_eq(NetCodec.decode_roster(full).size(), 2, "and the full packet still works")
|
||||
|
||||
|
||||
# --- Inventory and loot -----------------------------------------------------
|
||||
|
||||
func test_the_snapshot_carries_the_observers_own_inventory() -> void:
|
||||
var p: SimPlayer = world.players[42]
|
||||
p.add_item(Items.HEALTH_POTION)
|
||||
p.inventory[2] = Items.WARDENS_RATION
|
||||
var snap := NetCodec.decode_snapshot(
|
||||
NetCodec.encode_snapshot(world, Protocol.COUNTDOWN_NONE, 42))
|
||||
var inv: Array = snap["inventory"]
|
||||
assert_eq(inv.size(), SimConfig.INVENTORY_SLOTS)
|
||||
assert_eq(Items.by_index(int(inv[0])), Items.HEALTH_POTION)
|
||||
assert_eq(Items.by_index(int(inv[1])), Items.NONE)
|
||||
assert_eq(Items.by_index(int(inv[2])), Items.WARDENS_RATION)
|
||||
|
||||
|
||||
## Nobody needs to know what a party member is carrying, and the cheapest way to
|
||||
## keep that true is to never put it on the wire.
|
||||
func test_the_snapshot_never_carries_another_players_inventory() -> void:
|
||||
var them := world.add_player(43, "them")
|
||||
them.pos = Vector2(120.0, -64.0)
|
||||
them.add_item(Items.HEALTH_POTION)
|
||||
var snap := NetCodec.decode_snapshot(
|
||||
NetCodec.encode_snapshot(world, Protocol.COUNTDOWN_NONE, 42))
|
||||
assert_eq((snap["players"] as Array).size(), 2, "they should still be visible")
|
||||
for inv_index in snap["inventory"]:
|
||||
assert_eq(int(inv_index), 0, "but their bag must not be in this packet")
|
||||
|
||||
|
||||
func test_ground_loot_round_trips() -> void:
|
||||
var l := world.spawn_loot(Items.HEALTH_POTION, Vector2(64.0, -32.0))
|
||||
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
|
||||
var got: Dictionary = snap["loot"][0]
|
||||
assert_eq(int(got["id"]), l.id)
|
||||
assert_almost_eq((got["pos"] as Vector2).x, 64.0, 0.01)
|
||||
assert_almost_eq((got["pos"] as Vector2).y, -32.0, 0.01)
|
||||
assert_eq(Items.by_index(int(got["item"])), Items.HEALTH_POTION)
|
||||
|
||||
|
||||
## The wire is where player-instanced loot is actually enforced. A peer is never
|
||||
## told another player's copy exists, so a modified client has nothing to
|
||||
## reveal -- this is an interest-management rule, not a UI convention.
|
||||
func test_another_players_instanced_loot_is_not_on_the_wire() -> void:
|
||||
var p: SimPlayer = world.players[42]
|
||||
world.spawn_loot(Items.WARDENS_RATION, p.pos + Vector2(10.0, 0.0), 43)
|
||||
world.spawn_loot(Items.WARDENS_RATION, p.pos + Vector2(20.0, 0.0), 42)
|
||||
world.spawn_loot(Items.HEALTH_POTION, p.pos + Vector2(30.0, 0.0))
|
||||
var snap := NetCodec.decode_snapshot(
|
||||
NetCodec.encode_snapshot(world, Protocol.COUNTDOWN_NONE, 42))
|
||||
assert_eq((snap["loot"] as Array).size(), 2,
|
||||
"my ration and the shared potion, never theirs")
|
||||
|
||||
|
||||
func test_distant_loot_is_not_sent() -> void:
|
||||
var p: SimPlayer = world.players[42]
|
||||
world.spawn_loot(Items.HEALTH_POTION,
|
||||
p.pos + Vector2(SimConfig.ACTOR_INTEREST_RADIUS + 200.0, 0.0))
|
||||
var snap := NetCodec.decode_snapshot(
|
||||
NetCodec.encode_snapshot(world, Protocol.COUNTDOWN_NONE, 42))
|
||||
assert_eq((snap["loot"] as Array).size(), 0)
|
||||
|
||||
|
||||
func test_item_events_round_trip() -> void:
|
||||
var events: Array[Dictionary] = [
|
||||
{"t": SimEvent.Type.ITEM_PICKED_UP, "peer": 42, "item": Items.HEALTH_POTION},
|
||||
{"t": SimEvent.Type.ITEM_USED, "peer": 42, "item": Items.WARDENS_RATION},
|
||||
{"t": SimEvent.Type.ITEM_DROPPED, "peer": 7, "item": Items.HEALTH_POTION},
|
||||
]
|
||||
var out: Array = NetCodec.decode_events(NetCodec.encode_events(1, events))["events"]
|
||||
assert_eq(out.size(), 3)
|
||||
assert_eq(int(out[0]["t"]), SimEvent.Type.ITEM_PICKED_UP)
|
||||
assert_eq(out[0]["item"], Items.HEALTH_POTION)
|
||||
assert_eq(int(out[1]["peer"]), 42)
|
||||
assert_eq(out[1]["item"], Items.WARDENS_RATION)
|
||||
assert_eq(int(out[2]["peer"]), 7)
|
||||
assert_eq(out[2]["item"], Items.HEALTH_POTION)
|
||||
|
||||
|
||||
## The events after the item ones must still decode. A fixed-width event whose
|
||||
## body length is wrong desynchronises the whole rest of the packet, and that
|
||||
## shows up as unrelated nonsense rather than as a decode error.
|
||||
func test_events_after_an_item_event_still_decode() -> void:
|
||||
var events: Array[Dictionary] = [
|
||||
{"t": SimEvent.Type.ITEM_USED, "peer": 42, "item": Items.HEALTH_POTION},
|
||||
{"t": SimEvent.Type.ENEMY_HIT, "id": 99, "dmg": 12, "hp": 400},
|
||||
{"t": SimEvent.Type.PLAYER_DIED, "peer": 42},
|
||||
]
|
||||
var out: Array = NetCodec.decode_events(NetCodec.encode_events(1, events))["events"]
|
||||
assert_eq(out.size(), 3)
|
||||
assert_eq(int(out[1]["id"]), 99)
|
||||
assert_eq(int(out[1]["hp"]), 400)
|
||||
assert_eq(int(out[2]["t"]), SimEvent.Type.PLAYER_DIED)
|
||||
|
||||
|
||||
func test_the_character_roster_carries_inventories() -> void:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 31
|
||||
var c := Character.create("Packrat", rng)
|
||||
c.inventory[0] = Items.HEALTH_POTION
|
||||
c.inventory[3] = Items.WARDENS_RATION
|
||||
var out := NetCodec.decode_characters(
|
||||
NetCodec.encode_characters([c] as Array[Character], c.id))
|
||||
var inv: Array = out["characters"][0]["inventory"]
|
||||
assert_eq(Items.by_index(int(inv[0])), Items.HEALTH_POTION)
|
||||
assert_eq(Items.by_index(int(inv[3])), Items.WARDENS_RATION)
|
||||
|
||||
|
||||
## The snapshot is parsed positionally from the front, so a packet with no
|
||||
## players, enemies or boss still has to land on the right byte for the
|
||||
## inventory and loot that follow.
|
||||
func test_an_empty_world_snapshot_still_decodes_its_tail() -> void:
|
||||
var empty := SimWorld.new(1)
|
||||
empty.spawn_loot(Items.HEALTH_POTION, Vector2(5.0, 5.0))
|
||||
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(empty))
|
||||
assert_eq((snap["players"] as Array).size(), 0)
|
||||
assert_eq((snap["inventory"] as Array).size(), SimConfig.INVENTORY_SLOTS)
|
||||
assert_eq((snap["loot"] as Array).size(), 1)
|
||||
|
||||
|
||||
func test_an_input_frame_carries_its_slot_over_the_wire() -> void:
|
||||
var frames: Array[InputFrame] = [
|
||||
InputFrame.make(10, Vector2.ZERO, 0.0, InputFrame.BTN_USE, 3),
|
||||
InputFrame.make(11, Vector2.ZERO, 0.0, InputFrame.BTN_DROP, 1),
|
||||
]
|
||||
var out := NetCodec.decode_inputs(NetCodec.encode_inputs(frames))
|
||||
assert_eq(out.size(), 2)
|
||||
assert_eq(out[0].slot, 3)
|
||||
assert_true(out[0].pressed(InputFrame.BTN_USE))
|
||||
assert_eq(out[1].slot, 1)
|
||||
assert_true(out[1].pressed(InputFrame.BTN_DROP))
|
||||
|
||||
Reference in New Issue
Block a user