Fix: every zone change was handing the player a blank character
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>
This commit is contained in:
2026-09-07 10:13:14 +02:00
parent e0c1e0d5c6
commit 42568a40ad
6 changed files with 122 additions and 13 deletions
+71 -1
View File
@@ -21,6 +21,12 @@ 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:
@@ -65,7 +71,11 @@ func _physics_process(_delta: float) -> void:
70: _take_it_at_the_npc()
80: _it_changed_things()
90: _refused_with_nothing_pending()
100: _finish()
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:
@@ -199,6 +209,66 @@ func _refused_with_nothing_pending() -> void:
_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():