Hide dead characters, suggest names, make the XP bar live
ci / verify (push) Successful in 46s

The roster sent to clients now contains living characters only. Retirement stays
server-side bookkeeping for archival; from the player's side a dead character is
simply gone, and listing it offers a choice that cannot be taken.

The XP bar only moved on a level-up or a character swap because it read the
character roster, which is re-sent only when the SET of characters changes.
Experience now rides the snapshot -- four bytes on a message already going out
at 20Hz -- and the server mirrors each grant into the world immediately rather
than only when a level is crossed.

The create field starts with a suggested name instead of blank, and offers
another after each creation.

Two bugs found while testing, both mine:

The first was a bad patch of my own: a change meant for the snapshot decoder
also matched inside decode_characters, which then read a four-byte field its
encoder never wrote and ran off the end of every packet. This is precisely the
"encodes but decodes wrong" failure the codec tests exist to catch, and it was
caught within a minute of the test being written.

Chasing that exposed a real robustness gap: StreamPeerBuffer.get_utf8_string()
pushes an engine error and returns garbage when the buffer is short, so a
truncated or hostile character/roster packet produced error spam instead of
degrading. Both decoders now bounds-check every field, with tests that slice
each packet at many lengths and assert it degrades rather than inventing
entries -- the same guarantee the input decoder already had.

