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:
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user