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
+30
-10
@@ -147,10 +147,19 @@ removed from the instance and left at the roster screen. The one exception is a
|
|||||||
linkdead player, which has nobody to show a roster to, so its body is left for
|
linkdead player, which has nobody to show a roster to, so its body is left for
|
||||||
the escape channel to resolve as before.
|
the escape channel to resolve as before.
|
||||||
|
|
||||||
**Experience is shared across the party, undivided.** Everyone alive in the
|
**Experience is shared across the party, undivided — among everyone who was
|
||||||
instance receives the full amount for a kill. Splitting it would make bringing a
|
there.** Everyone alive receives the full amount for a kill. Splitting it would
|
||||||
friend cost you progress, which is the opposite of what the hub roster exists to
|
make bringing a friend cost you progress, which is the opposite of what the hub
|
||||||
encourage.
|
roster exists to encourage.
|
||||||
|
|
||||||
|
Presence is a separate question from the split, and it has a radius:
|
||||||
|
`SimConfig.XP_AWARD_RADIUS`, ten percent past `FOG_VIEW_RADIUS`. Anyone who
|
||||||
|
watched something die is inside it; a player parked in the entrance room while
|
||||||
|
the party clears the dungeon is not, and used to collect the whole run. Ten
|
||||||
|
percent past sight rather than exactly on it, so the edge of the fog is
|
||||||
|
comfortably inside and nobody loses a kill they actually saw. One consequence
|
||||||
|
worth knowing: an upgraded shot outranges this several times over, so a kill
|
||||||
|
made somewhere you cannot see pays nothing.
|
||||||
|
|
||||||
**A level-up heals by the amount it added.** Gaining a level mid-fight should
|
**A level-up heals by the amount it added.** Gaining a level mid-fight should
|
||||||
feel like relief, not like the bar you were watching got further from full.
|
feel like relief, not like the bar you were watching got further from full.
|
||||||
@@ -168,12 +177,23 @@ than the one-second escape channel, and it would make that channel pointless.
|
|||||||
The menu greys the button out so the rule is visible, but the server refuses
|
The menu greys the button out so the rule is visible, but the server refuses
|
||||||
regardless of what any client's UI allows.
|
regardless of what any client's UI allows.
|
||||||
|
|
||||||
**Health regenerates at 0.5% of MAXIMUM per second, with no out-of-combat
|
**Health regenerates at 0.5% of MAXIMUM per second in a dungeon, with no
|
||||||
gate.** A percentage rather than a flat rate, so it does not become irrelevant
|
out-of-combat gate.** A percentage rather than a flat rate, so it does not become
|
||||||
at level 15 — a capped character regains 1.2 hp/s against a level 1's 0.5, and
|
irrelevant at level 15 — a capped character regains 1.2 hp/s against a level 1's
|
||||||
both take about 200 seconds to heal from nothing. No combat gate because at this
|
0.5. No combat gate because at this rate it cannot out-heal anything actually
|
||||||
rate it cannot out-heal anything actually shooting at you, and a trickle that
|
shooting at you, and a trickle that never stops is easier to reason about than a
|
||||||
never stops is easier to reason about than a timer players have to learn.
|
timer players have to learn.
|
||||||
|
|
||||||
|
**The hub regenerates at 10% per second, and health is carried through a door.**
|
||||||
|
Arriving anywhere used to set health to maximum, which meant a run cost nothing:
|
||||||
|
walk in, take what you can, walk out, walk back in whole. Current health now
|
||||||
|
lives on the character record — the same reason the inventory does, since
|
||||||
|
`SimWorld` builds a blank player on every instance change — and the hub is where
|
||||||
|
it is paid off, over about ten seconds. Fast enough that recovering is not a
|
||||||
|
chore, slow enough that it is a decision to make before walking back in. The
|
||||||
|
dungeon rate is unchanged, so waiting inside one is still not a plan.
|
||||||
|
|
||||||
|
More friction is expected here later; this is the floor, not the design.
|
||||||
|
|
||||||
**A dead character is gone, as far as the player is concerned.** Retirement is
|
**A dead character is gone, as far as the player is concerned.** Retirement is
|
||||||
the server's own bookkeeping for archival and troubleshooting; the roster the
|
the server's own bookkeeping for archival and troubleshooting; the roster the
|
||||||
|
|||||||
@@ -142,6 +142,15 @@ couple of minutes instead of a quarter of an hour, and because it is a portal
|
|||||||
rather than a launch flag you can compare it to a real run without restarting
|
rather than a launch flag you can compare it to a real run without restarting
|
||||||
the server.
|
the server.
|
||||||
|
|
||||||
|
**It is a debugging tool and does not ship.** It pays exactly the same
|
||||||
|
experience a real run does — `Progression.xp_for_boss` ignores its argument and
|
||||||
|
`xp_for_enemy` keys on the enemy id, which the difficulty multipliers do not
|
||||||
|
touch — while its boss has 8% of its health and its trash drops ten times as
|
||||||
|
often. That makes it strictly the best place to level and to farm, which is
|
||||||
|
correct for a harness and would be ruinous in a release. Remove it from
|
||||||
|
`Dungeons.ORDER` (and its `P` marker from the lobby stamp) before shipping, or
|
||||||
|
give `DungeonDef` an `xp_mult` alongside the three multipliers it already has.
|
||||||
|
|
||||||
Adding a dungeon is one entry in `Dungeons.ORDER` plus one more `P` marker in
|
Adding a dungeon is one entry in `Dungeons.ORDER` plus one more `P` marker in
|
||||||
the lobby stamp — the Nth marker, in reading order, opens the Nth entry, so the
|
the lobby stamp — the Nth marker, in reading order, opens the Nth entry, so the
|
||||||
two have to stay in the same order.
|
two have to stay in the same order.
|
||||||
|
|||||||
@@ -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
|
## 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.
|
## it cannot outpace anything that is actually shooting at you.
|
||||||
const HP_REGEN_PERCENT_PER_SEC := 0.5
|
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
|
## 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
|
## 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
|
const FOG_VIEW_RADIUS := 460.0
|
||||||
|
|
||||||
# --- Interest management ----------------------------------------------------
|
# --- 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.
|
## 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
|
## 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
|
## 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
|
# Nothing here to escape from, and completing the channel in the hub means
|
||||||
# transferring to the hub -- see SimWorld.escape_enabled.
|
# transferring to the hub -- see SimWorld.escape_enabled.
|
||||||
inst.world.escape_enabled = false
|
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
|
inst.state = State.ACTIVE
|
||||||
# A single inert practice target so players can feel out the gun before
|
# A single inert practice target so players can feel out the gun before
|
||||||
# committing to a run.
|
# 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.
|
## bags, and so a server restart does not quietly confiscate everyone's potions.
|
||||||
var inventory: Array[StringName] = []
|
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 --
|
## 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.
|
## 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
|
# Items.ORDER being appended to, and a human editing it should be able
|
||||||
# to tell what a character is carrying.
|
# to tell what a character is carrying.
|
||||||
"inventory": _inventory_ids(),
|
"inventory": _inventory_ids(),
|
||||||
|
"hp": hp,
|
||||||
"upgrades": _name_list(upgrades),
|
"upgrades": _name_list(upgrades),
|
||||||
"pending_choices": pending_choices,
|
"pending_choices": pending_choices,
|
||||||
"offer": _name_list(offer),
|
"offer": _name_list(offer),
|
||||||
@@ -180,6 +192,11 @@ static func from_dict(d: Dictionary) -> Character:
|
|||||||
var carried_id := StringName(String(raw))
|
var carried_id := StringName(String(raw))
|
||||||
carried.append(carried_id if Items.get_def(carried_id) != null else Items.NONE)
|
carried.append(carried_id if Items.get_def(carried_id) != null else Items.NONE)
|
||||||
c.set_inventory(carried)
|
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
|
# 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
|
# 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.
|
# 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()
|
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.
|
## 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
|
## 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:
|
for ev in events:
|
||||||
match int(ev["t"]):
|
match int(ev["t"]):
|
||||||
SimEvent.Type.ENEMY_DIED:
|
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:
|
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_PICKED_UP, SimEvent.Type.ITEM_USED, \
|
||||||
SimEvent.Type.ITEM_DROPPED:
|
SimEvent.Type.ITEM_DROPPED:
|
||||||
# The simulation moved items between the ground and a bag; the
|
# 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])
|
% [peer_id, peer_names.get(peer_id, "?"), inst.id])
|
||||||
_broadcast_roster()
|
_broadcast_roster()
|
||||||
return
|
return
|
||||||
|
_bank_health(inst, peer_id)
|
||||||
inst.remove_peer(peer_id)
|
inst.remove_peer(peer_id)
|
||||||
_forget_peer(peer_id)
|
_forget_peer(peer_id)
|
||||||
GameLog.info("server", "peer %d disconnected" % 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 dungeon; with no persistence yet, "safely back in the hub" and "gone" are
|
||||||
## the same outcome, so it is simply removed.
|
## the same outcome, so it is simply removed.
|
||||||
func _release_linkdead(peer_id: int, inst: Instance) -> void:
|
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)
|
inst.remove_peer(peer_id)
|
||||||
GameLog.info("server", "peer %d released from instance %d after drop"
|
GameLog.info("server", "peer %d released from instance %d after drop"
|
||||||
% [peer_id, inst.id])
|
% [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 account: int = peer_accounts[peer_id]
|
||||||
var previous := instance_of(peer_id)
|
var previous := instance_of(peer_id)
|
||||||
if previous != null:
|
if previous != null:
|
||||||
|
_bank_health(previous, peer_id)
|
||||||
previous.remove_peer(peer_id)
|
previous.remove_peer(peer_id)
|
||||||
peer_characters[peer_id] = c.id
|
peer_characters[peer_id] = c.id
|
||||||
peer_names[peer_id] = c.display_name
|
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:
|
if c == null or p == null:
|
||||||
return
|
return
|
||||||
p.adopt(c)
|
p.adopt(c)
|
||||||
# Arriving anywhere is a fresh start. adopt() only clamps health down to the
|
# Health comes off the record, not from a full bar. Arriving used to heal
|
||||||
# new ceiling, which would leave a character whose upgrades RAISED it
|
# you outright, which meant a run cost nothing: walk in, take what you can,
|
||||||
# arriving wounded for no reason.
|
# walk out, walk back in whole. It is paid off in the hub instead, which
|
||||||
p.hp = p.max_hp
|
# 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:
|
func _transfer(peer_id: int, to: Instance) -> void:
|
||||||
var from := instance_of(peer_id)
|
var from := instance_of(peer_id)
|
||||||
if from != null:
|
if from != null:
|
||||||
|
_bank_health(from, peer_id)
|
||||||
from.remove_peer(peer_id)
|
from.remove_peer(peer_id)
|
||||||
_place(peer_id, to)
|
_place(peer_id, to)
|
||||||
_broadcast_roster()
|
_broadcast_roster()
|
||||||
@@ -618,16 +629,26 @@ func _stream_map(peer_id: int, inst: Instance) -> void:
|
|||||||
NetCodec.encode_map_chunks(inst.world.map, batch))
|
NetCodec.encode_map_chunks(inst.world.map, batch))
|
||||||
|
|
||||||
|
|
||||||
## Experience is shared by everyone alive in the instance, undivided. Splitting
|
## Experience is shared, undivided, by everyone alive and near enough to
|
||||||
## it would make bringing a friend cost you progress, which is the opposite of
|
## [param at] to have been part of it.
|
||||||
## what a co-op game wants; the hub roster exists to help people group up.
|
##
|
||||||
func _award_kill(inst: Instance, amount: int) -> void:
|
## 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:
|
if amount <= 0 or inst.kind != Protocol.InstanceKind.DUNGEON:
|
||||||
return
|
return
|
||||||
|
var reach_sq := SimConfig.XP_AWARD_RADIUS * SimConfig.XP_AWARD_RADIUS
|
||||||
for peer in inst.peers:
|
for peer in inst.peers:
|
||||||
var p: SimPlayer = inst.world.players.get(peer)
|
var p: SimPlayer = inst.world.players.get(peer)
|
||||||
if p == null or not p.alive:
|
if p == null or not p.alive:
|
||||||
continue
|
continue
|
||||||
|
if p.pos.distance_squared_to(at) > reach_sq:
|
||||||
|
continue
|
||||||
_grant_xp(peer, amount)
|
_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
|
## 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
|
## "wrote it down" must not be a way to lose an item -- or, far worse, a way to
|
||||||
## duplicate one.
|
## 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:
|
func _persist_inventory(inst: Instance, peer_id: int) -> void:
|
||||||
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
|
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
|
||||||
var character_id: String = peer_characters.get(peer_id, "")
|
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
|
# to re-serialise every account on the server. ServerRuntime flushes once at
|
||||||
# the end of the tick.
|
# the end of the tick.
|
||||||
store.set_inventory(account, character_id, p.inventory, true)
|
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:
|
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.
|
## 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
|
## 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.
|
## 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:
|
func adopt(c: Character) -> void:
|
||||||
character_id = c.id
|
character_id = c.id
|
||||||
display_name = c.display_name
|
display_name = c.display_name
|
||||||
@@ -126,7 +136,6 @@ func adopt(c: Character) -> void:
|
|||||||
colour = c.colour
|
colour = c.colour
|
||||||
stats = PlayerStats.build(c.upgrades)
|
stats = PlayerStats.build(c.upgrades)
|
||||||
recompute_max_hp()
|
recompute_max_hp()
|
||||||
hp = mini(hp, max_hp)
|
|
||||||
set_inventory(c.inventory)
|
set_inventory(c.inventory)
|
||||||
|
|
||||||
|
|
||||||
@@ -187,13 +196,19 @@ func heal_percent(percent: float) -> int:
|
|||||||
return hp - before
|
return hp - before
|
||||||
|
|
||||||
|
|
||||||
## One tick of passive healing. Returns true if the visible hit points changed,
|
## One tick of passive healing at [param percent_per_sec] of maximum health.
|
||||||
## so the caller can decide whether anything needs announcing.
|
## Returns true if the visible hit points changed, so the caller can decide
|
||||||
func regenerate() -> bool:
|
## 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:
|
if not alive or hp >= max_hp:
|
||||||
regen_carry = 0.0
|
regen_carry = 0.0
|
||||||
return false
|
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
|
* SimConfig.TICK_DELTA
|
||||||
if regen_carry < 1.0:
|
if regen_carry < 1.0:
|
||||||
return false
|
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
|
## for as long as the key is held. A world you can leave is the default; the hub
|
||||||
## is the one that opts out.
|
## is the one that opts out.
|
||||||
var escape_enabled: bool = true
|
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 _next_actor_id: int = 1
|
||||||
var _ctx := EmitContext.new()
|
var _ctx := EmitContext.new()
|
||||||
@@ -217,7 +220,7 @@ func step() -> void:
|
|||||||
|
|
||||||
func _step_players() -> void:
|
func _step_players() -> void:
|
||||||
for p in players.values():
|
for p in players.values():
|
||||||
p.regenerate()
|
p.regenerate(regen_percent_per_sec)
|
||||||
if p.spawn_grace > 0:
|
if p.spawn_grace > 0:
|
||||||
p.spawn_grace -= 1
|
p.spawn_grace -= 1
|
||||||
if p.fire_cooldown > 0:
|
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)
|
_drop_loot(e.def.loot, e.pos)
|
||||||
# The def id rides along so the instance layer can score it without
|
# The def id rides along so the instance layer can score it without
|
||||||
# looking up an actor that is about to stop existing.
|
# 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
|
## 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:
|
if boss.hp <= 0:
|
||||||
boss.alive = false
|
boss.alive = false
|
||||||
_drop_loot(boss.def.loot, boss.pos)
|
_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
|
return applied
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -362,3 +362,44 @@ func test_an_immediate_write_clears_the_pending_one() -> void:
|
|||||||
var reloaded := CharacterStore.new(store._path)
|
var reloaded := CharacterStore.new(store._path)
|
||||||
reloaded.load_from_disk()
|
reloaded.load_from_disk()
|
||||||
assert_eq(reloaded.get_character(ACC, c.id).total_xp, 7)
|
assert_eq(reloaded.get_character(ACC, c.id).total_xp, 7)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Health on the record ---------------------------------------------------
|
||||||
|
# SimWorld builds a blank player on every instance change, so anything that must
|
||||||
|
# survive walking through a door lives here, exactly like the inventory.
|
||||||
|
|
||||||
|
func test_health_survives_a_save_and_reload() -> void:
|
||||||
|
var c := store.create_character(ACC, "Wounded")
|
||||||
|
store.set_health(ACC, c.id, 137)
|
||||||
|
store.flush()
|
||||||
|
var reloaded := CharacterStore.new(store._path)
|
||||||
|
assert_true(reloaded.load_from_disk())
|
||||||
|
assert_eq(reloaded.get_character(ACC, c.id).hp, 137)
|
||||||
|
|
||||||
|
|
||||||
|
func test_a_fresh_character_is_recorded_as_whole_rather_than_as_a_number() -> void:
|
||||||
|
var c := store.create_character(ACC, "New")
|
||||||
|
assert_eq(c.hp, Character.HP_FULL,
|
||||||
|
"a character who has never been hurt has no health number to keep")
|
||||||
|
|
||||||
|
|
||||||
|
func test_a_save_from_before_health_was_kept_loads_as_whole() -> void:
|
||||||
|
var d := Character.create("Old", RandomNumberGenerator.new()).to_dict()
|
||||||
|
d.erase("hp")
|
||||||
|
assert_eq(Character.from_dict(d).hp, Character.HP_FULL)
|
||||||
|
|
||||||
|
|
||||||
|
func test_a_nonsense_health_in_a_save_loads_as_whole_not_as_dead() -> void:
|
||||||
|
# Under permadeath, a character that loads at zero health is unrecoverable,
|
||||||
|
# so every unreadable value has to fail in the generous direction.
|
||||||
|
for bad in [0, -5, "banana"]:
|
||||||
|
var d := Character.create("Odd", RandomNumberGenerator.new()).to_dict()
|
||||||
|
d["hp"] = bad
|
||||||
|
assert_eq(Character.from_dict(d).hp, Character.HP_FULL,
|
||||||
|
"hp %s must load as whole" % bad)
|
||||||
|
|
||||||
|
|
||||||
|
func test_health_is_never_banked_at_zero() -> void:
|
||||||
|
var c := store.create_character(ACC, "Edge")
|
||||||
|
store.set_health(ACC, c.id, 0)
|
||||||
|
assert_eq(c.hp, 1, "the store floors it; a live character is never on zero")
|
||||||
@@ -96,3 +96,50 @@ func test_it_cannot_outpace_incoming_fire() -> void:
|
|||||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||||
world.step()
|
world.step()
|
||||||
assert_lt(p.hp, start, "standing in fire must still lose health")
|
assert_lt(p.hp, start, "standing in fire must still lose health")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Where you are decides how fast you heal ---------------------------------
|
||||||
|
# Arriving anywhere used to set health to maximum, which made a run free: walk
|
||||||
|
# in, take what you can, walk out, walk back in whole. The hub pays that off
|
||||||
|
# over about ten seconds instead.
|
||||||
|
|
||||||
|
func test_the_hub_heals_far_faster_than_a_dungeon() -> void:
|
||||||
|
var hub := Instance.make_lobby(1)
|
||||||
|
assert_eq(hub.world.regen_percent_per_sec,
|
||||||
|
SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC)
|
||||||
|
var run := Instance.make_dungeon(2, 99)
|
||||||
|
assert_eq(run.world.regen_percent_per_sec, SimConfig.HP_REGEN_PERCENT_PER_SEC)
|
||||||
|
assert_gt(hub.world.regen_percent_per_sec, run.world.regen_percent_per_sec,
|
||||||
|
"recovering is what the hub is for; a dungeon must not be a rest stop")
|
||||||
|
|
||||||
|
|
||||||
|
func test_a_full_bar_takes_about_ten_seconds_in_the_hub() -> void:
|
||||||
|
var hub := Instance.make_lobby(1)
|
||||||
|
var p := hub.world.add_player(1, "hurt")
|
||||||
|
p.hp = 1
|
||||||
|
for _t in SimConfig.TICK_RATE * 10:
|
||||||
|
hub.step()
|
||||||
|
assert_eq(p.hp, p.max_hp, "ten seconds of standing in the hub is a full bar")
|
||||||
|
|
||||||
|
|
||||||
|
func test_the_hub_still_takes_a_noticeable_moment() -> void:
|
||||||
|
# Fast, but not the instant heal this replaced: half a second must not do it.
|
||||||
|
var hub := Instance.make_lobby(1)
|
||||||
|
var p := hub.world.add_player(1, "hurt")
|
||||||
|
p.hp = 1
|
||||||
|
for _t in SimConfig.TICK_RATE / 2:
|
||||||
|
hub.step()
|
||||||
|
assert_lt(p.hp, p.max_hp, "arriving in the hub is not a heal button")
|
||||||
|
assert_gt(p.hp, 1, "but it is visibly happening")
|
||||||
|
|
||||||
|
|
||||||
|
func test_a_dungeon_cannot_be_waited_out() -> void:
|
||||||
|
var run := Instance.make_dungeon(2, 99)
|
||||||
|
var p := run.world.add_player(1, "hurt")
|
||||||
|
p.hp = 1
|
||||||
|
p.spawn_grace = 0
|
||||||
|
var before := p.hp
|
||||||
|
for _t in SimConfig.TICK_RATE * 10:
|
||||||
|
p.regenerate(run.world.regen_percent_per_sec)
|
||||||
|
assert_lt(p.hp - before, p.max_hp / 10,
|
||||||
|
"ten seconds in a dungeon must not undo a fight")
|
||||||
@@ -351,3 +351,81 @@ func test_a_refused_flood_is_logged_once_and_not_once_per_packet() -> void:
|
|||||||
assert_true(srv._accept_control(PEER))
|
assert_true(srv._accept_control(PEER))
|
||||||
assert_false(srv._peer_control_warned.has(PEER),
|
assert_false(srv._peer_control_warned.has(PEER),
|
||||||
"behaving again re-arms the warning for the next burst")
|
"behaving again re-arms the warning for the next burst")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Who gets paid for a kill -----------------------------------------------
|
||||||
|
# Experience is undivided among everyone who was there, which is settled. Who
|
||||||
|
# counts as "there" was not: without a radius, a player parked in the entrance
|
||||||
|
# room collected the full experience for a boss they never saw.
|
||||||
|
|
||||||
|
func _paying_server(where: Instance) -> ServerRuntime:
|
||||||
|
var srv := _runtime()
|
||||||
|
srv.store = CharacterStore.new("user://test_award_%d.json" % randi())
|
||||||
|
srv.store.load_from_disk()
|
||||||
|
srv.instances[where.id] = where
|
||||||
|
return srv
|
||||||
|
|
||||||
|
|
||||||
|
func _seat(srv: ServerRuntime, where: Instance, peer: int, at: Vector2) -> SimPlayer:
|
||||||
|
var c := srv.store.create_character(peer, "p%d" % peer)
|
||||||
|
srv.peer_accounts[peer] = peer
|
||||||
|
srv.peer_characters[peer] = c.id
|
||||||
|
srv.peer_instance[peer] = where.id
|
||||||
|
where.add_peer(peer, c.display_name)
|
||||||
|
var p: SimPlayer = where.world.players[peer]
|
||||||
|
p.pos = at
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
func _xp_of(srv: ServerRuntime, peer: int) -> int:
|
||||||
|
return srv.store.get_character(peer, srv.peer_characters[peer]).total_xp
|
||||||
|
|
||||||
|
|
||||||
|
func test_only_players_near_the_kill_are_paid() -> void:
|
||||||
|
var run := Instance.make_dungeon(9, 4242)
|
||||||
|
var srv := _paying_server(run)
|
||||||
|
var kill := Vector2(500.0, 500.0)
|
||||||
|
_seat(srv, run, 11, kill + Vector2(50.0, 0.0))
|
||||||
|
_seat(srv, run, 22, kill + Vector2(SimConfig.XP_AWARD_RADIUS + 200.0, 0.0))
|
||||||
|
|
||||||
|
srv._award_kill(run, 50, kill)
|
||||||
|
assert_eq(_xp_of(srv, 11), 50, "standing over it counts")
|
||||||
|
assert_eq(_xp_of(srv, 22), 0, "parked across the map does not")
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path))
|
||||||
|
|
||||||
|
|
||||||
|
func test_the_radius_reaches_past_what_a_player_can_see() -> void:
|
||||||
|
# Anyone who watched it die must be paid, so the boundary sits outside the
|
||||||
|
# fog rather than on it.
|
||||||
|
var run := Instance.make_dungeon(9, 4242)
|
||||||
|
var srv := _paying_server(run)
|
||||||
|
var kill := Vector2.ZERO
|
||||||
|
_seat(srv, run, 11, Vector2(SimConfig.FOG_VIEW_RADIUS, 0.0))
|
||||||
|
srv._award_kill(run, 50, kill)
|
||||||
|
assert_eq(_xp_of(srv, 11), 50,
|
||||||
|
"at the very edge of sight you still saw it happen")
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path))
|
||||||
|
|
||||||
|
|
||||||
|
func test_a_kill_still_pays_the_whole_party_that_was_there() -> void:
|
||||||
|
# The radius decides who was present; it must not turn into a split.
|
||||||
|
var run := Instance.make_dungeon(9, 4242)
|
||||||
|
var srv := _paying_server(run)
|
||||||
|
var kill := Vector2(100.0, 100.0)
|
||||||
|
for peer in [11, 22, 33]:
|
||||||
|
_seat(srv, run, peer, kill + Vector2(40.0, 0.0))
|
||||||
|
srv._award_kill(run, 50, kill)
|
||||||
|
for peer in [11, 22, 33]:
|
||||||
|
assert_eq(_xp_of(srv, peer), 50, "undivided, for everyone present")
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path))
|
||||||
|
|
||||||
|
|
||||||
|
func test_the_dead_are_not_paid_however_close_they_are_lying() -> void:
|
||||||
|
var run := Instance.make_dungeon(9, 4242)
|
||||||
|
var srv := _paying_server(run)
|
||||||
|
var kill := Vector2.ZERO
|
||||||
|
var corpse := _seat(srv, run, 11, kill)
|
||||||
|
corpse.alive = false
|
||||||
|
srv._award_kill(run, 50, kill)
|
||||||
|
assert_eq(_xp_of(srv, 11), 0)
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path))
|
||||||
@@ -18,6 +18,8 @@ var _step: int = 0
|
|||||||
var _srv: ServerRuntime
|
var _srv: ServerRuntime
|
||||||
var _account: int = 424242
|
var _account: int = 424242
|
||||||
var _character: Character
|
var _character: Character
|
||||||
|
var _wounded: int = 0
|
||||||
|
var _hub_arrival: int = 0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -77,6 +79,11 @@ func _physics_process(_delta: float) -> void:
|
|||||||
_check(false, "the dungeon had a living enemy to kill")
|
_check(false, "the dungeon had a living enemy to kill")
|
||||||
_finish()
|
_finish()
|
||||||
return
|
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
|
victim.hp = 1
|
||||||
inst.world._damage_enemy(victim, 100)
|
inst.world._damage_enemy(victim, 100)
|
||||||
_srv._dispatch_events(inst)
|
_srv._dispatch_events(inst)
|
||||||
@@ -116,6 +123,68 @@ func _physics_process(_delta: float) -> void:
|
|||||||
_check(_srv.store.characters_for(_account).size() == before_count,
|
_check(_srv.store.characters_for(_account).size() == before_count,
|
||||||
"creating a character inside a dungeon is refused too")
|
"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:
|
elif _step == 90:
|
||||||
# Stop behaving like a bot before the kill. A bot auto-creates a
|
# Stop behaving like a bot before the kill. A bot auto-creates a
|
||||||
# replacement the instant it sees an empty roster -- correct in play,
|
# replacement the instant it sees an empty roster -- correct in play,
|
||||||
|
|||||||
Reference in new issue
Block a user