From 5ad59d871c49533b3c67c7996b210d53556094d6 Mon Sep 17 00:00:00 2001 From: Nomads_Reach <144523850+NomadsReach@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:02:23 -0400 Subject: [PATCH] Cover scoped NPC authority handoff end to end --- server/tests/test_networking_v2.py | 136 ++++++++++++++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/server/tests/test_networking_v2.py b/server/tests/test_networking_v2.py index b97191f..0f8e3f0 100644 --- a/server/tests/test_networking_v2.py +++ b/server/tests/test_networking_v2.py @@ -38,6 +38,21 @@ def recv_until(sock: socket.socket, packet_type: str, timeout: float = 2.0) -> d raise AssertionError(f"did not receive packet type {packet_type}") +def recv_authority( + sock: socket.socket, + cell: str, + world: str = "0000003C", + timeout: float = 2.0, +) -> dict: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + remaining = max(0.01, deadline - time.monotonic()) + packet = recv_until(sock, "npcAuthority", timeout=remaining) + if packet.get("authorityCellId") == cell and packet.get("authorityWorldspaceId", "") == world: + return packet + raise AssertionError(f"did not receive npcAuthority for {cell}/{world}") + + def assert_no_packet_type(sock: socket.socket, packet_type: str, timeout: float = 0.2) -> None: with pytest.raises(AssertionError): recv_until(sock, packet_type, timeout=timeout) @@ -76,6 +91,45 @@ def transform( } +def npc_state( + cell: str, + epoch: int, + x: float, + world: str = "0000003C", + source_form_id: str = "000000AA", +) -> dict: + return { + "type": "npcState", + "authorityEpoch": epoch, + "authorityCellId": cell, + "authorityWorldspaceId": world, + "npcs": [ + { + "npcId": 1, + "baseFormId": "0000000F", + "sourceFormId": source_form_id, + "x": x, + "y": 0.0, + "z": 0.0, + "angleZ": 0.0, + "cellId": cell, + "worldspaceId": world, + "isDead": False, + } + ], + } + + +def wait_for_stat(server: FalloutTogetherServer, key: str, minimum: int, timeout: float = 2.0) -> int: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = int(server.get_stats()[key]) + if value >= minimum: + return value + time.sleep(0.01) + raise AssertionError(f"stat {key} did not reach {minimum}") + + def start_server(max_players: int = 16) -> FalloutTogetherServer: server = FalloutTogetherServer(host="127.0.0.1", port=0, max_players=max_players) server.start() @@ -179,6 +233,86 @@ def test_transform_interest_filters_distant_peer(): server.stop() +def test_scoped_npc_authorities_publish_independently(): + server = start_server() + a, a_welcome = connect(server) + hello(a) + b, b_welcome = connect(server) + hello(b) + + send_packet(a, transform("00000010", 0.0, 0.0)) + authority_a = recv_authority(a, "00000010") + assert authority_a["authorityPlayerId"] == a_welcome["playerId"] + + send_packet(b, transform("00000020", 25000.0, 0.0)) + authority_b = recv_authority(b, "00000020") + assert authority_b["authorityPlayerId"] == b_welcome["playerId"] + + send_packet(a, npc_state("00000010", authority_a["authorityEpoch"], 0.0)) + send_packet(b, npc_state("00000020", authority_b["authorityEpoch"], 25000.0, source_form_id="000000AB")) + assert wait_for_stat(server, "npcStatePacketsReceived", 2) == 2 + assert server.get_stats()["npcAuthorityRejects"] == 0 + + a.close() + b.close() + server.stop() + + +def test_stale_npc_authority_epoch_cannot_publish_after_handoff(): + server = start_server() + a, a_welcome = connect(server) + hello(a) + b, b_welcome = connect(server) + hello(b) + + send_packet(a, transform("00000010", 0.0, 0.0)) + initial_a = recv_authority(a, "00000010") + initial_b = recv_authority(b, "00000010") + assert initial_a["authorityPlayerId"] == a_welcome["playerId"] + assert initial_b["authorityEpoch"] == initial_a["authorityEpoch"] + + send_packet(b, transform("00000010", 64.0, 0.0)) + current_b = recv_authority(b, "00000010") + assert current_b["authorityPlayerId"] == a_welcome["playerId"] + assert current_b["authorityEpoch"] == initial_a["authorityEpoch"] + + send_packet(a, npc_state("00000010", initial_a["authorityEpoch"], 0.0)) + wait_for_stat(server, "npcStatePacketsReceived", 1) + + send_packet(a, transform("00000020", 0.0, 0.0, movement_type="cell_change")) + handoff = recv_authority(b, "00000010") + assert handoff["authorityPlayerId"] == b_welcome["playerId"] + assert handoff["authorityEpoch"] > initial_a["authorityEpoch"] + + send_packet(a, npc_state("00000010", initial_a["authorityEpoch"], 0.0)) + wait_for_stat(server, "npcAuthorityRejects", 1) + assert server.get_stats()["npcStatePacketsReceived"] == 1 + + send_packet(b, npc_state("00000010", handoff["authorityEpoch"], 64.0, source_form_id="000000AB")) + wait_for_stat(server, "npcStatePacketsReceived", 2) + + a.close() + b.close() + server.stop() + + +def test_scoped_npc_packet_rejects_entry_outside_authority_scope(): + server = start_server() + sock, _ = connect(server) + hello(sock) + send_packet(sock, transform("00000010", 0.0, 0.0)) + authority = recv_authority(sock, "00000010") + + packet = npc_state("00000010", authority["authorityEpoch"], 0.0) + packet["npcs"][0]["cellId"] = "00000011" + send_packet(sock, packet) + wait_for_stat(server, "npcAuthorityRejects", 1) + assert server.get_stats()["npcStatePacketsReceived"] == 0 + + sock.close() + server.stop() + + def test_rejected_normal_teleport_is_corrected_and_never_relayed(): server = start_server() a, a_welcome = connect(server) @@ -267,4 +401,4 @@ def test_oversized_unterminated_packet_closes_session(): ended = recv_until(sock, "sessionEnded") assert ended["code"] == "packet_too_large" sock.close() - server.stop() \ No newline at end of file + server.stop()