Sync from GitHub main #1

Open
nomad wants to merge 145 commits from sync/from-github into main
Showing only changes of commit d310d77428 - Show all commits
+146 -51
View File
@@ -13,6 +13,7 @@ from typing import Any
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 world_state_presets import normalize_fw_console_arg, relay_weather_form_id
HOST = "0.0.0.0"
@@ -152,12 +153,6 @@ def _scope_from_state(state: dict[str, Any] | None) -> tuple[str, str, float, fl
def states_share_interest(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool:
"""Return True when two states should see each other.
Missing scope data intentionally falls back to True for compatibility with older clients.
Interiors are matched by exact cell. Exteriors can also see nearby peers in the same
worldspace so cell-border crossings do not make players pop in and out abruptly.
"""
a_scope = _scope_from_state(a)
b_scope = _scope_from_state(b)
if a_scope is None or b_scope is None:
@@ -226,6 +221,8 @@ class FalloutTogetherServer:
self._world_state_host_player_id: int | None = None
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._npc_authority = NpcAuthorityManager()
self._connect_attempts: dict[str, deque[float]] = {}
self._stats: dict[str, int] = {
@@ -246,6 +243,8 @@ class FalloutTogetherServer:
"worldStatePacketsBroadcast": 0,
"npcStatePacketsReceived": 0,
"npcStatePacketsBroadcast": 0,
"npcAuthorityChanges": 0,
"npcAuthorityRejects": 0,
"combatHitsReceived": 0,
"combatHitsRouted": 0,
"worldStateHostPacketsBroadcast": 0,
@@ -335,6 +334,7 @@ class FalloutTogetherServer:
"pendingConnections": pending,
"nextPlayerId": self._next_player_id,
"protocolVersion": PROTOCOL_VERSION,
"npcAuthorityScopes": len(self._npc_authority.assignments()),
}
)
return stats
@@ -442,6 +442,8 @@ class FalloutTogetherServer:
self._next_player_id = 1
self._world_state_host_player_id = None
self._last_npc_state = None
self._last_npc_state_by_scope.clear()
self._npc_authority.clear()
self._connect_attempts.clear()
self._log(f"Commonwealth Online server listening on {self.host}:{self.port}")
@@ -603,7 +605,14 @@ class FalloutTogetherServer:
"serverName": self.server_name,
"serverDescription": self.server_description,
"protocolVersion": PROTOCOL_VERSION,
"capabilities": ["interest-v1", "hello-v2", "bounded-framing", "rate-limit-v1", "movement-correction-v1"],
"capabilities": [
"interest-v1",
"hello-v2",
"bounded-framing",
"rate-limit-v1",
"movement-correction-v1",
"npc-authority-epoch-v1",
],
},
)
buffer = b""
@@ -720,23 +729,24 @@ class FalloutTogetherServer:
self._reject_packet(client, "Malformed transform")
return False
accepted_monotonic = time.monotonic()
movement_valid, movement_reason = self._validate_transform_movement(
client,
normalized,
accepted_monotonic,
)
movement_valid, movement_reason = self._validate_transform_movement(client, normalized, accepted_monotonic)
if not movement_valid:
with self._lock:
self._stats["movementPacketsRejected"] += 1
self._reject_packet(client, movement_reason)
self._send_position_correction(client, movement_reason)
return False
previous_scope = scope_from_transform(client.last_transform)
normalized["playerId"] = client.player_id
normalized["serverTime"] = time.time()
client.record_transform(normalized, accepted_monotonic)
with self._lock:
self._stats["transformPacketsReceived"] += 1
self._broadcast_transform(client, normalized)
self._reconcile_npc_authority()
current_scope = scope_from_transform(normalized)
if current_scope is not None and current_scope != previous_scope:
self._send_npc_authority_for_client(client, current_scope)
return True
if packet_type == "worldState":
@@ -755,18 +765,47 @@ class FalloutTogetherServer:
return True
if packet_type == "npcState":
if not self._is_world_host(client):
self._reject_packet(client, "npcState from non-authority client", warning=False)
return False
normalized = self._normalize_npc_state_packet(packet)
if normalized is None:
self._reject_packet(client, "Malformed npcState")
return False
if client.protocol_version >= PROTOCOL_VERSION:
scope = self._npc_scope_from_packet(normalized)
epoch = normalized.get("authorityEpoch")
if scope is None or not _is_int(epoch, 1):
with self._lock:
self._stats["npcAuthorityRejects"] += 1
self._reject_packet(client, "Protocol V2 npcState missing valid authority scope/epoch")
return False
if not self._npc_authority.authorize(client.player_id, scope, epoch):
with self._lock:
self._stats["npcAuthorityRejects"] += 1
self._reject_packet(client, "Stale or unauthorized npcState authority epoch")
return False
if any(
npc.get("cellId") != scope.cell_id or npc.get("worldspaceId", "") != scope.worldspace_id
for npc in normalized.get("npcs", [])
):
with self._lock:
self._stats["npcAuthorityRejects"] += 1
self._reject_packet(client, "npcState contains NPCs outside declared authority scope")
return False
normalized["authorityCellId"] = scope.cell_id
normalized["authorityWorldspaceId"] = scope.worldspace_id
with self._lock:
self._last_npc_state_by_scope[scope] = dict(normalized)
else:
if not self._is_world_host(client):
self._reject_packet(client, "Legacy npcState from non-authority client", warning=False)
return False
with self._lock:
self._last_npc_state = dict(normalized)
normalized["playerId"] = client.player_id
normalized["serverTime"] = time.time()
normalized["fullReplace"] = True
with self._lock:
self._last_npc_state = dict(normalized)
self._stats["npcStatePacketsReceived"] += 1
self._broadcast_npc_state(client, normalized)
return True
@@ -794,11 +833,7 @@ class FalloutTogetherServer:
def _handle_hello(self, client: ClientSession, packet: dict[str, Any]) -> bool:
version = packet.get("protocolVersion")
if not _is_int(version, 1, 0xFFFF) or version != PROTOCOL_VERSION:
self._end_client_session(
client,
code=SESSION_ENDED_PROTOCOL,
reason=f"Server requires protocol {PROTOCOL_VERSION}.",
)
self._end_client_session(client, code=SESSION_ENDED_PROTOCOL, reason=f"Server requires protocol {PROTOCOL_VERSION}.")
return False
if not self._activate_client(client, version):
self._end_client_session(client, code=SESSION_ENDED_FULL, reason="Server is full.")
@@ -854,42 +889,30 @@ class FalloutTogetherServer:
normalized["actionEvents"] = _normalize_action_events(packet["actionEvents"])
return normalized
def _validate_transform_movement(
self,
client: ClientSession,
packet: dict[str, Any],
now_monotonic: float,
) -> tuple[bool, str]:
def _validate_transform_movement(self, client: ClientSession, packet: dict[str, Any], now_monotonic: float) -> tuple[bool, str]:
previous, previous_monotonic = client.get_last_transform_anchor()
if previous is None or previous_monotonic is None:
return True, "first transform"
movement_type = packet.get("movementType", "normal")
if movement_type in MOVEMENT_TRANSITION_TYPES:
return True, f"explicit {movement_type} transition"
previous_cell = previous.get("cellId", "")
current_cell = packet.get("cellId", "")
previous_world = previous.get("worldspaceId", "")
current_world = packet.get("worldspaceId", "")
if previous_cell != current_cell or previous_world != current_world:
return False, "scope changed without an explicit movement transition"
elapsed = now_monotonic - previous_monotonic
if not math.isfinite(elapsed) or elapsed < 0.0:
elapsed = 0.0
elapsed = min(elapsed, MAX_MOVEMENT_VALIDATION_ELAPSED_SECONDS)
dx = packet["x"] - previous["x"]
dy = packet["y"] - previous["y"]
dz = packet["z"] - previous["z"]
distance = math.sqrt((dx * dx) + (dy * dy) + (dz * dz))
allowed_distance = MOVEMENT_GRACE_DISTANCE + (MAX_NORMAL_MOVEMENT_SPEED * elapsed)
if not math.isfinite(distance) or distance > allowed_distance:
return (
False,
f"normal movement exceeded server envelope: distance={distance:.1f}, allowed={allowed_distance:.1f}, elapsed={elapsed:.3f}s",
)
return False, f"normal movement exceeded server envelope: distance={distance:.1f}, allowed={allowed_distance:.1f}, elapsed={elapsed:.3f}s"
return True, "normal movement accepted"
def _send_position_correction(self, client: ClientSession, reason: str) -> None:
@@ -959,8 +982,30 @@ class FalloutTogetherServer:
clean_npcs.append(clean)
normalized = dict(packet)
normalized["npcs"] = clean_npcs
if "authorityEpoch" in packet:
if not _is_int(packet["authorityEpoch"], 1):
return None
normalized["authorityEpoch"] = int(packet["authorityEpoch"])
if "authorityCellId" in packet:
if not _is_hex_form_id(packet["authorityCellId"], allow_zero=False):
return None
normalized["authorityCellId"] = _normalize_hex_form_id(packet["authorityCellId"])
if "authorityWorldspaceId" in packet:
world = packet["authorityWorldspaceId"]
if not _is_hex_form_id(world, allow_empty=True, allow_zero=True):
return None
normalized["authorityWorldspaceId"] = _normalize_hex_form_id(world) if world else ""
return normalized
def _npc_scope_from_packet(self, packet: dict[str, Any]) -> ScopeKey | None:
cell = packet.get("authorityCellId")
world = packet.get("authorityWorldspaceId", "")
if not _is_hex_form_id(cell, allow_zero=False):
return None
if not _is_hex_form_id(world, allow_empty=True, allow_zero=True):
return None
return ScopeKey(_normalize_hex_form_id(cell), _normalize_hex_form_id(world) if world else "")
def _normalize_combat_hit_packet(self, packet: dict[str, Any]) -> dict[str, Any] | None:
target = packet.get("targetPlayerId")
sequence = packet.get("sequence")
@@ -988,6 +1033,52 @@ class FalloutTogetherServer:
with self._lock:
return self._world_state_host_player_id == client.player_id
def _authority_packet(self, assignment_player_id: int, epoch: int, scope: ScopeKey) -> dict[str, Any]:
return {
"type": "npcAuthority",
"authorityPlayerId": assignment_player_id,
"authorityEpoch": epoch,
"authorityCellId": scope.cell_id,
"authorityWorldspaceId": scope.worldspace_id,
"serverTime": time.time(),
}
def _reconcile_npc_authority(self) -> None:
with self._lock:
players = []
for client in self._active_clients_locked():
if client.protocol_version < PROTOCOL_VERSION:
continue
scope = scope_from_transform(client.last_transform)
if scope is not None:
players.append((client.player_id, scope))
changes = self._npc_authority.reconcile(players)
for change in changes:
self._last_npc_state_by_scope.pop(change.scope, None)
for change in changes:
packet = self._authority_packet(change.player_id, change.epoch, change.scope)
successful, failed = self._broadcast_to_active(packet, v2_only=True)
with self._lock:
self._stats["npcAuthorityChanges"] += 1
for client in failed:
self._disconnect_client(client)
self._log(
f"NPC authority scope {change.scope.cell_id}/{change.scope.worldspace_id or '<interior>'}: "
f"{change.previous_player_id} -> {change.player_id}, epoch {change.epoch} "
f"(notified {successful} client(s))."
)
def _send_npc_authority_for_client(self, client: ClientSession, scope: ScopeKey) -> None:
if client.protocol_version < PROTOCOL_VERSION:
return
assignment = self._npc_authority.get(scope)
if assignment is None:
return
try:
self._send_packet(client, self._authority_packet(assignment.player_id, assignment.epoch, scope))
except (OSError, ValueError):
self._disconnect_client(client)
def _send_packet(self, client: ClientSession, packet: dict[str, Any], *, broadcast: bool = False) -> None:
encoded = json.dumps(packet, separators=(",", ":"), allow_nan=False).encode("utf-8") + b"\n"
if len(encoded) > MAX_PACKET_CHARS:
@@ -1001,10 +1092,7 @@ class FalloutTogetherServer:
def _send_existing_transforms_to_client(self, new_client: ClientSession) -> None:
with self._lock:
peers = [
client for client in self._active_clients_locked()
if client.connection != new_client.connection and client.last_transform is not None
]
peers = [client for client in self._active_clients_locked() if client.connection != new_client.connection and client.last_transform is not None]
successful = 0
for peer in peers:
if not states_share_interest(peer.last_transform, new_client.last_transform):
@@ -1073,6 +1161,11 @@ class FalloutTogetherServer:
self._disconnect_client(client)
def _send_existing_npc_state_to_client(self, client: ClientSession) -> None:
if client.protocol_version >= PROTOCOL_VERSION:
scope = scope_from_transform(client.last_transform)
with self._lock:
snapshot = dict(self._last_npc_state_by_scope[scope]) if scope in self._last_npc_state_by_scope else None
else:
with self._lock:
snapshot = dict(self._last_npc_state) if self._last_npc_state is not None else None
if snapshot is None or snapshot.get("playerId") == client.player_id:
@@ -1113,11 +1206,7 @@ class FalloutTogetherServer:
if snapshot.get("timeHHmm"):
packets.append({"type": "serverWorldState", "timeHHmm": snapshot["timeHHmm"], "serverTime": time.time()})
if snapshot.get("weatherConsoleArg"):
weather_packet: dict[str, Any] = {
"type": "serverWorldState",
"weatherConsoleArg": snapshot["weatherConsoleArg"],
"serverTime": time.time(),
}
weather_packet: dict[str, Any] = {"type": "serverWorldState", "weatherConsoleArg": snapshot["weatherConsoleArg"], "serverTime": time.time()}
if snapshot.get("weatherFormId"):
weather_packet["weatherFormId"] = snapshot["weatherFormId"]
packets.append(weather_packet)
@@ -1135,11 +1224,19 @@ class FalloutTogetherServer:
for client in {failed_client.connection: failed_client for failed_client in failed}.values():
self._disconnect_client(client)
def _broadcast_to_active(self, packet: dict[str, Any], *, exclude: ClientSession | None = None) -> tuple[int, list[ClientSession]]:
def _broadcast_to_active(
self,
packet: dict[str, Any],
*,
exclude: ClientSession | None = None,
v2_only: bool = False,
) -> tuple[int, list[ClientSession]]:
with self._lock:
recipients = [
client for client in self._active_clients_locked()
if exclude is None or client.connection != exclude.connection
client
for client in self._active_clients_locked()
if (exclude is None or client.connection != exclude.connection)
and (not v2_only or client.protocol_version >= PROTOCOL_VERSION)
]
successful = 0
failed: list[ClientSession] = []
@@ -1199,6 +1296,7 @@ class FalloutTogetherServer:
return
self._log(f"Client disconnected: {client.label} (player {client.player_id})")
self._broadcast_disconnect(client)
self._reconcile_npc_authority()
if was_host:
self._reassign_world_state_host()
@@ -1253,10 +1351,7 @@ class FalloutTogetherServer:
def _reject_packet(self, client: ClientSession, reason: str, *, warning: bool = True) -> None:
with self._lock:
self._stats["packetsRejected"] += 1
self._log(
f"Rejected packet from {client.label} (player {client.player_id}): {reason}",
level="warning" if warning else "debug",
)
self._log(f"Rejected packet from {client.label} (player {client.player_id}): {reason}", level="warning" if warning else "debug")
def _should_log(self, level: str) -> bool:
configured = _LOG_LEVELS.get(self.log_verbosity, _LOG_LEVELS[DEFAULT_LOG_VERBOSITY])