Every colder message here checks get_available_bytes() before it reads, and has a test named for it. Snapshots and events -- twenty and sixty times a second, the two that actually matter -- did neither. A snapshot's counts are a u8 and two u16s read straight off the wire and then looped without a bounds check. A garbage enemy count is up to 65535 dictionaries built out of an empty buffer, and a garbage tick is fed directly to the client's clock estimate and its input-lead resync. The channel is unreliable and ENet's checksum is off, so a short snapshot is a network event, not only an attack. It now answers valid:false and the client drops it -- one dropped snapshot costs 50ms of interpolation. Events were worse in kind. The decoder read a type byte and matched on it, so a type it did not recognise consumed one byte and carried on, reading every field after it at the wrong offset: one stray byte became a stream of bullets spawned at invented positions, which the client then applied. Lengths are now declared up front and an unknown type stops the stream. The prefix is kept, because the channel is reliable and ordered and throwing the packet away would strand every despawn in it. Both size tables are pinned to their encoders by tests that encode one of each record and compare, so they cannot drift -- and a new SimEvent.Type with no declared length fails a test rather than shipping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
654 lines
27 KiB
GDScript
654 lines
27 KiB
GDScript
extends GutTest
|
|
## Codec round-trips. A field that encodes but decodes wrong shows up as a
|
|
## mysterious gameplay bug three layers away, so it is pinned down here.
|
|
|
|
var world: SimWorld
|
|
|
|
|
|
func before_each() -> void:
|
|
world = SimWorld.new(7)
|
|
var p := world.add_player(42, "ada")
|
|
p.pos = Vector2(120.5, -64.25)
|
|
p.aim = 1.25
|
|
p.hp = 73
|
|
p.escape_ticks = 30
|
|
p.last_input_tick = 555
|
|
world.spawn_enemy(Content.turret(), Vector2(-200.0, 100.0))
|
|
world.spawn_boss(Content.warden())
|
|
world.tick = 9001
|
|
|
|
|
|
func test_snapshot_round_trips_player_state() -> void:
|
|
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
|
|
assert_eq(int(snap["tick"]), 9001)
|
|
var rec: Dictionary = snap["players"][0]
|
|
assert_eq(int(rec["peer"]), 42)
|
|
assert_almost_eq((rec["pos"] as Vector2).x, 120.5, 0.01)
|
|
assert_almost_eq((rec["pos"] as Vector2).y, -64.25, 0.01)
|
|
assert_almost_eq(float(rec["aim"]), 1.25, 0.001)
|
|
assert_eq(int(rec["hp"]), 73)
|
|
assert_eq(int(rec["last_input_tick"]), 555)
|
|
assert_true((int(rec["flags"]) & Protocol.F_ALIVE) != 0)
|
|
assert_true((int(rec["flags"]) & Protocol.F_ESCAPING) != 0)
|
|
assert_almost_eq(float(rec["escape"]), 30.0 / float(SimConfig.ESCAPE_CHANNEL_TICKS), 0.01)
|
|
|
|
|
|
func test_snapshot_carries_enemy_radius_for_late_joiners() -> void:
|
|
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
|
|
var e: Dictionary = snap["enemies"][0]
|
|
assert_almost_eq(float(e["radius"]), Content.turret().radius, 0.5)
|
|
assert_eq(int(e["visual"]), Content.turret().visual)
|
|
|
|
|
|
func test_snapshot_carries_the_boss() -> void:
|
|
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
|
|
assert_not_null(snap["boss"])
|
|
assert_eq(int(snap["boss"]["hp"]), Content.warden().max_hp)
|
|
|
|
|
|
func test_dead_enemies_are_not_sent() -> void:
|
|
for e in world.enemies.values():
|
|
e.alive = false
|
|
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
|
|
assert_eq((snap["enemies"] as Array).size(), 0)
|
|
|
|
|
|
func test_event_round_trip_preserves_bullet_spawn() -> void:
|
|
var ev := {
|
|
"t": SimEvent.Type.BULLET_SPAWN, "uid": 8123,
|
|
"pos": Vector2(10.0, -20.0), "vel": Vector2(0.0, 150.0),
|
|
"r": 7.5, "life": 300, "kind": SimConfig.KIND_NEEDLE,
|
|
"team": SimConfig.TEAM_ENEMY, "accel": 12.0, "turn": 0.02,
|
|
}
|
|
var packet := NetCodec.decode_events(NetCodec.encode_events(77, [ev]))
|
|
assert_eq(int(packet["tick"]), 77)
|
|
var out: Dictionary = packet["events"][0]
|
|
assert_eq(int(out["uid"]), 8123)
|
|
assert_eq(out["pos"], Vector2(10.0, -20.0))
|
|
assert_eq(out["vel"], Vector2(0.0, 150.0))
|
|
assert_almost_eq(float(out["r"]), 7.5, 0.001)
|
|
assert_eq(int(out["life"]), 300)
|
|
assert_almost_eq(float(out["accel"]), 12.0, 0.001)
|
|
assert_almost_eq(float(out["turn"]), 0.02, 0.0001)
|
|
|
|
|
|
func test_server_only_events_never_reach_the_wire() -> void:
|
|
var events: Array[Dictionary] = [
|
|
{"t": SimEvent.Type.PORTAL_USED, "peer": 3},
|
|
{"t": SimEvent.Type.ESCAPE_COMPLETED, "peer": 3},
|
|
{"t": SimEvent.Type.PLAYER_DIED, "peer": 3},
|
|
]
|
|
var packet := NetCodec.decode_events(NetCodec.encode_events(1, events))
|
|
assert_eq((packet["events"] as Array).size(), 1,
|
|
"instance transfers are the server's business, not the client's")
|
|
assert_eq(int(packet["events"][0]["t"]), SimEvent.Type.PLAYER_DIED)
|
|
|
|
|
|
func test_input_round_trip() -> void:
|
|
var frames: Array[InputFrame] = [
|
|
InputFrame.make(10, Vector2(1, 0), 0.0, InputFrame.BTN_FIRE),
|
|
InputFrame.make(11, Vector2(0, 1), 1.0, 0),
|
|
]
|
|
var out := NetCodec.decode_inputs(NetCodec.encode_inputs(frames))
|
|
assert_eq(out.size(), 2)
|
|
assert_eq(out[0].tick, 10)
|
|
assert_eq(out[1].tick, 11)
|
|
|
|
|
|
func test_truncated_input_packet_is_rejected_not_read_past() -> void:
|
|
var frames: Array[InputFrame] = [InputFrame.make(10, Vector2.ONE, 0.0, 1)]
|
|
var data := NetCodec.encode_inputs(frames)
|
|
# Claim three frames but send one. A hostile client will try exactly this.
|
|
data[0] = 3
|
|
assert_eq(NetCodec.decode_inputs(data).size(), 0)
|
|
|
|
|
|
func test_empty_input_packet_is_safe() -> void:
|
|
assert_eq(NetCodec.decode_inputs(PackedByteArray()).size(), 0)
|
|
|
|
|
|
# --- Roster -----------------------------------------------------------------
|
|
|
|
func test_roster_round_trips() -> void:
|
|
var entries: Array[Dictionary] = [
|
|
{"peer": 7, "name": "ada", "kind": Protocol.InstanceKind.LOBBY,
|
|
"instance": 1, "alive": true},
|
|
{"peer": 9, "name": "grace", "kind": Protocol.InstanceKind.DUNGEON,
|
|
"instance": 4, "alive": false},
|
|
]
|
|
var out := NetCodec.decode_roster(NetCodec.encode_roster(entries))
|
|
assert_eq(out.size(), 2)
|
|
assert_eq(int(out[0]["peer"]), 7)
|
|
assert_eq(String(out[0]["name"]), "ada")
|
|
assert_eq(int(out[0]["kind"]), Protocol.InstanceKind.LOBBY)
|
|
assert_true(out[0]["alive"])
|
|
assert_eq(String(out[1]["name"]), "grace")
|
|
assert_eq(int(out[1]["kind"]), Protocol.InstanceKind.DUNGEON,
|
|
"the hub needs to know someone is already inside")
|
|
assert_eq(int(out[1]["instance"]), 4)
|
|
assert_false(out[1]["alive"])
|
|
|
|
|
|
func test_roster_survives_unicode_names() -> void:
|
|
var entries: Array[Dictionary] = [
|
|
{"peer": 1, "name": "ゆき", "kind": 0, "instance": 1, "alive": true}]
|
|
var out := NetCodec.decode_roster(NetCodec.encode_roster(entries))
|
|
assert_eq(String(out[0]["name"]), "ゆき")
|
|
|
|
|
|
func test_empty_roster_is_safe() -> void:
|
|
assert_eq(NetCodec.decode_roster(PackedByteArray()).size(), 0)
|
|
assert_eq(NetCodec.decode_roster(NetCodec.encode_roster([] as Array[Dictionary])).size(), 0)
|
|
|
|
|
|
# --- Snapshot extras --------------------------------------------------------
|
|
|
|
func test_snapshot_carries_spawn_grace_and_linkdead_flags() -> void:
|
|
var p: SimPlayer = world.players[42]
|
|
p.spawn_grace = 30
|
|
p.linkdead = true
|
|
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
|
|
var flags := int(snap["players"][0]["flags"])
|
|
assert_true((flags & Protocol.F_SPAWN_GRACE) != 0)
|
|
assert_true((flags & Protocol.F_LINKDEAD) != 0)
|
|
|
|
|
|
func test_cleared_countdown_round_trips() -> void:
|
|
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world, 17))
|
|
assert_eq(int(snap["cleared_countdown"]), 17)
|
|
|
|
|
|
func test_countdown_defaults_to_not_applicable() -> void:
|
|
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
|
|
assert_eq(int(snap["cleared_countdown"]), Protocol.COUNTDOWN_NONE)
|
|
|
|
|
|
# --- Characters -------------------------------------------------------------
|
|
|
|
func test_character_roster_round_trips() -> void:
|
|
var rng := RandomNumberGenerator.new()
|
|
rng.seed = 5
|
|
var a := Character.create("Ada", rng)
|
|
a.grant_xp(Progression.total_xp_for_level(4))
|
|
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)
|
|
var chars: Array = out["characters"]
|
|
assert_eq(chars.size(), 2)
|
|
assert_eq(String(chars[0]["name"]), "Ada")
|
|
assert_eq(int(chars[0]["level"]), a.level)
|
|
assert_eq(int(chars[0]["max_hp"]), a.max_hp(),
|
|
"the roster shows each character's own health ceiling")
|
|
assert_true(chars[0]["active"])
|
|
|
|
|
|
## Colour is a character's only identity until cosmetics exist, so it has to
|
|
## survive the wire exactly rather than approximately.
|
|
func test_character_colour_survives_the_wire() -> void:
|
|
var rng := RandomNumberGenerator.new()
|
|
rng.seed = 11
|
|
var c := Character.create("Hue", rng)
|
|
var out := NetCodec.decode_characters(
|
|
NetCodec.encode_characters([c] as Array[Character], ""))
|
|
var got: Color = out["characters"][0]["colour"]
|
|
assert_eq(got.to_rgba32(), c.colour.to_rgba32())
|
|
|
|
|
|
func test_an_empty_character_list_is_safe() -> void:
|
|
var out := NetCodec.decode_characters(
|
|
NetCodec.encode_characters([] as Array[Character], ""))
|
|
assert_eq((out["characters"] as Array).size(), 0)
|
|
assert_eq(NetCodec.decode_characters(PackedByteArray())["characters"].size(), 0)
|
|
|
|
|
|
## Max health is per-character now, so a snapshot that assumed a constant would
|
|
## draw every levelled player's bar wrong.
|
|
func test_the_snapshot_carries_each_players_own_max_health() -> void:
|
|
var p: SimPlayer = world.players[42]
|
|
p.level = 5
|
|
p.max_hp = Progression.max_hp_for_level(5)
|
|
p.hp = 42
|
|
p.colour = Color(0.2, 0.6, 0.9)
|
|
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
|
|
var rec: Dictionary = snap["players"][0]
|
|
assert_eq(int(rec["hp"]), 42)
|
|
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")
|
|
|
|
|
|
# --- Inventory and loot -----------------------------------------------------
|
|
|
|
func test_the_snapshot_carries_the_observers_own_inventory() -> void:
|
|
var p: SimPlayer = world.players[42]
|
|
p.add_item(Items.HEALTH_POTION)
|
|
p.inventory[2] = Items.WARDENS_RATION
|
|
var snap := NetCodec.decode_snapshot(
|
|
NetCodec.encode_snapshot(world, Protocol.COUNTDOWN_NONE, 42))
|
|
var inv: Array = snap["inventory"]
|
|
assert_eq(inv.size(), SimConfig.INVENTORY_SLOTS)
|
|
assert_eq(Items.by_index(int(inv[0])), Items.HEALTH_POTION)
|
|
assert_eq(Items.by_index(int(inv[1])), Items.NONE)
|
|
assert_eq(Items.by_index(int(inv[2])), Items.WARDENS_RATION)
|
|
|
|
|
|
## Nobody needs to know what a party member is carrying, and the cheapest way to
|
|
## keep that true is to never put it on the wire.
|
|
func test_the_snapshot_never_carries_another_players_inventory() -> void:
|
|
var them := world.add_player(43, "them")
|
|
them.pos = Vector2(120.0, -64.0)
|
|
them.add_item(Items.HEALTH_POTION)
|
|
var snap := NetCodec.decode_snapshot(
|
|
NetCodec.encode_snapshot(world, Protocol.COUNTDOWN_NONE, 42))
|
|
assert_eq((snap["players"] as Array).size(), 2, "they should still be visible")
|
|
for inv_index in snap["inventory"]:
|
|
assert_eq(int(inv_index), 0, "but their bag must not be in this packet")
|
|
|
|
|
|
func test_ground_loot_round_trips() -> void:
|
|
var l := world.spawn_loot(Items.HEALTH_POTION, Vector2(64.0, -32.0))
|
|
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
|
|
var got: Dictionary = snap["loot"][0]
|
|
assert_eq(int(got["id"]), l.id)
|
|
assert_almost_eq((got["pos"] as Vector2).x, 64.0, 0.01)
|
|
assert_almost_eq((got["pos"] as Vector2).y, -32.0, 0.01)
|
|
assert_eq(Items.by_index(int(got["item"])), Items.HEALTH_POTION)
|
|
|
|
|
|
## The wire is where player-instanced loot is actually enforced. A peer is never
|
|
## told another player's copy exists, so a modified client has nothing to
|
|
## reveal -- this is an interest-management rule, not a UI convention.
|
|
func test_another_players_instanced_loot_is_not_on_the_wire() -> void:
|
|
var p: SimPlayer = world.players[42]
|
|
world.spawn_loot(Items.WARDENS_RATION, p.pos + Vector2(10.0, 0.0), 43)
|
|
world.spawn_loot(Items.WARDENS_RATION, p.pos + Vector2(20.0, 0.0), 42)
|
|
world.spawn_loot(Items.HEALTH_POTION, p.pos + Vector2(30.0, 0.0))
|
|
var snap := NetCodec.decode_snapshot(
|
|
NetCodec.encode_snapshot(world, Protocol.COUNTDOWN_NONE, 42))
|
|
assert_eq((snap["loot"] as Array).size(), 2,
|
|
"my ration and the shared potion, never theirs")
|
|
|
|
|
|
func test_distant_loot_is_not_sent() -> void:
|
|
var p: SimPlayer = world.players[42]
|
|
world.spawn_loot(Items.HEALTH_POTION,
|
|
p.pos + Vector2(SimConfig.ACTOR_INTEREST_RADIUS + 200.0, 0.0))
|
|
var snap := NetCodec.decode_snapshot(
|
|
NetCodec.encode_snapshot(world, Protocol.COUNTDOWN_NONE, 42))
|
|
assert_eq((snap["loot"] as Array).size(), 0)
|
|
|
|
|
|
func test_item_events_round_trip() -> void:
|
|
var events: Array[Dictionary] = [
|
|
{"t": SimEvent.Type.ITEM_PICKED_UP, "peer": 42, "item": Items.HEALTH_POTION},
|
|
{"t": SimEvent.Type.ITEM_USED, "peer": 42, "item": Items.WARDENS_RATION},
|
|
{"t": SimEvent.Type.ITEM_DROPPED, "peer": 7, "item": Items.HEALTH_POTION},
|
|
]
|
|
var out: Array = NetCodec.decode_events(NetCodec.encode_events(1, events))["events"]
|
|
assert_eq(out.size(), 3)
|
|
assert_eq(int(out[0]["t"]), SimEvent.Type.ITEM_PICKED_UP)
|
|
assert_eq(out[0]["item"], Items.HEALTH_POTION)
|
|
assert_eq(int(out[1]["peer"]), 42)
|
|
assert_eq(out[1]["item"], Items.WARDENS_RATION)
|
|
assert_eq(int(out[2]["peer"]), 7)
|
|
assert_eq(out[2]["item"], Items.HEALTH_POTION)
|
|
|
|
|
|
## The events after the item ones must still decode. A fixed-width event whose
|
|
## body length is wrong desynchronises the whole rest of the packet, and that
|
|
## shows up as unrelated nonsense rather than as a decode error.
|
|
func test_events_after_an_item_event_still_decode() -> void:
|
|
var events: Array[Dictionary] = [
|
|
{"t": SimEvent.Type.ITEM_USED, "peer": 42, "item": Items.HEALTH_POTION},
|
|
{"t": SimEvent.Type.ENEMY_HIT, "id": 99, "dmg": 12, "hp": 400},
|
|
{"t": SimEvent.Type.PLAYER_DIED, "peer": 42},
|
|
]
|
|
var out: Array = NetCodec.decode_events(NetCodec.encode_events(1, events))["events"]
|
|
assert_eq(out.size(), 3)
|
|
assert_eq(int(out[1]["id"]), 99)
|
|
assert_eq(int(out[1]["hp"]), 400)
|
|
assert_eq(int(out[2]["t"]), SimEvent.Type.PLAYER_DIED)
|
|
|
|
|
|
func test_the_character_roster_carries_inventories() -> void:
|
|
var rng := RandomNumberGenerator.new()
|
|
rng.seed = 31
|
|
var c := Character.create("Packrat", rng)
|
|
c.inventory[0] = Items.HEALTH_POTION
|
|
c.inventory[3] = Items.WARDENS_RATION
|
|
var out := NetCodec.decode_characters(
|
|
NetCodec.encode_characters([c] as Array[Character], c.id))
|
|
var inv: Array = out["characters"][0]["inventory"]
|
|
assert_eq(Items.by_index(int(inv[0])), Items.HEALTH_POTION)
|
|
assert_eq(Items.by_index(int(inv[3])), Items.WARDENS_RATION)
|
|
|
|
|
|
## The snapshot is parsed positionally from the front, so a packet with no
|
|
## players, enemies or boss still has to land on the right byte for the
|
|
## inventory and loot that follow.
|
|
func test_an_empty_world_snapshot_still_decodes_its_tail() -> void:
|
|
var empty := SimWorld.new(1)
|
|
empty.spawn_loot(Items.HEALTH_POTION, Vector2(5.0, 5.0))
|
|
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(empty))
|
|
assert_eq((snap["players"] as Array).size(), 0)
|
|
assert_eq((snap["inventory"] as Array).size(), SimConfig.INVENTORY_SLOTS)
|
|
assert_eq((snap["loot"] as Array).size(), 1)
|
|
|
|
|
|
func test_an_input_frame_carries_its_slot_over_the_wire() -> void:
|
|
var frames: Array[InputFrame] = [
|
|
InputFrame.make(10, Vector2.ZERO, 0.0, InputFrame.BTN_USE, 3),
|
|
InputFrame.make(11, Vector2.ZERO, 0.0, InputFrame.BTN_DROP, 1),
|
|
]
|
|
var out := NetCodec.decode_inputs(NetCodec.encode_inputs(frames))
|
|
assert_eq(out.size(), 2)
|
|
assert_eq(out[0].slot, 3)
|
|
assert_true(out[0].pressed(InputFrame.BTN_USE))
|
|
assert_eq(out[1].slot, 1)
|
|
assert_true(out[1].pressed(InputFrame.BTN_DROP))
|
|
|
|
|
|
# --- Portals ----------------------------------------------------------------
|
|
|
|
func test_portals_round_trip() -> void:
|
|
var portals: Array[SimPortal] = [
|
|
SimPortal.make(Vector2(-160.0, 48.0), Dungeons.STANDARD),
|
|
SimPortal.make(Vector2(160.0, 48.0), Dungeons.PROVING),
|
|
]
|
|
var out := NetCodec.decode_portals(NetCodec.encode_portals(portals))
|
|
assert_eq(out.size(), 2)
|
|
assert_almost_eq((out[0]["pos"] as Vector2).x, -160.0, 0.01)
|
|
assert_eq(out[0]["dungeon"], Dungeons.STANDARD)
|
|
assert_almost_eq((out[1]["pos"] as Vector2).x, 160.0, 0.01)
|
|
assert_eq(out[1]["dungeon"], Dungeons.PROVING)
|
|
|
|
|
|
func test_a_truncated_portal_packet_does_not_invent_an_entrance() -> void:
|
|
var portals: Array[SimPortal] = [
|
|
SimPortal.make(Vector2(-160.0, 48.0), Dungeons.STANDARD),
|
|
SimPortal.make(Vector2(160.0, 48.0), Dungeons.PROVING),
|
|
]
|
|
var full := NetCodec.encode_portals(portals)
|
|
for cut in range(1, full.size()):
|
|
var out := NetCodec.decode_portals(full.slice(0, cut))
|
|
assert_lte(out.size(), 2,
|
|
"a portal list cut at %d bytes must degrade, not grow" % cut)
|
|
assert_eq(NetCodec.decode_portals(full).size(), 2)
|
|
|
|
|
|
func test_an_empty_portal_list_is_safe() -> void:
|
|
assert_eq(NetCodec.decode_portals(
|
|
NetCodec.encode_portals([] as Array[SimPortal])).size(), 0)
|
|
assert_eq(NetCodec.decode_portals(PackedByteArray()).size(), 0)
|
|
|
|
|
|
# --- Upgrade state ----------------------------------------------------------
|
|
|
|
func test_upgrade_state_round_trips() -> void:
|
|
var offer: Array[StringName] = [Upgrades.SNIPER, Upgrades.POISON, Upgrades.ERASER]
|
|
var taken: Array[StringName] = [Upgrades.SPREAD, Upgrades.SPREAD, Upgrades.GLASS_CANNON]
|
|
var out := NetCodec.decode_upgrade_state(
|
|
NetCodec.encode_upgrade_state(2, offer, taken))
|
|
assert_eq(int(out["pending"]), 2)
|
|
assert_eq(out["offer"], offer)
|
|
assert_eq(out["taken"], taken, "duplicates are meaningful and must survive")
|
|
|
|
|
|
func test_an_empty_upgrade_state_is_safe() -> void:
|
|
var none: Array[StringName] = []
|
|
var out := NetCodec.decode_upgrade_state(
|
|
NetCodec.encode_upgrade_state(0, none, none))
|
|
assert_eq(int(out["pending"]), 0)
|
|
assert_eq((out["offer"] as Array).size(), 0)
|
|
assert_eq((out["taken"] as Array).size(), 0)
|
|
assert_eq(int(NetCodec.decode_upgrade_state(PackedByteArray())["pending"]), 0)
|
|
|
|
|
|
func test_a_truncated_upgrade_packet_does_not_read_past_the_end() -> void:
|
|
var offer: Array[StringName] = [Upgrades.SNIPER, Upgrades.POISON]
|
|
var taken: Array[StringName] = [Upgrades.SPREAD, Upgrades.DOUBLESHOT, Upgrades.ERASER]
|
|
var full := NetCodec.encode_upgrade_state(1, offer, taken)
|
|
for cut in range(1, full.size()):
|
|
var out := NetCodec.decode_upgrade_state(full.slice(0, cut))
|
|
assert_lte((out["offer"] as Array).size(), 2)
|
|
assert_lte((out["taken"] as Array).size(), 3)
|
|
assert_eq((NetCodec.decode_upgrade_state(full)["taken"] as Array).size(), 3)
|
|
|
|
|
|
## An index this build does not know decodes to nothing and is dropped, rather
|
|
## than becoming whatever upgrade happens to sit at that position.
|
|
func test_an_unknown_upgrade_index_is_dropped_not_guessed() -> void:
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.put_u8(1)
|
|
b.put_u8(2)
|
|
b.put_u8(Upgrades.index_of(Upgrades.SNIPER))
|
|
b.put_u8(200)
|
|
b.put_u16(0)
|
|
var out := NetCodec.decode_upgrade_state(b.data_array)
|
|
assert_eq(out["offer"], [Upgrades.SNIPER] as Array[StringName])
|
|
|
|
|
|
func test_telegraph_events_round_trip() -> void:
|
|
var events: Array[Dictionary] = [
|
|
{"t": SimEvent.Type.TELEGRAPH, "pos": Vector2(-120.5, 64.25),
|
|
"r": 70.0, "ticks": 90},
|
|
]
|
|
var out: Array = NetCodec.decode_events(NetCodec.encode_events(1, events))["events"]
|
|
assert_eq(out.size(), 1)
|
|
assert_almost_eq((out[0]["pos"] as Vector2).x, -120.5, 0.01)
|
|
assert_almost_eq((out[0]["pos"] as Vector2).y, 64.25, 0.01)
|
|
assert_almost_eq(float(out[0]["r"]), 70.0, 0.01)
|
|
assert_eq(int(out[0]["ticks"]), 90)
|
|
|
|
|
|
## A telegraph carries a fixed-width body like every other event. If its length
|
|
## were wrong the rest of the packet would decode as nonsense rather than fail.
|
|
func test_events_after_a_telegraph_still_decode() -> void:
|
|
var events: Array[Dictionary] = [
|
|
{"t": SimEvent.Type.TELEGRAPH, "pos": Vector2(10.0, 20.0), "r": 50.0, "ticks": 60},
|
|
{"t": SimEvent.Type.ENEMY_HIT, "id": 77, "dmg": 130, "hp": 900},
|
|
{"t": SimEvent.Type.BOSS_PHASE, "phase": 2},
|
|
]
|
|
var out: Array = NetCodec.decode_events(NetCodec.encode_events(1, events))["events"]
|
|
assert_eq(out.size(), 3)
|
|
assert_eq(int(out[1]["id"]), 77)
|
|
assert_eq(int(out[1]["hp"]), 900)
|
|
assert_eq(int(out[2]["phase"]), 2)
|
|
|
|
|
|
# --- Truncation on the two hot streams --------------------------------------
|
|
# Every cold message here already refuses to read past the end of its buffer.
|
|
# Snapshots and events -- the two that arrive twenty and sixty times a second --
|
|
# did not, and the snapshot channel is unreliable and unchecksummed, so a short
|
|
# packet is a network event rather than only an attack.
|
|
|
|
func _populated_world() -> SimWorld:
|
|
var w := SimWorld.new(1)
|
|
w.add_player(1, "a")
|
|
w.add_player(2, "b")
|
|
w.spawn_enemy(Content.turret(), Vector2(40.0, 0.0))
|
|
w.spawn_boss(Content.boss(&"warden"))
|
|
w.spawn_loot(Items.HEALTH_POTION, Vector2(10.0, 10.0))
|
|
return w
|
|
|
|
|
|
func test_the_snapshot_record_sizes_match_what_the_encoder_writes() -> void:
|
|
# The guards are arithmetic on these constants, so if they drift from the
|
|
# encoder the decoder starts refusing good packets or accepting short ones.
|
|
var empty := SimWorld.new(1)
|
|
var base := NetCodec.encode_snapshot(empty).size()
|
|
assert_eq(base, NetCodec.SNAPSHOT_HEADER + 1 + 2 + 1 + SimConfig.INVENTORY_SLOTS + 2,
|
|
"an empty world is header + three counts + the boss flag + the bag")
|
|
|
|
var one_player := SimWorld.new(1)
|
|
one_player.add_player(1, "a")
|
|
assert_eq(NetCodec.encode_snapshot(one_player).size() - base,
|
|
NetCodec.SNAPSHOT_PLAYER)
|
|
|
|
var one_enemy := SimWorld.new(1)
|
|
one_enemy.spawn_enemy(Content.turret(), Vector2.ZERO)
|
|
assert_eq(NetCodec.encode_snapshot(one_enemy).size() - base,
|
|
NetCodec.SNAPSHOT_ENEMY)
|
|
|
|
var one_boss := SimWorld.new(1)
|
|
one_boss.spawn_boss(Content.boss(&"warden"))
|
|
assert_eq(NetCodec.encode_snapshot(one_boss).size() - base,
|
|
NetCodec.SNAPSHOT_BOSS)
|
|
|
|
var one_item := SimWorld.new(1)
|
|
one_item.spawn_loot(Items.HEALTH_POTION, Vector2.ZERO)
|
|
assert_eq(NetCodec.encode_snapshot(one_item).size() - base,
|
|
NetCodec.SNAPSHOT_LOOT)
|
|
|
|
|
|
func test_a_truncated_snapshot_is_refused_rather_than_read_past_the_end() -> void:
|
|
var full := NetCodec.encode_snapshot(_populated_world())
|
|
assert_true(NetCodec.decode_snapshot(full)["valid"], "the whole packet is fine")
|
|
# Every prefix short of the whole thing must be refused, not half-applied.
|
|
for cut in range(0, full.size()):
|
|
var snap := NetCodec.decode_snapshot(full.slice(0, cut))
|
|
assert_false(snap["valid"], "a %d-byte prefix must not decode" % cut)
|
|
|
|
|
|
func test_a_snapshot_claiming_more_actors_than_it_carries_is_refused() -> void:
|
|
var data := NetCodec.encode_snapshot(_populated_world())
|
|
# Byte 5 is the player count; claim 255 of them.
|
|
data[NetCodec.SNAPSHOT_HEADER] = 255
|
|
var snap := NetCodec.decode_snapshot(data)
|
|
assert_false(snap["valid"],
|
|
"a hostile count is 255 records read out of a buffer that has none")
|
|
assert_eq((snap["players"] as Array).size(), 0)
|
|
|
|
|
|
func test_an_invalid_snapshot_carries_a_usable_empty_shape() -> void:
|
|
# The caller drops it, but it must not be a shape that errors on the way out.
|
|
var snap := NetCodec.decode_snapshot(PackedByteArray())
|
|
assert_false(snap["valid"])
|
|
assert_eq(snap["tick"], 0)
|
|
assert_eq((snap["players"] as Array).size(), 0)
|
|
assert_null(snap["boss"])
|
|
|
|
|
|
func test_every_event_type_declares_the_length_the_encoder_writes() -> void:
|
|
# The decoder needs a length BEFORE it parses, because an event of unknown
|
|
# size does not spoil one record -- it unaligns every byte after it.
|
|
var samples := {
|
|
SimEvent.Type.BULLET_SPAWN: {"uid": 1, "pos": Vector2.ONE, "vel": Vector2.ONE,
|
|
"r": 4.0, "life": 60, "kind": 0, "team": 0, "accel": 0.0, "turn": 0.0},
|
|
SimEvent.Type.BULLET_DESPAWN: {"uid": 1},
|
|
SimEvent.Type.PLAYER_HIT: {"peer": 1, "dmg": 5, "hp": 10},
|
|
SimEvent.Type.PLAYER_DIED: {"peer": 1},
|
|
SimEvent.Type.ESCAPE_STARTED: {"peer": 1},
|
|
SimEvent.Type.ESCAPE_CANCELLED: {"peer": 1},
|
|
SimEvent.Type.PLAYER_FIRED: {"peer": 1},
|
|
SimEvent.Type.PLAYER_RESPAWNED: {"peer": 1, "pos": Vector2.ONE},
|
|
SimEvent.Type.ITEM_PICKED_UP: {"peer": 1, "item": Items.HEALTH_POTION},
|
|
SimEvent.Type.ITEM_USED: {"peer": 1, "item": Items.HEALTH_POTION},
|
|
SimEvent.Type.ITEM_DROPPED: {"peer": 1, "item": Items.HEALTH_POTION},
|
|
SimEvent.Type.ENEMY_HIT: {"id": 1, "dmg": 5, "hp": 10},
|
|
SimEvent.Type.ENEMY_DIED: {"id": 1},
|
|
SimEvent.Type.BOSS_PHASE: {"phase": 1},
|
|
SimEvent.Type.BOSS_DIED: {},
|
|
SimEvent.Type.TELEGRAPH: {"pos": Vector2.ONE, "r": 8.0, "ticks": 30},
|
|
}
|
|
var header := 6 # u32 tick + u16 count
|
|
for t: int in SimEvent.Type.values():
|
|
if NetCodec.SERVER_ONLY.has(t):
|
|
assert_eq(NetCodec.event_payload_size(t), -1,
|
|
"a server-only event is never on the wire, so it has no length")
|
|
continue
|
|
assert_true(samples.has(t),
|
|
"SimEvent.Type %d is new: give it a sample and a payload size" % t)
|
|
var ev: Dictionary = (samples[t] as Dictionary).duplicate()
|
|
ev["t"] = t
|
|
var wrote := NetCodec.encode_events(0, [ev] as Array[Dictionary]).size()
|
|
assert_eq(wrote - header - 1, NetCodec.event_payload_size(t),
|
|
"declared length for SimEvent.Type %d does not match the encoder" % t)
|
|
|
|
|
|
func test_a_truncated_event_packet_stops_instead_of_inventing_bullets() -> void:
|
|
var evs: Array[Dictionary] = [
|
|
{"t": SimEvent.Type.BULLET_SPAWN, "uid": 7, "pos": Vector2(1.0, 2.0),
|
|
"vel": Vector2(3.0, 4.0), "r": 4.0, "life": 60, "kind": 0, "team": 1,
|
|
"accel": 0.0, "turn": 0.0},
|
|
{"t": SimEvent.Type.ENEMY_DIED, "id": 9},
|
|
]
|
|
var full := NetCodec.encode_events(11, evs)
|
|
assert_eq((NetCodec.decode_events(full)["events"] as Array).size(), 2)
|
|
# Lop off the last event's payload: the first must still arrive, the second
|
|
# must not be conjured from whatever is left.
|
|
var short := full.slice(0, full.size() - 2)
|
|
var got: Array = NetCodec.decode_events(short)["events"]
|
|
assert_eq(got.size(), 1, "the sound prefix is kept; the torn record is not")
|
|
assert_eq(int(got[0]["t"]), SimEvent.Type.BULLET_SPAWN)
|
|
|
|
|
|
func test_an_unknown_event_type_stops_the_stream_rather_than_desyncing_it() -> void:
|
|
var evs: Array[Dictionary] = [{"t": SimEvent.Type.ENEMY_DIED, "id": 9}]
|
|
var data := NetCodec.encode_events(3, evs)
|
|
# Claim there are two events and give the second an unparseable type.
|
|
data[4] = 2
|
|
data.append(200)
|
|
data.append_array([1, 2, 3, 4, 5, 6, 7, 8])
|
|
var got: Array = NetCodec.decode_events(data)["events"]
|
|
assert_eq(got.size(), 1,
|
|
"an event whose length is unknown unaligns everything after it, " +
|
|
"so the only safe answer is to stop")
|