Test reliable Protocol V2 player state relay
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user