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

+69
View File
@@ -18,6 +18,8 @@ var _step: int = 0
var _srv: ServerRuntime
var _account: int = 424242
var _character: Character
var _wounded: int = 0
var _hub_arrival: int = 0
func _ready() -> void:
@@ -77,6 +79,11 @@ func _physics_process(_delta: float) -> void:
_check(false, "the dungeon had a living enemy to kill")
_finish()
return
# Stand over it. Experience now has a radius, so a kill the player is
# nowhere near pays nothing -- which would make this check depend on
# where the generator happened to put the enemy.
var mine_now: SimPlayer = inst.world.players[Net.LOCAL_PEER]
mine_now.pos = victim.pos
victim.hp = 1
inst.world._damage_enemy(victim, 100)
_srv._dispatch_events(inst)
@@ -116,6 +123,68 @@ func _physics_process(_delta: float) -> void:
_check(_srv.store.characters_for(_account).size() == before_count,
"creating a character inside a dungeon is refused too")
elif _step == 65:
# A kill nobody was near pays nobody. The radius exists because without
# it a player parked in the entrance room collected the full experience
# for a boss they never saw.
var inst := _srv.instance_of(Net.LOCAL_PEER)
var far: SimEnemy = null
for e in inst.world.enemies.values():
if e.alive:
far = e
break
if far == null:
_check(false, "a second enemy to kill out of sight")
else:
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
p.pos = far.pos + Vector2(SimConfig.XP_AWARD_RADIUS + 400.0, 0.0)
var before: int = _srv.store.get_character(_account, _character.id).total_xp
far.hp = 1
inst.world._damage_enemy(far, 100)
_srv._dispatch_events(inst)
var after: int = _srv.store.get_character(_account, _character.id).total_xp
_check(after == before,
"a kill out of range awards nothing (%d -> %d)" % [before, after])
elif _step == 82:
# Health is carried, not reset. Arriving anywhere used to set it to
# maximum, which made a run free: walk in, take what you can, walk out,
# walk back in whole.
var inst := _srv.instance_of(Net.LOCAL_PEER)
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
p.spawn_grace = 0
p.hp = int(float(p.max_hp) * 0.4)
_wounded = p.hp
_check(_wounded > 0 and _wounded < p.max_hp, "the player is wounded")
elif _step == 84:
_srv._send_to_lobby(Net.LOCAL_PEER)
var inst := _srv.instance_of(Net.LOCAL_PEER)
_check(inst != null and inst.kind == Protocol.InstanceKind.LOBBY,
"escaped to the hub")
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
_check(p.hp < p.max_hp, "the wound came with them (%d/%d)" % [p.hp, p.max_hp])
_check(absi(p.hp - _wounded) <= 2,
"and it is the same wound, not a new number (%d vs %d)" % [p.hp, _wounded])
_hub_arrival = p.hp
elif _step == 88:
var inst := _srv.instance_of(Net.LOCAL_PEER)
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
_check(p.hp > _hub_arrival,
"the hub is healing it back (%d -> %d)" % [_hub_arrival, p.hp])
_check(p.hp < p.max_hp, "but has not finished in four ticks")
_srv._send_to_dungeon(Net.LOCAL_PEER)
elif _step == 89:
var inst := _srv.instance_of(Net.LOCAL_PEER)
_check(inst != null and inst.kind == Protocol.InstanceKind.DUNGEON,
"back into a dungeon")
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
_check(p.hp < p.max_hp,
"and walked in still hurt (%d/%d) -- entering is not a heal"
% [p.hp, p.max_hp])
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,