Add serverWorldState packet and GUI controls

Introduce a server-authoritative world-state packet to control weather and time from the dev server GUI. Adds protocol docs and message type (serverWorldState), a new Weather/Time tab in dev_server_app.py, server-side broadcasting in server_core.py, and preset utilities in server/world_state_presets.py. Plugin changes (F4TNetworking.cpp, F4TWorldStateSync.*) queue and apply incoming serverWorldState on the game thread, run `set gamehour` for all clients, and run `fw <8-digit-id>` on the assigned player 1 then force the host to relay a worldState snapshot. Also includes parsing/validation of hex form IDs, logging improvements, and minimal fake client handling. This ensures consistent server-driven time/weather and documents usage and presets.
This commit is contained in:
2026-06-23 23:03:34 +12:00
parent 70933db6e5
commit 4c17e70620
14 changed files with 757 additions and 3 deletions
+96
View File
@@ -10,6 +10,7 @@ from PySide6.QtWidgets import (
QApplication,
QAbstractItemView,
QGridLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
@@ -25,6 +26,7 @@ from PySide6.QtWidgets import (
from fake_player import FakePlayerManager
from server_core import FalloutTogetherServer
from world_state_presets import TIME_PRESETS, WEATHER_PRESETS, format_hhmm_label
REFRESH_INTERVAL_MS = 500
@@ -50,6 +52,7 @@ class DevServerWindow(QMainWindow):
)
self.stat_labels: dict[str, QLabel] = {}
self.world_state_labels: dict[str, QLabel] = {}
self.selected_fake_client_key: int | None = None
self.setWindowTitle("Commonwealth Online Dev Server")
@@ -73,6 +76,7 @@ class DevServerWindow(QMainWindow):
tabs = QTabWidget()
tabs.addTab(self._create_server_console_tab(), "Server Console")
tabs.addTab(self._create_fake_clients_tab(), "Fake Clients")
tabs.addTab(self._create_weather_time_tab(), "Weather / Time")
return tabs
def _create_server_console_tab(self) -> QWidget:
@@ -158,6 +162,76 @@ class DevServerWindow(QMainWindow):
layout.addStretch()
return layout
def _create_weather_time_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
status_group = QGroupBox("Server World State")
status_layout = QGridLayout(status_group)
status_fields = [
("time", "Current time (HHmm)"),
("weather", "Current weather"),
]
for index, (key, label_text) in enumerate(status_fields):
label = QLabel(f"{label_text}:")
value = QLabel("Not set")
value.setWordWrap(True)
self.world_state_labels[key] = value
status_layout.addWidget(label, index, 0)
status_layout.addWidget(value, index, 1)
layout.addWidget(status_group)
weather_group = QGroupBox("Set Weather")
weather_layout = QGridLayout(weather_group)
for index, (label, fw_console_arg) in enumerate(WEATHER_PRESETS):
button = QPushButton(label)
button.setToolTip(f"Runs `fw {fw_console_arg}` on player 1.")
button.clicked.connect(
lambda _checked=False, console_arg=fw_console_arg: self._set_server_weather(console_arg)
)
weather_layout.addWidget(button, index // 3, index % 3)
layout.addWidget(weather_group)
time_group = QGroupBox("Set Time (set gamehour to HHmm)")
time_layout = QGridLayout(time_group)
for index, (label, hhmm) in enumerate(TIME_PRESETS):
display = f"{label} ({format_hhmm_label(hhmm)})"
button = QPushButton(display)
button.setToolTip(f"Runs `set gamehour to {hhmm}` on all connected clients.")
button.clicked.connect(lambda _checked=False, value=hhmm: self._set_server_time(value))
time_layout.addWidget(button, index // 3, index % 3)
layout.addWidget(time_group)
hint = QLabel(
"Time is sent to all clients. Weather runs `fw <8-digit-id>` on player 1 only, "
"then player 1 relays weather to other clients. Edit preset IDs in "
"server/world_state_presets.py to match what works in your console."
)
hint.setWordWrap(True)
layout.addWidget(hint)
layout.addStretch()
return tab
def _set_server_weather(self, fw_console_arg: str) -> None:
if not self.server.is_running():
self._append_log("Start the server before setting weather.")
return
if self.server.set_server_weather(fw_console_arg):
self._append_log(f"Requested server weather command: fw {fw_console_arg}")
self._refresh_server_state()
def _set_server_time(self, hhmm: str) -> None:
if not self.server.is_running():
self._append_log("Start the server before setting time.")
return
if self.server.set_server_time(hhmm):
self._append_log(f"Requested server time: {hhmm}")
self._refresh_server_state()
def _create_fake_clients_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
@@ -442,6 +516,28 @@ class DevServerWindow(QMainWindow):
self._refresh_clients_table(clients)
self._refresh_fake_clients_table()
self._refresh_world_state_labels()
def _refresh_world_state_labels(self) -> None:
world_state = self.server.get_server_world_state()
time_label = self.world_state_labels.get("time")
weather_label = self.world_state_labels.get("weather")
if time_label is not None:
hhmm = world_state.get("timeHHmm")
if hhmm:
time_label.setText(f"{hhmm} ({format_hhmm_label(hhmm)})")
else:
time_label.setText("Not set")
if weather_label is not None:
console_arg = world_state.get("weatherConsoleArg")
form_id = world_state.get("weatherFormId")
if console_arg:
suffix = f" (relay 0x{form_id})" if form_id else ""
weather_label.setText(f"fw {console_arg}{suffix}")
else:
weather_label.setText("Not set")
def _refresh_clients_table(self, clients: list[dict[str, Any]]) -> None:
self.clients_table.setRowCount(len(clients))
+2
View File
@@ -260,6 +260,8 @@ def handle_packet(packet: dict[str, Any]) -> None:
elif packet_type == "worldState":
log(f"Received worldState: {packet}")
update_host_world_state(packet)
elif packet_type == "serverWorldState":
log(f"Received serverWorldState: {packet}")
elif packet_type == "worldStateHost":
log(f"Received worldStateHost: {packet}")
update_world_state_host(packet)
+107
View File
@@ -9,6 +9,10 @@ from typing import Any
from client_session import ClientSession
from lan_discovery import DISCOVERY_PORT, LanDiscoveryResponder
from world_state_presets import (
normalize_fw_console_arg,
relay_weather_form_id,
)
HOST = "0.0.0.0"
@@ -42,6 +46,7 @@ class FalloutTogetherServer:
self._started_at: float | None = None
self._next_player_id = 1
self._world_state_host_player_id: int | None = None
self._server_world_state: dict[str, str] = {}
self._stats: dict[str, int] = {
"clientsConnected": 0,
@@ -54,6 +59,7 @@ class FalloutTogetherServer:
"worldStatePacketsReceived": 0,
"worldStatePacketsBroadcast": 0,
"worldStateHostPacketsBroadcast": 0,
"serverWorldStatePacketsBroadcast": 0,
"disconnectPacketsBroadcast": 0,
}
@@ -137,6 +143,43 @@ class FalloutTogetherServer:
return stats
def get_server_world_state(self) -> dict[str, str]:
with self._lock:
return dict(self._server_world_state)
def set_server_time(self, hhmm: str) -> bool:
text = str(hhmm).strip()
if not text.isdigit() or len(text) > 4:
self._log(f"Rejected invalid server time HHmm value: {hhmm!r}")
return False
normalized = text.zfill(4)
hours = int(normalized[:2])
minutes = int(normalized[2:])
if hours > 23 or minutes > 59:
self._log(f"Rejected out-of-range server time HHmm value: {hhmm!r}")
return False
with self._lock:
self._server_world_state["timeHHmm"] = normalized
self._broadcast_server_world_state()
return True
def set_server_weather(self, fw_console_arg: str) -> bool:
try:
normalized_fw_arg = normalize_fw_console_arg(fw_console_arg)
except ValueError:
self._log(f"Rejected invalid server weather console id: {fw_console_arg!r}")
return False
with self._lock:
self._server_world_state["weatherConsoleArg"] = normalized_fw_arg
self._server_world_state["weatherFormId"] = relay_weather_form_id(normalized_fw_arg)
self._broadcast_server_world_state()
return True
def add_log_listener(self, callback: Callable[[str], None]) -> None:
with self._log_lock:
if callback not in self._log_listeners:
@@ -405,6 +448,70 @@ class FalloutTogetherServer:
f"gameHour={game_hour}, gameDaysPassed={game_days_passed}{weather_detail}"
)
def _broadcast_server_world_state(self) -> None:
with self._lock:
if not self._running:
self._log("Cannot broadcast server world state while the server is stopped.")
return
recipients = list(self._clients.values())
snapshot = dict(self._server_world_state)
time_hhmm = snapshot.get("timeHHmm")
weather_console_arg = snapshot.get("weatherConsoleArg")
weather_form_id = snapshot.get("weatherFormId")
if not time_hhmm and not weather_console_arg:
self._log("Server world state broadcast skipped: no time or weather set.")
return
server_time = time.time()
failed_recipients: list[ClientSession] = []
successful_sends = 0
if time_hhmm:
time_packet: dict[str, Any] = {
"type": "serverWorldState",
"timeHHmm": time_hhmm,
"serverTime": server_time,
}
for recipient in recipients:
try:
self._send_packet(recipient, time_packet, broadcast=True)
successful_sends += 1
except OSError:
failed_recipients.append(recipient)
self._log(
f"Broadcast serverWorldState time to {len(recipients)} client(s): timeHHmm={time_hhmm}"
)
if weather_console_arg:
weather_packet: dict[str, Any] = {
"type": "serverWorldState",
"weatherConsoleArg": weather_console_arg,
"serverTime": server_time,
}
if weather_form_id:
weather_packet["weatherFormId"] = weather_form_id
for recipient in recipients:
try:
self._send_packet(recipient, weather_packet, broadcast=True)
successful_sends += 1
except OSError:
failed_recipients.append(recipient)
self._log(
"Broadcast serverWorldState weather command to "
f"{len(recipients)} client(s): fw {weather_console_arg}"
)
with self._lock:
self._stats["serverWorldStatePacketsBroadcast"] += successful_sends
for recipient in failed_recipients:
self._disconnect_client(recipient)
def _reassign_world_state_host(self) -> None:
with self._lock:
if not self._clients:
+66
View File
@@ -0,0 +1,66 @@
"""Preset weather and time values for the dev server Weather/Time tab."""
from __future__ import annotations
# Each preset is (label, fw_console_arg).
# `fw_console_arg` is passed directly to the in-game `fw` command on player 1
# (8-digit hex form ID, not an editor name like CommonwealthRain).
WEATHER_PRESETS: list[tuple[str, str]] = [
("Clear", "00148c3c"),
("Cloudy", "00148c42"),
("Overcast", "00148c43"),
("Fog", "00148c44"),
("Rain", "00148c45"),
("Radstorm", "00148c47"),
("Glowing Sea", "001b9779"),
]
# HHmm values for the in-game `set gamehour to HHmm` console command.
TIME_PRESETS: list[tuple[str, str]] = [
("Midnight", "0000"),
("Dawn", "0600"),
("Morning", "0900"),
("Noon", "1200"),
("Afternoon", "1500"),
("Evening", "1800"),
("Dusk (7 PM)", "1900"),
("Night", "2200"),
]
def normalize_fw_console_arg(value: str) -> str:
text = str(value).strip().lower()
if text.startswith("0x"):
text = text[2:]
return f"{int(text, 16):08x}"
def relay_weather_form_id(fw_console_arg: str) -> str:
return f"{int(fw_console_arg, 16):08X}"
def hhmm_to_game_hour(hhmm: str) -> float | None:
text = str(hhmm).strip()
if not text.isdigit() or len(text) > 4:
return None
padded = text.zfill(4)
hours = int(padded[:2])
minutes = int(padded[2:])
if hours > 23 or minutes > 59:
return None
return hours + (minutes / 60.0)
def format_hhmm_label(hhmm: str) -> str:
text = str(hhmm).strip().zfill(4)
hours = int(text[:2])
minutes = int(text[2:])
suffix = "AM" if hours < 12 else "PM"
display_hour = hours % 12
if display_hour == 0:
display_hour = 12
if minutes:
return f"{display_hour}:{minutes:02d} {suffix}"
return f"{display_hour} {suffix}"