Health survives a door, and experience needs you to be there

Arriving anywhere set health to maximum, so a run cost nothing: walk in,
take what you could, walk out, walk back in whole. Current health now
lives on the character record for the same reason the inventory does --
SimWorld builds a blank player on every instance change, so anything that
must survive a door has to be written down -- and it is banked at every
transfer, every disconnect, and on every item transaction, since a potion
moves an item and hit points in one action.

The hub is where that damage is paid off, at 10% of maximum per second:
about ten seconds from nothing, fast enough not to be a chore and slow
enough to be a decision before walking back in. The dungeon rate is
unchanged, so waiting one out is still not a plan. Regeneration is a
property of the world now rather than a constant.

adopt() no longer touches current health. It is called mid-session by
on_choose_upgrade, and reading health from the record there would snap a
player back to whatever was last written to disk; recompute_max_hp still
clamps downwards so Glass Cannon takes the hit points above the new
ceiling with it. ServerRuntime._adopt_character stays the one place a
record becomes a live player.

Experience is still undivided, but now only among players within
XP_AWARD_RADIUS of the kill -- ten percent past what a player can see, so
anyone who watched it die is inside and someone parked in the entrance
room is not. The death events carry a position for this, and deliberately
do not put it on the wire: encode_events still writes the id alone.

diag_progression covers the whole chain end to end -- wounded in a
dungeon, escaped to the hub with the wound intact, healing there, and
walking back in still hurt -- because a unit test cannot transfer a peer.
Both halves were mutation-tested against it; the unit suite alone missed
the arrival heal entirely.

The Proving Grounds is recorded in the roadmap as a debugging tool that
does not ship: it pays full experience for a boss with 8% health.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5 committed 2026-09-07 21:48:20 +02:00
1 parent 54086b5604
commit 4aeb6d229d
13 files changed
+405 -28

No files matched your search

+52 -10
View File
@@ -119,9 +119,11 @@ func _dispatch_events(inst: Instance) -> void:
for ev in events:
match int(ev["t"]):
SimEvent.Type.ENEMY_DIED:
_award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", ""))))
_award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", ""))),
ev.get("pos", Vector2.ZERO))
SimEvent.Type.BOSS_DIED:
_award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", ""))))
_award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", ""))),
ev.get("pos", Vector2.ZERO))
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
SimEvent.Type.ITEM_DROPPED:
# The simulation moved items between the ground and a bag; the
@@ -198,6 +200,7 @@ func on_peer_disconnected(peer_id: int) -> void:
% [peer_id, peer_names.get(peer_id, "?"), inst.id])
_broadcast_roster()
return
_bank_health(inst, peer_id)
inst.remove_peer(peer_id)
_forget_peer(peer_id)
GameLog.info("server", "peer %d disconnected" % peer_id)
@@ -218,6 +221,9 @@ func _forget_peer(peer_id: int) -> void:
## the dungeon; with no persistence yet, "safely back in the hub" and "gone" are
## the same outcome, so it is simply removed.
func _release_linkdead(peer_id: int, inst: Instance) -> void:
# Whatever the channel left it on. Pulling the plug at one hit point must
# not be a way to come back whole.
_bank_health(inst, peer_id)
inst.remove_peer(peer_id)
GameLog.info("server", "peer %d released from instance %d after drop"
% [peer_id, inst.id])
@@ -348,6 +354,7 @@ func _enter_world_as(peer_id: int, c: Character) -> void:
var account: int = peer_accounts[peer_id]
var previous := instance_of(peer_id)
if previous != null:
_bank_health(previous, peer_id)
previous.remove_peer(peer_id)
peer_characters[peer_id] = c.id
peer_names[peer_id] = c.display_name
@@ -505,15 +512,19 @@ func _adopt_character(peer_id: int, inst: Instance) -> void:
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
# Health comes off the record, not from a full bar. Arriving used to heal
# you outright, which meant a run cost nothing: walk in, take what you can,
# walk out, walk back in whole. It is paid off in the hub instead, which
# regenerates fast (SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC) but not
# instantly. HP_FULL covers a new character and a save written before health
# was kept; the clamp covers upgrades that moved the ceiling either way.
p.hp = p.max_hp if c.hp == Character.HP_FULL else clampi(c.hp, 1, p.max_hp)
func _transfer(peer_id: int, to: Instance) -> void:
var from := instance_of(peer_id)
if from != null:
_bank_health(from, peer_id)
from.remove_peer(peer_id)
_place(peer_id, to)
_broadcast_roster()
@@ -618,16 +629,26 @@ func _stream_map(peer_id: int, inst: Instance) -> void:
NetCodec.encode_map_chunks(inst.world.map, batch))
## Experience is shared by everyone alive in the instance, undivided. Splitting
## it would make bringing a friend cost you progress, which is the opposite of
## what a co-op game wants; the hub roster exists to help people group up.
func _award_kill(inst: Instance, amount: int) -> void:
## Experience is shared, undivided, by everyone alive and near enough to
## [param at] to have been part of it.
##
## Undivided is the settled decision: splitting it would make bringing a friend
## cost you progress, which is the opposite of what a co-op game wants. The
## radius is a separate question -- who was there -- and it exists because
## without one a player parked at the entrance collected full boss experience
## for the whole run. SimConfig.XP_AWARD_RADIUS is ten percent past what a
## player can see, so anyone who watched it die is inside it and anyone who
## could not possibly have known is not.
func _award_kill(inst: Instance, amount: int, at: Vector2) -> void:
if amount <= 0 or inst.kind != Protocol.InstanceKind.DUNGEON:
return
var reach_sq := SimConfig.XP_AWARD_RADIUS * SimConfig.XP_AWARD_RADIUS
for peer in inst.peers:
var p: SimPlayer = inst.world.players.get(peer)
if p == null or not p.alive:
continue
if p.pos.distance_squared_to(at) > reach_sq:
continue
_grant_xp(peer, amount)
@@ -637,6 +658,23 @@ func _award_kill(inst: Instance, amount: int) -> void:
## point of persisting an inventory is that a crash between "picked it up" and
## "wrote it down" must not be a way to lose an item -- or, far worse, a way to
## duplicate one.
## Copy a player's current health back onto its character record.
##
## Called wherever the [SimPlayer] is about to stop existing -- every transfer,
## every disconnect -- because SimWorld builds a blank one on the other side and
## whatever was not written down is gone. Also called on item transactions, so
## drinking a potion is banked with the potion that was spent.
func _bank_health(inst: Instance, peer_id: int) -> 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
var p: SimPlayer = inst.world.players.get(peer_id)
if p == null or not p.alive:
return # a dead character is retired; its health is not a thing anymore
store.set_health(account, character_id, p.hp)
func _persist_inventory(inst: Instance, peer_id: int) -> void:
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
var character_id: String = peer_characters.get(peer_id, "")
@@ -649,6 +687,10 @@ func _persist_inventory(inst: Instance, peer_id: int) -> void:
# to re-serialise every account on the server. ServerRuntime flushes once at
# the end of the tick.
store.set_inventory(account, character_id, p.inventory, true)
# A potion moves an item and hit points in the same action; banking one
# without the other would let a restart hand the potion back.
if p.alive:
store.set_health(account, character_id, p.hp)
func _grant_xp(peer_id: int, amount: int) -> void: