Add server broadcasts, fake client, timestamps

Enable transform broadcast testing and time syncing: server now assigns incrementing player IDs, sends a welcome packet with playerId and serverTime, and appends serverTime/playerId to incoming transform packets before broadcasting them to all other connected clients. Added thread-safe client tracking, send_packet helper, client removal on send errors, and improved logging. Plugin now includes a clientTime timestamp in transform packets. Added server/fake_client.py to receive and print welcome and broadcasted transform packets, and updated server README and dev-log with usage and test notes.
This commit is contained in:
2026-05-31 19:06:58 +12:00
parent 4d43ecfc6c
commit bdbe5d7ac0
5 changed files with 253 additions and 16 deletions
+39 -2
View File
@@ -4,7 +4,8 @@ This folder is for the external multiplayer test server.
The current server is a tiny local-only TCP test server. It listens on
`127.0.0.1:7777`, accepts one or more clients, reads newline-separated JSON
packets, and prints received player transform packets.
packets, prints received player transform packets, and broadcasts transform
packets to every other connected client.
## Run
@@ -20,11 +21,47 @@ should print matching `transform` packets.
If the server is not running, the plugin should log a warning and Fallout 4
should continue launching normally.
## First Server Goals
## Broadcast Test
Terminal 1:
```bash
cd server
python server.py
```
Terminal 2:
```bash
cd server
python fake_client.py
```
Then launch Fallout 4 through F4SE and move the player.
Expected result:
- The server assigns a `playerId` to each connected client and sends each client
a `welcome` packet.
- The fake client prints its `welcome` packet.
- The server prints transform packets received from Fallout 4.
- The server adds `playerId` and `serverTime` to each transform packet.
- The server broadcasts those transform packets to connected clients except the
sender.
- `fake_client.py` receives and prints the broadcast transform packets.
The fake client exists so broadcast behavior can be tested before coordinating a
second Fallout 4/F4SE instance. It is only a receiver for now and does not send
movement.
## Current Server Goals
- Start local server
- Accept client connections
- Receive transform packets
- Assign server-owned player IDs
- Add server timestamps
- Broadcast transforms to other connected clients
- Handle disconnects
## Not Planned Yet
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import json
import socket
from typing import Any
HOST = "127.0.0.1"
PORT = 7777
def log(message: str) -> None:
print(message, flush=True)
def handle_packet(packet: dict[str, Any]) -> None:
packet_type = packet.get("type")
if packet_type == "welcome":
log(f"Welcome packet: {packet}")
elif packet_type == "transform":
log(f"Received transform: {packet}")
else:
log(f"Received packet: {packet}")
def handle_line(line: str) -> None:
if not line:
return
try:
packet = json.loads(line)
except json.JSONDecodeError as error:
log(f"Invalid JSON from server: {error}: {line}")
return
if not isinstance(packet, dict):
log(f"Invalid packet from server: expected JSON object: {packet}")
return
handle_packet(packet)
def main() -> None:
# This fake client lets us verify server broadcast behavior before trying to
# run and coordinate a second Fallout 4/F4SE instance.
with socket.create_connection((HOST, PORT)) as connection:
log(f"Connected to Fallout 4 Together server at {HOST}:{PORT}")
buffer = ""
while True:
chunk = connection.recv(4096)
if not chunk:
log("Server disconnected.")
break
buffer += chunk.decode("utf-8", errors="replace")
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
handle_line(line.strip())
if __name__ == "__main__":
try:
main()
except ConnectionRefusedError:
log(f"Could not connect to Fallout 4 Together server at {HOST}:{PORT}. Is server.py running?")
except KeyboardInterrupt:
log("\nFake client stopped.")
except OSError as error:
log(f"Disconnected from server: {error}")
+97 -11
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
import json
import socket
import threading
import time
from dataclasses import dataclass
from typing import Any
@@ -10,19 +12,66 @@ HOST = "127.0.0.1"
PORT = 7777
ACCEPT_TIMEOUT_SECONDS = 0.5
clients_lock = threading.Lock()
clients: dict[socket.socket, "Client"] = {}
next_player_id = 1
@dataclass
class Client:
connection: socket.socket
address: tuple[str, int]
player_id: int
@property
def label(self) -> str:
return f"{self.address[0]}:{self.address[1]}"
def log(message: str) -> None:
print(message, flush=True)
def assign_client(connection: socket.socket, address: tuple[str, int]) -> Client:
global next_player_id
with clients_lock:
# The server owns player IDs so early clients do not need to coordinate
# identity with each other or know anything about remote players yet.
client = Client(connection=connection, address=address, player_id=next_player_id)
next_player_id += 1
clients[connection] = client
return client
def remove_client(client: Client) -> None:
with clients_lock:
clients.pop(client.connection, None)
def send_packet(connection: socket.socket, packet: dict[str, Any]) -> None:
encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n"
connection.sendall(encoded)
def handle_client(connection: socket.socket, address: tuple[str, int]) -> None:
client = f"{address[0]}:{address[1]}"
log(f"Client connected: {client}")
client = assign_client(connection, address)
log(f"Client connected: {client.label} (player {client.player_id})")
with connection:
buffer = ""
try:
send_packet(
connection,
{
"type": "welcome",
"playerId": client.player_id,
"serverTime": time.time(),
},
)
while True:
chunk = connection.recv(4096)
if not chunk:
@@ -33,27 +82,64 @@ def handle_client(connection: socket.socket, address: tuple[str, int]) -> None:
line, buffer = buffer.split("\n", 1)
handle_line(client, line.strip())
except ConnectionResetError:
log(f"Client disconnected unexpectedly: {client}")
log(f"Client disconnected unexpectedly: {client.label} (player {client.player_id})")
except OSError as error:
log(f"Client connection error: {client.label} (player {client.player_id}): {error}")
finally:
log(f"Client disconnected: {client}")
remove_client(client)
log(f"Client disconnected: {client.label} (player {client.player_id})")
def handle_line(client: str, line: str) -> None:
def handle_line(client: Client, line: str) -> None:
if not line:
return
try:
packet = json.loads(line)
except json.JSONDecodeError as error:
log(f"Invalid JSON from {client}: {error}: {line}")
log(f"Invalid JSON from {client.label}: {error}: {line}")
return
if not isinstance(packet, dict):
log(f"Invalid packet from {client.label}: expected JSON object: {packet}")
return
if packet.get("type") == "transform":
packet["playerId"] = client.player_id
packet["serverTime"] = time.time()
print_packet(client, packet)
if packet.get("type") == "transform":
broadcast_transform(client, packet)
def print_packet(client: str, packet: dict[str, Any]) -> None:
def broadcast_transform(sender: Client, packet: dict[str, Any]) -> None:
with clients_lock:
recipients = [client for client in clients.values() if client.connection != sender.connection]
failed_recipients: list[Client] = []
for recipient in recipients:
try:
# Transforms go to every other client only. The sender already knows
# its own movement; echoing it back would create duplicate local state.
send_packet(recipient.connection, packet)
except OSError:
failed_recipients.append(recipient)
for recipient in failed_recipients:
remove_client(recipient)
try:
recipient.connection.close()
except OSError:
pass
log(f"Broadcast transform from player {sender.player_id} to {len(recipients) - len(failed_recipients)} other client(s)")
def print_packet(client: Client, packet: dict[str, Any]) -> None:
if packet.get("type") != "transform":
log(f"Packet from {client}: {packet}")
log(f"Packet from {client.label}: {packet}")
return
try:
@@ -62,12 +148,12 @@ def print_packet(client: str, packet: dict[str, Any]) -> None:
z = float(packet["z"])
angle_z = float(packet["angleZ"])
except (KeyError, TypeError, ValueError):
log(f"Malformed transform packet from {client}: {packet}")
log(f"Malformed transform packet from {client.label}: {packet}")
return
movement_type = packet.get("movementType", "normal")
extra_fields = []
for field_name in ("cellId", "worldspaceId"):
for field_name in ("playerId", "cellId", "worldspaceId", "clientTime", "serverTime"):
if field_name in packet:
extra_fields.append(f"{field_name}={packet[field_name]}")
@@ -77,7 +163,7 @@ def print_packet(client: str, packet: dict[str, Any]) -> None:
log(
"Transform from "
f"{client}: x={x:.2f}, y={y:.2f}, z={z:.2f}, angleZ={angle_z:.2f}, "
f"{client.label}: x={x:.2f}, y={y:.2f}, z={z:.2f}, angleZ={angle_z:.2f}, "
f"movementType={movement_type}{extra_details}"
)