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