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:
1 parent
54086b5604
commit
4aeb6d229d
13 files changed
+405
-28
No files matched your search
+52
-10
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user