extends Node ## End-to-end check of the progression wiring: kill -> experience -> level -> ## health, and death -> character retired -> roster offered. ## ## godot --headless --path . res://tools/diag_progression.tscn ## ## Runs as a scene because ServerRuntime needs the Net autoload, which a ## --script run does not have. Exits non-zero on any failure, so it can gate. ## ## The store side is unit-tested in isolation; what this covers is the wiring ## between the simulation's events and the account that banks them, which is ## exactly the part a bot cannot be relied on to exercise (bots are poor shots). const STORE_PATH := "user://diag_progression.json" var _fails: Array[String] = [] var _step: int = 0 var _srv: ServerRuntime var _account: int = 424242 var _character: Character func _ready() -> void: DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH)) GameOpts.bot_client = true GameOpts.account_override = _account if Net.host(27401) != 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 _physics_process(_delta: float) -> void: _step += 1 if _step == 20: _character = _srv.store.last_played(_account) _check(_character != null, "a character exists after login") if _character == null: _finish() return _check(_character.level == 1, "starts at level 1") _check(_srv.peer_characters.get(Net.LOCAL_PEER, "") == _character.id, "the peer is playing it") elif _step == 40: # Into a dungeon, which is where experience is earned. _srv._send_to_dungeon(Net.LOCAL_PEER) elif _step == 60: 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 var before: int = _srv.store.get_character(_account, _character.id).total_xp # Kill something the way a hit would: through the world's own damage # path, so the event carries the def id the scorer reads. var victim: SimEnemy = null for e in inst.world.enemies.values(): if e.alive: victim = e break if victim == null: _check(false, "the dungeon had a living enemy to kill") _finish() return victim.hp = 1 inst.world._damage_enemy(victim, 100) _srv._dispatch_events(inst) var after: int = _srv.store.get_character(_account, _character.id).total_xp _check(after > before, "killing an enemy awards experience (%d -> %d)" % [before, after]) # Mirrored into the world immediately, which is what lets the bar move # per kill instead of waiting for the next roster message. var mine: SimPlayer = inst.world.players[Net.LOCAL_PEER] _check(mine.total_xp == after, "the experience reaches the world at once") elif _step == 80: # Enough experience to cross a level boundary, and check health follows. var inst := _srv.instance_of(Net.LOCAL_PEER) var p: SimPlayer = inst.world.players[Net.LOCAL_PEER] var hp_before := p.max_hp _srv._grant_xp(Net.LOCAL_PEER, Progression.total_xp_for_level(3)) var c := _srv.store.get_character(_account, _character.id) _check(c.level >= 2, "experience produces levels (now %d)" % c.level) _check(p.level == c.level, "the player in the world levels with it") _check(p.max_hp > hp_before, "max health follows the level (%d -> %d)" % [hp_before, p.max_hp]) _check(p.max_hp == Progression.max_hp_for_level(c.level), "and matches the curve exactly") elif _step == 70: # Swapping inside a dungeon would be an instant, uninterruptible exit # from danger. The server must refuse it whatever the UI allows. var before: String = _srv.peer_characters.get(Net.LOCAL_PEER, "") _srv.on_select_character(Net.LOCAL_PEER, before) _check(_srv.peer_characters.get(Net.LOCAL_PEER, "") == before, "a character swap inside a dungeon is refused") # Through the server's entry point, not the store's -- the store has no # opinion about where you are, and calling it directly would bypass the # guard this is checking (and leave a stray character behind). var before_count := _srv.store.characters_for(_account).size() _srv.on_create_character(Net.LOCAL_PEER, "spare") _check(_srv.store.characters_for(_account).size() == before_count, "creating a character inside a dungeon is refused too") elif _step == 90: # Stop behaving like a bot before the kill. A bot auto-creates a # replacement the instant it sees an empty roster -- correct in play, # but it would mask exactly what the next steps are checking. GameOpts.bot_client = false elif _step == 100: var inst := _srv.instance_of(Net.LOCAL_PEER) var p: SimPlayer = inst.world.players[Net.LOCAL_PEER] p.spawn_grace = 0 p.hp = 1 inst.world._damage_player(p, 9999) _srv._dispatch_events(inst) var c := _srv.store.get_character(_account, _character.id) _check(c != null, "the dead character is kept, not deleted") _check(not c.active, "and is marked inactive") _check(c.died_unix > 0, "with a time of death recorded") _check(_srv.store.active_characters(_account).is_empty(), "no living characters remain") elif _step == 110: # The roster the client actually holds must contain no dead character: # retirement is server-side bookkeeping, and from the player's side a # dead character is simply gone. var shown := 0 for c in Net.client.characters: shown += 1 _check(shown == 0, "the dead character is not offered to the client") _check(_srv.store.characters_for(_account).size() == 1, "but the server still holds the record") elif _step == 120: # With nothing alive, the player must be left with no character rather # than silently resurrected into the dead one. _check(_srv.peer_characters.get(Net.LOCAL_PEER, "") == "", "the player is left without a character to play") _check(Net.client.needs_character(), "so the client shows the roster screen") _finish() func _finish() -> void: print("---") if _fails.is_empty(): print("PROGRESSION_OK") else: print("PROGRESSION_FAIL (%d)" % _fails.size()) Net.shutdown() get_tree().quit(0 if _fails.is_empty() else 1)