Files
transcience/tests/unit/test_loot.gd
T
claude 050b8251a7
ci / verify (push) Successful in 48s
Stage 3: inventory, ground loot, and two loot visibilities
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>
2026-09-04 21:16:15 +02:00

358 lines
13 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.portal_enabled = true
world.portal_pos = Vector2.ZERO
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")