Files
transcience/src/meta/progression.gd
T
claude cb2c1e7840 Scale health and damage by ten so percentages have somewhere to land
A shot is 60, a fresh character has 1000, the Warden has 36000. Enemy health,
boss health and every emitter's damage were scaled with them, so every ratio
is unchanged and time to kill is exactly what it was -- tests/unit/test_content
now pins shots-to-kill per enemy and hits-to-kill a player so a careless edit
to either side shows up as a number a designer recognises.

The reason is rounding rather than balance. Damage is an integer, and at a base
of 6 the +5% every upgrade carries computed to 6.3 and rounded straight back to
6: a player took their first upgrade, was told it made them stronger, and
nothing happened. diag_upgrades used to print "damage matches the formula
(6 -> 6)"; it now prints (60 -> 63). The boss's per-phase armour multiplier had
the same problem, turning 1.15 into an effective 1.17.

The practice dummy stopped relying on a huge health pool at the same time. Its
old 100000 was already past the u16 the snapshot sends enemy health in, and
once a shot did 60 a patient player could have destroyed the hub's only
practice target for everyone until the next restart. EnemyDef.indestructible
says what was actually meant, and a test asserts nothing else uses it.

check.sh clean, 364 tests, SMOKE PASS, all four diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:16:38 +02:00

91 lines
3.0 KiB
GDScript

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. Scaled with everything else
## -- see the note on SimConfig.PLAYER_MAX_HP.
const HP_PER_LEVEL := 100
## 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