a943aa19f6
ci / verify (push) Successful in 47s
Two labelled portals now stand side by side in the hub. The Proving Grounds runs the same generator, the same rooms, the same enemies and the same four-phase Warden -- enemies at a fifth health, the boss at 288 instead of 3600, and trash dropping potions 80% of the time instead of 8%. A manual pass over loot, the inventory, dropping and every boss phase takes a couple of minutes rather than a quarter of an hour. It is multipliers over the shared content rather than a parallel copy: a duplicated Content would drift the first time anything was tuned, and "identical but easier" would quietly stop being true. And it is a portal rather than a launch flag, so the two can be compared back to back without restarting the server -- which is most of the point. Which dungeon you enter is resolved from the player's server-side position, and PORTAL_USED carries the answer. There is deliberately no client message that names a dungeon: one would let any client ask for the generous loot table and bring the results back to the hub. Instance matching compares dungeon ids too, so walking into one entrance can never drop you into the other's run on timing alone. SimWorld.portals replaces portal_pos/portal_enabled, enter_instance carries the portal list and the dungeon id (the client needs the latter to scale the boss bar's ceiling the way the server scaled the boss), and Protocol.VERSION goes to 7. Also pins what happens when two players reach for one item on the same tick: exactly one gets it -- the loop is sequential and the pickup erases the entity before the next player looks. The tie-break is join order rather than distance, which is arbitrary rather than designed, so it is recorded as such. Stale doc fixed while here: MapGen.build() still claimed the client rebuilds the map from the seed, which has not been true since map streaming landed and is the opposite of the rule. check.sh clean, 288 tests, SMOKE PASS (18 assertions, both dungeon kinds opened over a real socket), all three diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
421 lines
16 KiB
GDScript
421 lines
16 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)
|