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
+8
View File
@@ -15,11 +15,19 @@ namespace F4T::WorldStateSync
bool applyTime{ false };
};
struct PendingServerWorldState
{
std::optional<std::string> timeHHmm;
std::optional<std::string> weatherFormId;
std::optional<std::string> weatherConsoleArg;
};
bool IsWorldStateHost();
std::optional<std::uint32_t> GetWorldStateHostPlayerId();
void SetWorldStateHostPlayerId(std::uint32_t a_hostPlayerId);
void Reset();
void SetPendingWorldState(PendingWorldState a_state);
void SetPendingServerWorldState(PendingServerWorldState a_state);
void PollHostCapture();
void ApplyPendingState();
}
+51
View File
@@ -359,6 +359,55 @@ namespace
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)
{
const auto gameHour = ReadDouble(a_packet, "gameHour");
@@ -603,6 +652,8 @@ namespace
HandleTransformPacket(packet);
} else if (*packetType == "worldState") {
HandleWorldStatePacket(packet);
} else if (*packetType == "serverWorldState") {
HandleServerWorldStatePacket(packet);
} else if (*packetType == "worldStateHost") {
HandleWorldStateHostPacket(packet);
} else if (*packetType == "disconnect") {
+211 -1
View File
@@ -5,8 +5,11 @@
#include "F4TNetworking.h"
#include "F4TRemotePlayerState.h"
#include "F4SE/Interfaces.h"
#include "RE/B/BGSSaveLoadManager.h"
#include "RE/C/Calendar.h"
#include "RE/C/Console.h"
#include "RE/L/LoadingMenu.h"
#include "RE/M/Main.h"
#include "RE/M/MainMenu.h"
@@ -21,6 +24,7 @@
#include "RE/U/UI.h"
#include <cmath>
#include <charconv>
#include <format>
#include <mutex>
#include <optional>
@@ -39,6 +43,8 @@ namespace
std::mutex g_pendingMutex;
bool g_hasPendingWorldState{ false };
F4T::WorldStateSync::PendingWorldState g_pendingWorldState{};
bool g_hasPendingServerWorldState{ false };
F4T::WorldStateSync::PendingServerWorldState g_pendingServerWorldState{};
struct LastSentWorldState
{
@@ -172,6 +178,102 @@ namespace
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)
{
if (!g_lastSentWorldState.initialized) {
@@ -287,6 +389,78 @@ namespace
g_lastSentWorldState.initialized = false;
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
@@ -348,6 +522,8 @@ namespace F4T::WorldStateSync
const std::scoped_lock lock(g_pendingMutex);
g_hasPendingWorldState = false;
g_pendingWorldState = {};
g_hasPendingServerWorldState = false;
g_pendingServerWorldState = {};
}
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()
{
if (!Networking::IsConnectedToServer() || !IsWorldStateHost()) {
@@ -436,7 +627,26 @@ namespace F4T::WorldStateSync
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;
}