Files
transcience/tools/diag_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

286 lines
10 KiB
GDScript

extends Node
## End-to-end check of inventory and loot: drop -> snapshot -> pick up ->
## persist -> use -> drop again, plus the two loot visibilities.
##
## godot --headless --path . res://tools/diag_loot.tscn
##
## Runs as a scene because ServerRuntime needs the Net autoload. Exits non-zero
## on any failure, so it gates like a test.
##
## It exists for the same reason tools/diag_progression.tscn does: the bot smoke
## test cannot cover this. Bots are poor shots, so they almost never produce a
## drop, and the one thing worth checking end to end here is precisely what
## happens after something dies.
##
## Input is driven through Net.send_input with real encoded frames rather than
## by calling the world's handlers, because the whole claim being tested is that
## a client can do all of this with nothing but an InputFrame.
const STORE_PATH := "user://diag_loot.json"
## A second player in the world who is deliberately NOT in inst.peers: nothing
## is ever sent to it, so it costs no transport, but it is a real SimPlayer as
## far as instanced drops and per-peer snapshot encoding are concerned.
const GHOST := 999
var _fails: Array[String] = []
var _step: int = 0
var _srv: ServerRuntime
var _account: int = 515151
var _character: Character
var _hp_before_use: int = 0
## Tick of the most recent frame this file sent, so each step can confirm the
## server actually consumed it.
var _last_press_tick: int = 0
## True once this file is the only thing sending input.
var _driving: bool = false
func _ready() -> void:
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH))
GameOpts.bot_client = true
GameOpts.boss_rush = true # a quiet room: the boss, and whatever we place
GameOpts.account_override = _account
if Net.host(27402) != OK:
push_error("could not host")
get_tree().quit(1)
return
_srv = Net.server
_srv.store = CharacterStore.new(STORE_PATH)
_srv.store.load_from_disk()
Net.start_local_client()
func _check(ok: bool, what: String) -> void:
if ok:
print(" ok %s" % what)
else:
print(" FAIL %s" % what)
_fails.append(what)
func _world() -> SimWorld:
var inst := _srv.instance_of(Net.LOCAL_PEER)
return inst.world if inst != null else null
func _me() -> SimPlayer:
var w := _world()
return w.players.get(Net.LOCAL_PEER) if w != null else null
## One press and its release, encoded and sent exactly as the client would.
## The release matters: item actions are edge-triggered, and a starved server
## coasts on the last frame it was given, so a press with no release would leave
## the button held forever and the next press would not be an edge at all.
func _press(buttons: int, slot: int = 0) -> void:
var w := _world()
var p := _me()
if w == null or p == null:
return
# Numbered from the last input the server ACCEPTED, not from the world tick.
# The bot was numbering its frames INPUT_TARGET_LEAD ahead of the server, so
# a frame numbered from the world tick sits behind last_input_tick and is
# discarded as a duplicate -- correctly, and silently. That is worth
# labouring over: when it happened, the pickup never occurred, and every
# later check still passed, because "the slot is empty" and "the item is on
# the floor" are both true of a world where nothing happened at all.
var base := maxi(w.tick + 2, p.last_input_tick + 1)
var frames: Array[InputFrame] = [
InputFrame.make(base, Vector2.ZERO, 0.0, buttons, slot),
InputFrame.make(base + 1, Vector2.ZERO, 0.0, 0, slot),
]
_last_press_tick = base + 1
Net.send_input(NetCodec.encode_inputs(frames))
## Assert the last press reached the simulation. Without this the whole file
## degrades into checking that nothing happened, which it would do quietly.
func _press_landed(what: String) -> void:
var p := _me()
_check(p != null and p.last_input_tick >= _last_press_tick,
"the %s press reached the simulation" % what)
func _stored_inventory() -> Array[StringName]:
var c := _srv.store.get_character(_account, _character.id)
return c.inventory if c != null else ([] as Array[StringName])
func _physics_process(_delta: float) -> void:
_step += 1
# The client is no longer numbering its own frames, so keep its counter
# level with the server's. Without this it warns about its input lead every
# few ticks -- expected noise, and expected noise in a diagnostic is how a
# real warning gets missed.
if _driving and _world() != null:
Net.client.input_tick = _world().tick + SimConfig.INPUT_TARGET_LEAD
match _step:
20: _login()
40: _srv._send_to_dungeon(Net.LOCAL_PEER)
60: _enter_dungeon()
70: _kill_something()
78: _see_the_drop()
80: _press(InputFrame.BTN_INTERACT)
88: _took_it()
90: _press(InputFrame.BTN_DROP, 0)
98: _dropped_it()
100: _press(InputFrame.BTN_INTERACT)
108: _took_it_again()
110: _press(InputFrame.BTN_USE, 0)
118: _drank_it()
120: _kill_the_boss()
126: _boss_loot()
132: _finish()
func _login() -> void:
_character = _srv.store.last_played(_account)
_check(_character != null, "a character exists after login")
if _character == null:
_finish()
return
_check(_character.inventory.size() == SimConfig.INVENTORY_SLOTS,
"and starts with %d empty slots" % SimConfig.INVENTORY_SLOTS)
func _enter_dungeon() -> void:
var inst := _srv.instance_of(Net.LOCAL_PEER)
_check(inst != null and inst.kind == Protocol.InstanceKind.DUNGEON,
"moved into a dungeon")
if inst == null:
_finish()
return
# Stop the bot driving its own input; from here every frame is one we sent
# deliberately, so a pickup can only happen because this file asked for it.
Net.client.set_physics_process(false)
_driving = true
var p := _me()
# Drop the bot's backlog. The client keeps roughly INPUT_TARGET_LEAD frames
# in flight, so without this the first press queues up behind them.
p.input_queue.clear()
# Long arrival protection instead of god mode: it is a state the game
# already has, so nothing here is testing a code path players never hit.
p.spawn_grace = 100000
func _kill_something() -> void:
var w := _world()
var p := _me()
var def := Content.drifter()
# Forced to a certain drop. The drop RATE is a unit-test question; what this
# file is for is everything that happens after the roll succeeds.
def.loot = [LootDrop.make(Items.HEALTH_POTION, 1.0)]
var e := w.spawn_enemy(def, p.pos + Vector2(60.0, 0.0))
w._damage_enemy(e, def.max_hp * 2)
_check(w.loot.size() == 1, "a kill leaves an item on the floor")
for l in w.loot.values():
_check(l.owner_peer == 0, "and trash loot is shared, not instanced")
# Stand on it, so the pickup below is about the button and not about
# walking there.
p.pos = l.pos
func _see_the_drop() -> void:
_check(Net.client.ground_loot().size() == 1,
"the item reaches the client through the snapshot")
var near := Net.client.loot_in_reach()
_check(not near.is_empty(), "and the client can tell it is in reach")
func _took_it() -> void:
_press_landed("interact")
var p := _me()
_check(p.inventory[0] == Items.HEALTH_POTION, "interact picks it up")
_check(_world().loot.size() == 0, "and it leaves the floor")
_check(_stored_inventory()[0] == Items.HEALTH_POTION,
"the pickup is written to the character store at once")
_check(Items.by_index(int(Net.client.my_inventory[0])) == Items.HEALTH_POTION,
"and the client's own bag agrees")
func _dropped_it() -> void:
_press_landed("drop")
var w := _world()
_check(_me().inventory[0] == Items.NONE, "dropping empties the slot")
_check(w.loot.size() == 1, "and puts it back on the floor")
_check(_stored_inventory()[0] == Items.NONE, "the store follows the drop too")
for l in w.loot.values():
_check(l.owner_peer == 0, "a dropped item is world-shared")
_me().pos = l.pos
func _took_it_again() -> void:
_press_landed("second interact")
var p := _me()
_check(p.inventory[0] == Items.HEALTH_POTION, "what you drop can be picked back up")
# Hurt, so the potion has something to do. Set directly rather than shot:
# what is being tested is the item, not hit resolution.
p.hp = maxi(p.max_hp / 4, 1)
_hp_before_use = p.hp
func _drank_it() -> void:
_press_landed("use")
var p := _me()
_check(p.hp > _hp_before_use + 5,
"using the potion heals (%d -> %d)" % [_hp_before_use, p.hp])
_check(p.inventory[0] == Items.NONE, "and consumes it")
_check(_stored_inventory()[0] == Items.NONE, "the store follows the use")
func _kill_the_boss() -> void:
var w := _world()
var p := _me()
# A second player, close enough to the boss to be sent the same snapshot.
var ghost := w.add_player(GHOST, "ghost")
ghost.pos = w.boss.pos + Vector2(40.0, 40.0)
ghost.spawn_grace = 100000
p.pos = w.boss.pos + Vector2(-40.0, 40.0)
w._damage_boss(w.boss.def.max_hp * 2)
func _boss_loot() -> void:
var w := _world()
var shared := 0
var mine := 0
var theirs := 0
for l in w.loot.values():
if l.owner_peer == 0:
shared += 1
elif l.owner_peer == Net.LOCAL_PEER:
mine += 1
elif l.owner_peer == GHOST:
theirs += 1
_check(shared == 1, "the boss leaves one shared potion for the party")
_check(mine == 1 and theirs == 1, "and one ration per living player")
# The visibility rule is enforced on the wire, so check it there rather than
# by inspecting the world: what matters is what each peer is actually told.
var for_me := NetCodec.decode_snapshot(NetCodec.encode_snapshot(
w, Protocol.COUNTDOWN_NONE, Net.LOCAL_PEER))
var for_them := NetCodec.decode_snapshot(NetCodec.encode_snapshot(
w, Protocol.COUNTDOWN_NONE, GHOST))
_check((for_me["loot"] as Array).size() == 2,
"my snapshot holds the shared potion and my ration, nothing else")
_check((for_them["loot"] as Array).size() == 2,
"and theirs holds the shared potion and their ration")
var my_ids := []
for l: Dictionary in for_me["loot"]:
my_ids.append(int(l["id"]))
var overlap := 0
for l: Dictionary in for_them["loot"]:
if my_ids.has(int(l["id"])):
overlap += 1
_check(overlap == 1,
"exactly one of the two items is the same entity -- the shared one")
func _finish() -> void:
print("---")
if _fails.is_empty():
print("LOOT_OK")
else:
print("LOOT_FAIL (%d)" % _fails.size())
Net.shutdown()
get_tree().quit(0 if _fails.is_empty() else 1)