diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3842b5f..802dfa3 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -174,3 +174,13 @@ 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. + +**A dead character is gone, as far as the player is concerned.** Retirement is +the server's own bookkeeping for archival and troubleshooting; the roster the +client receives contains living characters only. Listing the dead would offer a +choice that cannot be taken. + +**Experience rides the snapshot, not the character roster.** The roster is only +re-sent when the *set* of characters changes, so a bar fed from it moved only on +level-up or a swap. The live total is four bytes on a message that already goes +out at 20 Hz. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index dbcad3b..c42b4d2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -85,7 +85,9 @@ play off server events. Enough to prove the pipeline, not a finished look. | 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` | +| XP percentage to next level | done | `HUD._draw_xp_bar`, fed live from the snapshot | +| Dead characters hidden from the roster | done | `ServerRuntime._send_characters` sends living only | +| Suggested name when creating | done | `Character.random_name` | | Passive health regeneration | done | `SimPlayer.regenerate`, 0.5%/s of maximum | Verified end to end by `tools/diag_progression.tscn`, which drives the real diff --git a/src/meta/character.gd b/src/meta/character.gd index 299d3ec..de6002c 100644 --- a/src/meta/character.gd +++ b/src/meta/character.gd @@ -25,6 +25,28 @@ var created_unix: int = 0 var died_unix: int = 0 +## Suggested names, offered when creating a character so the field is never +## blank. Deliberately a pair of short word lists rather than a big table: the +## point is a usable default the player can overwrite, not a naming system. +const NAME_FIRST: PackedStringArray = [ + "Ash", "Bram", "Cass", "Dorn", "Elm", "Fen", "Gale", "Hale", "Iva", "Jory", + "Kit", "Lark", "Mox", "Nell", "Orin", "Pike", "Quill", "Ren", "Sable", "Thorn", + "Vesper", "Wren", "Yarrow", "Zel", +] +const NAME_LAST: PackedStringArray = [ + "blade", "briar", "creek", "dusk", "ember", "fell", "grim", "hollow", + "iron", "kettle", "moor", "night", "quarry", "rook", "shade", "thistle", + "vale", "wick", +] + + +static func random_name(rng: RandomNumberGenerator) -> String: + return "%s%s" % [ + NAME_FIRST[rng.randi() % NAME_FIRST.size()], + NAME_LAST[rng.randi() % NAME_LAST.size()], + ] + + static func create(character_name: String, rng: RandomNumberGenerator) -> Character: var c := Character.new() # Random-but-readable: full saturation and high value, so two characters are diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index 17570e0..ba31e54 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -44,6 +44,9 @@ var my_hp: int = SimConfig.PLAYER_MAX_HP ## Follows the character's level, so the HUD bar cannot be computed from a ## constant. var my_max_hp: int = SimConfig.PLAYER_MAX_HP +## Lifetime experience, straight from the snapshot so the bar moves per kill +## rather than per roster message. +var my_total_xp: int = 0 var my_alive: bool = true var my_escape: float = 0.0 var my_escaping: bool = false @@ -364,6 +367,7 @@ func _reconcile(rec: Dictionary) -> void: my_spawn_grace = (int(rec["flags"]) & Protocol.F_SPAWN_GRACE) != 0 my_escape = float(rec["escape"]) my_respawn_wait = float(rec["respawn_wait"]) + my_total_xp = int(rec["total_xp"]) if my_alive: request_respawn = false hud_dirty.emit() diff --git a/src/net/net_codec.gd b/src/net/net_codec.gd index 5f29b1b..704ff86 100644 --- a/src/net/net_codec.gd +++ b/src/net/net_codec.gd @@ -69,6 +69,10 @@ static func encode_snapshot(world: SimWorld, # wasteful -- but it is four bytes, and the alternative is a separate # message plus the join-ordering bug where someone arrives before it. b.put_u32(p.colour.to_rgba32()) + # Experience rides the snapshot rather than waiting for a roster + # message: the bar has to move on every kill, and the roster is only + # re-sent when the set of characters actually changes. + b.put_u32(maxi(p.total_xp, 0)) var live_enemies: Array[SimEnemy] = [] for e in world.enemies.values(): @@ -125,6 +129,7 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary: "respawn_wait": float(b.get_u8()) / 10.0, "last_input_tick": b.get_u32(), "colour": Color.hex(b.get_u32()), + "total_xp": b.get_u32(), }) var ecount := b.get_u16() @@ -317,11 +322,16 @@ static func decode_roster(data: PackedByteArray) -> Array[Dictionary]: b.data_array = data var count := b.get_u8() for _i in count: - # get_utf8_string reads its own length prefix, so a truncated packet - # yields empty strings rather than reading off the end. + if b.get_available_bytes() < 4: + break + var peer := b.get_u32() + var display := _safe_utf8(b) + # kind, instance, alive. + if b.get_available_bytes() < 1 + 4 + 1: + break out.append({ - "peer": b.get_u32(), - "name": b.get_utf8_string(), + "peer": peer, + "name": display, "kind": b.get_u8(), "instance": b.get_u32(), "alive": b.get_u8() == 1, @@ -386,6 +396,24 @@ static func encode_characters(chars: Array[Character], selected: String) -> Pack return b.data_array +## Length-prefixed string read that refuses to run off the end. +## +## StreamPeerBuffer.get_utf8_string() reads a length and then that many bytes, +## and pushes an engine error if the buffer is short -- so a truncated or +## hostile packet turns into error spam plus a garbage value. Returns an empty +## string and leaves the cursor at the end instead, which callers detect via +## get_available_bytes(). +static func _safe_utf8(b: StreamPeerBuffer) -> String: + if b.get_available_bytes() < 4: + b.seek(b.get_size()) + return "" + var length := b.get_u32() + if length > b.get_available_bytes(): + b.seek(b.get_size()) + return "" + return b.get_data(length)[1].get_string_from_utf8() if length > 0 else "" + + ## Returns { "selected": String, "characters": Array[Dictionary] }. static func decode_characters(data: PackedByteArray) -> Dictionary: var out: Array[Dictionary] = [] @@ -394,14 +422,21 @@ static func decode_characters(data: PackedByteArray) -> Dictionary: var b := StreamPeerBuffer.new() b.big_endian = false b.data_array = data - var selected := b.get_utf8_string() + var selected := _safe_utf8(b) + if b.get_available_bytes() < 1: + return {"selected": selected, "characters": out} var count := b.get_u8() + # Bytes each entry needs after its two strings: level, xp, progress, + # max_hp, active, colour. + var fixed := 1 + 4 + 1 + 2 + 1 + 4 for _i in count: - if b.get_available_bytes() <= 0: + var id := _safe_utf8(b) + var display := _safe_utf8(b) + if id.is_empty() or b.get_available_bytes() < fixed: break out.append({ - "id": b.get_utf8_string(), - "name": b.get_utf8_string(), + "id": id, + "name": display, "level": b.get_u8(), "xp": b.get_u32(), "progress": float(b.get_u8()) / 255.0, diff --git a/src/net/server_runtime.gd b/src/net/server_runtime.gd index 4af5a0f..46617ae 100644 --- a/src/net/server_runtime.gd +++ b/src/net/server_runtime.gd @@ -222,8 +222,11 @@ func _send_characters(peer_id: int) -> void: var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) if account == AuthProvider.NO_ACCOUNT: return + # Living characters only. Retirement is bookkeeping for the server's own + # archive -- from the player's side a dead character is simply gone, and + # listing it would offer a choice that cannot be taken. Net.send_characters(peer_id, NetCodec.encode_characters( - store.characters_for(account), peer_characters.get(peer_id, ""))) + store.active_characters(account), peer_characters.get(peer_id, ""))) func on_select_character(peer_id: int, character_id: String) -> void: @@ -439,6 +442,16 @@ func _grant_xp(peer_id: int, amount: int) -> void: if account == AuthProvider.NO_ACCOUNT or character_id.is_empty(): return var levels := store.grant_xp(account, character_id, amount) + var earned := store.get_character(account, character_id) + if earned == null: + return + # Mirrored into the world every grant, not only on a level-up, so the bar + # tracks each kill. + var here := instance_of(peer_id) + if here != null: + var mine: SimPlayer = here.world.players.get(peer_id) + if mine != null: + mine.total_xp = earned.total_xp if levels <= 0: return # A level raises max health immediately, and heals by the amount gained -- diff --git a/src/sim/sim_player.gd b/src/sim/sim_player.gd index e2e675c..56fe220 100644 --- a/src/sim/sim_player.gd +++ b/src/sim/sim_player.gd @@ -10,6 +10,9 @@ var display_name: String = "player" ## progression -- experience is banked by ServerRuntime, which owns the store. var character_id: String = "" var level: int = Progression.START_LEVEL +## Lifetime experience, mirrored from the character so it can ride the snapshot. +## The store remains the source of truth; this is a copy for display. +var total_xp: int = 0 var colour := Color.WHITE var pos := Vector2.ZERO var aim: float = 0.0 @@ -88,6 +91,7 @@ func adopt(c: Character) -> void: character_id = c.id display_name = c.display_name level = c.level + total_xp = c.total_xp colour = c.colour max_hp = c.max_hp() hp = mini(hp, max_hp) diff --git a/src/ui/character_select.gd b/src/ui/character_select.gd index da9fcf7..a3a3a6a 100644 --- a/src/ui/character_select.gd +++ b/src/ui/character_select.gd @@ -17,11 +17,13 @@ var _status: Label var _title: Label var _close_button: Button var _known: Array[Dictionary] = [] +var _rng := RandomNumberGenerator.new() var _selected: String = "" func _ready() -> void: layer = 30 + _rng.randomize() var root := Control.new() root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(root) @@ -57,6 +59,7 @@ func _ready() -> void: panel.add_child(row) _name_field = LineEdit.new() _name_field.placeholder_text = "new character name" + _name_field.text = Character.random_name(_rng) _name_field.max_length = Character.MAX_NAME _name_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(_name_field) @@ -84,7 +87,8 @@ func _on_create() -> void: set_status("give the character a name") return create_requested.emit(wanted) - _name_field.text = "" + # Offer the next suggestion straight away rather than clearing to blank. + _name_field.text = Character.random_name(_rng) func set_status(text: String) -> void: @@ -100,10 +104,10 @@ func refresh(characters: Array[Dictionary], selected: String) -> void: for child in _list.get_children(): child.queue_free() - var living := 0 + # Everything the server sends is alive: a dead character is gone as far as + # the player is concerned, and retirement is the server's own bookkeeping. + var living := characters.size() for c in characters: - if c["active"]: - living += 1 _list.add_child(_row_for(c)) if characters.is_empty(): @@ -130,24 +134,18 @@ func _row_for(c: Dictionary) -> Control: swatch.custom_minimum_size = Vector2(14.0, 14.0) row.add_child(swatch) - var alive: bool = c["active"] + var playing := String(c["id"]) == _selected var label := Label.new() - var suffix := "" - if not alive: - suffix = " (dead)" - elif String(c["id"]) == _selected: - suffix = " <- playing" label.text = " %s level %d %d hp%s" % [ - c["name"], int(c["level"]), int(c["max_hp"]), suffix] - label.add_theme_color_override("font_color", - Color(0.85, 0.9, 1.0) if alive else Color(0.55, 0.4, 0.42)) + c["name"], int(c["level"]), int(c["max_hp"]), + " <- playing" if playing else ""] + label.add_theme_color_override("font_color", Color(0.85, 0.9, 1.0)) label.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(label) var play := Button.new() play.text = "Play" - # A dead character is listed as a record, never as an option. - play.disabled = not alive or String(c["id"]) == _selected + play.disabled = playing play.pressed.connect(func() -> void: select_requested.emit(String(c["id"]))) row.add_child(play) return row diff --git a/src/ui/hud.gd b/src/ui/hud.gd index 6c3ec7b..c347280 100644 --- a/src/ui/hud.gd +++ b/src/ui/hud.gd @@ -94,7 +94,8 @@ func _status_text() -> String: var who := client.current_character() var name_part := "" if not who.is_empty(): - name_part = "%s lv %d " % [who["name"], int(who["level"])] + name_part = "%s lv %d " % [ + who["name"], Progression.level_for_xp(client.my_total_xp)] return "%s%s instance %d hp %d/%d %d fps" % [ name_part, where, client.instance_id, client.my_hp, client.my_max_hp, Engine.get_frames_per_second()] @@ -152,12 +153,13 @@ func _draw_hud() -> void: ## A thin bar under health: progress toward the next level, and the level ## itself. Drawn from the server's numbers, never recomputed locally. func _draw_xp_bar(at: Vector2) -> void: - var who := client.current_character() - if who.is_empty(): + if client.current_character().is_empty(): return - var level := int(who["level"]) + # Derived from the snapshot's live experience total rather than the roster's + # copy, which only arrives when the set of characters changes. + var level := Progression.level_for_xp(client.my_total_xp) var capped := level >= Progression.MAX_LEVEL - var progress: float = 1.0 if capped else who["progress"] + var progress := Progression.level_progress(client.my_total_xp) 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) diff --git a/tests/unit/test_character_store.gd b/tests/unit/test_character_store.gd index 8161eac..842057f 100644 --- a/tests/unit/test_character_store.gd +++ b/tests/unit/test_character_store.gd @@ -148,3 +148,26 @@ func test_names_are_sanitised() -> void: var with_control := "ab" + String.chr(7) + String.chr(10) + "cd" assert_eq(Character.sanitize_name(with_control), "abcd", "control characters would let a name break the HUD's layout") + + +## The suggested name exists so the create field is never blank. It only has to +## be non-empty and survive sanitising -- it is a starting point, not an +## identity. +func test_suggested_names_are_usable() -> void: + var rng := RandomNumberGenerator.new() + rng.seed = 3 + for _i in 50: + var n := Character.random_name(rng) + assert_false(n.is_empty()) + assert_lte(n.length(), Character.MAX_NAME) + assert_eq(Character.sanitize_name(n), n, + "a suggestion must survive the sanitiser unchanged") + + +func test_suggested_names_vary() -> void: + var rng := RandomNumberGenerator.new() + rng.seed = 9 + var seen := {} + for _i in 40: + seen[Character.random_name(rng)] = true + assert_gt(seen.size(), 10, "a fixed suggestion would be worse than none") diff --git a/tests/unit/test_net_codec.gd b/tests/unit/test_net_codec.gd index 5fac95f..289944f 100644 --- a/tests/unit/test_net_codec.gd +++ b/tests/unit/test_net_codec.gd @@ -170,8 +170,7 @@ func test_character_roster_round_trips() -> void: rng.seed = 5 var a := Character.create("Ada", rng) a.grant_xp(Progression.total_xp_for_level(4)) - var b := Character.create("Departed", rng) - b.retire() + var b := Character.create("Second", rng) var out := NetCodec.decode_characters( NetCodec.encode_characters([a, b] as Array[Character], a.id)) assert_eq(String(out["selected"]), a.id) @@ -182,7 +181,6 @@ func test_character_roster_round_trips() -> void: assert_eq(int(chars[0]["max_hp"]), a.max_hp(), "the roster shows each character's own health ceiling") assert_true(chars[0]["active"]) - assert_false(chars[1]["active"], "a dead character is listed, not hidden") ## Colour is a character's only identity until cosmetics exist, so it has to @@ -218,3 +216,58 @@ func test_the_snapshot_carries_each_players_own_max_health() -> void: assert_eq(int(rec["max_hp"]), Progression.max_hp_for_level(5)) assert_almost_eq((rec["colour"] as Color).b, 0.9, 0.02, "party members are told apart by colour, so it rides the snapshot") + + +## The bar has to move on every kill, so experience rides the snapshot rather +## than waiting for a roster message that is only sent when the SET of +## characters changes. +func test_the_snapshot_carries_live_experience() -> void: + var p: SimPlayer = world.players[42] + p.total_xp = 1234 + var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world)) + assert_eq(int(snap["players"][0]["total_xp"]), 1234) + + +## The failure mode this file exists for, made explicit. A decoder that reads a +## field its encoder never wrote runs off the end of the buffer -- which is what +## happened when a snapshot change was applied to the character decoder by +## mistake. +func test_a_truncated_character_packet_does_not_read_past_the_end() -> void: + var rng := RandomNumberGenerator.new() + rng.seed = 21 + var full := NetCodec.encode_characters( + [Character.create("Ada", rng), Character.create("Bee", rng)] as Array[Character], "") + for cut in [1, 4, 9, 17, 25, 33]: + if cut >= full.size(): + continue + var out := NetCodec.decode_characters(full.slice(0, cut)) + assert_lte((out["characters"] as Array).size(), 2, + "a packet cut at %d bytes must degrade, not invent entries" % cut) + + +func test_the_character_decoder_consumes_exactly_what_the_encoder_wrote() -> void: + var rng := RandomNumberGenerator.new() + rng.seed = 22 + var chars := [Character.create("Solo", rng)] as Array[Character] + var data := NetCodec.encode_characters(chars, "") + # Append a sentinel: if the decoder reads the right number of bytes, the + # sentinel is untouched and the entry still decodes cleanly. + var padded := data.duplicate() + padded.append_array(PackedByteArray([0xAB, 0xCD])) + var out := NetCodec.decode_characters(padded) + assert_eq((out["characters"] as Array).size(), 1) + assert_eq(String(out["characters"][0]["name"]), "Solo") + + +## Truncation safety for the other string-carrying message, for the same reason. +func test_a_truncated_roster_packet_does_not_read_past_the_end() -> void: + var entries: Array[Dictionary] = [ + {"peer": 1, "name": "someone", "kind": 0, "instance": 1, "alive": true}, + {"peer": 2, "name": "another", "kind": 1, "instance": 3, "alive": true}, + ] + var full := NetCodec.encode_roster(entries) + for cut in range(1, full.size(), 3): + var out := NetCodec.decode_roster(full.slice(0, cut)) + assert_lte(out.size(), 2, + "a roster cut at %d bytes must degrade, not invent entries" % cut) + assert_eq(NetCodec.decode_roster(full).size(), 2, "and the full packet still works") diff --git a/tools/diag_progression.gd b/tools/diag_progression.gd index 5d99e59..292a3c7 100644 --- a/tools/diag_progression.gd +++ b/tools/diag_progression.gd @@ -82,6 +82,10 @@ func _physics_process(_delta: float) -> void: _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]) + # Mirrored into the world immediately, which is what lets the bar move + # per kill instead of waiting for the next roster message. + var mine: SimPlayer = inst.world.players[Net.LOCAL_PEER] + _check(mine.total_xp == after, "the experience reaches the world at once") elif _step == 80: # Enough experience to cross a level boundary, and check health follows. @@ -132,6 +136,17 @@ func _physics_process(_delta: float) -> void: _check(_srv.store.active_characters(_account).is_empty(), "no living characters remain") + elif _step == 110: + # The roster the client actually holds must contain no dead character: + # retirement is server-side bookkeeping, and from the player's side a + # dead character is simply gone. + var shown := 0 + for c in Net.client.characters: + shown += 1 + _check(shown == 0, "the dead character is not offered to the client") + _check(_srv.store.characters_for(_account).size() == 1, + "but the server still holds the record") + elif _step == 120: # With nothing alive, the player must be left with no character rather # than silently resurrected into the dead one.