Files
transcience/tools/diag_progression.gd
T
claudeandClaude Opus 5 4aeb6d229d 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>
2026-09-07 21:48:20 +02:00

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)