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>
237 lines
8.9 KiB
GDScript
237 lines
8.9 KiB
GDScript
extends Node
|
|
## End-to-end check of the progression wiring: kill -> experience -> level ->
|
|
## health, and death -> character retired -> roster offered.
|
|
##
|
|
## godot --headless --path . res://tools/diag_progression.tscn
|
|
##
|
|
## Runs as a scene because ServerRuntime needs the Net autoload, which a
|
|
## --script run does not have. Exits non-zero on any failure, so it can gate.
|
|
##
|
|
## The store side is unit-tested in isolation; what this covers is the wiring
|
|
## between the simulation's events and the account that banks them, which is
|
|
## exactly the part a bot cannot be relied on to exercise (bots are poor shots).
|
|
|
|
const STORE_PATH := "user://diag_progression.json"
|
|
|
|
var _fails: Array[String] = []
|
|
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:
|
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH))
|
|
GameOpts.bot_client = true
|
|
GameOpts.account_override = _account
|
|
if Net.host(27401) != OK:
|
|
push_error("could not host")
|
|
get_tree().quit(1)
|
|
return
|
|
_srv = Net.server
|
|
_srv.store = CharacterStore.new(STORE_PATH)
|
|
_srv.store.load_from_disk()
|
|
Net.start_local_client()
|
|
|
|
|
|
func _check(ok: bool, what: String) -> void:
|
|
if ok:
|
|
print(" ok %s" % what)
|
|
else:
|
|
print(" FAIL %s" % what)
|
|
_fails.append(what)
|
|
|
|
|
|
func _physics_process(_delta: float) -> void:
|
|
_step += 1
|
|
if _step == 20:
|
|
_character = _srv.store.last_played(_account)
|
|
_check(_character != null, "a character exists after login")
|
|
if _character == null:
|
|
_finish()
|
|
return
|
|
_check(_character.level == 1, "starts at level 1")
|
|
_check(_srv.peer_characters.get(Net.LOCAL_PEER, "") == _character.id,
|
|
"the peer is playing it")
|
|
|
|
elif _step == 40:
|
|
# Into a dungeon, which is where experience is earned.
|
|
_srv._send_to_dungeon(Net.LOCAL_PEER)
|
|
|
|
elif _step == 60:
|
|
var inst := _srv.instance_of(Net.LOCAL_PEER)
|
|
_check(inst != null and inst.kind == Protocol.InstanceKind.DUNGEON,
|
|
"moved into a dungeon")
|
|
if inst == null:
|
|
_finish()
|
|
return
|
|
var before: int = _srv.store.get_character(_account, _character.id).total_xp
|
|
# Kill something the way a hit would: through the world's own damage
|
|
# path, so the event carries the def id the scorer reads.
|
|
var victim: SimEnemy = null
|
|
for e in inst.world.enemies.values():
|
|
if e.alive:
|
|
victim = e
|
|
break
|
|
if victim == null:
|
|
_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)
|
|
var after: int = _srv.store.get_character(_account, _character.id).total_xp
|
|
_check(after > before, "killing an enemy awards experience (%d -> %d)" % [before, after])
|
|
# Mirrored into the world immediately, which is what lets the bar move
|
|
# per kill instead of waiting for the next roster message.
|
|
var mine: SimPlayer = inst.world.players[Net.LOCAL_PEER]
|
|
_check(mine.total_xp == after, "the experience reaches the world at once")
|
|
|
|
elif _step == 80:
|
|
# Enough experience to cross a level boundary, and check health follows.
|
|
var inst := _srv.instance_of(Net.LOCAL_PEER)
|
|
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
|
|
var hp_before := p.max_hp
|
|
_srv._grant_xp(Net.LOCAL_PEER, Progression.total_xp_for_level(3))
|
|
var c := _srv.store.get_character(_account, _character.id)
|
|
_check(c.level >= 2, "experience produces levels (now %d)" % c.level)
|
|
_check(p.level == c.level, "the player in the world levels with it")
|
|
_check(p.max_hp > hp_before,
|
|
"max health follows the level (%d -> %d)" % [hp_before, p.max_hp])
|
|
_check(p.max_hp == Progression.max_hp_for_level(c.level),
|
|
"and matches the curve exactly")
|
|
|
|
elif _step == 70:
|
|
# Swapping inside a dungeon would be an instant, uninterruptible exit
|
|
# from danger. The server must refuse it whatever the UI allows.
|
|
var before: String = _srv.peer_characters.get(Net.LOCAL_PEER, "")
|
|
_srv.on_select_character(Net.LOCAL_PEER, before)
|
|
_check(_srv.peer_characters.get(Net.LOCAL_PEER, "") == before,
|
|
"a character swap inside a dungeon is refused")
|
|
# Through the server's entry point, not the store's -- the store has no
|
|
# opinion about where you are, and calling it directly would bypass the
|
|
# guard this is checking (and leave a stray character behind).
|
|
var before_count := _srv.store.characters_for(_account).size()
|
|
_srv.on_create_character(Net.LOCAL_PEER, "spare")
|
|
_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,
|
|
# but it would mask exactly what the next steps are checking.
|
|
GameOpts.bot_client = false
|
|
|
|
elif _step == 100:
|
|
var inst := _srv.instance_of(Net.LOCAL_PEER)
|
|
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
|
|
p.spawn_grace = 0
|
|
p.hp = 1
|
|
inst.world._damage_player(p, 9999)
|
|
_srv._dispatch_events(inst)
|
|
var c := _srv.store.get_character(_account, _character.id)
|
|
_check(c != null, "the dead character is kept, not deleted")
|
|
_check(not c.active, "and is marked inactive")
|
|
_check(c.died_unix > 0, "with a time of death recorded")
|
|
_check(_srv.store.active_characters(_account).is_empty(),
|
|
"no living characters remain")
|
|
|
|
elif _step == 110:
|
|
# The roster the client actually holds must contain no dead character:
|
|
# retirement is server-side bookkeeping, and from the player's side a
|
|
# dead character is simply gone.
|
|
var shown := 0
|
|
for c in Net.client.characters:
|
|
shown += 1
|
|
_check(shown == 0, "the dead character is not offered to the client")
|
|
_check(_srv.store.characters_for(_account).size() == 1,
|
|
"but the server still holds the record")
|
|
|
|
elif _step == 120:
|
|
# With nothing alive, the player must be left with no character rather
|
|
# than silently resurrected into the dead one.
|
|
_check(_srv.peer_characters.get(Net.LOCAL_PEER, "") == "",
|
|
"the player is left without a character to play")
|
|
_check(Net.client.needs_character(),
|
|
"so the client shows the roster screen")
|
|
_finish()
|
|
|
|
|
|
func _finish() -> void:
|
|
print("---")
|
|
if _fails.is_empty():
|
|
print("PROGRESSION_OK")
|
|
else:
|
|
print("PROGRESSION_FAIL (%d)" % _fails.size())
|
|
Net.shutdown()
|
|
get_tree().quit(0 if _fails.is_empty() else 1)
|