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,10 +10,19 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Server-authoritative weather and time control via `serverWorldState` packets from the dev server GUI.
|
||||||
|
- **Weather / Time** tab in `server/dev_server_app.py` with preset buttons for `fw` weather form IDs and `set gamehour to HHmm` time values.
|
||||||
|
- `server/world_state_presets.py` with shared weather and time preset definitions.
|
||||||
|
- `protocol/server-world-state.md` documenting the new packet type.
|
||||||
- Event-driven world time resync via optional `timeSync` field on `worldState` packets; host (client 1) pushes time after menus, loading screens, save/load, wait/sleep, and other time freezes.
|
- Event-driven world time resync via optional `timeSync` field on `worldState` packets; host (client 1) pushes time after menus, loading screens, save/load, wait/sleep, and other time freezes.
|
||||||
- `timeSync` tracking in `server/fake_client.py` host world-state snapshot output.
|
- `timeSync` tracking in `server/fake_client.py` host world-state snapshot output.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Server GUI weather now runs `fw <8-digit-form-id>` on player 1 only (not editor names), scheduled on the game thread.
|
||||||
|
- Weather `serverWorldState` packets are broadcast to all clients so player 1's game instance always receives them.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- Weather presets store the exact `fw` console argument (8-digit hex); edit `server/world_state_presets.py` only if a preset ID fails in your console.
|
||||||
- World time no longer re-applies on every `worldState` heartbeat; weather heartbeats continue at ~1 Hz while time stays in sync during normal gameplay.
|
- World time no longer re-applies on every `worldState` heartbeat; weather heartbeats continue at ~1 Hz while time stays in sync during normal gameplay.
|
||||||
- Reformatted `changelog.md` to follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
- Reformatted `changelog.md` to follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ both send and receive paths:
|
|||||||
host sends `worldState` packets with `gameHour`, `gameDaysPassed`, optional
|
host sends `worldState` packets with `gameHour`, `gameDaysPassed`, optional
|
||||||
`weatherFormId`, and optional `timeSync`. Weather heartbeats run at ~1 Hz;
|
`weatherFormId`, and optional `timeSync`. Weather heartbeats run at ~1 Hz;
|
||||||
time is pushed with `timeSync: true` on connect, after time freezes (menus,
|
time is pushed with `timeSync: true` on connect, after time freezes (menus,
|
||||||
loads, saves), and on large time jumps. Promoted hosts continue with their
|
loads, saves), and on large time jumps. The dev server GUI can also broadcast
|
||||||
|
`serverWorldState` to all clients for admin weather (`fw`) and time
|
||||||
|
(`set gamehour to HHmm`) commands. Promoted hosts continue with their
|
||||||
already-synced local time and weather so remaining clients should not see a jump.
|
already-synced local time and weather so remaining clients should not see a jump.
|
||||||
12. On the game-thread update path, the proxy controller reads a copied
|
12. On the game-thread update path, the proxy controller reads a copied
|
||||||
remote-player snapshot before attempting any actor work. The snapshot is
|
remote-player snapshot before attempting any actor work. The snapshot is
|
||||||
|
|||||||
+131
@@ -9,6 +9,137 @@ failed experiments, successful tests, and next steps.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-23 - GUI Weather Uses Numeric `fw` ID
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Fixed dev server GUI weather by matching manual console behaviour: player 1 runs `fw <8-digit-hex-form-id>` on the game thread instead of `fw <editorName>`. Weather packets are broadcast to all clients; only player 1 executes.
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `plugin/src/F4TWorldStateSync.cpp`
|
||||||
|
- `plugin/src/F4TNetworking.cpp`
|
||||||
|
- `plugin/include/F4TWorldStateSync.h`
|
||||||
|
- `server/world_state_presets.py`
|
||||||
|
- `server/server_core.py`
|
||||||
|
- `server/dev_server_app.py`
|
||||||
|
- `protocol/server-world-state.md`
|
||||||
|
- `changelog.md`
|
||||||
|
|
||||||
|
### Details
|
||||||
|
- `weatherConsoleArg` is the exact `fw` argument (8-digit hex, e.g. `00148c45`).
|
||||||
|
- `weatherFormId` is used for host `worldState` relay after `fw` runs.
|
||||||
|
- Preset IDs in `world_state_presets.py` may differ from what works in a given load order; edit them to match console-tested IDs.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- User confirmed `fw` works with a numeric form ID (e.g. `fw 0000015E`) but not with editor names like `fw CommonwealthRain`.
|
||||||
|
- Plugin rebuild and GUI retest pending.
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- None currently known.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- Rebuild plugin, connect game client as player 1 (first to connect), click a weather preset outdoors.
|
||||||
|
- Check `CommonwealthOnline.log` for `Player 1 executed server weather command: fw ...`.
|
||||||
|
- Update `WEATHER_PRESETS` if needed to use IDs verified in your console.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-23 - Host-Console Weather Relay
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Server GUI weather now runs only on client 1: the host executes `fw <editorId>` as if typed in the console, then immediately relays `worldState` so other clients sync through the existing host weather path.
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `plugin/src/F4TWorldStateSync.cpp`
|
||||||
|
- `server/server_core.py`
|
||||||
|
- `server/dev_server_app.py`
|
||||||
|
- `protocol/server-world-state.md`
|
||||||
|
- `changelog.md`
|
||||||
|
|
||||||
|
### Details
|
||||||
|
- Non-host clients ignore weather fields in `serverWorldState`.
|
||||||
|
- Server sends weather packets only to the connected world-state host.
|
||||||
|
- Host force-broadcasts `worldState` with the requested `weatherFormId` after running `fw`.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Not re-tested in-game in this session.
|
||||||
|
- Recommended: two clients outdoors, client 1 connected first; set Rain from GUI; confirm host log shows `fw CommonwealthRain` and client 2 receives host `worldState`.
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- None currently known.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- In-game verification that host console `fw` works from plugin `ExecuteCommand`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-23 - Server Weather Apply Fix
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Fixed dev server GUI weather not applying and clients drifting to different weather after a server time change.
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `plugin/src/F4TWorldStateSync.cpp`
|
||||||
|
- `plugin/include/F4TWorldStateSync.h`
|
||||||
|
- `plugin/src/F4TNetworking.cpp`
|
||||||
|
- `server/server_core.py`
|
||||||
|
- `server/world_state_presets.py`
|
||||||
|
- `server/dev_server_app.py`
|
||||||
|
- `protocol/server-world-state.md`
|
||||||
|
- `changelog.md`
|
||||||
|
|
||||||
|
### Details
|
||||||
|
- Weather now uses `Sky::ForceWeather` with hex `weatherFormId` instead of `fw` via `ExecuteCommand`.
|
||||||
|
- Server broadcasts the full snapshot on every GUI change so time buttons also re-send weather.
|
||||||
|
- Host weather capture state updates after server weather apply to avoid stale host heartbeats.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Not re-tested in-game in this session.
|
||||||
|
- Recommended: two clients outdoors, set weather from GUI, then change time and confirm both stay on the same weather.
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- None currently known.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- Verify Glowing Sea form ID (`001B9779`) in-game; adjust preset if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-23 - Server Weather and Time GUI
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Added server-authoritative weather and time control from the dev server GUI. A new **Weather / Time** tab broadcasts `serverWorldState` packets to all connected clients, which apply `fw <editorId>` for weather and `set gamehour to HHmm` for time.
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `server/server_core.py`
|
||||||
|
- `server/dev_server_app.py`
|
||||||
|
- `server/world_state_presets.py`
|
||||||
|
- `server/fake_client.py`
|
||||||
|
- `plugin/include/F4TWorldStateSync.h`
|
||||||
|
- `plugin/src/F4TWorldStateSync.cpp`
|
||||||
|
- `plugin/src/F4TNetworking.cpp`
|
||||||
|
- `protocol/server-world-state.md`
|
||||||
|
- `protocol/message-types.md`
|
||||||
|
- `docs/protocol.md`
|
||||||
|
- `changelog.md`
|
||||||
|
|
||||||
|
### Details
|
||||||
|
- Dev server stores the latest requested time/weather and broadcasts to every connected client.
|
||||||
|
- Plugin queues `serverWorldState` on the network thread and runs console commands on the game thread.
|
||||||
|
- Weather presets use Commonwealth editor IDs (`CommonwealthClear`, `CommonwealthRain`, etc.) because `setweather` does not work on Fallout 4.
|
||||||
|
- Time presets use HHmm values such as `1900` for 7:00 PM.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Not run in-game in this session.
|
||||||
|
- Recommended: start dev server GUI, connect one or two game clients outdoors, use Weather / Time tab buttons, verify `CommonwealthOnline.log` shows applied server commands and in-game weather/time changes.
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- None currently known.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- In-game verification with two clients and mixed interior/exterior weather apply behaviour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-06-23 - Event-Driven Time Sync
|
## 2026-06-23 - Event-Driven Time Sync
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Long-term versions may move to a binary protocol if performance becomes a proble
|
|||||||
- `transform`
|
- `transform`
|
||||||
- `worldState`
|
- `worldState`
|
||||||
- `worldStateHost`
|
- `worldStateHost`
|
||||||
|
- `serverWorldState`
|
||||||
- `disconnect`
|
- `disconnect`
|
||||||
- `ping`
|
- `ping`
|
||||||
- `pong`
|
- `pong`
|
||||||
|
|||||||
@@ -15,11 +15,19 @@ namespace F4T::WorldStateSync
|
|||||||
bool applyTime{ false };
|
bool applyTime{ false };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct PendingServerWorldState
|
||||||
|
{
|
||||||
|
std::optional<std::string> timeHHmm;
|
||||||
|
std::optional<std::string> weatherFormId;
|
||||||
|
std::optional<std::string> weatherConsoleArg;
|
||||||
|
};
|
||||||
|
|
||||||
bool IsWorldStateHost();
|
bool IsWorldStateHost();
|
||||||
std::optional<std::uint32_t> GetWorldStateHostPlayerId();
|
std::optional<std::uint32_t> GetWorldStateHostPlayerId();
|
||||||
void SetWorldStateHostPlayerId(std::uint32_t a_hostPlayerId);
|
void SetWorldStateHostPlayerId(std::uint32_t a_hostPlayerId);
|
||||||
void Reset();
|
void Reset();
|
||||||
void SetPendingWorldState(PendingWorldState a_state);
|
void SetPendingWorldState(PendingWorldState a_state);
|
||||||
|
void SetPendingServerWorldState(PendingServerWorldState a_state);
|
||||||
void PollHostCapture();
|
void PollHostCapture();
|
||||||
void ApplyPendingState();
|
void ApplyPendingState();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,6 +359,55 @@ namespace
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void HandleServerWorldStatePacket(const Json& a_packet)
|
||||||
|
{
|
||||||
|
F4T::WorldStateSync::PendingServerWorldState pendingState{};
|
||||||
|
|
||||||
|
const auto timeHHmm = ReadString(a_packet, "timeHHmm");
|
||||||
|
if (timeHHmm && !timeHHmm->empty()) {
|
||||||
|
pendingState.timeHHmm = *timeHHmm;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto weatherConsoleArg = ReadString(a_packet, "weatherConsoleArg");
|
||||||
|
if (weatherConsoleArg && !weatherConsoleArg->empty()) {
|
||||||
|
pendingState.weatherConsoleArg = *weatherConsoleArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto weatherFormId = ReadString(a_packet, "weatherFormId");
|
||||||
|
if (weatherFormId && !weatherFormId->empty()) {
|
||||||
|
pendingState.weatherFormId = *weatherFormId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingState.timeHHmm && !pendingState.weatherConsoleArg && !pendingState.weatherFormId) {
|
||||||
|
LogThrottledWarning(
|
||||||
|
"server_world_state_empty",
|
||||||
|
"Ignoring serverWorldState packet without timeHHmm or weather fields.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ShouldLogRemoteUpdate(1)) {
|
||||||
|
std::string details;
|
||||||
|
if (pendingState.timeHHmm) {
|
||||||
|
details += std::format("timeHHmm={}", *pendingState.timeHHmm);
|
||||||
|
}
|
||||||
|
if (pendingState.weatherConsoleArg) {
|
||||||
|
if (!details.empty()) {
|
||||||
|
details += ", ";
|
||||||
|
}
|
||||||
|
details += std::format("weatherConsoleArg={}", *pendingState.weatherConsoleArg);
|
||||||
|
}
|
||||||
|
if (pendingState.weatherFormId) {
|
||||||
|
if (!details.empty()) {
|
||||||
|
details += ", ";
|
||||||
|
}
|
||||||
|
details += std::format("weatherFormId={}", *pendingState.weatherFormId);
|
||||||
|
}
|
||||||
|
LogInfoWithLocalPlayerPrefix(std::format("Queued serverWorldState: {}.", details));
|
||||||
|
}
|
||||||
|
|
||||||
|
F4T::WorldStateSync::SetPendingServerWorldState(std::move(pendingState));
|
||||||
|
}
|
||||||
|
|
||||||
void HandleWorldStatePacket(const Json& a_packet)
|
void HandleWorldStatePacket(const Json& a_packet)
|
||||||
{
|
{
|
||||||
const auto gameHour = ReadDouble(a_packet, "gameHour");
|
const auto gameHour = ReadDouble(a_packet, "gameHour");
|
||||||
@@ -603,6 +652,8 @@ namespace
|
|||||||
HandleTransformPacket(packet);
|
HandleTransformPacket(packet);
|
||||||
} else if (*packetType == "worldState") {
|
} else if (*packetType == "worldState") {
|
||||||
HandleWorldStatePacket(packet);
|
HandleWorldStatePacket(packet);
|
||||||
|
} else if (*packetType == "serverWorldState") {
|
||||||
|
HandleServerWorldStatePacket(packet);
|
||||||
} else if (*packetType == "worldStateHost") {
|
} else if (*packetType == "worldStateHost") {
|
||||||
HandleWorldStateHostPacket(packet);
|
HandleWorldStateHostPacket(packet);
|
||||||
} else if (*packetType == "disconnect") {
|
} else if (*packetType == "disconnect") {
|
||||||
|
|||||||
@@ -5,8 +5,11 @@
|
|||||||
#include "F4TNetworking.h"
|
#include "F4TNetworking.h"
|
||||||
#include "F4TRemotePlayerState.h"
|
#include "F4TRemotePlayerState.h"
|
||||||
|
|
||||||
|
#include "F4SE/Interfaces.h"
|
||||||
|
|
||||||
#include "RE/B/BGSSaveLoadManager.h"
|
#include "RE/B/BGSSaveLoadManager.h"
|
||||||
#include "RE/C/Calendar.h"
|
#include "RE/C/Calendar.h"
|
||||||
|
#include "RE/C/Console.h"
|
||||||
#include "RE/L/LoadingMenu.h"
|
#include "RE/L/LoadingMenu.h"
|
||||||
#include "RE/M/Main.h"
|
#include "RE/M/Main.h"
|
||||||
#include "RE/M/MainMenu.h"
|
#include "RE/M/MainMenu.h"
|
||||||
@@ -21,6 +24,7 @@
|
|||||||
#include "RE/U/UI.h"
|
#include "RE/U/UI.h"
|
||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <charconv>
|
||||||
#include <format>
|
#include <format>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
@@ -39,6 +43,8 @@ namespace
|
|||||||
std::mutex g_pendingMutex;
|
std::mutex g_pendingMutex;
|
||||||
bool g_hasPendingWorldState{ false };
|
bool g_hasPendingWorldState{ false };
|
||||||
F4T::WorldStateSync::PendingWorldState g_pendingWorldState{};
|
F4T::WorldStateSync::PendingWorldState g_pendingWorldState{};
|
||||||
|
bool g_hasPendingServerWorldState{ false };
|
||||||
|
F4T::WorldStateSync::PendingServerWorldState g_pendingServerWorldState{};
|
||||||
|
|
||||||
struct LastSentWorldState
|
struct LastSentWorldState
|
||||||
{
|
{
|
||||||
@@ -172,6 +178,102 @@ namespace
|
|||||||
return currentWeather->GetFormID();
|
return currentWeather->GetFormID();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::optional<std::uint32_t> ParseHexFormId(std::string_view a_hex)
|
||||||
|
{
|
||||||
|
if (a_hex.empty()) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint32_t value = 0;
|
||||||
|
const auto* begin = a_hex.data();
|
||||||
|
const auto* end = begin + a_hex.size();
|
||||||
|
const auto result = std::from_chars(begin, end, value, 16);
|
||||||
|
if (result.ec != std::errc{} || result.ptr != end) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ForceHostWorldStateBroadcast(std::optional<std::uint32_t> a_forcedWeatherFormId, bool a_sendTime)
|
||||||
|
{
|
||||||
|
if (!F4T::WorldStateSync::IsWorldStateHost() || !F4T::Networking::IsConnectedToServer()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
float gameHour = 0.0F;
|
||||||
|
float gameDaysPassed = 0.0F;
|
||||||
|
if (!TryReadCalendarValues(gameHour, gameDaysPassed)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasWeatherFormId = false;
|
||||||
|
std::uint32_t weatherFormId = 0U;
|
||||||
|
if (a_forcedWeatherFormId) {
|
||||||
|
hasWeatherFormId = true;
|
||||||
|
weatherFormId = *a_forcedWeatherFormId;
|
||||||
|
} else if (const auto currentWeatherFormId = TryReadExteriorWeatherFormId()) {
|
||||||
|
hasWeatherFormId = true;
|
||||||
|
weatherFormId = *currentWeatherFormId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!F4T::Networking::SendWorldStatePacket(
|
||||||
|
gameHour,
|
||||||
|
gameDaysPassed,
|
||||||
|
hasWeatherFormId,
|
||||||
|
weatherFormId,
|
||||||
|
a_sendTime)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a_sendTime) {
|
||||||
|
g_lastSentWorldState.gameHour = gameHour;
|
||||||
|
g_lastSentWorldState.gameDaysPassed = gameDaysPassed;
|
||||||
|
}
|
||||||
|
g_lastSentWorldState.hasWeatherFormId = hasWeatherFormId;
|
||||||
|
g_lastSentWorldState.weatherFormId = hasWeatherFormId ? std::optional<std::uint32_t>{ weatherFormId } : std::nullopt;
|
||||||
|
g_lastSentWorldState.initialized = true;
|
||||||
|
g_lastHostSendTime = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
if (ShouldLog("server_world_state_host_relay")) {
|
||||||
|
if (hasWeatherFormId) {
|
||||||
|
LogInfo(std::format(
|
||||||
|
"Relayed host worldState after server weather command: gameHour={:.2f}, weatherFormId=0x{:08X}.",
|
||||||
|
gameHour,
|
||||||
|
weatherFormId));
|
||||||
|
} else {
|
||||||
|
LogInfo(std::format(
|
||||||
|
"Relayed host worldState after server weather command: gameHour={:.2f}, weatherFormId=<none>.",
|
||||||
|
gameHour));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::string> BuildHostWeatherConsoleArg(
|
||||||
|
const std::optional<std::string>& a_weatherConsoleArg,
|
||||||
|
const std::optional<std::string>& a_weatherFormId)
|
||||||
|
{
|
||||||
|
if (a_weatherConsoleArg && !a_weatherConsoleArg->empty()) {
|
||||||
|
return *a_weatherConsoleArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a_weatherFormId && !a_weatherFormId->empty()) {
|
||||||
|
if (const auto parsed = ParseHexFormId(*a_weatherFormId)) {
|
||||||
|
return std::format("{:08x}", *parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsAssignedPlayerOne()
|
||||||
|
{
|
||||||
|
const auto assignedPlayerId = F4T::RemotePlayerState::GetAssignedPlayerId();
|
||||||
|
return assignedPlayerId && *assignedPlayerId == 1U;
|
||||||
|
}
|
||||||
|
|
||||||
bool ShouldSendTimeState(float a_gameHour, float a_gameDaysPassed)
|
bool ShouldSendTimeState(float a_gameHour, float a_gameDaysPassed)
|
||||||
{
|
{
|
||||||
if (!g_lastSentWorldState.initialized) {
|
if (!g_lastSentWorldState.initialized) {
|
||||||
@@ -287,6 +389,78 @@ namespace
|
|||||||
g_lastSentWorldState.initialized = false;
|
g_lastSentWorldState.initialized = false;
|
||||||
g_lastHostSendTime = std::chrono::steady_clock::now();
|
g_lastHostSendTime = std::chrono::steady_clock::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ApplyServerWorldState(const F4T::WorldStateSync::PendingServerWorldState& a_state)
|
||||||
|
{
|
||||||
|
if (a_state.timeHHmm && !a_state.timeHHmm->empty()) {
|
||||||
|
const auto command = std::format("set gamehour to {}", *a_state.timeHHmm);
|
||||||
|
RE::Console::ExecuteCommand(command.c_str());
|
||||||
|
|
||||||
|
float gameHour = 0.0F;
|
||||||
|
float gameDaysPassed = 0.0F;
|
||||||
|
if (TryReadCalendarValues(gameHour, gameDaysPassed)) {
|
||||||
|
g_latestHostTime.gameHour = gameHour;
|
||||||
|
g_latestHostTime.gameDaysPassed = gameDaysPassed;
|
||||||
|
g_latestHostTime.initialized = true;
|
||||||
|
g_lastAppliedWorldState.gameHour = gameHour;
|
||||||
|
g_lastAppliedWorldState.gameDaysPassed = gameDaysPassed;
|
||||||
|
g_lastAppliedWorldState.initialized = true;
|
||||||
|
|
||||||
|
if (F4T::WorldStateSync::IsWorldStateHost()) {
|
||||||
|
g_lastSentWorldState.gameHour = gameHour;
|
||||||
|
g_lastSentWorldState.gameDaysPassed = gameDaysPassed;
|
||||||
|
g_lastSentWorldState.initialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ShouldLog("server_world_state_time_apply")) {
|
||||||
|
LogInfo(std::format("Applied server time command: {}", command));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool hasWeatherRequest =
|
||||||
|
(a_state.weatherConsoleArg && !a_state.weatherConsoleArg->empty()) ||
|
||||||
|
(a_state.weatherFormId && !a_state.weatherFormId->empty());
|
||||||
|
if (!hasWeatherRequest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsAssignedPlayerOne()) {
|
||||||
|
if (ShouldLog("server_world_state_weather_skip")) {
|
||||||
|
LogInfo("Ignored server weather command on non-player-1 client.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto fwArg = BuildHostWeatherConsoleArg(a_state.weatherConsoleArg, a_state.weatherFormId);
|
||||||
|
if (!fwArg) {
|
||||||
|
if (ShouldLog("server_world_state_weather_lookup_failed")) {
|
||||||
|
LogWarning("Could not build server weather fw argument.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto command = std::format("fw {}", *fwArg);
|
||||||
|
RE::Console::ExecuteCommand(command.c_str());
|
||||||
|
|
||||||
|
if (ShouldLog("server_world_state_weather_apply")) {
|
||||||
|
LogInfo(std::format("Player 1 executed server weather command: {}", command));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::uint32_t> forcedWeatherFormId;
|
||||||
|
if (a_state.weatherFormId && !a_state.weatherFormId->empty()) {
|
||||||
|
forcedWeatherFormId = ParseHexFormId(*a_state.weatherFormId);
|
||||||
|
} else if (const auto parsed = ParseHexFormId(*fwArg)) {
|
||||||
|
forcedWeatherFormId = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (F4T::WorldStateSync::IsWorldStateHost()) {
|
||||||
|
ForceHostWorldStateBroadcast(forcedWeatherFormId, false);
|
||||||
|
} else if (ShouldLog("server_world_state_weather_no_relay")) {
|
||||||
|
LogWarning(
|
||||||
|
"Player 1 applied server weather but is not world-state host; other clients may not sync.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace F4T::WorldStateSync
|
namespace F4T::WorldStateSync
|
||||||
@@ -348,6 +522,8 @@ namespace F4T::WorldStateSync
|
|||||||
const std::scoped_lock lock(g_pendingMutex);
|
const std::scoped_lock lock(g_pendingMutex);
|
||||||
g_hasPendingWorldState = false;
|
g_hasPendingWorldState = false;
|
||||||
g_pendingWorldState = {};
|
g_pendingWorldState = {};
|
||||||
|
g_hasPendingServerWorldState = false;
|
||||||
|
g_pendingServerWorldState = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
g_lastSentWorldState = {};
|
g_lastSentWorldState = {};
|
||||||
@@ -372,6 +548,21 @@ namespace F4T::WorldStateSync
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SetPendingServerWorldState(PendingServerWorldState a_state)
|
||||||
|
{
|
||||||
|
const auto* taskInterface = F4SE::GetTaskInterface();
|
||||||
|
if (!taskInterface) {
|
||||||
|
const std::scoped_lock lock(g_pendingMutex);
|
||||||
|
g_pendingServerWorldState = std::move(a_state);
|
||||||
|
g_hasPendingServerWorldState = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
taskInterface->AddTask([state = std::move(a_state)]() mutable {
|
||||||
|
ApplyServerWorldState(state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void PollHostCapture()
|
void PollHostCapture()
|
||||||
{
|
{
|
||||||
if (!Networking::IsConnectedToServer() || !IsWorldStateHost()) {
|
if (!Networking::IsConnectedToServer() || !IsWorldStateHost()) {
|
||||||
@@ -436,7 +627,26 @@ namespace F4T::WorldStateSync
|
|||||||
|
|
||||||
void ApplyPendingState()
|
void ApplyPendingState()
|
||||||
{
|
{
|
||||||
if (!Networking::IsConnectedToServer() || IsWorldStateHost()) {
|
if (!Networking::IsConnectedToServer()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingServerWorldState pendingServerState{};
|
||||||
|
bool hasPendingServerState = false;
|
||||||
|
{
|
||||||
|
const std::scoped_lock lock(g_pendingMutex);
|
||||||
|
if (g_hasPendingServerWorldState) {
|
||||||
|
pendingServerState = g_pendingServerWorldState;
|
||||||
|
g_hasPendingServerWorldState = false;
|
||||||
|
hasPendingServerState = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPendingServerState) {
|
||||||
|
ApplyServerWorldState(pendingServerState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsWorldStateHost()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ The first protocol should be simple and focused on player transform sync.
|
|||||||
- Welcome packet
|
- Welcome packet
|
||||||
- Transform packet
|
- Transform packet
|
||||||
- World state packet (host time and weather) — see [`world-state.md`](world-state.md)
|
- World state packet (host time and weather) — see [`world-state.md`](world-state.md)
|
||||||
|
- Server world state packet (dev GUI weather/time) — see [`server-world-state.md`](server-world-state.md)
|
||||||
- Disconnect packet
|
- Disconnect packet
|
||||||
|
|
||||||
## Future Packet Types
|
## Future Packet Types
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ welcome
|
|||||||
transform
|
transform
|
||||||
worldState
|
worldState
|
||||||
worldStateHost
|
worldStateHost
|
||||||
|
serverWorldState
|
||||||
disconnect
|
disconnect
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -23,10 +24,12 @@ Current behavior:
|
|||||||
server, and broadcast to other connected clients.
|
server, and broadcast to other connected clients.
|
||||||
- `worldStateHost` is sent in `welcome` and when the host disconnects to
|
- `worldStateHost` is sent in `welcome` and when the host disconnects to
|
||||||
reassign host to the lowest remaining `playerId`.
|
reassign host to the lowest remaining `playerId`.
|
||||||
|
- `serverWorldState` is sent by the server to all connected clients when the
|
||||||
|
dev server GUI sets weather or time.
|
||||||
- `disconnect` is sent by the server when a client disconnects.
|
- `disconnect` is sent by the server when a client disconnects.
|
||||||
|
|
||||||
The Fallout 4 plugin sends transform packets and receives `welcome`,
|
The Fallout 4 plugin sends transform packets and receives `welcome`,
|
||||||
`transform`, `worldState`, and `disconnect` packets. Received transforms are
|
`transform`, `worldState`, `serverWorldState`, and `disconnect` packets. Received transforms are
|
||||||
stored as plain remote-player state. Non-host clients apply received world state
|
stored as plain remote-player state. Non-host clients apply received world state
|
||||||
on the game thread.
|
on the game thread.
|
||||||
|
|
||||||
@@ -39,6 +42,7 @@ welcome
|
|||||||
transform
|
transform
|
||||||
worldState
|
worldState
|
||||||
worldStateHost
|
worldStateHost
|
||||||
|
serverWorldState
|
||||||
disconnect
|
disconnect
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Server World State Packet
|
||||||
|
|
||||||
|
Server-authoritative weather and time control from the dev server GUI.
|
||||||
|
|
||||||
|
## Authority
|
||||||
|
|
||||||
|
- **Time** (`timeHHmm`): sent to **all** clients; each runs `set gamehour to HHmm`.
|
||||||
|
- **Weather** (`weatherConsoleArg`): sent to **all** clients, but only **player 1** runs `fw <8-digit-id>`. Player 1 should also be the world-state host so the usual `worldState` relay updates other clients.
|
||||||
|
|
||||||
|
## Packet Format
|
||||||
|
|
||||||
|
Time-only (all clients):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "serverWorldState",
|
||||||
|
"timeHHmm": "1900",
|
||||||
|
"serverTime": 1780212128.301
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Weather (all clients receive; only player 1 acts):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "serverWorldState",
|
||||||
|
"weatherConsoleArg": "00148c45",
|
||||||
|
"weatherFormId": "00148C45",
|
||||||
|
"serverTime": 1780212128.301
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Field Definitions
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `type` | string | yes | Must be `"serverWorldState"` |
|
||||||
|
| `timeHHmm` | string | no | 24-hour clock value for `set gamehour to HHmm` |
|
||||||
|
| `weatherConsoleArg` | string | no | Exact argument for `fw` on player 1 (8-digit lowercase hex) |
|
||||||
|
| `weatherFormId` | string | no | Relay form ID used in host `worldState` broadcast |
|
||||||
|
| `serverTime` | number (double) | no | Relay timestamp |
|
||||||
|
|
||||||
|
## Client Apply Rules
|
||||||
|
|
||||||
|
- **Time:** all clients apply `set gamehour to HHmm` on the game thread.
|
||||||
|
- **Weather:** only the client with assigned `playerId == 1` runs `fw <weatherConsoleArg>`.
|
||||||
|
- Weather commands are scheduled on the F4SE game thread from the network receive path.
|
||||||
|
- After player 1 runs `fw`, the world-state host immediately relays `worldState` with `weatherFormId`.
|
||||||
|
|
||||||
|
Edit `server/world_state_presets.py` so each preset uses the exact `fw` ID that works in your console (for example `0000015e`).
|
||||||
|
|
||||||
|
## Preset Weather Console Args
|
||||||
|
|
||||||
|
| Label | Default `fw` arg |
|
||||||
|
|-------|------------------|
|
||||||
|
| Clear | `00148c3c` |
|
||||||
|
| Cloudy | `00148c42` |
|
||||||
|
| Overcast | `00148c43` |
|
||||||
|
| Fog | `00148c44` |
|
||||||
|
| Rain | `00148c45` |
|
||||||
|
| Radstorm | `00148c47` |
|
||||||
|
| Glowing Sea | `001b9779` |
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
Older clients that do not handle `serverWorldState` ignore the packet safely.
|
||||||
@@ -10,6 +10,7 @@ from PySide6.QtWidgets import (
|
|||||||
QApplication,
|
QApplication,
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
|
QGroupBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QHeaderView,
|
QHeaderView,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -25,6 +26,7 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from fake_player import FakePlayerManager
|
from fake_player import FakePlayerManager
|
||||||
from server_core import FalloutTogetherServer
|
from server_core import FalloutTogetherServer
|
||||||
|
from world_state_presets import TIME_PRESETS, WEATHER_PRESETS, format_hhmm_label
|
||||||
|
|
||||||
|
|
||||||
REFRESH_INTERVAL_MS = 500
|
REFRESH_INTERVAL_MS = 500
|
||||||
@@ -50,6 +52,7 @@ class DevServerWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.stat_labels: dict[str, QLabel] = {}
|
self.stat_labels: dict[str, QLabel] = {}
|
||||||
|
self.world_state_labels: dict[str, QLabel] = {}
|
||||||
self.selected_fake_client_key: int | None = None
|
self.selected_fake_client_key: int | None = None
|
||||||
|
|
||||||
self.setWindowTitle("Commonwealth Online Dev Server")
|
self.setWindowTitle("Commonwealth Online Dev Server")
|
||||||
@@ -73,6 +76,7 @@ class DevServerWindow(QMainWindow):
|
|||||||
tabs = QTabWidget()
|
tabs = QTabWidget()
|
||||||
tabs.addTab(self._create_server_console_tab(), "Server Console")
|
tabs.addTab(self._create_server_console_tab(), "Server Console")
|
||||||
tabs.addTab(self._create_fake_clients_tab(), "Fake Clients")
|
tabs.addTab(self._create_fake_clients_tab(), "Fake Clients")
|
||||||
|
tabs.addTab(self._create_weather_time_tab(), "Weather / Time")
|
||||||
return tabs
|
return tabs
|
||||||
|
|
||||||
def _create_server_console_tab(self) -> QWidget:
|
def _create_server_console_tab(self) -> QWidget:
|
||||||
@@ -158,6 +162,76 @@ class DevServerWindow(QMainWindow):
|
|||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
return layout
|
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:
|
def _create_fake_clients_tab(self) -> QWidget:
|
||||||
tab = QWidget()
|
tab = QWidget()
|
||||||
layout = QVBoxLayout(tab)
|
layout = QVBoxLayout(tab)
|
||||||
@@ -442,6 +516,28 @@ class DevServerWindow(QMainWindow):
|
|||||||
|
|
||||||
self._refresh_clients_table(clients)
|
self._refresh_clients_table(clients)
|
||||||
self._refresh_fake_clients_table()
|
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:
|
def _refresh_clients_table(self, clients: list[dict[str, Any]]) -> None:
|
||||||
self.clients_table.setRowCount(len(clients))
|
self.clients_table.setRowCount(len(clients))
|
||||||
|
|||||||
@@ -260,6 +260,8 @@ def handle_packet(packet: dict[str, Any]) -> None:
|
|||||||
elif packet_type == "worldState":
|
elif packet_type == "worldState":
|
||||||
log(f"Received worldState: {packet}")
|
log(f"Received worldState: {packet}")
|
||||||
update_host_world_state(packet)
|
update_host_world_state(packet)
|
||||||
|
elif packet_type == "serverWorldState":
|
||||||
|
log(f"Received serverWorldState: {packet}")
|
||||||
elif packet_type == "worldStateHost":
|
elif packet_type == "worldStateHost":
|
||||||
log(f"Received worldStateHost: {packet}")
|
log(f"Received worldStateHost: {packet}")
|
||||||
update_world_state_host(packet)
|
update_world_state_host(packet)
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ from typing import Any
|
|||||||
|
|
||||||
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 world_state_presets import (
|
||||||
|
normalize_fw_console_arg,
|
||||||
|
relay_weather_form_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
HOST = "0.0.0.0"
|
HOST = "0.0.0.0"
|
||||||
@@ -42,6 +46,7 @@ class FalloutTogetherServer:
|
|||||||
self._started_at: float | None = None
|
self._started_at: float | None = None
|
||||||
self._next_player_id = 1
|
self._next_player_id = 1
|
||||||
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._stats: dict[str, int] = {
|
self._stats: dict[str, int] = {
|
||||||
"clientsConnected": 0,
|
"clientsConnected": 0,
|
||||||
@@ -54,6 +59,7 @@ class FalloutTogetherServer:
|
|||||||
"worldStatePacketsReceived": 0,
|
"worldStatePacketsReceived": 0,
|
||||||
"worldStatePacketsBroadcast": 0,
|
"worldStatePacketsBroadcast": 0,
|
||||||
"worldStateHostPacketsBroadcast": 0,
|
"worldStateHostPacketsBroadcast": 0,
|
||||||
|
"serverWorldStatePacketsBroadcast": 0,
|
||||||
"disconnectPacketsBroadcast": 0,
|
"disconnectPacketsBroadcast": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +143,43 @@ class FalloutTogetherServer:
|
|||||||
|
|
||||||
return stats
|
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:
|
def add_log_listener(self, callback: Callable[[str], None]) -> None:
|
||||||
with self._log_lock:
|
with self._log_lock:
|
||||||
if callback not in self._log_listeners:
|
if callback not in self._log_listeners:
|
||||||
@@ -405,6 +448,70 @@ class FalloutTogetherServer:
|
|||||||
f"gameHour={game_hour}, gameDaysPassed={game_days_passed}{weather_detail}"
|
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:
|
def _reassign_world_state_host(self) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if not self._clients:
|
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