From 4d5e07d1cca69fcd0079eb15caa4e9176fac0809 Mon Sep 17 00:00:00 2001 From: Nomads_Reach <144523850+NomadsReach@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:40:21 -0400 Subject: [PATCH] Test reliable Protocol V2 player state relay --- server/tests/test_player_state_integration.py | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 server/tests/test_player_state_integration.py diff --git a/server/tests/test_player_state_integration.py b/server/tests/test_player_state_integration.py new file mode 100644 index 0000000..fd9ac09 --- /dev/null +++ b/server/tests/test_player_state_integration.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import json +import socket +import time + +import pytest + +from server_core import PROTOCOL_VERSION, FalloutTogetherServer + + +_RECV_BUFFERS: dict[socket.socket, bytes] = {} + + +def recv_packet(sock: socket.socket, timeout: float = 2.0) -> dict: + sock.settimeout(timeout) + data = _RECV_BUFFERS.get(sock, b"") + while b"\n" not in data: + chunk = sock.recv(4096) + if not chunk: + raise ConnectionError("socket closed before a complete packet was received") + data += chunk + line, remainder = data.split(b"\n", 1) + _RECV_BUFFERS[sock] = remainder + return json.loads(line.decode("utf-8")) + + +def recv_until(sock: socket.socket, packet_type: str, timeout: float = 2.0) -> dict: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + remaining = max(0.01, deadline - time.monotonic()) + try: + packet = recv_packet(sock, timeout=remaining) + except socket.timeout: + break + if packet.get("type") == packet_type: + return packet + raise AssertionError(f"did not receive packet type {packet_type}") + + +def send_packet(sock: socket.socket, packet: dict) -> None: + sock.sendall(json.dumps(packet, separators=(",", ":")).encode() + b"\n") + + +def start_server() -> FalloutTogetherServer: + server = FalloutTogetherServer(host="127.0.0.1", port=0) + server.start() + deadline = time.monotonic() + 2.0 + while not server.is_running() and time.monotonic() < deadline: + time.sleep(0.01) + return server + + +def connect_v2(server: FalloutTogetherServer) -> tuple[socket.socket, dict]: + sock = socket.create_connection(("127.0.0.1", server.port), timeout=2.0) + welcome = recv_until(sock, "welcome") + send_packet(sock, {"type": "hello", "protocolVersion": PROTOCOL_VERSION}) + ready = recv_until(sock, "sessionReady") + assert ready["playerId"] == welcome["playerId"] + return sock, welcome + + +def player_state(**overrides) -> dict: + packet = { + "type": "playerState", + "playerId": 999999, + "characterName": "Sole Survivor", + "equippedItems": [{"slot": "rightHand", "formId": "ff"}], + "appearance": { + "version": 4, + "raceFormId": "13746", + "height": 1.0, + "headParts": ["1a2b"], + }, + "actionEvents": [{"sequence": 3, "type": 3, "eventName": "fireSingle"}], + } + packet.update(overrides) + return packet + + +def transform(**overrides) -> dict: + packet = { + "type": "transform", + "x": 0.0, + "y": 0.0, + "z": 0.0, + "angleZ": 0.0, + "cellId": "00000001", + "worldspaceId": "0000003C", + "movementType": "normal", + } + packet.update(overrides) + return packet + + +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 test_player_state_relay_uses_server_owned_identity(): + server = start_server() + sender, sender_welcome = connect_v2(server) + receiver, _ = connect_v2(server) + try: + send_packet(sender, player_state()) + relayed = recv_until(receiver, "playerState") + assert relayed["playerId"] == sender_welcome["playerId"] + assert relayed["characterName"] == "Sole Survivor" + assert relayed["equippedItems"] == [{"slot": "rightHand", "formId": "000000FF"}] + assert relayed["appearance"]["raceFormId"] == "00013746" + assert relayed["actionEvents"][0]["sequence"] == 3 + wait_for_stat(server, "playerStatePacketsReceived", 1) + wait_for_stat(server, "playerStatePacketsBroadcast", 1) + finally: + sender.close() + receiver.close() + server.stop() + + +def test_v2_transform_strips_reliable_player_state_fields(): + server = start_server() + sender, _ = connect_v2(server) + receiver, _ = connect_v2(server) + try: + send_packet( + sender, + transform( + equippedItems=[{"slot": "rightHand", "formId": "000000FF"}], + appearance={"version": 4, "raceFormId": "00013746"}, + actionEvents=[{"sequence": 3, "type": 3, "eventName": "fireSingle"}], + characterName="must-not-ride-transform", + ), + ) + relayed = recv_until(receiver, "transform") + assert "equippedItems" not in relayed + assert "appearance" not in relayed + assert "actionEvents" not in relayed + assert "characterName" not in relayed + finally: + sender.close() + receiver.close() + server.stop() + + +def test_new_v2_client_receives_cached_player_state_before_any_transform(): + server = start_server() + sender, sender_welcome = connect_v2(server) + receiver = None + try: + send_packet(sender, player_state(actionEvents=[])) + wait_for_stat(server, "playerStatePacketsReceived", 1) + + receiver, _ = connect_v2(server) + cached = recv_until(receiver, "playerState") + assert cached["playerId"] == sender_welcome["playerId"] + assert cached["characterName"] == "Sole Survivor" + finally: + sender.close() + if receiver is not None: + receiver.close() + server.stop() + + +def test_malformed_player_state_is_rejected_before_relay(): + server = start_server() + sender, _ = connect_v2(server) + receiver, _ = connect_v2(server) + try: + before = server.get_stats()["packetsRejected"] + send_packet(sender, player_state(equippedItems=[{"slot": "rightHand", "formId": "not-a-form"}])) + wait_for_stat(server, "packetsRejected", before + 1) + assert server.get_stats()["playerStatePacketsReceived"] == 0 + with pytest.raises(AssertionError): + recv_until(receiver, "playerState", timeout=0.2) + finally: + sender.close() + receiver.close() + server.stop()