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
@@ -64,6 +64,12 @@ const SPAWN_GRACE_TICKS := 120 # 2 seconds
|
||||
## simpler to reason about than a timer players have to learn, and at this rate
|
||||
## it cannot outpace anything that is actually shooting at you.
|
||||
const HP_REGEN_PERCENT_PER_SEC := 0.5
|
||||
## What the hub heals at instead. Arriving anywhere used to snap you to full,
|
||||
## which made health free: enter a dungeon, take what you could, leave, walk
|
||||
## back in whole. Recovering it takes ten seconds of standing in the hub now,
|
||||
## so the run you just left cost you something -- and the cost is time in the
|
||||
## one place nothing is shooting at you, not a resource to manage.
|
||||
const LOBBY_HP_REGEN_PERCENT_PER_SEC := 10.0
|
||||
|
||||
## A downed player cannot leave for the hub until this has elapsed. Enforced in
|
||||
## the simulation rather than only by grey-ing out the button, because a
|
||||
@@ -193,6 +199,16 @@ const DUNGEON_CLEARED_EXIT_TICKS := 1800 # 30 seconds
|
||||
const FOG_VIEW_RADIUS := 460.0
|
||||
|
||||
# --- Interest management ----------------------------------------------------
|
||||
## How near a kill you have to be to be paid for it. Ten percent beyond what a
|
||||
## player can see, so someone who watched it die is always inside and someone
|
||||
## parked at the entrance never is. Experience stays undivided among everyone
|
||||
## who qualifies (docs/DECISIONS.md) -- this decides who was there, not how much
|
||||
## each of them gets.
|
||||
##
|
||||
## Worth knowing: an upgraded shot outranges this several times over, so a kill
|
||||
## made far enough away that you cannot see it pays nothing.
|
||||
const XP_AWARD_RADIUS := FOG_VIEW_RADIUS * 1.1
|
||||
|
||||
## Actors further than this from a player are not sent to that player at all.
|
||||
## Comfortably beyond FOG_VIEW_RADIUS so nothing visibly pops in at the edge of
|
||||
## sight, but far short of a dungeon, so a modified client cannot read enemy
|
||||
|
||||
@@ -50,6 +50,10 @@ static func make_lobby(instance_id: int) -> Instance:
|
||||
# Nothing here to escape from, and completing the channel in the hub means
|
||||
# transferring to the hub -- see SimWorld.escape_enabled.
|
||||
inst.world.escape_enabled = false
|
||||
# The hub is where a run's damage is paid off. Arriving used to heal you to
|
||||
# full, which made health free between runs; ten seconds of standing here
|
||||
# does the same job at a price.
|
||||
inst.world.regen_percent_per_sec = SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC
|
||||
inst.state = State.ACTIVE
|
||||
# A single inert practice target so players can feel out the gun before
|
||||
# committing to a run.
|
||||
|
||||
@@ -28,6 +28,17 @@ var died_unix: int = 0
|
||||
## bags, and so a server restart does not quietly confiscate everyone's potions.
|
||||
var inventory: Array[StringName] = []
|
||||
|
||||
## Current health, or [constant HP_FULL] for "however much this character can
|
||||
## have". Stored for the same reason the inventory is: SimWorld builds a blank
|
||||
## player on every instance change, so anything that must survive walking
|
||||
## through a door has to live on the record. Without it, entering and leaving
|
||||
## was a free heal, and health cost nothing between runs.
|
||||
##
|
||||
## HP_FULL rather than a number so a save written before this existed, and a
|
||||
## character that has never been hurt, both load as whole rather than as dead.
|
||||
const HP_FULL := -1
|
||||
var hp: int = HP_FULL
|
||||
|
||||
## Upgrades taken, in the order they were taken. Duplicates are meaningful --
|
||||
## two Split Shots really is two splits -- so this is a list, not a set.
|
||||
##
|
||||
@@ -140,6 +151,7 @@ func to_dict() -> Dictionary:
|
||||
# Items.ORDER being appended to, and a human editing it should be able
|
||||
# to tell what a character is carrying.
|
||||
"inventory": _inventory_ids(),
|
||||
"hp": hp,
|
||||
"upgrades": _name_list(upgrades),
|
||||
"pending_choices": pending_choices,
|
||||
"offer": _name_list(offer),
|
||||
@@ -180,6 +192,11 @@ static func from_dict(d: Dictionary) -> Character:
|
||||
var carried_id := StringName(String(raw))
|
||||
carried.append(carried_id if Items.get_def(carried_id) != null else Items.NONE)
|
||||
c.set_inventory(carried)
|
||||
# Anything unreadable, negative or absent means full. The failure that
|
||||
# matters is a save loading as a character with no health left, which under
|
||||
# permadeath is not something a player can recover from.
|
||||
var saved_hp := int(d.get("hp", HP_FULL))
|
||||
c.hp = saved_hp if saved_hp > 0 else HP_FULL
|
||||
# Unknown upgrade ids are dropped rather than kept as dead entries, so a
|
||||
# save from a build with an upgrade this one lacks still produces coherent
|
||||
# stats instead of a phantom that counts toward the +5% and does nothing.
|
||||
|
||||
@@ -132,6 +132,16 @@ func set_inventory(account_id: int, character_id: String, items: Array[StringNam
|
||||
save()
|
||||
|
||||
|
||||
## Bank a character's current health. Deferred like the inventory: it changes
|
||||
## every tick a player is regenerating, and the server flushes once a tick.
|
||||
func set_health(account_id: int, character_id: String, hp: int) -> void:
|
||||
var c := get_character(account_id, character_id)
|
||||
if c == null:
|
||||
return
|
||||
c.hp = maxi(hp, 1)
|
||||
save_deferred()
|
||||
|
||||
|
||||
## Award level-up choices and, if nothing is on the table yet, roll one.
|
||||
##
|
||||
## The offer is rolled HERE and stored, not generated on demand when the screen
|
||||
|
||||
+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:
|
||||
|
||||
+20
-5
@@ -118,6 +118,16 @@ func can_request_respawn() -> bool:
|
||||
## Adopt a character's stats. Called when a player picks or switches character.
|
||||
## Inventory comes along: it is stored on the character, so swapping in the hub
|
||||
## swaps bags too rather than handing one character another's potions.
|
||||
## Adopt a character's stats. Called when a player picks or switches character,
|
||||
## and again whenever an upgrade changes the numbers.
|
||||
##
|
||||
## Deliberately does NOT set 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, because Glass Cannon halving your ceiling has to take the
|
||||
## hit points above it with them. Where current health comes from on arrival is
|
||||
## ServerRuntime._adopt_character, which is the one place a record becomes a
|
||||
## live player.
|
||||
func adopt(c: Character) -> void:
|
||||
character_id = c.id
|
||||
display_name = c.display_name
|
||||
@@ -126,7 +136,6 @@ func adopt(c: Character) -> void:
|
||||
colour = c.colour
|
||||
stats = PlayerStats.build(c.upgrades)
|
||||
recompute_max_hp()
|
||||
hp = mini(hp, max_hp)
|
||||
set_inventory(c.inventory)
|
||||
|
||||
|
||||
@@ -187,13 +196,19 @@ func heal_percent(percent: float) -> int:
|
||||
return hp - before
|
||||
|
||||
|
||||
## One tick of passive healing. Returns true if the visible hit points changed,
|
||||
## so the caller can decide whether anything needs announcing.
|
||||
func regenerate() -> bool:
|
||||
## One tick of passive healing at [param percent_per_sec] of maximum health.
|
||||
## Returns true if the visible hit points changed, so the caller can decide
|
||||
## whether anything needs announcing.
|
||||
##
|
||||
## The rate is a property of where you are standing rather than a constant: the
|
||||
## hub heals far faster than a dungeon does, because recovering between runs
|
||||
## should take a moment rather than a resource, and recovering DURING one
|
||||
## should not be possible at all by waiting.
|
||||
func regenerate(percent_per_sec: float = SimConfig.HP_REGEN_PERCENT_PER_SEC) -> bool:
|
||||
if not alive or hp >= max_hp:
|
||||
regen_carry = 0.0
|
||||
return false
|
||||
regen_carry += float(max_hp) * (SimConfig.HP_REGEN_PERCENT_PER_SEC / 100.0) \
|
||||
regen_carry += float(max_hp) * (percent_per_sec / 100.0) \
|
||||
* SimConfig.TICK_DELTA
|
||||
if regen_carry < 1.0:
|
||||
return false
|
||||
|
||||
+12
-3
@@ -54,6 +54,9 @@ var spawn_grace_ticks: int = 0
|
||||
## for as long as the key is held. A world you can leave is the default; the hub
|
||||
## is the one that opts out.
|
||||
var escape_enabled: bool = true
|
||||
## Passive healing here, as a percentage of maximum health per second. The hub
|
||||
## overrides it -- see SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC.
|
||||
var regen_percent_per_sec: float = SimConfig.HP_REGEN_PERCENT_PER_SEC
|
||||
|
||||
var _next_actor_id: int = 1
|
||||
var _ctx := EmitContext.new()
|
||||
@@ -217,7 +220,7 @@ func step() -> void:
|
||||
|
||||
func _step_players() -> void:
|
||||
for p in players.values():
|
||||
p.regenerate()
|
||||
p.regenerate(regen_percent_per_sec)
|
||||
if p.spawn_grace > 0:
|
||||
p.spawn_grace -= 1
|
||||
if p.fire_cooldown > 0:
|
||||
@@ -862,7 +865,12 @@ func _damage_enemy(e: SimEnemy, amount: int, silent: bool = false) -> void:
|
||||
_drop_loot(e.def.loot, e.pos)
|
||||
# The def id rides along so the instance layer can score it without
|
||||
# looking up an actor that is about to stop existing.
|
||||
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id, "def": String(e.def.id)})
|
||||
# "pos" is read by ServerRuntime to decide who was close enough to be
|
||||
# paid, and is deliberately not in the wire format -- encode_events
|
||||
# writes the id and nothing else. A client has no use for it, and every
|
||||
# field that does not go out is one that cannot be mined.
|
||||
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id,
|
||||
"def": String(e.def.id), "pos": e.pos})
|
||||
|
||||
|
||||
## Returns the damage actually applied, after the phase's armour multiplier, so
|
||||
@@ -880,7 +888,8 @@ func _damage_boss(amount: int, silent: bool = false, scale: bool = true) -> int:
|
||||
if boss.hp <= 0:
|
||||
boss.alive = false
|
||||
_drop_loot(boss.def.loot, boss.pos)
|
||||
events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id)})
|
||||
events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id),
|
||||
"pos": boss.pos})
|
||||
return applied
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user