Stage 2: accounts, characters, permadeath, levels and experience
ci / verify (push) Successful in 47s

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.
This commit is contained in:
2026-09-04 00:44:34 +02:00
parent ff5e527ad4
commit 4765bbce28
36 changed files with 1613 additions and 34 deletions
+89
View File
@@ -0,0 +1,89 @@
class_name Progression
extends RefCounted
## Levels, experience and what a level is worth.
##
## Pure functions over integers, so the curve can be tuned and tested without a
## server, a character, or a running game. The server is the only thing that
## ever calls the mutating side of this; a client is told its level and takes
## the server's word for it.
const MAX_LEVEL := 15
const START_LEVEL := 1
## Hit points added per level gained. Level 1 is SimConfig.PLAYER_MAX_HP, so a
## capped character has PLAYER_MAX_HP + 14 * this.
const HP_PER_LEVEL := 10
## Experience for the first level-up. The curve is tuned so one full clear of a
## depth-1 dungeon lands a little past this -- the first run should end with a
## level and some change, not exactly on the line.
const BASE_XP := 200
## How sharply the requirement grows. 1.0 would be linear; this makes level 15 a
## long-term goal without making level 2 feel far away.
const XP_CURVE := 1.35
## Experience awarded per kill, by what died.
const XP_DRIFTER := 10
const XP_TURRET := 12
const XP_STALKER := 8
const XP_BOSS := 200
## Experience needed to go from [param level] to the next one. Zero at the cap,
## which is what makes "already maxed" a total-ordering question rather than a
## special case at every call site.
static func xp_to_next(level: int) -> int:
if level >= MAX_LEVEL:
return 0
return int(round(float(BASE_XP) * pow(float(level), XP_CURVE)))
## Total experience to reach [param level] from level 1.
static func total_xp_for_level(level: int) -> int:
var total := 0
for l in range(START_LEVEL, mini(level, MAX_LEVEL)):
total += xp_to_next(l)
return total
## The level a given lifetime experience total corresponds to.
static func level_for_xp(total_xp: int) -> int:
var level := START_LEVEL
var spent := 0
while level < MAX_LEVEL:
var need := xp_to_next(level)
if total_xp - spent < need:
break
spent += need
level += 1
return level
## Progress through the current level, 0..1. Returns 1.0 at the cap so a
## progress bar reads as full rather than empty.
static func level_progress(total_xp: int) -> float:
var level := level_for_xp(total_xp)
if level >= MAX_LEVEL:
return 1.0
var into := total_xp - total_xp_for_level(level)
var need := xp_to_next(level)
return clampf(float(into) / float(maxi(need, 1)), 0.0, 1.0)
static func max_hp_for_level(level: int) -> int:
var l := clampi(level, START_LEVEL, MAX_LEVEL)
return SimConfig.PLAYER_MAX_HP + (l - START_LEVEL) * HP_PER_LEVEL
## Experience for killing an enemy, by content id. Unknown ids award nothing
## rather than a default, so a new enemy that nobody scored is obvious in play
## instead of quietly paying out.
static func xp_for_enemy(id: StringName) -> int:
match id:
Content.ENEMY_DRIFTER: return XP_DRIFTER
Content.ENEMY_TURRET: return XP_TURRET
Content.ENEMY_STALKER: return XP_STALKER
return 0
static func xp_for_boss(_id: StringName) -> int:
return XP_BOSS