1dc1952a3c
ci / verify (push) Successful in 45s
(1) Bullets appeared to trail the ship. Two independent causes, measured with
the new tools/diag_prediction.gd rather than guessed at:
- ServerRuntime ticked before ClientRuntime, so input sampled on frame N was
not consumed until frame N+1, leaving the drawn ship a constant one tick
(4.00px at 240 u/s) ahead of the authoritative one that bullets spawn from.
ClientRuntime now sets process_physics_priority = -10. Gap on a listen
server: 4.00px -> 0.10px mean, 0.30px worst.
- PLAYER_MUZZLE_OFFSET was PLAYER_RADIUS + 6 = 12px against a 13px drawn
ship, so bullets were born inside the sprite. Regression from the previous
commit's hitbox shrink; it now derives from PLAYER_VISUAL_RADIUS.
(2) No more timed respawn. A downed player stays down until they ask for the
hub (E), which is an ordinary input -- the server has no "revive me" message.
(3) Escape channel 3s -> 1s, and damage no longer cancels it. An interruptible
channel makes killing the process strictly better than using the button, so a
dropped connection now runs the same channel: the player stays in the world as
linkdead, still killable, and is only released once it completes. Instances
refuse to close while a linkdead body is resolving, or a solo drop would delete
it on the next tick and hand the exploit straight back.
(4) Escape opens an in-game menu: return to hub (routed through the same held-
escape channel, not a new message), disconnect, quit.
(5) Server pushes a roster so the hub shows who is online and which dungeon
they are in. Entering a dungeon grants 2s arrival protection -- invulnerable
AND weapons-cold, since invulnerability alone would make the spawn a free
firing position -- flagged in the snapshot and drawn on every protected ship.
(6) Cleared dungeons hold the party 30s (was 5s) with a visible countdown.
(7) The hub's grey circle was a 100k-HP target dummy that read as scenery. Now
drawn as a bullseye so its purpose is legible.
Protocol version 1 -> 2. 91 tests (was 78); smoke.sh gains a bot that is
SIGKILLed mid-dungeon to prove the disconnect path end to end. check.sh,
test.sh and smoke.sh all pass.
164 lines
6.0 KiB
GDScript
164 lines
6.0 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)
|