Add host-authoritative world-state sync

Implement host-authoritative time/weather sync across server and clients. Adds a new worldState packet (and worldStateHost for host assignment) and server-side host tracking/reassignment to the lowest remaining playerId when the host disconnects. Plugin-side changes introduce F4T::WorldStateSync (new header + implementation), polling/apply hooks in the game-thread main loop, and Networking support to send/receive/parse worldState/worldStateHost packets. The server validates host-only sends, relays worldState to peers, broadcasts worldStateHost on host handoff, and augments logging/stats. Protocol and documentation files updated (protocol/world-state.md, docs/*) and server/fake_client.py extended to track/print host world-state snapshots.
This commit is contained in:
2026-06-22 21:50:59 +12:00
parent 9ceb6601ae
commit 99595ef87c
15 changed files with 1042 additions and 33 deletions
+82 -1
View File
@@ -11,6 +11,8 @@ HOST = "127.0.0.1"
PORT = 7777
remote_players: dict[int, dict[str, Any]] = {}
host_world_state: dict[str, Any] = {}
world_state_host_player_id: int | None = None
def log(message: str) -> None:
@@ -52,6 +54,70 @@ def get_optional_float(packet: dict[str, Any], field_name: str, default: float =
return parsed_value
def get_optional_game_time(packet: dict[str, Any], field_name: str, default: float = 0.0) -> float:
value = packet.get(field_name, default)
try:
parsed_value = float(value)
except (TypeError, ValueError):
return default
if not math.isfinite(parsed_value):
return default
return parsed_value
def update_host_world_state(packet: dict[str, Any]) -> None:
global host_world_state
host_world_state = {
"gameHour": get_optional_game_time(packet, "gameHour"),
"gameDaysPassed": get_optional_game_time(packet, "gameDaysPassed"),
"weatherFormId": packet.get("weatherFormId", ""),
"clientTime": packet.get("clientTime", 0.0),
"serverTime": packet.get("serverTime", 0.0),
"lastReceivedLocalTime": time.time(),
}
log("Updated host world state")
print_host_world_state(host_world_state)
def print_host_world_state(world_state: dict[str, Any]) -> None:
client_time = world_state["clientTime"] if world_state["clientTime"] is not None else "missing from packet"
server_time = world_state["serverTime"] if world_state["serverTime"] is not None else "missing from packet"
weather_form_id = world_state["weatherFormId"] or "<none>"
log(
"\n".join(
[
f"Game Hour: {world_state['gameHour']:.3f}",
f"Game Days Passed: {world_state['gameDaysPassed']:.3f}",
f"Weather Form ID: {weather_form_id}",
f"Client Time: {client_time}",
f"Last Server Time: {server_time}",
f"Last Received Local Time: {world_state['lastReceivedLocalTime']}",
]
)
)
def update_world_state_host(packet: dict[str, Any]) -> None:
global world_state_host_player_id
host_id = packet.get("worldStateHostPlayerId")
if host_id is None:
warn(f"Ignoring worldStateHost packet without worldStateHostPlayerId: {packet}")
return
try:
world_state_host_player_id = int(host_id)
except (TypeError, ValueError):
warn(f"Ignoring worldStateHost packet with invalid worldStateHostPlayerId: {packet}")
return
log(f"World-state host reassigned to player {world_state_host_player_id}")
def update_remote_player_state(packet: dict[str, Any]) -> None:
required_fields = (
"type",
@@ -176,10 +242,25 @@ def handle_disconnect_packet(packet: dict[str, Any]) -> None:
def handle_packet(packet: dict[str, Any]) -> None:
packet_type = packet.get("type")
if packet_type == "welcome":
log(f"Welcome packet: {packet}")
host_id = packet.get("worldStateHostPlayerId")
if host_id is not None:
try:
global world_state_host_player_id
world_state_host_player_id = int(host_id)
log(f"Welcome packet: playerId={packet.get('playerId')}, worldStateHostPlayerId={world_state_host_player_id}")
except (TypeError, ValueError):
log(f"Welcome packet: {packet}")
else:
log(f"Welcome packet: {packet}")
elif packet_type == "transform":
log(f"Received transform: {packet}")
update_remote_player_state(packet)
elif packet_type == "worldState":
log(f"Received worldState: {packet}")
update_host_world_state(packet)
elif packet_type == "worldStateHost":
log(f"Received worldStateHost: {packet}")
update_world_state_host(packet)
elif packet_type == "disconnect":
handle_disconnect_packet(packet)
else:
+127 -9
View File
@@ -41,6 +41,7 @@ class FalloutTogetherServer:
self._running = False
self._started_at: float | None = None
self._next_player_id = 1
self._world_state_host_player_id: int | None = None
self._stats: dict[str, int] = {
"clientsConnected": 0,
@@ -50,6 +51,9 @@ class FalloutTogetherServer:
"packetsBroadcast": 0,
"transformPacketsReceived": 0,
"transformPacketsBroadcast": 0,
"worldStatePacketsReceived": 0,
"worldStatePacketsBroadcast": 0,
"worldStateHostPacketsBroadcast": 0,
"disconnectPacketsBroadcast": 0,
}
@@ -220,6 +224,8 @@ class FalloutTogetherServer:
self._next_player_id += 1
self._clients[connection] = client
self._stats["clientsConnected"] += 1
if self._world_state_host_player_id is None:
self._world_state_host_player_id = client.player_id
return client
@@ -238,14 +244,18 @@ class FalloutTogetherServer:
buffer = ""
try:
self._send_packet(
client,
{
"type": "welcome",
"playerId": client.player_id,
"serverTime": time.time(),
},
)
with self._lock:
world_state_host_player_id = self._world_state_host_player_id
welcome_packet: dict[str, Any] = {
"type": "welcome",
"playerId": client.player_id,
"serverTime": time.time(),
}
if world_state_host_player_id is not None:
welcome_packet["worldStateHostPlayerId"] = world_state_host_player_id
self._send_packet(client, welcome_packet)
while self.is_running():
chunk = connection.recv(4096)
@@ -291,10 +301,28 @@ class FalloutTogetherServer:
with self._lock:
self._stats["transformPacketsReceived"] += 1
if packet.get("type") == "worldState":
with self._lock:
world_state_host_player_id = self._world_state_host_player_id
if world_state_host_player_id is None or client.player_id != world_state_host_player_id:
self._log(
f"Ignoring worldState from non-host {client.label} "
f"(player {client.player_id}, host is player {world_state_host_player_id})."
)
return
packet["playerId"] = client.player_id
packet["serverTime"] = time.time()
with self._lock:
self._stats["worldStatePacketsReceived"] += 1
self._print_packet(client, packet)
if packet.get("type") == "transform":
self._broadcast_transform(client, packet)
elif packet.get("type") == "worldState":
self._broadcast_world_state(client, packet)
def _send_packet(self, client: ClientSession, packet: dict[str, Any], *, broadcast: bool = False) -> None:
encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n"
@@ -307,6 +335,9 @@ class FalloutTogetherServer:
self._stats["packetsBroadcast"] += 1
def _disconnect_client(self, client: ClientSession) -> None:
with self._lock:
was_world_state_host = client.player_id == self._world_state_host_player_id
if not self._remove_client(client):
return
@@ -314,6 +345,9 @@ class FalloutTogetherServer:
self._log(f"Client disconnected: {client.label} (player {client.player_id})")
self._broadcast_disconnect(client)
if was_world_state_host:
self._reassign_world_state_host()
def _close_client_socket(self, client: ClientSession) -> None:
try:
client.connection.close()
@@ -343,6 +377,71 @@ class FalloutTogetherServer:
self._log(f"Broadcast transform from player {sender.player_id} to {successful_sends} other client(s)")
def _broadcast_world_state(self, sender: ClientSession, packet: dict[str, Any]) -> None:
with self._lock:
recipients = [client for client in self._clients.values() if client.connection != sender.connection]
failed_recipients: list[ClientSession] = []
successful_sends = 0
for recipient in recipients:
try:
self._send_packet(recipient, packet, broadcast=True)
successful_sends += 1
except OSError:
failed_recipients.append(recipient)
with self._lock:
self._stats["worldStatePacketsBroadcast"] += successful_sends
for recipient in failed_recipients:
self._disconnect_client(recipient)
game_hour = packet.get("gameHour", "?")
game_days_passed = packet.get("gameDaysPassed", "?")
weather_form_id = packet.get("weatherFormId", "")
weather_detail = f", weatherFormId={weather_form_id}" if weather_form_id else ""
self._log(
f"Broadcast worldState from player {sender.player_id} to {successful_sends} other client(s): "
f"gameHour={game_hour}, gameDaysPassed={game_days_passed}{weather_detail}"
)
def _reassign_world_state_host(self) -> None:
with self._lock:
if not self._clients:
self._world_state_host_player_id = None
self._log("World-state host cleared; no clients remain.")
return
new_host_player_id = min(client.player_id for client in self._clients.values())
self._world_state_host_player_id = new_host_player_id
recipients = list(self._clients.values())
packet = {
"type": "worldStateHost",
"worldStateHostPlayerId": new_host_player_id,
"serverTime": time.time(),
}
failed_recipients: list[ClientSession] = []
successful_sends = 0
for recipient in recipients:
try:
self._send_packet(recipient, packet, broadcast=True)
successful_sends += 1
except OSError:
failed_recipients.append(recipient)
with self._lock:
self._stats["worldStateHostPacketsBroadcast"] += successful_sends
for recipient in failed_recipients:
self._disconnect_client(recipient)
self._log(
f"Reassigned world-state host to player {new_host_player_id} "
f"and notified {successful_sends} client(s)."
)
def _broadcast_disconnect(self, disconnected_client: ClientSession) -> None:
packet = {
"type": "disconnect",
@@ -374,7 +473,26 @@ class FalloutTogetherServer:
)
def _print_packet(self, client: ClientSession, packet: dict[str, Any]) -> None:
if packet.get("type") != "transform":
packet_type = packet.get("type")
if packet_type == "worldState":
game_hour = packet.get("gameHour", "?")
game_days_passed = packet.get("gameDaysPassed", "?")
weather_form_id = packet.get("weatherFormId", "")
extra_fields = []
for field_name in ("playerId", "clientTime", "serverTime"):
if field_name in packet:
extra_fields.append(f"{field_name}={packet[field_name]}")
extra_details = ""
if extra_fields:
extra_details = ", " + ", ".join(extra_fields)
weather_detail = f", weatherFormId={weather_form_id}" if weather_form_id else ""
self._log(
f"WorldState from {client.label}: gameHour={game_hour}, "
f"gameDaysPassed={game_days_passed}{weather_detail}{extra_details}"
)
return
if packet_type != "transform":
self._log(f"Packet from {client.label}: {packet}")
return