extends Node ## End-to-end check of the upgrade loop: level -> banked choice -> the three ## on the table -> spent at the NPC -> new stats -> persisted. ## ## godot --headless --path . res://tools/diag_upgrades.tscn ## ## Runs as a scene because ServerRuntime needs the Net autoload. Exits non-zero ## on any failure, so it gates like a test. ## ## The unit tests cover the formula and the store in isolation. What only shows ## up here is the wiring between them: that a level earned in a dungeon reaches ## the hub as a choice, that the server refuses one made from the wrong place, ## and that taking it changes the numbers the player is actually shooting with. const STORE_PATH := "user://diag_upgrades.json" var _fails: Array[String] = [] var _step: int = 0 var _srv: ServerRuntime var _account: int = 616161 var _character: Character var _offer_before: Array[StringName] = [] var _damage_before: int = 0 ## What the character was carrying and shooting with before it changed zones. var _carried: Array[StringName] = [] var _upgrades_before: Array[StringName] = [] var _stats_before: PlayerStats = null var _level_before: int = 0 var _xp_before: int = 0 func _ready() -> void: DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH)) GameOpts.bot_client = true GameOpts.account_override = _account if Net.host(27403) != 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 _me() -> SimPlayer: var inst := _srv.instance_of(Net.LOCAL_PEER) return inst.world.players.get(Net.LOCAL_PEER) if inst != null else null func _stored() -> Character: return _srv.store.get_character(_account, _character.id) func _physics_process(_delta: float) -> void: _step += 1 match _step: 20: _login() 30: _earn_a_level() 40: _refused_in_a_dungeon() 50: _back_to_the_hub() 60: _refused_away_from_the_npc() 70: _take_it_at_the_npc() 80: _it_changed_things() 90: _refused_with_nothing_pending() 95: _walk_into_a_dungeon() 105: _still_mine_in_the_dungeon() 110: _walk_back_to_the_hub() 120: _still_mine_in_the_hub() 130: _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.upgrades.is_empty(), "and starts with no upgrades") _check(_character.pending_choices == 0, "and nothing to spend") _check(Net.client.upgrades_pending == 0, "the client agrees") _damage_before = _me().stats.damage _check(_damage_before == SimConfig.PLAYER_BULLET_DAMAGE, "shooting for the base damage") ## Levelling happens in a dungeon; the NPC is in the hub. The choice has to ## survive the journey or it would be lost the moment it was earned. func _earn_a_level() -> void: _srv._send_to_dungeon(Net.LOCAL_PEER) _srv._grant_xp(Net.LOCAL_PEER, Progression.total_xp_for_level(3)) var c := _stored() _check(c.level >= 2, "experience produced a level (now %d)" % c.level) _check(c.pending_choices == c.level - 1, "one choice per level gained (%d)" % c.pending_choices) _check(c.offer.size() == SimConfig.UPGRADE_CHOICES, "and %d options on the table" % SimConfig.UPGRADE_CHOICES) _check(Net.client.upgrades_pending == c.pending_choices, "the client was told without asking") _check(Net.client.upgrade_offer.size() == SimConfig.UPGRADE_CHOICES, "and was given the same table") _offer_before = c.offer.duplicate() func _refused_in_a_dungeon() -> void: Net.choose_upgrade(0) _check(_stored().upgrades.is_empty(), "an upgrade cannot be taken from inside a dungeon") _check(_stored().offer == _offer_before, "and the refusal does not reroll the table") func _back_to_the_hub() -> void: _srv._send_to_lobby(Net.LOCAL_PEER) # Stop the bot steering itself around the hub -- see _stand_at_the_npc(). Net.client.set_physics_process(false) func _refused_away_from_the_npc() -> void: var inst := _srv.instance_of(Net.LOCAL_PEER) _check(inst != null and inst.kind == Protocol.InstanceKind.LOBBY, "back in the hub") var p := _me() # Far from the quartermaster. Where a player is standing is the one thing a # modified client cannot fake, which is why the check lives here and not in # the UI that greys out the button. p.pos = inst.world.upgrade_npc + Vector2(SimConfig.UPGRADE_NPC_RADIUS * 4.0, 0.0) Net.choose_upgrade(0) _check(_stored().upgrades.is_empty(), "standing across the room is not standing at the NPC") _check(_stored().pending_choices > 0, "and the choice is still owed") ## Standing at the NPC is set deliberately from here on. Whether the player ## happens to be near it is exactly what is under test, and a bot steering ## itself around the hub would decide that at random. func _stand_at_the_npc() -> void: var inst := _srv.instance_of(Net.LOCAL_PEER) if inst != null and _me() != null: _me().pos = inst.world.upgrade_npc func _take_it_at_the_npc() -> void: var inst := _srv.instance_of(Net.LOCAL_PEER) var owed_before := _stored().pending_choices _stand_at_the_npc() _check(inst.world.at_upgrade_npc(_me().pos), "setup: standing at the NPC") Net.choose_upgrade(1) var c := _stored() _check(c.upgrades.size() == 1, "the upgrade is taken") _check(c.upgrades[0] == _offer_before[1], "and it is the one the index named, from the server's own table") _check(c.pending_choices == owed_before - 1, "exactly one choice was spent (%d -> %d)" % [owed_before, c.pending_choices]) _check(not c.offer.is_empty() or c.pending_choices == 0, "a table is on offer exactly while something is pending") _check(c.offer != _offer_before, "and the next table is a fresh one") func _it_changed_things() -> void: var p := _me() var c := _stored() _check(p.stats.upgrade_count == c.upgrades.size(), "the player in the world is shooting with the new list") var expected := PlayerStats.build(c.upgrades) _check(p.stats.damage == expected.damage, "damage matches the formula (%d -> %d)" % [_damage_before, p.stats.damage]) _check(p.max_hp == maxi(1, roundi(float(Progression.max_hp_for_level(p.level)) * expected.max_hp_mult)), "and maximum health follows the multiplier") _check(Net.client.upgrades_taken.size() == c.upgrades.size(), "the client's copy was refreshed") # Persisted, not merely held in memory: the character store is what makes an # upgrade survive a restart, and it is written the moment one is taken. var reloaded := CharacterStore.new(STORE_PATH) reloaded.load_from_disk() var saved := reloaded.get_character(_account, _character.id) _check(saved != null and saved.upgrades == c.upgrades, "the upgrade is on disk already") _check(saved != null and saved.offer == c.offer, "and so is the table, so a crash is not a reroll") func _refused_with_nothing_pending() -> void: var c := _stored() # Bounded rather than a plain "while owed": a choice that were ever refused # here would otherwise spin forever instead of reporting a failure, which is # precisely what it did the first time this was written. var guard := Progression.MAX_LEVEL + 2 while c.pending_choices > 0 and guard > 0: guard -= 1 _stand_at_the_npc() Net.choose_upgrade(0) c = _stored() _check(c.pending_choices == 0, "every owed choice could actually be spent") var held := c.upgrades.size() _stand_at_the_npc() Net.choose_upgrade(0) _check(_stored().upgrades.size() == held, "with nothing owed, asking again gets nothing") _check(_stored().offer.is_empty(), "and no table is left on the counter") ## Everything below is about ONE bug, and it is worth naming: the world hands ## back a blank player on every instance transfer, and for a long time only ## character *select* gave it back its character. Every portal and every escape ## silently reset the player to level 1 with base stats and an empty bag. The ## record on disk stayed correct the whole time, so it read as a display ## glitch, and nothing here or in the test suite looked. func _walk_into_a_dungeon() -> void: var inst := _srv.instance_of(Net.LOCAL_PEER) var p := _me() # Carry something, the way a player would after a pickup. p.add_item(Items.HEALTH_POTION) _srv._persist_inventory(inst, Net.LOCAL_PEER) _carried = p.inventory.duplicate() _upgrades_before = _stored().upgrades.duplicate() _stats_before = p.stats _level_before = p.level _xp_before = p.total_xp _check(_level_before > 1, "setup: the character has levelled") _check(not _upgrades_before.is_empty(), "setup: and holds an upgrade") _srv._send_to_dungeon(Net.LOCAL_PEER) func _carried_through(where: String) -> void: var p := _me() if p == null: _check(false, "there is a player in the %s at all" % where) return _check(p.level == _level_before, "%s: level survives the trip (%d)" % [where, p.level]) _check(p.total_xp == _xp_before, "%s: so does experience" % where) _check(p.stats.upgrade_count == _upgrades_before.size(), "%s: and the upgrades (%d)" % [where, p.stats.upgrade_count]) _check(p.stats.damage == _stats_before.damage, "%s: so the gun still hits for %d" % [where, p.stats.damage]) # Compared against the CHARACTER's health, not against a formula fed the # player's own level -- that agrees with itself even when the level is # wrong, which is exactly the state this whole section exists to catch. _check(p.max_hp == _stored().max_hp(), "%s: and maximum health matches the record (%d)" % [where, p.max_hp]) _check(p.inventory == _carried, "%s: the bag came too" % where) func _still_mine_in_the_dungeon() -> void: var inst := _srv.instance_of(Net.LOCAL_PEER) _check(inst != null and inst.kind == Protocol.InstanceKind.DUNGEON, "walked into a dungeon") _carried_through("dungeon") func _walk_back_to_the_hub() -> void: _srv._send_to_lobby(Net.LOCAL_PEER) func _still_mine_in_the_hub() -> void: var inst := _srv.instance_of(Net.LOCAL_PEER) _check(inst != null and inst.kind == Protocol.InstanceKind.LOBBY, "escaped back to the hub") _carried_through("hub") func _finish() -> void: print("---") if _fails.is_empty(): print("UPGRADES_OK") else: print("UPGRADES_FAIL (%d)" % _fails.size()) Net.shutdown() get_tree().quit(0 if _fails.is_empty() else 1)