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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+43
-8
@@ -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,
|
||||
|
||||
@@ -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 --
|
||||
|
||||
@@ -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)
|
||||
|
||||
+13
-15
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user