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() # And stop it COASTING. A starved server repeats the last frame it was # given for INPUT_MAX_AGE ticks, so the bot's final movement vector kept # walking the player for half a second after the takeover -- far enough off # the item it had been placed on that the pickup found nothing. The press # itself arrived correctly, which is why "the press reached the simulation" # passed while everything it should have caused failed. p.held_input = InputFrame.new() # 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)