Files
transcience/tools/diag_progression.gd
T
claude 4765bbce28
ci / verify (push) Successful in 47s
Stage 2: accounts, characters, permadeath, levels and experience
Identity is shaped like Steamworks so swapping to it is one subclass and no
schema change: the client presents an opaque ticket, the server validates it
into a stable 64-bit account id, and nothing downstream sees anything else.
LocalAuthProvider takes any ticket at face value -- insecure on purpose, and
labelled as such everywhere, because the point is the shape rather than the
security. Do not ship it.

Characters persist as JSON keyed by account. Account ids are written as decimal
strings because they are 64-bit and JSON numbers are doubles, which would
silently round them. A corrupt store aborts the server rather than starting
empty: starting empty looks like it worked and then saves over every character
on the first level-up.

Levels 1-15, +10 max health each, level DERIVED from lifetime experience rather
than stored beside it, so a hand-edited save cannot produce a level 12 character
with a level 3's experience. Experience is shared undivided across everyone
alive in the instance -- splitting it would make bringing a friend cost you
progress. A level-up heals by what it added, so gaining one mid-fight is relief
rather than a bar that moved further from full.

Death is permanent and unbinds the character entirely: no "return to the hub as
the character who just died", because the run is over. The record is retired,
never deleted. The five-character cap counts LIVING characters only -- counting
the dead would lock a player out of their own account after five deaths.

Verified by tools/diag_progression.tscn, which drives the real server through
kill -> xp -> level -> health and death -> retire -> roster. The bot smoke test
cannot cover that: bots are poor shots and rarely kill anything. Writing it
caught two real ordering bugs -- the death event was dispatched before the
payload that tells the player they died, and the dead character stayed bound to
the peer.

Also added --account and --store so several clients and test runs can coexist
on one machine. The smoke test now uses a scratch store; without it a rerun
resumed the previous run's characters and "a character was created" quietly
stopped being true.

193 tests. check.sh, test.sh, smoke.sh, diag_progression and diag_prediction
all pass.
2026-09-04 00:44:34 +02:00

138 lines
4.6 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 == 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)