|
|
|
@@ -0,0 +1,285 @@
|
|
|
|
|
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)
|