245 lines
8.1 KiB
Python
245 lines
8.1 KiB
Python
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()
|