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 establish_transforms( server: FalloutTogetherServer, sender: socket.socket, receiver: socket.socket, *, same_interest: bool, ) -> None: send_packet(sender, transform()) receiver_transform = transform( cellId="00000001" if same_interest else "00000002", worldspaceId="0000003C" if same_interest else "00000099", ) send_packet(receiver, receiver_transform) wait_for_stat(server, "transformPacketsReceived", 2) def test_player_state_relay_uses_server_owned_identity(): server = start_server() sender, sender_welcome = connect_v2(server) receiver, _ = connect_v2(server) try: establish_transforms(server, sender, receiver, same_interest=True) 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_out_of_interest_player_state_strips_action_events_but_keeps_durable_state(): server = start_server() sender, _ = connect_v2(server) receiver, _ = connect_v2(server) try: establish_transforms(server, sender, receiver, same_interest=False) send_packet(sender, player_state()) relayed = recv_until(receiver, "playerState") assert relayed["characterName"] == "Sole Survivor" assert relayed["equippedItems"] == [{"slot": "rightHand", "formId": "000000FF"}] assert "actionEvents" not in relayed finally: sender.close() receiver.close() server.stop() def test_out_of_interest_action_only_player_state_is_not_relayed(): server = start_server() sender, _ = connect_v2(server) receiver, _ = connect_v2(server) try: establish_transforms(server, sender, receiver, same_interest=False) send_packet( sender, { "type": "playerState", "actionEvents": [{"sequence": 4, "type": 3, "eventName": "fireSingle"}], }, ) wait_for_stat(server, "playerStatePacketsReceived", 1) with pytest.raises(AssertionError): recv_until(receiver, "playerState", timeout=0.2) finally: sender.close() receiver.close() server.stop() def test_new_v2_client_receives_cached_player_state_without_stale_action_events(): server = start_server() sender, sender_welcome = connect_v2(server) receiver = None try: send_packet(sender, transform()) wait_for_stat(server, "transformPacketsReceived", 1) send_packet(sender, player_state()) 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" assert cached["equippedItems"] == [{"slot": "rightHand", "formId": "000000FF"}] assert "actionEvents" not in cached 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()