diff --git a/server/server_core.py b/server/server_core.py index f37a6a5..28f733c 100644 --- a/server/server_core.py +++ b/server/server_core.py @@ -14,6 +14,7 @@ from ban_store import BanStore from client_session import ClientSession from lan_discovery import DISCOVERY_PORT, LanDiscoveryResponder from npc_authority import NpcAuthorityManager, ScopeKey, scope_from_transform +from player_state import normalize_player_state_packet from world_state_presets import normalize_fw_console_arg, relay_weather_form_id HOST = "0.0.0.0" @@ -222,6 +223,7 @@ class FalloutTogetherServer: self._server_world_state: dict[str, str] = {} self._last_npc_state: dict[str, Any] | None = None self._last_npc_state_by_scope: dict[ScopeKey, dict[str, Any]] = {} + self._last_player_state_by_player_id: dict[int, dict[str, Any]] = {} self._npc_authority = NpcAuthorityManager() self._connect_attempts: dict[str, deque[float]] = {} @@ -237,6 +239,8 @@ class FalloutTogetherServer: "transformPacketsReceived": 0, "transformPacketsBroadcast": 0, "transformPacketsInterestFiltered": 0, + "playerStatePacketsReceived": 0, + "playerStatePacketsBroadcast": 0, "movementPacketsRejected": 0, "movementCorrectionsSent": 0, "worldStatePacketsReceived": 0, @@ -443,6 +447,7 @@ class FalloutTogetherServer: self._world_state_host_player_id = None self._last_npc_state = None self._last_npc_state_by_scope.clear() + self._last_player_state_by_player_id.clear() self._npc_authority.clear() self._connect_attempts.clear() @@ -568,6 +573,7 @@ class FalloutTogetherServer: }, ) self._send_existing_transforms_to_client(client) + self._send_existing_player_states_to_client(client) self._send_existing_npc_state_to_client(client) if became_host: self._broadcast_world_state_host_assignment(client.player_id) @@ -612,6 +618,7 @@ class FalloutTogetherServer: "rate-limit-v1", "movement-correction-v1", "npc-authority-epoch-v1", + "player-state-v1", ], }, ) @@ -728,6 +735,9 @@ class FalloutTogetherServer: if normalized is None: self._reject_packet(client, "Malformed transform") return False + if client.protocol_version >= PROTOCOL_VERSION: + for reliable_field in ("equippedItems", "appearance", "actionEvents", "characterName"): + normalized.pop(reliable_field, None) accepted_monotonic = time.monotonic() movement_valid, movement_reason = self._validate_transform_movement(client, normalized, accepted_monotonic) if not movement_valid: @@ -749,6 +759,22 @@ class FalloutTogetherServer: self._send_npc_authority_for_client(client, current_scope) return True + if packet_type == "playerState": + if client.protocol_version < PROTOCOL_VERSION: + self._reject_packet(client, "playerState requires Protocol V2", warning=False) + return False + normalized = normalize_player_state_packet(packet, _normalize_action_events) + if normalized is None: + self._reject_packet(client, "Malformed playerState") + return False + normalized["playerId"] = client.player_id + normalized["serverTime"] = time.time() + with self._lock: + self._last_player_state_by_player_id[client.player_id] = dict(normalized) + self._stats["playerStatePacketsReceived"] += 1 + self._broadcast_player_state(client, normalized) + return True + if packet_type == "worldState": if not self._is_world_host(client): self._reject_packet(client, "worldState from non-authority client", warning=False) @@ -1108,6 +1134,27 @@ class FalloutTogetherServer: with self._lock: self._stats["transformPacketsBroadcast"] += successful + def _send_existing_player_states_to_client(self, new_client: ClientSession) -> None: + if new_client.protocol_version < PROTOCOL_VERSION: + return + with self._lock: + snapshots = [ + dict(packet) + for player_id, packet in self._last_player_state_by_player_id.items() + if player_id != new_client.player_id + ] + successful = 0 + for packet in snapshots: + packet["serverTime"] = time.time() + try: + self._send_packet(new_client, packet, broadcast=True) + successful += 1 + except (OSError, ValueError): + self._disconnect_client(new_client) + break + with self._lock: + self._stats["playerStatePacketsBroadcast"] += successful + def _broadcast_transform(self, sender: ClientSession, packet: dict[str, Any]) -> None: with self._lock: recipients = [client for client in self._active_clients_locked() if client.connection != sender.connection] @@ -1129,6 +1176,13 @@ class FalloutTogetherServer: for recipient in failed: self._disconnect_client(recipient) + def _broadcast_player_state(self, sender: ClientSession, packet: dict[str, Any]) -> None: + successful, failed = self._broadcast_to_active(packet, exclude=sender, v2_only=True) + with self._lock: + self._stats["playerStatePacketsBroadcast"] += successful + for client in failed: + self._disconnect_client(client) + def _broadcast_world_state(self, sender: ClientSession, packet: dict[str, Any]) -> None: successful, failed = self._broadcast_to_active(packet, exclude=sender) with self._lock: @@ -1280,6 +1334,8 @@ class FalloutTogetherServer: def _remove_client(self, client: ClientSession) -> bool: with self._lock: removed = self._clients.pop(client.connection, None) is not None + if removed: + self._last_player_state_by_player_id.pop(client.player_id, None) if removed and client.gameplay_active: self._stats["clientsDisconnected"] += 1 return removed