ci / verify (push) Successful in 52s
Health survives a run now, so a character sitting on the select screen can be nearly dead -- and the screen was still printing only the ceiling. "level 11, 2000 hp" for a character on 160 is a lie told at the exact moment it matters most, because the next thing that happens is the player walks it into a dungeon under permadeath. The roster message carries current hit points alongside the maximum, and the screen shows a fraction only when it means something: a healthy character still reads "1300 hp" rather than "1300/1300". The field is inserted mid-record, which shifts every field after it, so the protocol version goes to 10 -- a mismatched client would read the active flag out of the middle of a health value. tools/screenshot.gd had no capture for this screen, which is how a UI change to it could have shipped unlooked-at. It now shows a made-up roster of three characters -- whole, badly hurt, nearly dead -- for the same reason the upgrade screen is fed a made-up offer: the point is the layout, not a dungeon run. Getting there needs the player walked off the quartermaster first, or the game scene re-opens the upgrade screen over the top of it every frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
681 lines
28 KiB
GDScript
681 lines
28 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")
|
|
|
|
|
|
func test_the_roster_carries_current_health_as_well_as_the_ceiling() -> void:
|
|
# Health survives a run now, so a character on the select screen can be
|
|
# badly hurt. Sending only the maximum would advertise it as whole, which
|
|
# under permadeath is the worst thing on that screen to be wrong about.
|
|
var rng := RandomNumberGenerator.new()
|
|
var whole := Character.create("Whole", rng)
|
|
var hurt := Character.create("Hurt", rng)
|
|
hurt.hp = 42
|
|
var got: Array = NetCodec.decode_characters(
|
|
NetCodec.encode_characters([whole, hurt] as Array[Character], ""))["characters"]
|
|
assert_eq(got.size(), 2)
|
|
assert_eq(int(got[0]["hp"]), int(got[0]["max_hp"]),
|
|
"a character that has never been hurt reads as full")
|
|
assert_eq(int(got[1]["hp"]), 42)
|
|
assert_gt(int(got[1]["max_hp"]), 42, "and the ceiling is still its own number")
|
|
|
|
|
|
func test_roster_health_is_never_sent_as_zero() -> void:
|
|
# A zero on this screen reads as a dead character the player is being
|
|
# offered, which cannot happen: the roster is living characters only.
|
|
var c := Character.create("Edge", RandomNumberGenerator.new())
|
|
c.hp = 0
|
|
var got: Array = NetCodec.decode_characters(
|
|
NetCodec.encode_characters([c] as Array[Character], ""))["characters"]
|
|
assert_gte(int(got[0]["hp"]), 1)
|