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
+13 -15
View File
@@ -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
+7 -5
View File
@@ -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)