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:
+82
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user