Files
transcience/tests/unit/test_loot.gd
T
claudeandClaude Opus 5 39d857c4d1 Bound what one packet can cost, and stop the hub eating its own loot
Selecting a character rewrote the entire store to disk, re-placed the
player, re-streamed its map chunks and broadcast a roster to everyone --
off one small reliable packet, at whatever rate a client cared to send.
Repeating a selection you already hold now answers yes and stops, and
select/create are paced per peer. Item moves and experience grants, the
two paths a client can trigger every tick, mark the store dirty and the
server flushes once per tick instead of once per event.

That flush also fixes experience quietly not being saved: grant_xp only
wrote when a level was crossed, and level is DERIVED from lifetime xp, so
the number nobody was keeping was the only number there is. A restart
rolled a character back to its last level-up.

The emergency escape ran in the hub, where completing it means
transferring into the instance you are already in: teleported to spawn,
healed to full, instanced loot dropped, every map chunk re-streamed, once
a second for as long as the key is held. The hub now opts out.

Ground loot evicts the oldest item when the floor fills. That is right
for a dungeon, which closes and takes its litter with it, and wrong for
the hub, which never closes -- a player dropping into a full floor
destroyed whatever someone else had left there longest, and drop is
edge-triggered per tick. A player's drop is refused instead; the world's
own drops still make room.

Also: a linkdead body is not in inst.peers, so closing a CLEARED dungeon
walked past it and left that peer's account and name in the dictionaries
forever -- with the new one-account guard, enough to lock someone out of
their own session. And several encoders wrote mini(size, 255) as a count
and then looped the whole array, which puts records after the count on
the wire; MAX_CLIENTS makes it unreachable, which is why it would have
survived to the day it was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:09:43 +02:00

453 lines
17 KiB
GDScript

