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:
@@ -171,3 +171,42 @@ func test_suggested_names_vary() -> void:
|
||||
for _i in 40:
|
||||
seen[Character.random_name(rng)] = true
|
||||
assert_gt(seen.size(), 10, "a fixed suggestion would be worse than none")
|
||||
|
||||
|
||||
## Inventories live on the character, which is what makes them survive a
|
||||
## restart, a character swap and a walk into a dungeon.
|
||||
func test_the_inventory_survives_a_save_and_reload() -> void:
|
||||
var c := store.create_character(ACC, "Packrat")
|
||||
var carried: Array[StringName] = [Items.HEALTH_POTION, Items.NONE, Items.WARDENS_RATION]
|
||||
store.set_inventory(ACC, c.id, carried)
|
||||
|
||||
var reloaded := CharacterStore.new(store._path)
|
||||
assert_true(reloaded.load_from_disk())
|
||||
var got := reloaded.get_character(ACC, c.id)
|
||||
assert_eq(got.inventory.size(), SimConfig.INVENTORY_SLOTS)
|
||||
assert_eq(got.inventory[0], Items.HEALTH_POTION)
|
||||
assert_eq(got.inventory[1], Items.NONE)
|
||||
assert_eq(got.inventory[2], Items.WARDENS_RATION)
|
||||
|
||||
|
||||
## A save file written by a build that had an item this one does not must still
|
||||
## load -- as an empty slot, never as a different item.
|
||||
func test_an_unknown_item_in_a_save_file_becomes_an_empty_slot() -> void:
|
||||
var restored := Character.from_dict({
|
||||
"id": "x", "name": "Old", "xp": 0,
|
||||
"inventory": ["health_potion", "phlogiston"],
|
||||
})
|
||||
assert_eq(restored.inventory[0], Items.HEALTH_POTION)
|
||||
assert_eq(restored.inventory[1], Items.NONE)
|
||||
|
||||
|
||||
func test_a_character_from_before_inventories_existed_still_loads() -> void:
|
||||
var restored := Character.from_dict({"id": "y", "name": "Legacy", "xp": 500})
|
||||
assert_eq(restored.inventory.size(), SimConfig.INVENTORY_SLOTS)
|
||||
assert_eq(restored.display_name, "Legacy")
|
||||
|
||||
|
||||
func test_setting_an_inventory_on_a_missing_character_is_harmless() -> void:
|
||||
var nothing: Array[StringName] = [Items.HEALTH_POTION]
|
||||
store.set_inventory(ACC, "no-such-id", nothing)
|
||||
assert_eq(store.characters_for(ACC).size(), 0)
|
||||
|
||||
@@ -41,3 +41,32 @@ func test_frame_is_exactly_the_declared_size() -> void:
|
||||
var b := StreamPeerBuffer.new()
|
||||
InputFrame.make(1, Vector2.ONE, 1.0, 7).write(b)
|
||||
assert_eq(b.data_array.size(), InputFrame.SIZE)
|
||||
|
||||
|
||||
## The slot is what makes "use item" expressible without a second message type.
|
||||
func test_round_trip_preserves_the_inventory_slot() -> void:
|
||||
var out := _round_trip(InputFrame.make(1, Vector2.ZERO, 0.0,
|
||||
InputFrame.BTN_USE, SimConfig.INVENTORY_SLOTS - 1))
|
||||
assert_eq(out.slot, SimConfig.INVENTORY_SLOTS - 1)
|
||||
assert_true(out.pressed(InputFrame.BTN_USE))
|
||||
assert_false(out.pressed(InputFrame.BTN_DROP))
|
||||
|
||||
|
||||
## Every slot the game has must survive the byte it is sent in. This is the
|
||||
## check that fails if the slot count ever outgrows the format.
|
||||
func test_every_slot_index_survives_the_wire() -> void:
|
||||
for i in SimConfig.INVENTORY_SLOTS:
|
||||
assert_eq(_round_trip(InputFrame.make(1, Vector2.ZERO, 0.0, 0, i)).slot, i)
|
||||
assert_lte(SimConfig.INVENTORY_SLOTS, 256,
|
||||
"the slot rides in one byte")
|
||||
|
||||
|
||||
## Button bits have to stay distinct, or one action would trigger another.
|
||||
func test_button_bits_do_not_overlap() -> void:
|
||||
var bits := [InputFrame.BTN_FIRE, InputFrame.BTN_ESCAPE, InputFrame.BTN_INTERACT,
|
||||
InputFrame.BTN_USE, InputFrame.BTN_DROP]
|
||||
var seen := 0
|
||||
for bit in bits:
|
||||
assert_eq(seen & bit, 0, "bit %d collides with an earlier one" % bit)
|
||||
assert_lte(bit, 128, "the button field is a single byte")
|
||||
seen |= bit
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
extends GutTest
|
||||
## The item registry. Small, but two of these are load-bearing: the wire index
|
||||
## and the art table both key off Items.ORDER, and a mismatch in either is
|
||||
## silent rather than loud.
|
||||
|
||||
|
||||
func test_every_registered_id_has_a_definition() -> void:
|
||||
for id in Items.ORDER:
|
||||
assert_not_null(Items.get_def(id), "%s is in ORDER with no definition" % id)
|
||||
|
||||
|
||||
func test_the_wire_index_round_trips() -> void:
|
||||
for id in Items.ORDER:
|
||||
assert_eq(Items.by_index(Items.index_of(id)), id)
|
||||
|
||||
|
||||
## Index 0 is reserved for "empty slot". If an item ever landed on it, every
|
||||
## empty slot in the game would quietly contain that item.
|
||||
func test_index_zero_is_reserved_for_nothing() -> void:
|
||||
assert_eq(Items.by_index(0), Items.NONE)
|
||||
assert_eq(Items.index_of(Items.NONE), 0)
|
||||
for id in Items.ORDER:
|
||||
assert_gt(Items.index_of(id), 0, "%s must not occupy the empty index" % id)
|
||||
|
||||
|
||||
## An id or index this build does not know reads as empty, never as whatever
|
||||
## happens to sit nearby. A client one version behind must see an unknown item
|
||||
## as an empty slot, not as a potion.
|
||||
func test_unknown_items_decay_to_nothing() -> void:
|
||||
assert_eq(Items.index_of(&"not_a_real_item"), 0)
|
||||
assert_eq(Items.by_index(200), Items.NONE)
|
||||
assert_eq(Items.by_index(-1), Items.NONE)
|
||||
assert_null(Items.get_def(&"not_a_real_item"))
|
||||
|
||||
|
||||
func test_the_index_fits_in_the_byte_the_wire_gives_it() -> void:
|
||||
assert_lte(Items.ORDER.size(), 254,
|
||||
"item ids are sent as a single byte; past this the format has to change")
|
||||
|
||||
|
||||
func test_there_is_an_icon_for_every_item() -> void:
|
||||
for id in Items.ORDER:
|
||||
var def := Items.get_def(id)
|
||||
assert_lt(def.visual, Art.ITEM_ICONS.size(),
|
||||
"%s has visual %d with no icon" % [id, def.visual])
|
||||
var icon := Art.item_icon(id)
|
||||
assert_lte(icon.end.x, float(Art.TILESET.get_width()))
|
||||
assert_lte(icon.end.y, float(Art.TILESET.get_height()))
|
||||
|
||||
|
||||
## Healing is a percentage of maximum health so a potion is worth the same slot
|
||||
## at level 15 as at level 1. A flat value would be a full heal early and noise
|
||||
## late, which is the wrong shape for the only consumable in the game.
|
||||
func test_the_health_potion_heals_a_share_of_maximum_health() -> void:
|
||||
var def := Items.get_def(Items.HEALTH_POTION)
|
||||
assert_eq(def.effect, ItemDef.Effect.HEAL)
|
||||
assert_gt(def.effect_value, 0.0)
|
||||
assert_lte(def.effect_value, 100.0)
|
||||
|
||||
var p := SimPlayer.new()
|
||||
p.max_hp = 200
|
||||
p.hp = 10
|
||||
assert_eq(p.heal_percent(def.effect_value), int(200.0 * def.effect_value / 100.0))
|
||||
|
||||
|
||||
func test_healing_never_overshoots_maximum_health() -> void:
|
||||
var p := SimPlayer.new()
|
||||
p.max_hp = 100
|
||||
p.hp = 95
|
||||
assert_eq(p.heal_percent(80.0), 5)
|
||||
assert_eq(p.hp, 100)
|
||||
assert_eq(p.heal_percent(80.0), 0, "already full heals for nothing")
|
||||
|
||||
|
||||
## The ration's entire job is to exercise the player-instanced loot path. If it
|
||||
## ever gains an effect, that job needs a new holder.
|
||||
func test_the_ration_does_nothing() -> void:
|
||||
assert_eq(Items.get_def(Items.WARDENS_RATION).effect, ItemDef.Effect.NONE)
|
||||
|
||||
|
||||
func test_a_player_starts_with_the_configured_number_of_empty_slots() -> void:
|
||||
var p := SimPlayer.new()
|
||||
assert_eq(p.inventory.size(), SimConfig.INVENTORY_SLOTS)
|
||||
assert_eq(p.free_slot(), 0)
|
||||
for item in p.inventory:
|
||||
assert_eq(item, Items.NONE)
|
||||
|
||||
|
||||
func test_slot_operations_refuse_indices_off_the_end() -> void:
|
||||
# The index arrives off the wire, so nonsense has to be safe rather than
|
||||
# merely unlikely.
|
||||
var p := SimPlayer.new()
|
||||
p.add_item(Items.HEALTH_POTION)
|
||||
assert_eq(p.take_slot(-1), Items.NONE)
|
||||
assert_eq(p.take_slot(9999), Items.NONE)
|
||||
assert_eq(p.inventory[0], Items.HEALTH_POTION, "and must not disturb the bag")
|
||||
|
||||
|
||||
func test_a_full_bag_refuses_more() -> void:
|
||||
var p := SimPlayer.new()
|
||||
for i in SimConfig.INVENTORY_SLOTS:
|
||||
assert_eq(p.add_item(Items.HEALTH_POTION), i)
|
||||
assert_eq(p.free_slot(), -1)
|
||||
assert_eq(p.add_item(Items.HEALTH_POTION), -1)
|
||||
|
||||
|
||||
## A save written when the game had a different slot count must still load.
|
||||
func test_setting_an_inventory_pads_and_trims_to_the_slot_count() -> void:
|
||||
var p := SimPlayer.new()
|
||||
var short: Array[StringName] = [Items.HEALTH_POTION]
|
||||
p.set_inventory(short)
|
||||
assert_eq(p.inventory.size(), SimConfig.INVENTORY_SLOTS)
|
||||
assert_eq(p.inventory[0], Items.HEALTH_POTION)
|
||||
assert_eq(p.inventory[SimConfig.INVENTORY_SLOTS - 1], Items.NONE)
|
||||
|
||||
var long: Array[StringName] = []
|
||||
for _i in SimConfig.INVENTORY_SLOTS + 5:
|
||||
long.append(Items.WARDENS_RATION)
|
||||
p.set_inventory(long)
|
||||
assert_eq(p.inventory.size(), SimConfig.INVENTORY_SLOTS)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ceonypgg5vaw
|
||||
@@ -0,0 +1,357 @@
|
||||
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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bj5m8a8m1lxdj
|
||||
@@ -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