extends GutTest ## The load-bearing assumption of the whole netcode: a client that only receives ## bullet spawn and despawn events can reproduce the server's bullet field ## exactly, by running the same integration. ## ## That is what lets the server send ~30 bytes once per bullet instead of a ## position for every bullet every snapshot. If this test ever fails, bullets on ## screen no longer match the bullets that can kill you, and the fix is not to ## loosen the tolerance -- it is to stop deriving anything the server did not ## send. var server: SimWorld var replica: SimWorld func before_each() -> void: server = SimWorld.new(99) server.authoritative = true replica = SimWorld.new(99) replica.authoritative = false func _sync_one_tick() -> Array[Dictionary]: server.step() var events := server.drain_events() # Order matters: the replica first advances the bullets it already knows # about to the server's new tick, then adopts the spawns, which arrive at # their post-step positions. replica.step() for ev in events: replica.apply_event(ev) return events func _mismatches() -> Array[String]: var out: Array[String] = [] var server_uids := {} for i in server.pool.high_water: if server.pool.alive[i] == 0: continue var id: int = server.pool.uid[i] server_uids[id] = true var j := replica.pool.find_by_uid(id) if j < 0: out.append("bullet %d missing from replica" % id) continue var d: float = (server.pool.pos[i] as Vector2).distance_to(replica.pool.pos[j]) if d > 0.01: out.append("bullet %d off by %.4f px" % [id, d]) for i in replica.pool.high_water: if replica.pool.alive[i] == 1 and not server_uids.has(replica.pool.uid[i]): out.append("bullet %d lingering on replica" % replica.pool.uid[i]) return out func test_boss_pattern_reproduces_exactly_on_the_replica() -> void: server.add_player(1, "tester") server.players[1].pos = Vector2(0.0, 220.0) server.spawn_boss(Content.warden()) for _i in 900: _sync_one_tick() assert_gt(server.pool.live_count, 30, "the fight should be producing bullets") assert_eq(_mismatches(), [] as Array[String]) func test_enemy_patterns_reproduce_exactly_on_the_replica() -> void: server.add_player(1, "tester") server.players[1].pos = Vector2(80.0, 200.0) server.spawn_enemy(Content.turret(), Vector2(-200.0, -100.0)) server.spawn_enemy(Content.turret(), Vector2(200.0, -100.0), 40) server.spawn_enemy(Content.drifter(), Vector2(0.0, -200.0)) for _i in 900: _sync_one_tick() assert_gt(server.pool.live_count, 5) assert_eq(_mismatches(), [] as Array[String]) func test_bullets_killed_by_a_hit_are_removed_from_the_replica() -> void: # A stationary player soaking fire produces plenty of early despawns, which # are the one thing the replica cannot derive on its own. server.add_player(1, "tester") # Just below the boss, inside the ring pattern rather than at its centre. server.players[1].pos = Vector2(0.0, 20.0) server.players[1].iframes = 0 server.spawn_boss(Content.warden()) var hits := 0 for _i in 900: for ev in _sync_one_tick(): if int(ev["t"]) == SimEvent.Type.PLAYER_HIT: hits += 1 assert_gt(hits, 0, "the test needs the player to actually get hit") assert_eq(_mismatches(), [] as Array[String]) func test_the_replica_never_invents_a_bullet() -> void: # Same enemies, but the replica is told nothing at all. It must stay empty: # emitters and AI are server-side only. replica.spawn_enemy(Content.turret(), Vector2.ZERO) replica.spawn_boss(Content.warden()) for _i in 600: replica.step() assert_eq(replica.pool.live_count, 0) func test_full_event_stream_survives_the_wire_format() -> void: # Same as the parity test, but every event is encoded and decoded on the way, # so a codec bug cannot hide behind in-process dictionaries. server.add_player(1, "tester") server.players[1].pos = Vector2(0.0, 220.0) server.spawn_boss(Content.warden()) for _i in 600: server.step() var packet := NetCodec.decode_events( NetCodec.encode_events(server.tick, server.drain_events())) replica.step() for ev: Dictionary in packet["events"]: replica.apply_event(ev) # float32 on the wire, so allow a hair more slack than the in-process test. var bad := 0 for i in server.pool.high_water: if server.pool.alive[i] == 0: continue var j := replica.pool.find_by_uid(server.pool.uid[i]) if j < 0 or (server.pool.pos[i] as Vector2).distance_to(replica.pool.pos[j]) > 1.0: bad += 1 assert_eq(bad, 0, "%d of %d bullets diverged through the codec" % [bad, server.pool.live_count])