extends GutTest
## Ground loot and the item actions that move things on and off it.
##
## Everything here goes through [method SimWorld.queue_input] rather than
## calling the private handlers, because the whole point of putting item actions
## in the input frame is that they are subject to the same rules as movement.
## A test that reached past that would prove nothing about what a client can
## actually do.
const ME := 1
const THEM := 2
var world: SimWorld
func before_each() -> void:
world = SimWorld.new(7)
var me := world.add_player(ME, "me")
me.pos = Vector2.ZERO
me.spawn_grace = 0
## One tick of held input. Ticks have to advance or queue_input discards the
## frame as a duplicate, which is exactly what it should do.
func _hold(peer: int, ticks: int, buttons: int, slot: int = 0) -> void:
for _i in ticks:
var frames: Array[InputFrame] = [
InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, buttons, slot)]
world.queue_input(peer, frames)
world.step()
## Press and release, so the next press is a fresh edge.
func _tap(peer: int, buttons: int, slot: int = 0) -> void:
_hold(peer, 1, buttons, slot)
_hold(peer, 1, 0, slot)
func _events_of(kind: int) -> Array:
var out := []
for ev in world.events:
if int(ev["t"]) == kind:
out.append(ev)
return out
# --- Picking up -------------------------------------------------------------
func test_pressing_interact_next_to_an_item_takes_it() -> void:
world.spawn_loot(Items.HEALTH_POTION, Vector2(20.0, 0.0))
_tap(ME, InputFrame.BTN_INTERACT)
assert_eq(world.players[ME].inventory[0], Items.HEALTH_POTION)
assert_eq(world.loot.size(), 0, "and it leaves the floor")
assert_eq(_events_of(SimEvent.Type.ITEM_PICKED_UP).size(), 1)
func test_an_item_out_of_reach_is_not_taken() -> void:
world.spawn_loot(Items.HEALTH_POTION,
Vector2(SimConfig.LOOT_PICKUP_RADIUS + 20.0, 0.0))
_tap(ME, InputFrame.BTN_INTERACT)
assert_eq(world.players[ME].free_slot(), 0, "nothing should have been picked up")
assert_eq(world.loot.size(), 1)
## Interact is held down while walking around, and the client repeats the last
## few frames every tick. Level-triggered, standing on a pile would hoover it up
## in four ticks.
func test_holding_interact_takes_exactly_one_item() -> void:
for i in 3:
world.spawn_loot(Items.HEALTH_POTION, Vector2(float(i) * 4.0, 0.0))
_hold(ME, 30, InputFrame.BTN_INTERACT)
assert_eq(world.loot.size(), 2, "two items should still be on the floor")
func test_releasing_and_pressing_again_takes_the_next_one() -> void:
world.spawn_loot(Items.HEALTH_POTION, Vector2(4.0, 0.0))
world.spawn_loot(Items.HEALTH_POTION, Vector2(8.0, 0.0))
_tap(ME, InputFrame.BTN_INTERACT)
_tap(ME, InputFrame.BTN_INTERACT)
assert_eq(world.loot.size(), 0)
## Nothing is destroyed by a full bag. Silently deleting the item would be the
## kind of loss a player cannot be compensated for.
func test_a_full_inventory_leaves_the_item_on_the_floor() -> void:
var me := world.players[ME]
for _i in SimConfig.INVENTORY_SLOTS:
me.add_item(Items.WARDENS_RATION)
world.spawn_loot(Items.HEALTH_POTION, Vector2(10.0, 0.0))
_tap(ME, InputFrame.BTN_INTERACT)
assert_eq(world.loot.size(), 1, "the item must survive a failed pickup")
assert_eq(_events_of(SimEvent.Type.ITEM_PICKED_UP).size(), 0)
## The portal and pickup share the interact key. A full bag must not leave a
## player standing on the portal unable to use it.
func test_a_failed_pickup_does_not_block_the_portal() -> void:
world.portals = [SimPortal.make(Vector2.ZERO, Dungeons.default_id())]
var me := world.players[ME]
for _i in SimConfig.INVENTORY_SLOTS:
me.add_item(Items.WARDENS_RATION)
world.spawn_loot(Items.HEALTH_POTION, Vector2(10.0, 0.0))
_hold(ME, 1, InputFrame.BTN_INTERACT)
assert_gt(_events_of(SimEvent.Type.PORTAL_USED).size(), 0,
"the portal should still answer when the pickup could not happen")
# --- Using ------------------------------------------------------------------
func test_using_a_potion_heals_and_consumes_it() -> void:
var me := world.players[ME]
me.add_item(Items.HEALTH_POTION)
me.hp = 10
_tap(ME, InputFrame.BTN_USE, 0)
assert_gt(me.hp, 10, "the potion should have healed")
assert_eq(me.inventory[0], Items.NONE, "and been consumed")
assert_eq(_events_of(SimEvent.Type.ITEM_USED).size(), 1)
## A mistimed keypress must not cost a potion. Refusing the use is the only way
## to make that true, since nothing else in the game asks for confirmation.
func test_a_potion_at_full_health_is_refused_rather_than_wasted() -> void:
var me := world.players[ME]
me.add_item(Items.HEALTH_POTION)
me.hp = me.max_hp
_tap(ME, InputFrame.BTN_USE, 0)
assert_eq(me.inventory[0], Items.HEALTH_POTION, "it should still be there")
## Refilling the slot mid-hold is what makes this test bite. Checking only that
## one potion was spent proves nothing: a level-triggered bug empties the slot
## on the first tick and then finds nothing left to spend either way.
func test_holding_the_use_key_spends_exactly_one_potion() -> void:
var me := world.players[ME]
me.add_item(Items.HEALTH_POTION)
me.hp = 1
_hold(ME, 5, InputFrame.BTN_USE, 0)
assert_eq(me.inventory[0], Items.NONE, "the first press should drink it")
# Same key still down, same slot, a fresh potion in it.
me.inventory[0] = Items.HEALTH_POTION
me.hp = 1
_hold(ME, 20, InputFrame.BTN_USE, 0)
assert_eq(me.inventory[0], Items.HEALTH_POTION,
"a held key is one action, however many frames carry it")
## Tapping a second slot while the first is still held is a real second action,
## so the slot has to be part of the edge and not just the button bit.
func test_a_different_slot_while_held_is_a_second_action() -> void:
var me := world.players[ME]
me.add_item(Items.WARDENS_RATION)
me.add_item(Items.WARDENS_RATION)
_hold(ME, 3, InputFrame.BTN_USE, 0)
_hold(ME, 3, InputFrame.BTN_USE, 1)
assert_eq(me.inventory[0], Items.NONE)
assert_eq(me.inventory[1], Items.NONE)
## The ration has no effect, and is still spent. "Does nothing" has to mean a
## completed transaction, or it proves nothing about the path it exists to test.
func test_a_useless_item_is_still_consumed() -> void:
var me := world.players[ME]
me.add_item(Items.WARDENS_RATION)
me.hp = me.max_hp
_tap(ME, InputFrame.BTN_USE, 0)
assert_eq(me.inventory[0], Items.NONE)
func test_using_an_empty_slot_does_nothing() -> void:
_tap(ME, InputFrame.BTN_USE, 2)
assert_eq(_events_of(SimEvent.Type.ITEM_USED).size(), 0)
## The slot index comes off the wire, so a hostile value has to be inert rather
## than merely unusual.
func test_a_slot_index_off_the_end_is_harmless() -> void:
var me := world.players[ME]
me.add_item(Items.HEALTH_POTION)
_tap(ME, InputFrame.BTN_USE, 200)
_tap(ME, InputFrame.BTN_DROP, 250)
assert_eq(me.inventory[0], Items.HEALTH_POTION)
assert_eq(world.loot.size(), 0)
# --- Dropping ---------------------------------------------------------------
func test_dropping_puts_the_item_back_on_the_floor() -> void:
var me := world.players[ME]
me.add_item(Items.HEALTH_POTION)
_tap(ME, InputFrame.BTN_DROP, 0)
assert_eq(me.inventory[0], Items.NONE)
assert_eq(world.loot.size(), 1)
assert_eq(_events_of(SimEvent.Type.ITEM_DROPPED).size(), 1)
## Same refill trick as the use test, for the same reason.
func test_holding_the_drop_key_drops_exactly_one_item() -> void:
var me := world.players[ME]
me.add_item(Items.HEALTH_POTION)
_hold(ME, 5, InputFrame.BTN_DROP, 0)
me.inventory[0] = Items.WARDENS_RATION
_hold(ME, 20, InputFrame.BTN_DROP, 0)
assert_eq(me.inventory[0], Items.WARDENS_RATION,
"holding drop must not empty the bag one slot per tick")
assert_eq(world.loot.size(), 1)
func test_what_you_drop_can_be_taken_by_someone_else() -> void:
var them := world.add_player(THEM, "them")
them.pos = Vector2(10.0, 0.0)
them.spawn_grace = 0
world.players[ME].add_item(Items.HEALTH_POTION)
_tap(ME, InputFrame.BTN_DROP, 0)
_tap(THEM, InputFrame.BTN_INTERACT)
assert_eq(them.inventory[0], Items.HEALTH_POTION,
"a dropped item is world-shared, whatever it was before")
## An instanced item becomes shared the moment it is dropped. That is the point
## of being able to drop things: a trophy you do not want should be able to
## reach someone who does.
func test_dropping_an_instanced_item_makes_it_shared() -> void:
world.players[ME].add_item(Items.WARDENS_RATION)
_tap(ME, InputFrame.BTN_DROP, 0)
for l in world.loot.values():
assert_eq(l.owner_peer, 0)
func test_dropping_an_empty_slot_does_nothing() -> void:
_tap(ME, InputFrame.BTN_DROP, 1)
assert_eq(world.loot.size(), 0)
# --- Visibility -------------------------------------------------------------
func test_instanced_loot_cannot_be_taken_by_anyone_else() -> void:
world.spawn_loot(Items.WARDENS_RATION, Vector2(8.0, 0.0), THEM)
_tap(ME, InputFrame.BTN_INTERACT)
assert_eq(world.loot.size(), 1, "it is not mine to take")
assert_eq(world.players[ME].free_slot(), 0)
func test_instanced_loot_can_be_taken_by_its_owner() -> void:
world.spawn_loot(Items.WARDENS_RATION, Vector2(8.0, 0.0), ME)
_tap(ME, InputFrame.BTN_INTERACT)
assert_eq(world.players[ME].inventory[0], Items.WARDENS_RATION)
## Nobody else can see it, so leaving it behind would be an entity the instance
## carries around invisibly until it closes.
func test_leaving_takes_your_instanced_loot_with_you() -> void:
world.spawn_loot(Items.WARDENS_RATION, Vector2(8.0, 0.0), ME)
world.spawn_loot(Items.HEALTH_POTION, Vector2(8.0, 0.0))
world.remove_player(ME)
assert_eq(world.loot.size(), 1, "the shared item stays")
for l in world.loot.values():
assert_eq(l.owner_peer, 0)
# --- Drops ------------------------------------------------------------------
func test_a_boss_kill_drops_a_shared_potion_and_a_ration_each() -> void:
var them := world.add_player(THEM, "them")
them.pos = Vector2(60.0, 0.0)
var boss := world.spawn_boss(Content.warden())
world._damage_boss(boss.def.max_hp * 2)
var shared := 0
var mine := 0
var theirs := 0
for l in world.loot.values():
if l.owner_peer == 0:
shared += 1
elif l.owner_peer == ME:
mine += 1
elif l.owner_peer == THEM:
theirs += 1
assert_eq(shared, 1, "one potion for the party to divide")
assert_eq(mine, 1, "and a ration each")
assert_eq(theirs, 1)
func test_a_dead_player_earns_no_instanced_drop() -> void:
var them := world.add_player(THEM, "them")
them.alive = false
var boss := world.spawn_boss(Content.warden())
world._damage_boss(boss.def.max_hp * 2)
for l in world.loot.values():
assert_ne(l.owner_peer, THEM, "you have to be alive for the kill")
## Rare means rare: a run should be survivable on what it hands you, never
## comfortably. Checked statistically rather than exactly, because the roll uses
## the world's RNG and pinning the exact count would pin the RNG.
func test_a_trash_enemy_drops_a_potion_only_sometimes() -> void:
var kills := 400
var dropped := 0
for i in kills:
var solo := SimWorld.new(i * 31 + 5)
var e := solo.spawn_enemy(Content.drifter(), Vector2.ZERO)
solo._damage_enemy(e, e.def.max_hp * 2)
dropped += solo.loot.size()
assert_gt(dropped, 0, "trash has to drop something eventually")
assert_lt(dropped, kills / 2,
"if most kills drop a potion, potions are not worth a slot")
func test_the_practice_dummy_drops_nothing() -> void:
var e := world.spawn_enemy(Content.dummy(), Vector2(100.0, 0.0))
world._damage_enemy(e, e.def.max_hp * 2)
assert_eq(world.loot.size(), 0, "the hub target is not a loot piñata")
# --- Housekeeping -----------------------------------------------------------
## Only the hub can realistically reach the cap -- dungeons close and take
## their litter with them -- but unbounded growth in the one world that never
## closes is worth a ceiling.
func test_ground_loot_is_capped() -> void:
for i in SimConfig.MAX_LOOT_PER_INSTANCE + 20:
world.spawn_loot(Items.HEALTH_POTION, Vector2(float(i), 200.0))
assert_lte(world.loot.size(), SimConfig.MAX_LOOT_PER_INSTANCE)
func test_loot_ids_never_collide_with_actor_ids() -> void:
var e := world.spawn_enemy(Content.turret(), Vector2(100.0, 100.0))
var l := world.spawn_loot(Items.HEALTH_POTION, Vector2.ZERO)
assert_ne(l.id, e.id, "loot shares the actor id space, so it must share the counter")
func test_an_unknown_item_cannot_be_put_on_the_floor() -> void:
assert_null(world.spawn_loot(&"phlogiston", Vector2.ZERO))
## The inventory belongs to the character, not to the room. Walking into a
## dungeon with the potions you were carrying is the entire point of carrying
## them.
func test_inventory_survives_an_instance_transition() -> void:
var me := world.players[ME]
me.add_item(Items.HEALTH_POTION)
me.reset_for_instance(Vector2(50.0, 50.0), SimConfig.SPAWN_GRACE_TICKS)
assert_eq(me.inventory[0], Items.HEALTH_POTION)
## A replica never decides anything, loot included. This is the same guarantee
## the rest of the simulation makes, checked for the newest actor type.
func test_a_replica_never_creates_loot_of_its_own() -> void:
var replica := SimWorld.new(7)
replica.authoritative = false
replica.add_player(ME, "me")
var e := replica.spawn_enemy(Content.drifter(), Vector2(10.0, 0.0))
e.hp = 1
for _i in 300:
replica.step()
assert_eq(replica.loot.size(), 0,
"a client must never invent an item for itself to pick up")
# --- Two players, one item ---------------------------------------------------
## There is no race here to lose. The simulation is single-threaded and
## `_step_players` walks the player list one at a time within a tick, and
## `_try_pickup` erases the item from `world.loot` the instant it succeeds --
## so the second player's search that same tick finds nothing.
func test_two_players_reaching_for_the_same_item_on_one_tick() -> void:
var them := world.add_player(THEM, "them")
them.pos = Vector2(6.0, 0.0)
them.spawn_grace = 0
world.players[ME].pos = Vector2(-6.0, 0.0)
world.spawn_loot(Items.HEALTH_POTION, Vector2.ZERO)
var at := world.tick + 1
var mine: Array[InputFrame] = [
InputFrame.make(at, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
var theirs: Array[InputFrame] = [
InputFrame.make(at, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
world.queue_input(ME, mine)
world.queue_input(THEM, theirs)
world.step()
var holders := 0
for p in world.players.values():
if p.inventory[0] == Items.HEALTH_POTION:
holders += 1
assert_eq(holders, 1, "exactly one player ends up with it")
assert_eq(world.loot.size(), 0, "and it is gone from the floor")
assert_eq(_events_of(SimEvent.Type.ITEM_PICKED_UP).size(), 1,
"one pickup happened, so one pickup is announced")
## The tie-break, recorded because it is arbitrary rather than designed: the
## player list is a Dictionary, Godot dictionaries iterate in insertion order,
## and players are inserted as they join. So on a genuine same-tick tie the
## player who has been in the instance longer wins, every time -- distance to
## the item does not enter into it.
##
## Worth knowing before it is mistaken for a fairness rule. If it ever needs to
## be one, resolving contested pickups by distance after the player loop is the
## natural change.
func test_the_same_tick_tie_break_is_join_order_not_distance() -> void:
var them := world.add_player(THEM, "them")
them.spawn_grace = 0
# The later joiner stands almost on top of it; the earlier one is further
# away but still in reach.
them.pos = Vector2(1.0, 0.0)
world.players[ME].pos = Vector2(SimConfig.LOOT_PICKUP_RADIUS - 2.0, 0.0)
world.spawn_loot(Items.HEALTH_POTION, Vector2.ZERO)
var at := world.tick + 1
var mine: Array[InputFrame] = [
InputFrame.make(at, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
var theirs: Array[InputFrame] = [
InputFrame.make(at, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
world.queue_input(ME, mine)
world.queue_input(THEM, theirs)
world.step()
assert_eq(world.players[ME].inventory[0], Items.HEALTH_POTION,
"the player who joined first wins, despite standing further away")
assert_eq(them.inventory[0], Items.NONE)
## Ground loot evicts the oldest item when the floor fills up, which is the
## right answer for a dungeon's own drops and the wrong one for a player's: the
## hub never closes, so dropping into a full floor destroyed whatever someone
## else had left there longest -- and drop is edge-triggered per tick, so a
## client could clear the hub floor in seconds.
func test_a_player_drop_never_destroys_someone_elses_item() -> void:
var w := SimWorld.new(1)
var mine := w.add_player(1, "me")
mine.pos = Vector2.ZERO
mine.inventory[0] = Items.HEALTH_POTION
var theirs: Array[int] = []
for i in SimConfig.MAX_LOOT_PER_INSTANCE:
theirs.append(w.spawn_loot(Items.WARDENS_RATION, Vector2(500.0, float(i))).id)
assert_eq(w.loot.size(), SimConfig.MAX_LOOT_PER_INSTANCE, "the floor is full")
w._drop_slot(mine, 0)
assert_eq(mine.inventory[0], Items.HEALTH_POTION,
"the drop is refused, so the item stays in the bag")
for id in theirs:
assert_true(w.loot.has(id), "nothing already on the floor may be evicted")
func test_the_cap_still_bites_for_drops_the_world_generates_itself() -> void:
# A dungeon's own drops keep the old behaviour: they must never fail to
# appear, and the instance closes and takes the litter with it anyway.
var w := SimWorld.new(1)
for i in SimConfig.MAX_LOOT_PER_INSTANCE + 5:
w.spawn_loot(Items.WARDENS_RATION, Vector2(float(i), 0.0))
assert_eq(w.loot.size(), SimConfig.MAX_LOOT_PER_INSTANCE)