206 tests. check.sh, test.sh, smoke.sh and both diagnostics pass.
This commit is contained in:
2026-09-04 01:12:53 +02:00
parent 7ef972e3b3
commit d8197885ca
12 changed files with 214 additions and 33 deletions
+10
View File
@@ -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 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 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. 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.
+3 -1
View File
@@ -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` | | 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 | | 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 | | 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 | | Passive health regeneration | done | `SimPlayer.regenerate`, 0.5%/s of maximum |
Verified end to end by `tools/diag_progression.tscn`, which drives the real Verified end to end by `tools/diag_progression.tscn`, which drives the real
+22
View File
@@ -25,6 +25,28 @@ var created_unix: int = 0
var died_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: static func create(character_name: String, rng: RandomNumberGenerator) -> Character:
var c := Character.new() var c := Character.new()
# Random-but-readable: full saturation and high value, so two characters are # Random-but-readable: full saturation and high value, so two characters are
+4
View File
@@ -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 ## Follows the character's level, so the HUD bar cannot be computed from a
## constant. ## constant.
var my_max_hp: int = SimConfig.PLAYER_MAX_HP 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_alive: bool = true
var my_escape: float = 0.0 var my_escape: float = 0.0
var my_escaping: bool = false 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_spawn_grace = (int(rec["flags"]) & Protocol.F_SPAWN_GRACE) != 0
my_escape = float(rec["escape"]) my_escape = float(rec["escape"])
my_respawn_wait = float(rec["respawn_wait"]) my_respawn_wait = float(rec["respawn_wait"])
my_total_xp = int(rec["total_xp"])
if my_alive: if my_alive:
request_respawn = false request_respawn = false
hud_dirty.emit() hud_dirty.emit()
+43 -8
View File
@@ -69,6 +69,10 @@ static func encode_snapshot(world: SimWorld,
# wasteful -- but it is four bytes, and the alternative is a separate # wasteful -- but it is four bytes, and the alternative is a separate
# message plus the join-ordering bug where someone arrives before it. # message plus the join-ordering bug where someone arrives before it.
b.put_u32(p.colour.to_rgba32()) 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] = [] var live_enemies: Array[SimEnemy] = []
for e in world.enemies.values(): 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, "respawn_wait": float(b.get_u8()) / 10.0,
"last_input_tick": b.get_u32(), "last_input_tick": b.get_u32(),
"colour": Color.hex(b.get_u32()), "colour": Color.hex(b.get_u32()),
"total_xp": b.get_u32(),
}) })
var ecount := b.get_u16() var ecount := b.get_u16()
@@ -317,11 +322,16 @@ static func decode_roster(data: PackedByteArray) -> Array[Dictionary]:
b.data_array = data b.data_array = data
var count := b.get_u8() var count := b.get_u8()
for _i in count: for _i in count:
# get_utf8_string reads its own length prefix, so a truncated packet if b.get_available_bytes() < 4:
# yields empty strings rather than reading off the end. 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({ out.append({
"peer": b.get_u32(), "peer": peer,
"name": b.get_utf8_string(), "name": display,
"kind": b.get_u8(), "kind": b.get_u8(),
"instance": b.get_u32(), "instance": b.get_u32(),
"alive": b.get_u8() == 1, "alive": b.get_u8() == 1,
@@ -386,6 +396,24 @@ static func encode_characters(chars: Array[Character], selected: String) -> Pack
return b.data_array 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] }. ## Returns { "selected": String, "characters": Array[Dictionary] }.
static func decode_characters(data: PackedByteArray) -> Dictionary: static func decode_characters(data: PackedByteArray) -> Dictionary:
var out: Array[Dictionary] = [] var out: Array[Dictionary] = []
@@ -394,14 +422,21 @@ static func decode_characters(data: PackedByteArray) -> Dictionary:
var b := StreamPeerBuffer.new() var b := StreamPeerBuffer.new()
b.big_endian = false b.big_endian = false
b.data_array = data 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() 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: 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 break
out.append({ out.append({
"id": b.get_utf8_string(), "id": id,
"name": b.get_utf8_string(), "name": display,
"level": b.get_u8(), "level": b.get_u8(),
"xp": b.get_u32(), "xp": b.get_u32(),
"progress": float(b.get_u8()) / 255.0, "progress": float(b.get_u8()) / 255.0,
+14 -1
View File
@@ -222,8 +222,11 @@ func _send_characters(peer_id: int) -> void:
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT) var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
if account == AuthProvider.NO_ACCOUNT: if account == AuthProvider.NO_ACCOUNT:
return 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( 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: 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(): if account == AuthProvider.NO_ACCOUNT or character_id.is_empty():
return return
var levels := store.grant_xp(account, character_id, amount) 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: if levels <= 0:
return return
# A level raises max health immediately, and heals by the amount gained -- # A level raises max health immediately, and heals by the amount gained --
+4
View File
@@ -10,6 +10,9 @@ var display_name: String = "player"
## progression -- experience is banked by ServerRuntime, which owns the store. ## progression -- experience is banked by ServerRuntime, which owns the store.
var character_id: String = "" var character_id: String = ""
var level: int = Progression.START_LEVEL 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 colour := Color.WHITE
var pos := Vector2.ZERO var pos := Vector2.ZERO
var aim: float = 0.0 var aim: float = 0.0
@@ -88,6 +91,7 @@ func adopt(c: Character) -> void:
character_id = c.id character_id = c.id
display_name = c.display_name display_name = c.display_name
level = c.level level = c.level
total_xp = c.total_xp
colour = c.colour colour = c.colour
max_hp = c.max_hp() max_hp = c.max_hp()
hp = mini(hp, max_hp) hp = mini(hp, max_hp)
+13 -15
View File
@@ -17,11 +17,13 @@ var _status: Label
var _title: Label var _title: Label
var _close_button: Button var _close_button: Button
var _known: Array[Dictionary] = [] var _known: Array[Dictionary] = []
var _rng := RandomNumberGenerator.new()
var _selected: String = "" var _selected: String = ""
func _ready() -> void: func _ready() -> void:
layer = 30 layer = 30
_rng.randomize()
var root := Control.new() var root := Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(root) add_child(root)
@@ -57,6 +59,7 @@ func _ready() -> void:
panel.add_child(row) panel.add_child(row)
_name_field = LineEdit.new() _name_field = LineEdit.new()
_name_field.placeholder_text = "new character name" _name_field.placeholder_text = "new character name"
_name_field.text = Character.random_name(_rng)
_name_field.max_length = Character.MAX_NAME _name_field.max_length = Character.MAX_NAME
_name_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL _name_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(_name_field) row.add_child(_name_field)
@@ -84,7 +87,8 @@ func _on_create() -> void:
set_status("give the character a name") set_status("give the character a name")
return return
create_requested.emit(wanted) 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: func set_status(text: String) -> void:
@@ -100,10 +104,10 @@ func refresh(characters: Array[Dictionary], selected: String) -> void:
for child in _list.get_children(): for child in _list.get_children():
child.queue_free() 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: for c in characters:
if c["active"]:
living += 1
_list.add_child(_row_for(c)) _list.add_child(_row_for(c))
if characters.is_empty(): if characters.is_empty():
@@ -130,24 +134,18 @@ func _row_for(c: Dictionary) -> Control:
swatch.custom_minimum_size = Vector2(14.0, 14.0) swatch.custom_minimum_size = Vector2(14.0, 14.0)
row.add_child(swatch) row.add_child(swatch)
var alive: bool = c["active"] var playing := String(c["id"]) == _selected
var label := Label.new() 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" % [ label.text = " %s level %d %d hp%s" % [
c["name"], int(c["level"]), int(c["max_hp"]), suffix] c["name"], int(c["level"]), int(c["max_hp"]),
label.add_theme_color_override("font_color", " <- playing" if playing else ""]
Color(0.85, 0.9, 1.0) if alive else Color(0.55, 0.4, 0.42)) label.add_theme_color_override("font_color", Color(0.85, 0.9, 1.0))
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(label) row.add_child(label)
var play := Button.new() var play := Button.new()
play.text = "Play" play.text = "Play"
# A dead character is listed as a record, never as an option. play.disabled = playing
play.disabled = not alive or String(c["id"]) == _selected
play.pressed.connect(func() -> void: select_requested.emit(String(c["id"]))) play.pressed.connect(func() -> void: select_requested.emit(String(c["id"])))
row.add_child(play) row.add_child(play)
return row return row
+7 -5
View File
@@ -94,7 +94,8 @@ func _status_text() -> String:
var who := client.current_character() var who := client.current_character()
var name_part := "" var name_part := ""
if not who.is_empty(): 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" % [ return "%s%s instance %d hp %d/%d %d fps" % [
name_part, where, client.instance_id, client.my_hp, client.my_max_hp, name_part, where, client.instance_id, client.my_hp, client.my_max_hp,
Engine.get_frames_per_second()] 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 ## A thin bar under health: progress toward the next level, and the level
## itself. Drawn from the server's numbers, never recomputed locally. ## itself. Drawn from the server's numbers, never recomputed locally.
func _draw_xp_bar(at: Vector2) -> void: func _draw_xp_bar(at: Vector2) -> void:
var who := client.current_character() if client.current_character().is_empty():
if who.is_empty():
return 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 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) 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, 5.0)), Color(0.1, 0.12, 0.18))
_canvas.draw_rect(Rect2(at, Vector2(BAR_W * progress, 5.0)), tint) _canvas.draw_rect(Rect2(at, Vector2(BAR_W * progress, 5.0)), tint)
+23
View File
@@ -148,3 +148,26 @@ func test_names_are_sanitised() -> void:
var with_control := "ab" + String.chr(7) + String.chr(10) + "cd" var with_control := "ab" + String.chr(7) + String.chr(10) + "cd"
assert_eq(Character.sanitize_name(with_control), "abcd", assert_eq(Character.sanitize_name(with_control), "abcd",
"control characters would let a name break the HUD's layout") "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")
+56 -3
View File
@@ -170,8 +170,7 @@ func test_character_roster_round_trips() -> void:
rng.seed = 5 rng.seed = 5
var a := Character.create("Ada", rng) var a := Character.create("Ada", rng)
a.grant_xp(Progression.total_xp_for_level(4)) a.grant_xp(Progression.total_xp_for_level(4))
var b := Character.create("Departed", rng) var b := Character.create("Second", rng)
b.retire()
var out := NetCodec.decode_characters( var out := NetCodec.decode_characters(
NetCodec.encode_characters([a, b] as Array[Character], a.id)) NetCodec.encode_characters([a, b] as Array[Character], a.id))
assert_eq(String(out["selected"]), 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(), assert_eq(int(chars[0]["max_hp"]), a.max_hp(),
"the roster shows each character's own health ceiling") "the roster shows each character's own health ceiling")
assert_true(chars[0]["active"]) 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 ## 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_eq(int(rec["max_hp"]), Progression.max_hp_for_level(5))
assert_almost_eq((rec["colour"] as Color).b, 0.9, 0.02, assert_almost_eq((rec["colour"] as Color).b, 0.9, 0.02,
"party members are told apart by colour, so it rides the snapshot") "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")
+15
View File
@@ -82,6 +82,10 @@ func _physics_process(_delta: float) -> void:
_srv._dispatch_events(inst) _srv._dispatch_events(inst)
var after: int = _srv.store.get_character(_account, _character.id).total_xp var after: int = _srv.store.get_character(_account, _character.id).total_xp
_check(after > before, "killing an enemy awards experience (%d -> %d)" % [before, after]) _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: elif _step == 80:
# Enough experience to cross a level boundary, and check health follows. # 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(), _check(_srv.store.active_characters(_account).is_empty(),
"no living characters remain") "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: elif _step == 120:
# With nothing alive, the player must be left with no character rather # With nothing alive, the player must be left with no character rather
# than silently resurrected into the dead one. # than silently resurrected into the dead one.