From 7ef972e3b386779f8c4aaad0d7a14b853ffb53ca Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 4 Sep 2026 00:56:29 +0200 Subject: [PATCH] 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. --- docs/DECISIONS.md | 13 +++++ docs/ROADMAP.md | 20 +++++--- src/core/sim_config.gd | 7 +++ src/net/protocol.gd | 15 +++++- src/net/server_runtime.gd | 14 ++++++ src/sim/sim_player.gd | 22 +++++++++ src/sim/sim_world.gd | 1 + src/ui/character_select.gd | 13 +++++ src/ui/game_menu.gd | 11 +++++ src/ui/hud.gd | 15 ++++-- src/view/game_scene.gd | 15 +++++- tests/unit/test_regen.gd | 96 ++++++++++++++++++++++++++++++++++++ tests/unit/test_regen.gd.uid | 1 + tools/diag_progression.gd | 15 ++++++ 14 files changed, 243 insertions(+), 15 deletions(-) create mode 100644 tests/unit/test_regen.gd create mode 100644 tests/unit/test_regen.gd.uid diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index f41a86a..3842b5f 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5cd6620..dbcad3b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -84,6 +84,9 @@ play off server events. Enough to prove the pipeline, not a finished look. | Levels 1–15, +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. diff --git a/src/core/sim_config.gd b/src/core/sim_config.gd index 107a79e..8615ed2 100644 --- a/src/core/sim_config.gd +++ b/src/core/sim_config.gd @@ -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 diff --git a/src/net/protocol.gd b/src/net/protocol.gd index 31ad048..f81f420 100644 --- a/src/net/protocol.gd +++ b/src/net/protocol.gd @@ -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 diff --git a/src/net/server_runtime.gd b/src/net/server_runtime.gd index aba7777..4af5a0f 100644 --- a/src/net/server_runtime.gd +++ b/src/net/server_runtime.gd @@ -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, diff --git a/src/sim/sim_player.gd b/src/sim/sim_player.gd index 76805ed..e2e675c 100644 --- a/src/sim/sim_player.gd +++ b/src/sim/sim_player.gd @@ -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 diff --git a/src/sim/sim_world.gd b/src/sim/sim_world.gd index 10eca23..e1dd68b 100644 --- a/src/sim/sim_world.gd +++ b/src/sim/sim_world.gd @@ -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: diff --git a/src/ui/character_select.gd b/src/ui/character_select.gd index b2e7a27..da9fcf7 100644 --- a/src/ui/character_select.gd +++ b/src/ui/character_select.gd @@ -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 diff --git a/src/ui/game_menu.gd b/src/ui/game_menu.gd index fb56f46..4310a10 100644 --- a/src/ui/game_menu.gd +++ b/src/ui/game_menu.gd @@ -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. diff --git a/src/ui/hud.gd b/src/ui/hud.gd index 968fdce..6c3ec7b 100644 --- a/src/ui/hud.gd +++ b/src/ui/hud.gd @@ -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. diff --git a/src/view/game_scene.gd b/src/view/game_scene.gd index 3b3fdbf..b291697 100644 --- a/src/view/game_scene.gd +++ b/src/view/game_scene.gd @@ -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) diff --git a/tests/unit/test_regen.gd b/tests/unit/test_regen.gd new file mode 100644 index 0000000..e202487 --- /dev/null +++ b/tests/unit/test_regen.gd @@ -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") diff --git a/tests/unit/test_regen.gd.uid b/tests/unit/test_regen.gd.uid new file mode 100644 index 0000000..312d058 --- /dev/null +++ b/tests/unit/test_regen.gd.uid @@ -0,0 +1 @@ +uid://c6aqvwj8hnc3q diff --git a/tools/diag_progression.gd b/tools/diag_progression.gd index 8ea956a..5d99e59 100644 --- a/tools/diag_progression.gd +++ b/tools/diag_progression.gd @@ -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,