Files
transcience/tests/unit/test_items.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

121 lines
4.3 KiB
GDScript

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)