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:
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user