Hub character swapping, XP percentage, passive health regeneration
ci / verify (push) Successful in 47s

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.
This commit is contained in:
2026-09-04 00:56:29 +02:00
parent 4765bbce28
commit 7ef972e3b3
14 changed files with 243 additions and 15 deletions
+13
View File
@@ -161,3 +161,16 @@ and then overwrite every character on the first level-up.
**Account ids are written as decimal strings in JSON.** They are 64-bit and JSON
numbers are doubles, which would silently round them.
**Character swapping is hub-only, enforced on the server.** Swapping inside a
dungeon would be an instant, uninterruptible exit from danger — strictly better
than the one-second escape channel, and it would make that channel pointless.
The menu greys the button out so the rule is visible, but the server refuses
regardless of what any client's UI allows.
**Health regenerates at 0.5% of MAXIMUM per second, with no out-of-combat
gate.** 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 combat gate because 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.
+13 -7
View File
@@ -84,6 +84,9 @@ play off server events. Enough to prove the pipeline, not a finished look.
| Levels 115, +10 max HP each | done | [src/meta/progression.gd](../src/meta/progression.gd) |
| XP from kills, bosses worth far more | done | `ServerRuntime._award_kill` |
| Colour visible in world and on the HUD | done | snapshot carries it; `WorldView._draw_ship` tints |
| Swap character from the hub | done | Esc menu → Change character; refused server-side in a dungeon |
| XP percentage to next level | done | `HUD._draw_xp_bar` |
| Passive health regeneration | done | `SimPlayer.regenerate`, 0.5%/s of maximum |
Verified end to end by `tools/diag_progression.tscn`, which drives the real
server through kill → xp → level → health and death → retire → roster. That
@@ -139,10 +142,10 @@ bullets spawn per shot, so they belong in the same place.
| Feature | Notes |
| --- | --- |
| Small always-on-screen inventory | Slot count unspecified. |
| Small always-on-screen inventory | **4 slots** for now, may grow. |
| Health potions: rare from trash, guaranteed from bosses | |
| World-shared loot | Player-instanced loot planned later. |
| A unique, useless food item from bosses | Exists specifically to test player-instanced loot. Confirm whether it should be instanced *now* or just marked for it. |
| A unique, useless food item from bosses | **Player-instanced now**, so the mechanism is exercised rather than deferred. |
| Dropping items so others can pick them up | |
---
@@ -185,13 +188,16 @@ Not oversights — each was considered and rejected for now, with the reasoning
Genuinely unspecified; do not guess at these, they change the design:
Deferred to the Stage 4 discussion (upgrades), but they block that stage:
1. Damage stacking order — additive pool then multiplicative, or something else?
2. Rarity weights for the four upgrade tiers.
3. Split shot geometry: ±22.5° from the original heading, or 45° to each side?
4. Poison: do applications stack, or refresh a single DoT?
5. Eraser: does it delete *enemy bullets* it passes through?
6. Inventory slot count.
7. Do unclaimed level-ups queue at the NPC?
8. Does the 5-character cap count only active characters?
9. Glass cannon's 50% health: of base HP, or of levelled max HP?
10. What advances dungeon depth? `--depth` is a dev flag; nothing raises it in play.
6. Do unclaimed level-ups queue at the NPC?
7. Glass cannon's 50% health: of base HP, or of levelled max HP?
Still open outside Stage 4:
8. What advances dungeon depth? `--depth` is a dev flag; nothing raises it in play.
+7
View File
@@ -51,6 +51,13 @@ const PLAYER_BULLET_DAMAGE := 6
## live bullet field is survivable. Both halves matter: invulnerability alone
## would make the spawn point a free firing position.
const SPAWN_GRACE_TICKS := 120 # 2 seconds
## Passive healing, as a percentage of MAXIMUM health per second -- so it
## scales with level rather than becoming irrelevant at level 15. There is
## deliberately no out-of-combat gate: a slow trickle that never stops is
## simpler to reason about than a timer players have to learn, and at this rate
## it cannot outpace anything that is actually shooting at you.
const HP_REGEN_PERCENT_PER_SEC := 0.5
## A downed player cannot leave for the hub until this has elapsed. Enforced
## here rather than only by grey-ing out the button, because a disabled button
## is a suggestion -- the server is the only thing a modified client cannot
+13 -2
View File
@@ -11,7 +11,8 @@ extends RefCounted
## the wire value of every event after it -- a mismatched client would
## mis-decode every hit and death, so the handshake has to reject it.
## 5: handshake carries an auth ticket instead of a bare name; added character
## list/select/create messages and per-player max health in the snapshot.
## list/select/create messages, per-player max health and colour in the
## snapshot.
const VERSION := 5
const DEFAULT_PORT := 27015
const MAX_CLIENTS := 32
@@ -29,7 +30,17 @@ enum InstanceKind { LOBBY, DUNGEON }
## Why a character selection failed. Sent rather than a bare "no", so the UI can
## say something useful instead of appearing broken.
enum SelectResult { OK, NO_SUCH_CHARACTER, CHARACTER_IS_DEAD, LIMIT_REACHED, NOT_AUTHENTICATED }
enum SelectResult {
OK,
NO_SUCH_CHARACTER,
CHARACTER_IS_DEAD,
LIMIT_REACHED,
NOT_AUTHENTICATED,
## Swapping is hub-only. Allowing it inside a dungeon would be an instant,
## uninterruptible exit from danger -- strictly better than the one-second
## escape channel, and it would make that channel pointless.
NOT_IN_HUB,
}
## Player flags packed into the snapshot's per-player byte.
const F_ALIVE := 1
+14
View File
@@ -233,6 +233,15 @@ func on_select_character(peer_id: int, character_id: String) -> void:
return
# Looked up against THIS account's characters, so a client cannot select
# somebody else's by guessing an id.
# Hub only. A player inside a dungeon who could swap character would have an
# instant escape from anything dangerous -- strictly better than the escape
# channel, and it would hollow out the whole reason that channel exists.
# Checked here rather than hidden in the UI, which a modified client ignores.
var here := instance_of(peer_id)
if here != null and here.kind != Protocol.InstanceKind.LOBBY:
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_IN_HUB,
"you can only change character in the hub")
return
var c := store.get_character(account, character_id)
if c == null:
Net.send_select_result(peer_id, Protocol.SelectResult.NO_SUCH_CHARACTER, "no such character")
@@ -251,6 +260,11 @@ func on_create_character(peer_id: int, character_name: String) -> void:
if account == AuthProvider.NO_ACCOUNT:
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AUTHENTICATED, "not signed in")
return
var where := instance_of(peer_id)
if where != null and where.kind != Protocol.InstanceKind.LOBBY:
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_IN_HUB,
"you can only change character in the hub")
return
var c := store.create_character(account, character_name)
if c == null:
Net.send_select_result(peer_id, Protocol.SelectResult.LIMIT_REACHED,
+22
View File
@@ -21,6 +21,10 @@ var alive: bool = true
var fire_cooldown: int = 0
## Ticks of arrival protection left: invulnerable, and unable to shoot.
var spawn_grace: int = 0
## Fractional health carried between ticks. Regeneration is well under one hit
## point per tick, so without this it would round to zero every tick and heal
## nothing at all.
var regen_carry: float = 0.0
## Ticks before a downed player may ask to return to the hub.
var respawn_lockout: int = 0
@@ -68,6 +72,7 @@ func reset_for_instance(spawn: Vector2, grace: int = 0) -> void:
alive = true
spawn_grace = grace
respawn_lockout = 0
regen_carry = 0.0
fire_cooldown = 0
escape_ticks = 0
input_queue.clear()
@@ -86,3 +91,20 @@ func adopt(c: Character) -> void:
colour = c.colour
max_hp = c.max_hp()
hp = mini(hp, max_hp)
## One tick of passive healing. Returns true if the visible hit points changed,
## so the caller can decide whether anything needs announcing.
func regenerate() -> bool:
if not alive or hp >= max_hp:
regen_carry = 0.0
return false
regen_carry += float(max_hp) * (SimConfig.HP_REGEN_PERCENT_PER_SEC / 100.0) \
* SimConfig.TICK_DELTA
if regen_carry < 1.0:
return false
var whole := int(regen_carry)
regen_carry -= float(whole)
var before := hp
hp = mini(hp + whole, max_hp)
return hp != before
+1
View File
@@ -172,6 +172,7 @@ func step() -> void:
func _step_players() -> void:
for p in players.values():
p.regenerate()
if p.spawn_grace > 0:
p.spawn_grace -= 1
if p.fire_cooldown > 0:
+13
View File
@@ -8,12 +8,14 @@ extends CanvasLayer
signal select_requested(character_id: String)
signal create_requested(character_name: String)
signal closed
var _list: VBoxContainer
var _name_field: LineEdit
var _create_button: Button
var _status: Label
var _title: Label
var _close_button: Button
var _known: Array[Dictionary] = []
var _selected: String = ""
@@ -63,6 +65,11 @@ func _ready() -> void:
_create_button.pressed.connect(_on_create)
row.add_child(_create_button)
_close_button = Button.new()
_close_button.text = "Back to the hub"
_close_button.pressed.connect(func() -> void: closed.emit())
panel.add_child(_close_button)
_status = Label.new()
_status.add_theme_font_size_override("font_size", 12)
_status.add_theme_color_override("font_color", Color(1.0, 0.6, 0.5))
@@ -144,3 +151,9 @@ func _row_for(c: Dictionary) -> Control:
play.pressed.connect(func() -> void: select_requested.emit(String(c["id"])))
row.add_child(play)
return row
## Whether the player may dismiss this screen. False when they have nothing to
## play, in which case there is nowhere to dismiss it to.
func set_dismissible(can_close: bool) -> void:
_close_button.visible = can_close
+11
View File
@@ -9,9 +9,11 @@ extends CanvasLayer
signal resumed
signal return_to_hub_requested
signal disconnect_requested
signal characters_requested
var _panel: VBoxContainer
var _hub_button: Button
var _characters_button: Button
var _disconnect_button: Button
var _note: Label
var _in_dungeon: bool = false
@@ -55,6 +57,9 @@ func _ready() -> void:
_hub_button = _button("Return to hub", func() -> void:
return_to_hub_requested.emit()
close())
_characters_button = _button("Change character", func() -> void:
characters_requested.emit()
close())
_button("Resume", func() -> void: close())
_disconnect_button = _button("Disconnect to menu", func() -> void:
disconnect_requested.emit()
@@ -108,6 +113,12 @@ func set_in_dungeon(in_dungeon: bool) -> void:
return
_in_dungeon = in_dungeon
_hub_button.disabled = not in_dungeon
# Swapping is hub-only, and the server enforces that regardless of what
# this button does -- but a disabled button explains the rule, where a
# refusal after the fact just looks broken.
_characters_button.disabled = in_dungeon
_characters_button.text = "Change character (hub only)" if in_dungeon \
else "Change character"
if in_dungeon:
# Disconnecting is not an escape: the server keeps the body in the
# world, channelling out, for the same second the escape button costs.
+11 -4
View File
@@ -156,10 +156,17 @@ func _draw_xp_bar(at: Vector2) -> void:
if who.is_empty():
return
var level := int(who["level"])
var progress: float = who["progress"] if level < Progression.MAX_LEVEL else 1.0
_canvas.draw_rect(Rect2(at, Vector2(BAR_W, 4.0)), Color(0.1, 0.12, 0.18))
_canvas.draw_rect(Rect2(at, Vector2(BAR_W * progress, 4.0)),
Color(0.6, 0.55, 1.0) if level < Progression.MAX_LEVEL else Color(1.0, 0.85, 0.4))
var capped := level >= Progression.MAX_LEVEL
var progress: float = 1.0 if capped else who["progress"]
var tint := Color(1.0, 0.85, 0.4) if capped else Color(0.6, 0.55, 1.0)
_canvas.draw_rect(Rect2(at, Vector2(BAR_W, 5.0)), Color(0.1, 0.12, 0.18))
_canvas.draw_rect(Rect2(at, Vector2(BAR_W * progress, 5.0)), tint)
# The number as well as the bar: "how far to the next level" is a question
# a bar answers vaguely and a percentage answers exactly.
var text := "MAX" if capped else "%d%% to level %d" % [
int(floor(progress * 100.0)), level + 1]
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(BAR_W + 10.0, 6.0),
text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, tint)
## Shown after the boss dies, so the victory lap has a visible clock on it.
+13 -2
View File
@@ -20,9 +20,13 @@ func _ready() -> void:
get_viewport().size_changed.connect(_recentre)
menu.return_to_hub_requested.connect(_on_return_to_hub)
menu.disconnect_requested.connect(_on_disconnect)
menu.characters_requested.connect(func() -> void: _roster_open = true)
hud.respawn_pressed.connect(_on_respawn_pressed)
characters.select_requested.connect(func(id: String) -> void: Net.select_character(id))
characters.select_requested.connect(func(id: String) -> void:
_roster_open = false
Net.select_character(id))
characters.create_requested.connect(func(n: String) -> void: Net.create_character(n))
characters.closed.connect(func() -> void: _roster_open = false)
var _screen_centre := Vector2.ZERO
@@ -60,7 +64,14 @@ func _process(_delta: float) -> void:
# The roster screen is shown exactly when there is nothing to play: first
# login, or after the last living character died.
if _bound != null:
characters.visible = _bound.needs_character() or _roster_open
# Forced open when there is nothing to play; opened deliberately from
# the menu otherwise. Never while in a dungeon -- the server refuses
# swaps there, so offering the screen would only invite a refusal.
var forced := _bound.needs_character()
if _bound.instance_kind != Protocol.InstanceKind.LOBBY:
_roster_open = false
characters.visible = forced or _roster_open
characters.set_dismissible(not forced)
_follow_camera()
menu.set_in_dungeon(_bound != null
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
+96
View File
@@ -0,0 +1,96 @@
extends GutTest
## Passive health regeneration.
var world: SimWorld
const PEER := 4
func before_each() -> void:
world = SimWorld.new(1)
world.add_player(PEER, "tester")
func _player() -> SimPlayer:
return world.players[PEER]
func test_it_heals_the_configured_share_of_maximum_per_second() -> void:
var p := _player()
p.hp = 1
for _i in SimConfig.TICK_RATE:
world.step()
var expected := float(p.max_hp) * SimConfig.HP_REGEN_PERCENT_PER_SEC / 100.0
assert_almost_eq(float(p.hp - 1), expected, 1.0,
"one second should restore %.1f%% of maximum" % SimConfig.HP_REGEN_PERCENT_PER_SEC)
## The reason for the fractional carry: at this rate a tick heals far less than
## one hit point, so truncating every tick would heal exactly nothing forever.
func test_fractions_accumulate_rather_than_being_lost() -> void:
var p := _player()
p.hp = 1
var per_tick := float(p.max_hp) * (SimConfig.HP_REGEN_PERCENT_PER_SEC / 100.0) \
* SimConfig.TICK_DELTA
assert_lt(per_tick, 1.0, "setup: a single tick must heal less than one hp")
for _i in SimConfig.TICK_RATE * 10:
world.step()
assert_gt(p.hp, 1, "ten seconds of sub-integer healing must still add up")
func test_it_scales_with_maximum_health_so_levels_do_not_dilute_it() -> void:
var low := _player()
low.max_hp = 100
low.hp = 1
for _i in SimConfig.TICK_RATE:
world.step()
var low_gain := low.hp - 1
var world2 := SimWorld.new(1)
var high := world2.add_player(PEER, "tester")
high.max_hp = 240
high.hp = 1
for _i in SimConfig.TICK_RATE:
world2.step()
assert_gt(high.hp - 1, low_gain,
"a levelled character should regain more per second, not the same")
func test_it_never_exceeds_maximum() -> void:
var p := _player()
p.hp = p.max_hp - 1
for _i in SimConfig.TICK_RATE * 30:
world.step()
assert_eq(p.hp, p.max_hp)
func test_the_dead_do_not_heal() -> void:
var p := _player()
p.alive = false
p.hp = 0
for _i in SimConfig.TICK_RATE * 10:
world.step()
assert_eq(p.hp, 0, "regeneration must not quietly revive a downed player")
## A replica never heals anyone: health, like damage, is the server's to decide.
func test_a_replica_world_does_not_regenerate() -> void:
world.authoritative = false
var p := _player()
p.hp = 1
for _i in SimConfig.TICK_RATE * 5:
world.step()
assert_eq(p.hp, 1)
## Slow enough that it cannot out-heal being shot, which is what makes an
## out-of-combat gate unnecessary.
func test_it_cannot_outpace_incoming_fire() -> void:
var p := _player()
p.pos = Vector2.ZERO
p.spawn_grace = 0
var start := p.hp
for _i in 60:
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 5,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
world.step()
assert_lt(p.hp, start, "standing in fire must still lose health")
+1
View File
@@ -0,0 +1 @@
uid://c6aqvwj8hnc3q
+15
View File
@@ -97,6 +97,21 @@ func _physics_process(_delta: float) -> void:
_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,