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
+148 -53
View File
@@ -13,6 +13,7 @@ from typing import Any
from ban_store import BanStore from ban_store import BanStore
from client_session import ClientSession from client_session import ClientSession
from lan_discovery import DISCOVERY_PORT, LanDiscoveryResponder 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 from world_state_presets import normalize_fw_console_arg, relay_weather_form_id
HOST = "0.0.0.0" 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: 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) a_scope = _scope_from_state(a)
b_scope = _scope_from_state(b) b_scope = _scope_from_state(b)
if a_scope is None or b_scope is None: 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._world_state_host_player_id: int | None = None
self._server_world_state: dict[str, str] = {} self._server_world_state: dict[str, str] = {}
self._last_npc_state: dict[str, Any] | None = None 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._connect_attempts: dict[str, deque[float]] = {}
self._stats: dict[str, int] = { self._stats: dict[str, int] = {
@@ -246,6 +243,8 @@ class FalloutTogetherServer:
"worldStatePacketsBroadcast": 0, "worldStatePacketsBroadcast": 0,
"npcStatePacketsReceived": 0, "npcStatePacketsReceived": 0,
"npcStatePacketsBroadcast": 0, "npcStatePacketsBroadcast": 0,
"npcAuthorityChanges": 0,
"npcAuthorityRejects": 0,
"combatHitsReceived": 0, "combatHitsReceived": 0,
"combatHitsRouted": 0, "combatHitsRouted": 0,
"worldStateHostPacketsBroadcast": 0, "worldStateHostPacketsBroadcast": 0,
@@ -335,6 +334,7 @@ class FalloutTogetherServer:
"pendingConnections": pending, "pendingConnections": pending,
"nextPlayerId": self._next_player_id, "nextPlayerId": self._next_player_id,
"protocolVersion": PROTOCOL_VERSION, "protocolVersion": PROTOCOL_VERSION,
"npcAuthorityScopes": len(self._npc_authority.assignments()),
} }
) )
return stats return stats
@@ -442,6 +442,8 @@ class FalloutTogetherServer:
self._next_player_id = 1 self._next_player_id = 1
self._world_state_host_player_id = None self._world_state_host_player_id = None
self._last_npc_state = None self._last_npc_state = None
self._last_npc_state_by_scope.clear()
self._npc_authority.clear()
self._connect_attempts.clear() self._connect_attempts.clear()
self._log(f"Commonwealth Online server listening on {self.host}:{self.port}") self._log(f"Commonwealth Online server listening on {self.host}:{self.port}")
@@ -603,7 +605,14 @@ class FalloutTogetherServer:
"serverName": self.server_name, "serverName": self.server_name,
"serverDescription": self.server_description, "serverDescription": self.server_description,
"protocolVersion": PROTOCOL_VERSION, "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"" buffer = b""
@@ -720,23 +729,24 @@ class FalloutTogetherServer:
self._reject_packet(client, "Malformed transform") self._reject_packet(client, "Malformed transform")
return False return False
accepted_monotonic = time.monotonic() accepted_monotonic = time.monotonic()
movement_valid, movement_reason = self._validate_transform_movement( movement_valid, movement_reason = self._validate_transform_movement(client, normalized, accepted_monotonic)
client,
normalized,
accepted_monotonic,
)
if not movement_valid: if not movement_valid:
with self._lock: with self._lock:
self._stats["movementPacketsRejected"] += 1 self._stats["movementPacketsRejected"] += 1
self._reject_packet(client, movement_reason) self._reject_packet(client, movement_reason)
self._send_position_correction(client, movement_reason) self._send_position_correction(client, movement_reason)
return False return False
previous_scope = scope_from_transform(client.last_transform)
normalized["playerId"] = client.player_id normalized["playerId"] = client.player_id
normalized["serverTime"] = time.time() normalized["serverTime"] = time.time()
client.record_transform(normalized, accepted_monotonic) client.record_transform(normalized, accepted_monotonic)
with self._lock: with self._lock:
self._stats["transformPacketsReceived"] += 1 self._stats["transformPacketsReceived"] += 1
self._broadcast_transform(client, normalized) 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 return True
if packet_type == "worldState": if packet_type == "worldState":
@@ -755,18 +765,47 @@ class FalloutTogetherServer:
return True return True
if packet_type == "npcState": 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) normalized = self._normalize_npc_state_packet(packet)
if normalized is None: if normalized is None:
self._reject_packet(client, "Malformed npcState") self._reject_packet(client, "Malformed npcState")
return False 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["playerId"] = client.player_id
normalized["serverTime"] = time.time() normalized["serverTime"] = time.time()
normalized["fullReplace"] = True normalized["fullReplace"] = True
with self._lock: with self._lock:
self._last_npc_state = dict(normalized)
self._stats["npcStatePacketsReceived"] += 1 self._stats["npcStatePacketsReceived"] += 1
self._broadcast_npc_state(client, normalized) self._broadcast_npc_state(client, normalized)
return True return True
@@ -794,11 +833,7 @@ class FalloutTogetherServer:
def _handle_hello(self, client: ClientSession, packet: dict[str, Any]) -> bool: def _handle_hello(self, client: ClientSession, packet: dict[str, Any]) -> bool:
version = packet.get("protocolVersion") version = packet.get("protocolVersion")
if not _is_int(version, 1, 0xFFFF) or version != PROTOCOL_VERSION: if not _is_int(version, 1, 0xFFFF) or version != PROTOCOL_VERSION:
self._end_client_session( self._end_client_session(client, code=SESSION_ENDED_PROTOCOL, reason=f"Server requires protocol {PROTOCOL_VERSION}.")
client,
code=SESSION_ENDED_PROTOCOL,
reason=f"Server requires protocol {PROTOCOL_VERSION}.",
)
return False return False
if not self._activate_client(client, version): if not self._activate_client(client, version):
self._end_client_session(client, code=SESSION_ENDED_FULL, reason="Server is full.") 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"]) normalized["actionEvents"] = _normalize_action_events(packet["actionEvents"])
return normalized return normalized
def _validate_transform_movement( def _validate_transform_movement(self, client: ClientSession, packet: dict[str, Any], now_monotonic: float) -> tuple[bool, str]:
self,
client: ClientSession,
packet: dict[str, Any],
now_monotonic: float,
) -> tuple[bool, str]:
previous, previous_monotonic = client.get_last_transform_anchor() previous, previous_monotonic = client.get_last_transform_anchor()
if previous is None or previous_monotonic is None: if previous is None or previous_monotonic is None:
return True, "first transform" return True, "first transform"
movement_type = packet.get("movementType", "normal") movement_type = packet.get("movementType", "normal")
if movement_type in MOVEMENT_TRANSITION_TYPES: if movement_type in MOVEMENT_TRANSITION_TYPES:
return True, f"explicit {movement_type} transition" return True, f"explicit {movement_type} transition"
previous_cell = previous.get("cellId", "") previous_cell = previous.get("cellId", "")
current_cell = packet.get("cellId", "") current_cell = packet.get("cellId", "")
previous_world = previous.get("worldspaceId", "") previous_world = previous.get("worldspaceId", "")
current_world = packet.get("worldspaceId", "") current_world = packet.get("worldspaceId", "")
if previous_cell != current_cell or previous_world != current_world: if previous_cell != current_cell or previous_world != current_world:
return False, "scope changed without an explicit movement transition" return False, "scope changed without an explicit movement transition"
elapsed = now_monotonic - previous_monotonic elapsed = now_monotonic - previous_monotonic
if not math.isfinite(elapsed) or elapsed < 0.0: if not math.isfinite(elapsed) or elapsed < 0.0:
elapsed = 0.0 elapsed = 0.0
elapsed = min(elapsed, MAX_MOVEMENT_VALIDATION_ELAPSED_SECONDS) elapsed = min(elapsed, MAX_MOVEMENT_VALIDATION_ELAPSED_SECONDS)
dx = packet["x"] - previous["x"] dx = packet["x"] - previous["x"]
dy = packet["y"] - previous["y"] dy = packet["y"] - previous["y"]
dz = packet["z"] - previous["z"] dz = packet["z"] - previous["z"]
distance = math.sqrt((dx * dx) + (dy * dy) + (dz * dz)) distance = math.sqrt((dx * dx) + (dy * dy) + (dz * dz))
allowed_distance = MOVEMENT_GRACE_DISTANCE + (MAX_NORMAL_MOVEMENT_SPEED * elapsed) allowed_distance = MOVEMENT_GRACE_DISTANCE + (MAX_NORMAL_MOVEMENT_SPEED * elapsed)
if not math.isfinite(distance) or distance > allowed_distance: if not math.isfinite(distance) or distance > allowed_distance:
return ( return False, f"normal movement exceeded server envelope: distance={distance:.1f}, allowed={allowed_distance:.1f}, elapsed={elapsed:.3f}s"
False,
f"normal movement exceeded server envelope: distance={distance:.1f}, allowed={allowed_distance:.1f}, elapsed={elapsed:.3f}s",
)
return True, "normal movement accepted" return True, "normal movement accepted"
def _send_position_correction(self, client: ClientSession, reason: str) -> None: def _send_position_correction(self, client: ClientSession, reason: str) -> None:
@@ -959,8 +982,30 @@ class FalloutTogetherServer:
clean_npcs.append(clean) clean_npcs.append(clean)
normalized = dict(packet) normalized = dict(packet)
normalized["npcs"] = clean_npcs 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 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: def _normalize_combat_hit_packet(self, packet: dict[str, Any]) -> dict[str, Any] | None:
target = packet.get("targetPlayerId") target = packet.get("targetPlayerId")
sequence = packet.get("sequence") sequence = packet.get("sequence")
@@ -988,6 +1033,52 @@ class FalloutTogetherServer:
with self._lock: with self._lock:
return self._world_state_host_player_id == client.player_id 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: 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" encoded = json.dumps(packet, separators=(",", ":"), allow_nan=False).encode("utf-8") + b"\n"
if len(encoded) > MAX_PACKET_CHARS: if len(encoded) > MAX_PACKET_CHARS:
@@ -1001,10 +1092,7 @@ class FalloutTogetherServer:
def _send_existing_transforms_to_client(self, new_client: ClientSession) -> None: def _send_existing_transforms_to_client(self, new_client: ClientSession) -> None:
with self._lock: with self._lock:
peers = [ peers = [client for client in self._active_clients_locked() if client.connection != new_client.connection and client.last_transform is not None]
client for client in self._active_clients_locked()
if client.connection != new_client.connection and client.last_transform is not None
]
successful = 0 successful = 0
for peer in peers: for peer in peers:
if not states_share_interest(peer.last_transform, new_client.last_transform): if not states_share_interest(peer.last_transform, new_client.last_transform):
@@ -1073,8 +1161,13 @@ class FalloutTogetherServer:
self._disconnect_client(client) self._disconnect_client(client)
def _send_existing_npc_state_to_client(self, client: ClientSession) -> None: def _send_existing_npc_state_to_client(self, client: ClientSession) -> None:
with self._lock: if client.protocol_version >= PROTOCOL_VERSION:
snapshot = dict(self._last_npc_state) if self._last_npc_state is not None else None 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: if snapshot is None or snapshot.get("playerId") == client.player_id:
return return
scoped = self._npc_packet_for_recipient(snapshot, client) scoped = self._npc_packet_for_recipient(snapshot, client)
@@ -1113,11 +1206,7 @@ class FalloutTogetherServer:
if snapshot.get("timeHHmm"): if snapshot.get("timeHHmm"):
packets.append({"type": "serverWorldState", "timeHHmm": snapshot["timeHHmm"], "serverTime": time.time()}) packets.append({"type": "serverWorldState", "timeHHmm": snapshot["timeHHmm"], "serverTime": time.time()})
if snapshot.get("weatherConsoleArg"): if snapshot.get("weatherConsoleArg"):
weather_packet: dict[str, Any] = { weather_packet: dict[str, Any] = {"type": "serverWorldState", "weatherConsoleArg": snapshot["weatherConsoleArg"], "serverTime": time.time()}
"type": "serverWorldState",
"weatherConsoleArg": snapshot["weatherConsoleArg"],
"serverTime": time.time(),
}
if snapshot.get("weatherFormId"): if snapshot.get("weatherFormId"):
weather_packet["weatherFormId"] = snapshot["weatherFormId"] weather_packet["weatherFormId"] = snapshot["weatherFormId"]
packets.append(weather_packet) packets.append(weather_packet)
@@ -1135,11 +1224,19 @@ class FalloutTogetherServer:
for client in {failed_client.connection: failed_client for failed_client in failed}.values(): for client in {failed_client.connection: failed_client for failed_client in failed}.values():
self._disconnect_client(client) 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: with self._lock:
recipients = [ recipients = [
client for client in self._active_clients_locked() client
if exclude is None or client.connection != exclude.connection 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 successful = 0
failed: list[ClientSession] = [] failed: list[ClientSession] = []
@@ -1199,6 +1296,7 @@ class FalloutTogetherServer:
return return
self._log(f"Client disconnected: {client.label} (player {client.player_id})") self._log(f"Client disconnected: {client.label} (player {client.player_id})")
self._broadcast_disconnect(client) self._broadcast_disconnect(client)
self._reconcile_npc_authority()
if was_host: if was_host:
self._reassign_world_state_host() self._reassign_world_state_host()
@@ -1253,10 +1351,7 @@ class FalloutTogetherServer:
def _reject_packet(self, client: ClientSession, reason: str, *, warning: bool = True) -> None: def _reject_packet(self, client: ClientSession, reason: str, *, warning: bool = True) -> None:
with self._lock: with self._lock:
self._stats["packetsRejected"] += 1 self._stats["packetsRejected"] += 1
self._log( self._log(f"Rejected packet from {client.label} (player {client.player_id}): {reason}", level="warning" if warning else "debug")
f"Rejected packet from {client.label} (player {client.player_id}): {reason}",
level="warning" if warning else "debug",
)
def _should_log(self, level: str) -> bool: def _should_log(self, level: str) -> bool:
configured = _LOG_LEVELS.get(self.log_verbosity, _LOG_LEVELS[DEFAULT_LOG_VERBOSITY]) configured = _LOG_LEVELS.get(self.log_verbosity, _LOG_LEVELS[DEFAULT_LOG_VERBOSITY])