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
+33 -6
View File
@@ -299,12 +299,10 @@ func _enter_world_as(peer_id: int, c: Character) -> void:
peer_characters[peer_id] = c.id
peer_names[peer_id] = c.display_name
store.set_last_played(account, c.id)
# _place adopts, so the old three-line adopt/reset/adopt dance here is gone:
# it existed to work around reset_for_instance clobbering the health that
# adopt had just computed, and both now derive it the same way.
_place(peer_id, lobby)
var p: SimPlayer = lobby.world.players.get(peer_id)
if p != null:
p.adopt(c)
p.reset_for_instance(lobby.world.spawn_point, 0)
p.adopt(c)
GameLog.info("server", "peer %d playing '%s' (level %d)"
% [peer_id, c.display_name, c.level])
_send_upgrades(peer_id)
@@ -381,6 +379,13 @@ func instance_of(peer_id: int) -> Instance:
func _place(peer_id: int, inst: Instance) -> void:
inst.add_peer(peer_id, peer_names.get(peer_id, "player"))
peer_instance[peer_id] = inst.id
# The world hands back a BLANK player -- level 1, base stats, empty bag --
# because SimWorld knows nothing about characters. Giving it back its
# character is this layer's job, and for a long time it only happened on
# character select: every portal and every escape quietly reset the player
# to level 1 with no upgrades and nothing carried. The record on disk was
# always correct, which is what made it look like a display bug.
_adopt_character(peer_id, inst)
# Size only: the seed stays server-side, or a client could rebuild the map.
peer_chunks[peer_id] = {}
Net.send_enter_instance(peer_id, inst.id, int(inst.kind), inst.world.tick,
@@ -400,6 +405,26 @@ func _place(peer_id: int, inst: Instance) -> void:
inst.exit_countdown_seconds(), peer_id))
## Give the player in [param inst] the character its peer is playing.
##
## The one place a character record becomes a live player, so there is a single
## thing to get right rather than one per transition.
func _adopt_character(peer_id: int, inst: Instance) -> void:
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
var character_id: String = peer_characters.get(peer_id, "")
if account == AuthProvider.NO_ACCOUNT or character_id.is_empty():
return # authenticated but still at the roster screen
var c := store.get_character(account, character_id)
var p: SimPlayer = inst.world.players.get(peer_id)
if c == null or p == null:
return
p.adopt(c)
# Arriving anywhere is a fresh start. adopt() only clamps health down to the
# new ceiling, which would leave a character whose upgrades RAISED it
# arriving wounded for no reason.
p.hp = p.max_hp
func _transfer(peer_id: int, to: Instance) -> void:
var from := instance_of(peer_id)
if from != null:
@@ -558,7 +583,9 @@ func _grant_xp(peer_id: int, amount: int) -> void:
if p != null:
var before := p.max_hp
p.level = c.level
p.max_hp = c.max_hp()
# Derived rather than copied from the character, so there is one
# formula for maximum health and not two that can disagree.
p.recompute_max_hp()
p.hp = mini(p.hp + (p.max_hp - before), p.max_hp)
GameLog.info("server", "peer %d reached level %d (%d choice(s) pending)"
% [peer_id, c.level, c.pending_choices])
+4 -5
View File
@@ -143,12 +143,11 @@ func _make_card(index: int, id: StringName) -> Control:
body.add_theme_font_size_override("font_size", 13)
card.add_child(body)
# Shown on every card, because it applies to every card. The brief asks for
# the choice screen to show this buff as well as the upgrade's own effects,
# and a player comparing three cards should not have to remember it.
# Shown on every card because it applies to every card. Deliberately NOT
# labelled "every upgrade": that a flat bonus rides along with all of them
# is a design principle, and the player only needs the number.
var bonus := Label.new()
bonus.text = "+%d%% damage (every upgrade)" % roundi(
SimConfig.UPGRADE_DAMAGE_BONUS * 100.0)
bonus.text = "+%d%% damage" % roundi(SimConfig.UPGRADE_DAMAGE_BONUS * 100.0)
bonus.add_theme_font_size_override("font_size", 12)
bonus.add_theme_color_override("font_color", Color(0.55, 0.8, 0.6))
card.add_child(bonus)