42568a40ad
ci / verify (push) Successful in 48s
SimWorld knows nothing about characters, so Instance.add_peer builds a fresh SimPlayer -- level 1, base stats, empty inventory. Something has to give that player back its character, and only character SELECT ever did. Every portal into a dungeon and every escape back to the hub therefore reset the player's level, experience, upgrades and bag. The record on disk stayed correct throughout, which is what made it read as a display glitch: the level shown was 1 because the level being played really was 1, and the first kill's experience grant partially repaired it, so the numbers appeared to come and go. _place now adopts, through a single _adopt_character that every transfer runs. That let _enter_world_as drop its adopt/reset/adopt dance -- three lines that existed only because reset_for_instance clobbered the health adopt had just computed -- and let the level-up path derive maximum health through recompute_max_hp instead of keeping a second copy of the formula. diag_upgrades now walks hub -> dungeon -> hub after taking an upgrade and asserts level, experience, upgrades, damage, maximum health and inventory all survive each leg. With the fix reverted it reports exactly what was described: level 1, no upgrades, base damage, empty bag. One of the new checks compared health against a formula fed the player's own level, which agrees with itself even when the level is wrong; it compares against the character record instead. Also drops "(every upgrade)" from the choice cards. That a flat bonus rides along with all of them is a design principle, not something a player needs told -- the number is enough. check.sh clean, 409 tests, SMOKE PASS, all four diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
280 lines
10 KiB
GDScript
280 lines
10 KiB
GDScript
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)
|