Files
transcience/tools/diag_progression.gd
T
claude 7ef972e3b3
ci / verify (push) Successful in 47s
Hub character swapping, XP percentage, passive health regeneration
Character swapping is hub-only, and refused by the SERVER rather than merely
greyed out in the menu. Allowing it inside a dungeon would be an instant,
uninterruptible exit from danger -- strictly better than the one-second escape
channel, which would make that channel pointless. Creating a character in a
dungeon is refused for the same reason. Both are covered by diag_progression.

Health regenerates at 0.5% of MAXIMUM per second. A percentage rather than a
flat rate so it does not become irrelevant at level 15: a capped character
regains 1.2 hp/s against a level 1's 0.5, and both take about 200 seconds to
heal from nothing. No out-of-combat gate -- at this rate it cannot out-heal
anything actually shooting at you, and a trickle that never stops is easier to
reason about than a timer players have to learn. A fractional carry is needed
because a tick heals well under one hit point, so truncating each tick would
heal exactly nothing; there is a test for that specifically.

The XP bar now states the percentage and the level it leads to, since a bar
answers "how far" vaguely and a number answers it exactly.

Recorded the two Stage 3 answers: 4 inventory slots, and the boss's food item
is player-instanced now so the mechanism gets exercised rather than deferred.

Writing the swap guard's test caught my own mistake: the first version created
its spare character through the store, which has no opinion about where you
are, so it bypassed the guard it was meant to prove and left a stray character
behind that broke a later assertion.

200 tests. check.sh, test.sh, smoke.sh and both diagnostics pass.
2026-09-04 00:56:29 +02:00

153 lines
5.5 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
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
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])
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 == 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 == 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)