Add host-authoritative world-state sync

Implement host-authoritative time/weather sync across server and clients. Adds a new worldState packet (and worldStateHost for host assignment) and server-side host tracking/reassignment to the lowest remaining playerId when the host disconnects. Plugin-side changes introduce F4T::WorldStateSync (new header + implementation), polling/apply hooks in the game-thread main loop, and Networking support to send/receive/parse worldState/worldStateHost packets. The server validates host-only sends, relays worldState to peers, broadcasts worldStateHost on host handoff, and augments logging/stats. Protocol and documentation files updated (protocol/world-state.md, docs/*) and server/fake_client.py extended to track/print host world-state snapshots.
This commit is contained in:
2026-06-22 21:50:59 +12:00
parent 9ceb6601ae
commit 99595ef87c
15 changed files with 1042 additions and 33 deletions
+21 -15
View File
@@ -27,23 +27,29 @@ both send and receive paths:
9. The server broadcasts `disconnect` packets when clients disconnect. 9. The server broadcasts `disconnect` packets when clients disconnect.
10. The plugin and fake client remove disconnected players from their remote 10. The plugin and fake client remove disconnected players from their remote
player tables. player tables.
11. On the game-thread update path, the proxy controller reads a copied 11. The server tracks a world-state host (`worldStateHostPlayerId`). The first
connected client becomes host; when the host disconnects, the server reassigns
host to the lowest remaining `playerId` and broadcasts `worldStateHost`. The
host sends low-frequency `worldState` packets with `gameHour`, `gameDaysPassed`,
and optional `weatherFormId`. Promoted hosts continue with their 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
remote-player snapshot before attempting any actor work. The snapshot is remote-player snapshot before attempting any actor work. The snapshot is
sorted by `playerId`, and only remote players with valid transform data in sorted by `playerId`, and only remote players with valid transform data in
the same cell are eligible for runtime proxy representation. the same cell are eligible for runtime proxy representation.
12. For each eligible remote `playerId`, the controller maintains a 13. For each eligible remote `playerId`, the controller maintains a
`remotePlayerId -> runtime proxy slot` mapping. Each slot stores its `remotePlayerId -> runtime proxy slot` mapping. Each slot stores its
`ObjectRefHandle`, lifecycle state, PlaceAtMe-backed spawn/candidate state, `ObjectRefHandle`, lifecycle state, PlaceAtMe-backed spawn/candidate state,
movement smoothing state, and data-only animation debug state. Stage 4 caps runtime movement smoothing state, and data-only animation debug state. Stage 4 caps runtime
proxy slots at four actors for testing. proxy slots at four actors for testing.
13. Runtime proxies are demand-driven. A slot executes the Stage 3.7 14. Runtime proxies are demand-driven. A slot executes the Stage 3.7
PlaceAtMe-backed spawn flow only when that remote player needs a same-cell PlaceAtMe-backed spawn flow only when that remote player needs a same-cell
representation, and only one PlaceAtMe spawn attempt may run at a time so representation, and only one PlaceAtMe spawn attempt may run at a time so
candidate isolation remains unambiguous. Stage 4 captures the remote candidate isolation remains unambiguous. Stage 4 captures the remote
player's current valid transform before spawning and moves the isolated player's current valid transform before spawning and moves the isolated
runtime actor to that transform immediately, so normal remote representation runtime actor to that transform immediately, so normal remote representation
does not visually start at the local player offset. does not visually start at the local player offset.
14. Runtime proxies receive a small game-thread neutralization pass when a 15. Runtime proxies receive a small game-thread neutralization pass when a
PlaceAtMe-backed actor is promoted, when a reusable slot is reassigned, while PlaceAtMe-backed actor is promoted, when a reusable slot is reassigned, while
active on a throttle, and when moved or kept in holding. The pass only uses active on a throttle, and when moved or kept in holding. The pass only uses
confirmed CommonLibF4 actor APIs/fields to stop combat, clear combat target confirmed CommonLibF4 actor APIs/fields to stop combat, clear combat target
@@ -53,60 +59,60 @@ both send and receive paths:
alarm, bump, or package movement intent is observed. It does not disable, alarm, bump, or package movement intent is observed. It does not disable,
hide, delete, despawn, alpha-fade, or force animation graph events on the hide, delete, despawn, alpha-fade, or force animation graph events on the
actor. actor.
15. Proxy visual updates are independent per slot. Normal movement is smoothed 16. Proxy visual updates are independent per slot. Normal movement is smoothed
toward that player’s latest target, while `cell_change`, `worldspace_change`, toward that player’s latest target, while `cell_change`, `worldspace_change`,
and `teleport` snap directly. Movement and animation-related remote state is and `teleport` snap directly. Movement and animation-related remote state is
still observed and logged only; no visual crouch/sneak, jump, weapon drawn, still observed and logged only; no visual crouch/sneak, jump, weapon drawn,
locomotion, animation graph, or actor-state application is attempted. locomotion, animation graph, or actor-state application is attempted.
16. When a remote player leaves the cell or disconnects, only that player’s 17. When a remote player leaves the cell or disconnects, only that player’s
runtime proxy is moved to the hidden holding position inside runtime proxy is moved to the hidden holding position inside
`F4TTestCell01`. Runtime actors are not disabled or deleted. Connected `F4TTestCell01`. Runtime actors are not disabled or deleted. Connected
players who leave the cell keep their slot reserved, while disconnected players who leave the cell keep their slot reserved, while disconnected
held slots become reusable only after the configured grace period. Runtime held slots become reusable only after the configured grace period. Runtime
slots use per-slot holding positions with spacing and a game-thread slots use per-slot holding positions with spacing and a game-thread
correction check so held actors do not stack or drift back into view. correction check so held actors do not stack or drift back into view.
17. The placed fallback reference `F4TProxyRemotePlayer01REF` remains a single 18. The placed fallback reference `F4TProxyRemotePlayer01REF` remains a single
backup representation. If runtime spawning fails for one deterministic backup representation. If runtime spawning fails for one deterministic
selected remote player, the fallback can represent that one player only; it selected remote player, the fallback can represent that one player only; it
is not used for multiple remote players. is not used for multiple remote players.
18. As part of the staged runtime proxy actor manager work, the same game-thread 19. As part of the staged runtime proxy actor manager work, the same game-thread
proxy controller also logs whether `Fallout4Together_Test.esp` is loaded and proxy controller also logs whether `Fallout4Together_Test.esp` is loaded and
whether the actor base `F4T_RemotePlayerProxy` resolves by editor ID. whether the actor base `F4T_RemotePlayerProxy` resolves by editor ID.
19. Stage 3.1 adds a temporary diagnostic layer around the single runtime proxy: 20. Stage 3.1 adds a temporary diagnostic layer around the single runtime proxy:
post-spawn visibility data, loaded-3D pointer checks where safe, console post-spawn visibility data, loaded-3D pointer checks where safe, console
helper logs, active source logs, and throttled post-movement diagnostics. helper logs, active source logs, and throttled post-movement diagnostics.
The diagnostic mode can force the runtime proxy near the local player to The diagnostic mode can force the runtime proxy near the local player to
prove whether the created actor is visibly renderable. prove whether the created actor is visibly renderable.
20. Stage 3.2 adds a temporary near-player visibility hold for that same single 21. Stage 3.2 adds a temporary near-player visibility hold for that same single
runtime proxy. During the hold, remote state is still selected and validated, runtime proxy. During the hold, remote state is still selected and validated,
but runtime proxy restore and remote movement are deferred long enough to but runtime proxy restore and remote movement are deferred long enough to
test whether a runtime-created actor ref can load visible 3D while kept near test whether a runtime-created actor ref can load visible 3D while kept near
the local player. the local player.
21. Stage 3.3 adds a temporary vanilla actor-base diagnostic layer. It attempts 22. Stage 3.3 adds a temporary vanilla actor-base diagnostic layer. It attempts
to resolve Codsworth as a visible vanilla `TESNPC` candidate, then passes to resolve Codsworth as a visible vanilla `TESNPC` candidate, then passes
that actor base into the same runtime spawn, handle validation, near-player that actor base into the same runtime spawn, handle validation, near-player
hold, and visibility diagnostic path. This isolates custom actor-base issues hold, and visibility diagnostic path. This isolates custom actor-base issues
from runtime-spawn/init issues without changing the final architecture. from runtime-spawn/init issues without changing the final architecture.
22. Stage 3.4 adds a temporary absolute FormID and existing-reference diagnostic 23. Stage 3.4 adds a temporary absolute FormID and existing-reference diagnostic
layer. It compares Codsworth base-form runtime spawning against moving the layer. It compares Codsworth base-form runtime spawning against moving the
existing placed Codsworth reference near the local player as a visibility existing placed Codsworth reference near the local player as a visibility
control. This separates runtime actor creation/init problems from vanilla control. This separates runtime actor creation/init problems from vanilla
actor-base or placed-reference movement problems and does not change the actor-base or placed-reference movement problems and does not change the
final architecture. final architecture.
23. Stage 3.5 adds a temporary PlaceAtMe-equivalent diagnostic after manual 24. Stage 3.5 adds a temporary PlaceAtMe-equivalent diagnostic after manual
console validation proved `player.placeatme 000179FF 1` can spawn visible console validation proved `player.placeatme 000179FF 1` can spawn visible
Codsworth in `F4TTestCell01`. The plugin executes that console placement path Codsworth in `F4TTestCell01`. The plugin executes that console placement path
from the local player, resolves the newly created Codsworth actor near the from the local player, resolves the newly created Codsworth actor near the
player, and holds/logs it separately from the `CreateReferenceAtLocation` player, and holds/logs it separately from the `CreateReferenceAtLocation`
runtime proxy path. runtime proxy path.
24. Stage 3.6 refines that temporary PlaceAtMe diagnostic layer. It snapshots 25. Stage 3.6 refines that temporary PlaceAtMe diagnostic layer. It snapshots
known Codsworth refs before executing `player.placeatme 000179FF 1`, accepts known Codsworth refs before executing `player.placeatme 000179FF 1`, accepts
only a newly isolated Codsworth ref for the primary diagnostic, and preserves only a newly isolated Codsworth ref for the primary diagnostic, and preserves
that actor at the original console-spawned location during a short settle that actor at the original console-spawned location during a short settle
window. This tests whether the actor becomes visible naturally or requires a window. This tests whether the actor becomes visible naturally or requires a
render/process refresh such as the manually observed pause-menu or alt-tab render/process refresh such as the manually observed pause-menu or alt-tab
behavior. Stage 3.6 does not change the final architecture. behavior. Stage 3.6 does not change the final architecture.
25. Stage 3.7 promotes the working PlaceAtMe-style spawn pattern into the 26. Stage 3.7 promotes the working PlaceAtMe-style spawn pattern into the
preferred single runtime proxy spawn path. The proxy controller uses the preferred single runtime proxy spawn path. The proxy controller uses the
resolved runtime FormID for `F4T_RemotePlayerProxy` when available, falls resolved runtime FormID for `F4T_RemotePlayerProxy` when available, falls
back to Codsworth only for diagnostics, defers active proxy selection during back to Codsworth only for diagnostics, defers active proxy selection during
+77
View File
@@ -7,6 +7,83 @@ failed experiments, successful tests, and next steps.
--- ---
---
## 2026-06-22 - World-State Host Reassignment
### Summary
When the world-state host disconnects, the server reassigns host to the lowest remaining `playerId` and broadcasts `worldStateHost`. Promoted clients seed host capture from their already-synced local calendar and weather so time and weather should not jump for remaining players.
### Files Changed
- `plugin/include/F4TWorldStateSync.h`
- `plugin/src/F4TWorldStateSync.cpp`
- `plugin/src/F4TNetworking.cpp`
- `server/server_core.py`
- `server/fake_client.py`
- `protocol/world-state.md`
- `protocol/message-types.md`
- `protocol/world-sync.md`
- `docs/architecture.md`
### Details
- Server tracks `_world_state_host_player_id` and includes `worldStateHostPlayerId` in `welcome`.
- New `worldStateHost` packet notifies clients when host changes after disconnect.
- Plugin `IsWorldStateHost()` compares assigned `playerId` to tracked host id.
- On promotion, pending apply state is cleared and `lastSent` is seeded from local time/weather to avoid an immediate disruptive resync.
### Testing
- Not run in-game in this session.
- Recommended: connect three clients, disconnect player 1, verify server reassigns to player 2 and remaining clients keep the same time/weather.
### Known Issues
- None currently known.
### Next Steps
- In-game verification of host handoff with two or three Fallout 4 instances.
---
## 2026-06-22 - Time and Weather Sync MVP
### Summary
Added host-authoritative time and weather sync via a new `worldState` packet. The first connected client (`playerId == 1`) publishes `gameHour`, `gameDaysPassed`, and optional `weatherFormId`; the Python relay validates and broadcasts to other clients; non-host plugin clients apply time and exterior weather on the game thread.
### Files Changed
- `plugin/include/F4TWorldStateSync.h`
- `plugin/include/F4TNetworking.h`
- `plugin/src/F4TWorldStateSync.cpp`
- `plugin/src/F4TNetworking.cpp`
- `plugin/src/main.cpp`
- `server/server_core.py`
- `server/fake_client.py`
- `protocol/world-state.md`
- `protocol/message-types.md`
- `protocol/world-sync.md`
- `docs/protocol.md`
- `docs/protocol-phase2-extensions.md`
- `docs/architecture.md`
### Details
- New `worldState` packet type with ~1 Hz host heartbeat plus immediate sends on weather or large time jumps.
- Server drops `worldState` from non-host clients and relays host packets to peers only.
- Non-host clients queue pending state on the network thread and apply calendar globals plus `Sky::ForceWeather` on the game thread (weather apply is exterior-only).
- `fake_client.py` tracks and prints the latest host world-state snapshot.
### Testing
- Not run in-game in this session.
- Recommended: start `server/server.py`, connect host first, connect `fake_client.py` or second game instance, change host time/weather outdoors, verify relay logs and client apply logs in `CommonwealthOnline.log`.
### Known Issues
- Host reassignment on disconnect is not implemented; clients keep the last applied state.
- Full calendar fields (`gameYear`, `gameMonth`, `gameDay`, `timeScale`) are out of scope for this MVP.
### Next Steps
- In-game verification with two Fallout 4 instances outdoors.
- Consider host handoff when player 1 disconnects.
- Add optional `fake_player.py` host simulation flag if useful for protocol testing without the game.
---
## 2026-06-18 - LAN scan fixes for cross-device discovery ## 2026-06-18 - LAN scan fixes for cross-device discovery
### Summary ### Summary
+19
View File
@@ -93,6 +93,25 @@ This document describes the Phase 2 protocol extensions to support actor state f
--- ---
## World State Packet
Host-authoritative time and weather sync uses a separate `worldState` packet.
Full field definitions live in [`protocol/world-state.md`](../protocol/world-state.md).
```json
{
"type": "worldState",
"playerId": 1,
"gameHour": 14.25,
"gameDaysPassed": 12.5,
"weatherFormId": "00123456",
"clientTime": 1717424000.123,
"serverTime": 1717424000.456
}
```
---
## Backward Compatibility ## Backward Compatibility
**Important:** The protocol is **backward compatible**. Existing clients that don't send Phase 2 fields will continue to work: **Important:** The protocol is **backward compatible**. Existing clients that don't send Phase 2 fields will continue to work:
+2
View File
@@ -14,6 +14,8 @@ Long-term versions may move to a binary protocol if performance becomes a proble
- `welcome` - `welcome`
- `transform` - `transform`
- `worldState`
- `worldStateHost`
- `disconnect` - `disconnect`
- `ping` - `ping`
- `pong` - `pong`
+5
View File
@@ -29,4 +29,9 @@ namespace F4T::Networking
bool a_weaponDrawn = false, bool a_weaponDrawn = false,
float a_movementSpeed = 0.0F, float a_movementSpeed = 0.0F,
float a_animationGraphSpeed = -1.0F); float a_animationGraphSpeed = -1.0F);
bool SendWorldStatePacket(
float a_gameHour,
float a_gameDaysPassed,
bool a_hasWeatherFormId,
std::uint32_t a_weatherFormId);
} }
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
namespace F4T::WorldStateSync
{
struct PendingWorldState
{
float gameHour{ 0.0F };
float gameDaysPassed{ 0.0F };
std::optional<std::uint32_t> weatherFormId;
bool hasWeatherFormId{ false };
};
bool IsWorldStateHost();
std::optional<std::uint32_t> GetWorldStateHostPlayerId();
void SetWorldStateHostPlayerId(std::uint32_t a_hostPlayerId);
void Reset();
void SetPendingWorldState(PendingWorldState a_state);
void PollHostCapture();
void ApplyPendingState();
}
+184 -3
View File
@@ -2,6 +2,7 @@
#include "F4TNetworking.h" #include "F4TNetworking.h"
#include "F4TRemotePlayerState.h" #include "F4TRemotePlayerState.h"
#include "F4TWorldStateSync.h"
#include <WinSock2.h> #include <WinSock2.h>
#include <WS2tcpip.h> #include <WS2tcpip.h>
@@ -169,9 +170,9 @@ namespace
} }
} }
std::optional<std::uint32_t> ReadPlayerId(const Json& a_packet) std::optional<std::uint32_t> ReadPlayerId(const Json& a_packet, const char* a_fieldName = "playerId")
{ {
const auto playerId = a_packet.find("playerId"); const auto playerId = a_packet.find(a_fieldName);
if (playerId == a_packet.end()) { if (playerId == a_packet.end()) {
return std::nullopt; return std::nullopt;
} }
@@ -306,7 +307,109 @@ namespace
} }
F4T::RemotePlayerState::SetAssignedPlayerId(*playerId); F4T::RemotePlayerState::SetAssignedPlayerId(*playerId);
LogInfoWithLocalPlayerPrefix(std::format("Assigned server playerId: {}", *playerId));
const auto worldStateHostPlayerId = ReadPlayerId(a_packet, "worldStateHostPlayerId");
if (worldStateHostPlayerId) {
F4T::WorldStateSync::SetWorldStateHostPlayerId(*worldStateHostPlayerId);
} else {
F4T::WorldStateSync::SetWorldStateHostPlayerId(1);
}
if (F4T::WorldStateSync::IsWorldStateHost()) {
LogInfoWithLocalPlayerPrefix(std::format(
"Assigned server playerId: {} (world-state host).",
*playerId));
} else if (const auto hostPlayerId = F4T::WorldStateSync::GetWorldStateHostPlayerId()) {
LogInfoWithLocalPlayerPrefix(std::format(
"Assigned server playerId: {} (world-state host is player {}).",
*playerId,
*hostPlayerId));
} else {
LogInfoWithLocalPlayerPrefix(std::format("Assigned server playerId: {}", *playerId));
}
}
void HandleWorldStateHostPacket(const Json& a_packet)
{
const auto hostPlayerId = ReadPlayerId(a_packet, "worldStateHostPlayerId");
if (!hostPlayerId) {
LogThrottledWarning(
"world_state_host_missing",
"Ignoring worldStateHost packet without a valid worldStateHostPlayerId.");
return;
}
F4T::WorldStateSync::SetWorldStateHostPlayerId(*hostPlayerId);
}
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;
}
void HandleWorldStatePacket(const Json& a_packet)
{
const auto gameHour = ReadDouble(a_packet, "gameHour");
const auto gameDaysPassed = ReadDouble(a_packet, "gameDaysPassed");
if (!gameHour || !gameDaysPassed) {
LogThrottledWarning(
"world_state_missing_fields",
"Ignoring worldState packet missing required gameHour or gameDaysPassed.");
return;
}
if (!std::isfinite(*gameHour) || !std::isfinite(*gameDaysPassed)) {
LogThrottledWarning("world_state_invalid_numbers", "Ignoring worldState packet with non-finite time values.");
return;
}
F4T::WorldStateSync::PendingWorldState pendingState{
static_cast<float>(*gameHour),
static_cast<float>(*gameDaysPassed),
std::nullopt,
false
};
const auto weatherFormIdText = ReadString(a_packet, "weatherFormId");
if (weatherFormIdText && !weatherFormIdText->empty()) {
if (const auto weatherFormId = ParseHexFormId(*weatherFormIdText)) {
pendingState.weatherFormId = *weatherFormId;
pendingState.hasWeatherFormId = true;
} else if (ShouldLogWarning("world_state_weather_parse")) {
LogWarningWithLocalPlayerPrefix(std::format(
"Ignoring invalid weatherFormId '{}' in worldState packet.",
*weatherFormIdText));
}
}
if (ShouldLogRemoteUpdate(1)) {
if (pendingState.hasWeatherFormId && pendingState.weatherFormId) {
LogInfoWithLocalPlayerPrefix(std::format(
"Queued host worldState: gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=0x{:08X}.",
pendingState.gameHour,
pendingState.gameDaysPassed,
*pendingState.weatherFormId));
} else {
LogInfoWithLocalPlayerPrefix(std::format(
"Queued host worldState: gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=<none>.",
pendingState.gameHour,
pendingState.gameDaysPassed));
}
}
F4T::WorldStateSync::SetPendingWorldState(std::move(pendingState));
} }
void HandleTransformPacket(const Json& a_packet) void HandleTransformPacket(const Json& a_packet)
@@ -494,6 +597,10 @@ namespace
HandleWelcomePacket(packet); HandleWelcomePacket(packet);
} else if (*packetType == "transform") { } else if (*packetType == "transform") {
HandleTransformPacket(packet); HandleTransformPacket(packet);
} else if (*packetType == "worldState") {
HandleWorldStatePacket(packet);
} else if (*packetType == "worldStateHost") {
HandleWorldStateHostPacket(packet);
} else if (*packetType == "disconnect") { } else if (*packetType == "disconnect") {
HandleDisconnectPacket(packet); HandleDisconnectPacket(packet);
} else { } else {
@@ -657,6 +764,7 @@ namespace F4T::Networking
F4T::RemotePlayerState::ClearAssignedPlayerId(); F4T::RemotePlayerState::ClearAssignedPlayerId();
F4T::RemotePlayerState::ClearRemotePlayers(); F4T::RemotePlayerState::ClearRemotePlayers();
F4T::WorldStateSync::Reset();
g_lastRemoteUpdateLogTimes.clear(); g_lastRemoteUpdateLogTimes.clear();
g_lastRemoteMovementStateLogTimes.clear(); g_lastRemoteMovementStateLogTimes.clear();
StartReceiveThread(); StartReceiveThread();
@@ -713,6 +821,7 @@ namespace F4T::Networking
StopReceiveThread(); StopReceiveThread();
F4T::RemotePlayerState::ClearAssignedPlayerId(); F4T::RemotePlayerState::ClearAssignedPlayerId();
F4T::RemotePlayerState::ClearRemotePlayers(); F4T::RemotePlayerState::ClearRemotePlayers();
F4T::WorldStateSync::Reset();
g_lastRemoteUpdateLogTimes.clear(); g_lastRemoteUpdateLogTimes.clear();
g_lastRemoteMovementStateLogTimes.clear(); g_lastRemoteMovementStateLogTimes.clear();
@@ -860,4 +969,76 @@ namespace F4T::Networking
return true; return true;
} }
bool SendWorldStatePacket(
float a_gameHour,
float a_gameDaysPassed,
bool a_hasWeatherFormId,
std::uint32_t a_weatherFormId)
{
if (!IsConnectedToServer()) {
return false;
}
if (!std::isfinite(a_gameHour) || !std::isfinite(a_gameDaysPassed)) {
return false;
}
const auto clientTime =
std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
std::array<char, 256> packet{};
int packetSize = 0;
if (a_hasWeatherFormId) {
packetSize = std::snprintf(
packet.data(),
packet.size(),
"{\"type\":\"worldState\",\"gameHour\":%.3f,\"gameDaysPassed\":%.3f,\"weatherFormId\":\"%08X\",\"clientTime\":%.3f}\n",
a_gameHour,
a_gameDaysPassed,
a_weatherFormId,
clientTime);
} else {
packetSize = std::snprintf(
packet.data(),
packet.size(),
"{\"type\":\"worldState\",\"gameHour\":%.3f,\"gameDaysPassed\":%.3f,\"clientTime\":%.3f}\n",
a_gameHour,
a_gameDaysPassed,
clientTime);
}
if (packetSize <= 0 || static_cast<std::size_t>(packetSize) >= packet.size()) {
LogWarningWithLocalPlayerPrefix("Could not format Commonwealth Online worldState packet.");
return false;
}
int bytesSent = 0;
{
const std::scoped_lock lock(g_socketMutex);
if (g_socket == INVALID_SOCKET) {
return false;
}
bytesSent = send(g_socket, packet.data(), packetSize, 0);
}
if (bytesSent == SOCKET_ERROR) {
const auto error = WSAGetLastError();
if (error != WSAEWOULDBLOCK) {
LogWarningWithLocalPlayerPrefix("Lost connection to Commonwealth Online local server.");
CloseSocket();
}
return false;
}
if (bytesSent != packetSize) {
LogWarningWithLocalPlayerPrefix("Could not send full Commonwealth Online worldState packet.");
CloseSocket();
return false;
}
return true;
}
} }
+397
View File
@@ -0,0 +1,397 @@
#include "pch.h"
#include "F4TWorldStateSync.h"
#include "F4TNetworking.h"
#include "F4TRemotePlayerState.h"
#include "RE/C/Calendar.h"
#include "RE/P/PlayerCharacter.h"
#include "RE/S/Sky.h"
#include "RE/T/TESDataHandler.h"
#include "RE/T/TESForm.h"
#include "RE/T/TESObjectCELL.h"
#include "RE/T/TESWeather.h"
#include <cmath>
#include <format>
#include <mutex>
#include <optional>
#include <string>
#include <unordered_map>
namespace
{
constexpr auto kWorldStateHeartbeatInterval = 1s;
constexpr float kGameHourSendThreshold = 0.05F;
constexpr float kGameDaysPassedSendThreshold = 0.001F;
constexpr float kGameHourApplyThreshold = 0.05F;
constexpr float kGameDaysPassedApplyThreshold = 0.001F;
constexpr auto kWorldStateLogInterval = 2s;
std::mutex g_pendingMutex;
bool g_hasPendingWorldState{ false };
F4T::WorldStateSync::PendingWorldState g_pendingWorldState{};
struct LastSentWorldState
{
float gameHour{ -1.0F };
float gameDaysPassed{ -1.0F };
std::optional<std::uint32_t> weatherFormId;
bool hasWeatherFormId{ false };
bool initialized{ false };
};
struct LastAppliedWorldState
{
float gameHour{ -1.0F };
float gameDaysPassed{ -1.0F };
std::optional<std::uint32_t> weatherFormId;
bool hasWeatherFormId{ false };
bool initialized{ false };
};
LastSentWorldState g_lastSentWorldState{};
LastAppliedWorldState g_lastAppliedWorldState{};
auto g_lastHostSendTime = std::chrono::steady_clock::time_point{};
std::optional<std::uint32_t> g_worldStateHostPlayerId{};
std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastLogTimes;
std::string GetLocalPlayerLogMessage(std::string_view a_message)
{
return F4T::Networking::GetLocalPlayerLogPrefix() + " " + std::string(a_message);
}
void LogInfo(std::string_view a_message)
{
const auto message = GetLocalPlayerLogMessage(a_message);
REX::INFO(std::string_view{ message });
}
void LogWarning(std::string_view a_message)
{
const auto message = GetLocalPlayerLogMessage(a_message);
REX::WARN(std::string_view{ message });
}
bool ShouldLog(const std::string& a_key)
{
const auto now = std::chrono::steady_clock::now();
const auto lastLog = g_lastLogTimes.find(a_key);
if (lastLog != g_lastLogTimes.end() && now - lastLog->second < kWorldStateLogInterval) {
return false;
}
g_lastLogTimes[a_key] = now;
return true;
}
bool IsLocalPlayerExterior()
{
const auto* player = RE::PlayerCharacter::GetSingleton();
if (!player) {
return false;
}
const auto* playerCell = player->GetParentCell();
return playerCell && playerCell->IsExterior();
}
bool TryReadCalendarValues(float& a_outGameHour, float& a_outGameDaysPassed)
{
const auto* calendar = RE::Calendar::GetSingleton();
if (!calendar || !calendar->gameHour || !calendar->gameDaysPassed) {
return false;
}
a_outGameHour = calendar->gameHour->GetValue();
a_outGameDaysPassed = calendar->gameDaysPassed->GetValue();
return std::isfinite(a_outGameHour) && std::isfinite(a_outGameDaysPassed);
}
std::optional<std::uint32_t> TryReadExteriorWeatherFormId()
{
const auto* sky = RE::Sky::GetSingleton();
if (!sky || sky->mode == RE::Sky::Mode::kInterior) {
return std::nullopt;
}
const auto* currentWeather = sky->currentWeather;
if (!currentWeather) {
return std::nullopt;
}
return currentWeather->GetFormID();
}
bool ShouldSendWorldState(
float a_gameHour,
float a_gameDaysPassed,
bool a_hasWeatherFormId,
std::optional<std::uint32_t> a_weatherFormId,
const std::chrono::steady_clock::time_point& a_now)
{
if (!g_lastSentWorldState.initialized) {
return true;
}
if (std::abs(a_gameHour - g_lastSentWorldState.gameHour) > kGameHourSendThreshold ||
std::abs(a_gameDaysPassed - g_lastSentWorldState.gameDaysPassed) > kGameDaysPassedSendThreshold) {
return true;
}
if (a_hasWeatherFormId != g_lastSentWorldState.hasWeatherFormId ||
a_weatherFormId != g_lastSentWorldState.weatherFormId) {
return true;
}
return g_lastHostSendTime.time_since_epoch().count() == 0 ||
a_now - g_lastHostSendTime >= kWorldStateHeartbeatInterval;
}
void ApplyTimeState(float a_gameHour, float a_gameDaysPassed)
{
auto* calendar = RE::Calendar::GetSingleton();
if (!calendar || !calendar->gameHour || !calendar->gameDaysPassed) {
return;
}
const auto localGameHour = calendar->gameHour->GetValue();
const auto localGameDaysPassed = calendar->gameDaysPassed->GetValue();
if (std::abs(localGameHour - a_gameHour) <= kGameHourApplyThreshold &&
std::abs(localGameDaysPassed - a_gameDaysPassed) <= kGameDaysPassedApplyThreshold) {
return;
}
calendar->gameHour->value = a_gameHour;
calendar->gameDaysPassed->value = a_gameDaysPassed;
if (auto* sky = RE::Sky::GetSingleton()) {
sky->currentGameHour = a_gameHour;
}
if (ShouldLog("world_state_time_apply")) {
LogInfo(std::format(
"Applied host world time: gameHour={:.2f}, gameDaysPassed={:.3f}.",
a_gameHour,
a_gameDaysPassed));
}
}
void ApplyWeatherState(bool a_hasWeatherFormId, std::optional<std::uint32_t> a_weatherFormId)
{
if (!IsLocalPlayerExterior()) {
return;
}
auto* sky = RE::Sky::GetSingleton();
if (!sky) {
return;
}
if (!a_hasWeatherFormId || !a_weatherFormId) {
return;
}
auto* weather = RE::TESForm::GetFormByID<RE::TESWeather>(*a_weatherFormId);
if (!weather) {
if (ShouldLog("world_state_weather_lookup_failed")) {
LogWarning(std::format(
"Could not resolve host weather form 0x{:08X} for world-state apply.",
*a_weatherFormId));
}
return;
}
if (g_lastAppliedWorldState.initialized &&
g_lastAppliedWorldState.hasWeatherFormId &&
g_lastAppliedWorldState.weatherFormId == a_weatherFormId &&
sky->overrideWeather == weather) {
return;
}
sky->ForceWeather(weather, true);
if (ShouldLog("world_state_weather_apply")) {
LogInfo(std::format("Applied host weather form 0x{:08X}.", *a_weatherFormId));
}
}
void SeedHostCaptureFromLocalState()
{
float gameHour = 0.0F;
float gameDaysPassed = 0.0F;
if (!TryReadCalendarValues(gameHour, gameDaysPassed)) {
g_lastSentWorldState = {};
g_lastHostSendTime = std::chrono::steady_clock::now();
return;
}
const auto weatherFormId = TryReadExteriorWeatherFormId();
g_lastSentWorldState.gameHour = gameHour;
g_lastSentWorldState.gameDaysPassed = gameDaysPassed;
g_lastSentWorldState.hasWeatherFormId = weatherFormId.has_value();
g_lastSentWorldState.weatherFormId = weatherFormId;
g_lastSentWorldState.initialized = true;
g_lastHostSendTime = std::chrono::steady_clock::now();
}
}
namespace F4T::WorldStateSync
{
bool IsWorldStateHost()
{
const auto assignedPlayerId = RemotePlayerState::GetAssignedPlayerId();
return assignedPlayerId && g_worldStateHostPlayerId && *assignedPlayerId == *g_worldStateHostPlayerId;
}
std::optional<std::uint32_t> GetWorldStateHostPlayerId()
{
return g_worldStateHostPlayerId;
}
void SetWorldStateHostPlayerId(std::uint32_t a_hostPlayerId)
{
const auto assignedPlayerId = RemotePlayerState::GetAssignedPlayerId();
const bool wasHost =
assignedPlayerId && g_worldStateHostPlayerId && *assignedPlayerId == *g_worldStateHostPlayerId;
const bool hostChanged = !g_worldStateHostPlayerId || *g_worldStateHostPlayerId != a_hostPlayerId;
g_worldStateHostPlayerId = a_hostPlayerId;
const bool isNowHost = assignedPlayerId && *assignedPlayerId == a_hostPlayerId;
if (isNowHost && (!wasHost || hostChanged)) {
{
const std::scoped_lock lock(g_pendingMutex);
g_hasPendingWorldState = false;
g_pendingWorldState = {};
}
SeedHostCaptureFromLocalState();
if (ShouldLog("world_state_host_promoted")) {
LogInfo(std::format(
"Promoted to world-state host (hostPlayerId={}). Continuing with current local time and weather.",
a_hostPlayerId));
}
return;
}
if (!isNowHost && wasHost) {
g_lastSentWorldState = {};
g_lastHostSendTime = {};
if (ShouldLog("world_state_host_demoted")) {
LogInfo(std::format("No longer world-state host; new host is player {}.", a_hostPlayerId));
}
return;
}
if (!isNowHost && hostChanged && ShouldLog("world_state_host_changed")) {
LogInfo(std::format("World-state host is now player {}.", a_hostPlayerId));
}
}
void Reset()
{
{
const std::scoped_lock lock(g_pendingMutex);
g_hasPendingWorldState = false;
g_pendingWorldState = {};
}
g_lastSentWorldState = {};
g_lastAppliedWorldState = {};
g_lastHostSendTime = {};
g_worldStateHostPlayerId = std::nullopt;
g_lastLogTimes.clear();
}
void SetPendingWorldState(PendingWorldState a_state)
{
if (IsWorldStateHost()) {
return;
}
{
const std::scoped_lock lock(g_pendingMutex);
g_pendingWorldState = std::move(a_state);
g_hasPendingWorldState = true;
}
}
void PollHostCapture()
{
if (!Networking::IsConnectedToServer() || !IsWorldStateHost()) {
return;
}
float gameHour = 0.0F;
float gameDaysPassed = 0.0F;
if (!TryReadCalendarValues(gameHour, gameDaysPassed)) {
return;
}
const auto weatherFormId = TryReadExteriorWeatherFormId();
const bool hasWeatherFormId = weatherFormId.has_value();
const auto now = std::chrono::steady_clock::now();
if (!ShouldSendWorldState(gameHour, gameDaysPassed, hasWeatherFormId, weatherFormId, now)) {
return;
}
if (!Networking::SendWorldStatePacket(
gameHour,
gameDaysPassed,
hasWeatherFormId,
hasWeatherFormId ? *weatherFormId : 0U)) {
return;
}
g_lastSentWorldState.gameHour = gameHour;
g_lastSentWorldState.gameDaysPassed = gameDaysPassed;
g_lastSentWorldState.hasWeatherFormId = hasWeatherFormId;
g_lastSentWorldState.weatherFormId = weatherFormId;
g_lastSentWorldState.initialized = true;
g_lastHostSendTime = now;
if (ShouldLog("world_state_host_send")) {
if (hasWeatherFormId) {
LogInfo(std::format(
"Sent worldState: gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=0x{:08X}.",
gameHour,
gameDaysPassed,
*weatherFormId));
} else {
LogInfo(std::format(
"Sent worldState: gameHour={:.2f}, gameDaysPassed={:.3f}, weatherFormId=<none>.",
gameHour,
gameDaysPassed));
}
}
}
void ApplyPendingState()
{
if (!Networking::IsConnectedToServer() || IsWorldStateHost()) {
return;
}
PendingWorldState pendingState{};
{
const std::scoped_lock lock(g_pendingMutex);
if (!g_hasPendingWorldState) {
return;
}
pendingState = g_pendingWorldState;
g_hasPendingWorldState = false;
}
ApplyTimeState(pendingState.gameHour, pendingState.gameDaysPassed);
ApplyWeatherState(pendingState.hasWeatherFormId, pendingState.weatherFormId);
g_lastAppliedWorldState.gameHour = pendingState.gameHour;
g_lastAppliedWorldState.gameDaysPassed = pendingState.gameDaysPassed;
g_lastAppliedWorldState.hasWeatherFormId = pendingState.hasWeatherFormId;
g_lastAppliedWorldState.weatherFormId = pendingState.weatherFormId;
g_lastAppliedWorldState.initialized = true;
}
}
+3
View File
@@ -6,6 +6,7 @@
#include "F4TProxyActorController.h" #include "F4TProxyActorController.h"
#include "F4TProxyAnimationSync.h" #include "F4TProxyAnimationSync.h"
#include "F4TProxyPuppet.h" #include "F4TProxyPuppet.h"
#include "F4TWorldStateSync.h"
#include "RE/A/ACTOR_STANCE.h" #include "RE/A/ACTOR_STANCE.h"
#include "RE/B/BSFixedString.h" #include "RE/B/BSFixedString.h"
@@ -654,6 +655,8 @@ namespace
// check on the game thread after load, while separate send and log gates keep updates useful. // check on the game thread after load, while separate send and log gates keep updates useful.
taskInterface->AddTaskPermanent([]() { taskInterface->AddTaskPermanent([]() {
PollLocalPlayerTransform(); PollLocalPlayerTransform();
F4T::WorldStateSync::PollHostCapture();
F4T::WorldStateSync::ApplyPendingState();
F4T::ProxyActorController::Update(); F4T::ProxyActorController::Update();
F4T::SessionLaunch::Update(); F4T::SessionLaunch::Update();
}); });
+1
View File
@@ -8,6 +8,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)
- Disconnect packet - Disconnect packet
## Future Packet Types ## Future Packet Types
+13 -3
View File
@@ -9,6 +9,8 @@ These message types are currently implemented:
```text ```text
welcome welcome
transform transform
worldState
worldStateHost
disconnect disconnect
``` ```
@@ -17,11 +19,16 @@ Current behavior:
- `welcome` is sent by the server after assigning a `playerId`. - `welcome` is sent by the server after assigning a `playerId`.
- `transform` is sent by the Fallout 4 plugin, processed by the server, and - `transform` is sent by the Fallout 4 plugin, processed by the server, and
broadcast to other connected clients. broadcast to other connected clients.
- `worldState` is sent by the current world-state host, validated by the
server, and broadcast to other connected clients.
- `worldStateHost` is sent in `welcome` and when the host disconnects to
reassign host to the lowest remaining `playerId`.
- `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`, and `disconnect` packets. Received transforms are stored as plain `transform`, `worldState`, and `disconnect` packets. Received transforms are
remote-player state only. stored as plain remote-player state. Non-host clients apply received world state
on the game thread.
## Current Receivers ## Current Receivers
@@ -30,11 +37,14 @@ The Fallout 4 plugin and `server/fake_client.py` currently receive:
```text ```text
welcome welcome
transform transform
worldState
worldStateHost
disconnect disconnect
``` ```
The plugin and fake client store broadcast transform packets in in-memory remote The plugin and fake client store broadcast transform packets in in-memory remote
player state tables and remove entries when disconnect packets arrive. player state tables and remove entries when disconnect packets arrive. The fake
client also tracks the latest host world-state snapshot.
## Planned Later ## Planned Later
+77
View File
@@ -0,0 +1,77 @@
# World State Packet
Host-authoritative time and weather sync for the Commonwealth Online prototype.
## Authority
- The server tracks a `worldStateHostPlayerId` for the session.
- The first connected client becomes host. When the host disconnects, the server
reassigns host to the lowest remaining `playerId` and broadcasts `worldStateHost`.
- Only the current host sends `worldState` packets.
- The Python relay validates the sender and broadcasts to all other clients.
Promoted hosts keep their already-synced local time and weather; reassignment should
not change world state for remaining clients.
## worldStateHost Packet
Sent in `welcome` (initial host) and broadcast when the host disconnects:
```json
{
"type": "worldStateHost",
"worldStateHostPlayerId": 2,
"serverTime": 1780212128.301
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | yes | Must be `"worldStateHost"` |
| `worldStateHostPlayerId` | integer | yes | Connected player ID that is now world-state host |
| `serverTime` | number (double) | no | Relay timestamp |
The `welcome` packet also includes optional `worldStateHostPlayerId` so new joiners
know the current host immediately.
## Packet Format
```json
{
"type": "worldState",
"playerId": 1,
"gameHour": 14.25,
"gameDaysPassed": 12.5,
"weatherFormId": "00123456",
"clientTime": 1780212128.301,
"serverTime": 1780212128.301
}
```
## Field Definitions
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | yes | Must be `"worldState"` |
| `playerId` | integer | yes (server) | Injected by server; identifies the sending host |
| `gameHour` | number (float) | yes | In-game hour of day, `0.0`–`24.0` |
| `gameDaysPassed` | number (float) | yes | In-game days passed global |
| `weatherFormId` | string | no | 8-character hex `TESWeather` form ID; omitted or `""` when host is indoors |
| `clientTime` | number (double) | no | Sender Unix epoch seconds |
| `serverTime` | number (double) | no | Relay timestamp added by server |
## Send Cadence (Host)
- About 1 Hz heartbeat while connected
- Immediate send when `weatherFormId` changes
- Immediate send when `gameHour` or `gameDaysPassed` jumps beyond a small threshold (wait, sleep, console time changes)
## Client Apply Rules
- **Time:** non-host clients snap local calendar globals when host values differ beyond threshold.
- **Weather:** non-host clients apply forced weather only when the local player is in an exterior cell.
- Missing optional fields use safe defaults on receive.
## Backward Compatibility
Older clients that do not send or parse `worldState` continue to work. Transform sync is unchanged.
+10 -2
View File
@@ -1,9 +1,17 @@
# World Sync # World Sync
World synchronization is a long-term problem and is not part of the first prototype.
Fallout 4 assumes a single player owns the world state. Multiplayer requires deciding which client or server controls each piece of world state. Fallout 4 assumes a single player owns the world state. Multiplayer requires deciding which client or server controls each piece of world state.
## Implemented (MVP)
Host-authoritative time and weather sync is implemented via the `worldState`
packet. See [`world-state.md`](world-state.md) for the field spec.
- Host: tracked by `worldStateHostPlayerId` (first connected client; reassigned to lowest remaining `playerId` on host disconnect)
- Synced fields: `gameHour`, `gameDaysPassed`, optional `weatherFormId`
- Weather apply: exterior cells only on non-host clients
- Server: pass-through relay with host-only send guard
## World State Examples ## World State Examples
World state can include: World state can include:
+82 -1
View File
@@ -11,6 +11,8 @@ HOST = "127.0.0.1"
PORT = 7777 PORT = 7777
remote_players: dict[int, dict[str, Any]] = {} remote_players: dict[int, dict[str, Any]] = {}
host_world_state: dict[str, Any] = {}
world_state_host_player_id: int | None = None
def log(message: str) -> None: def log(message: str) -> None:
@@ -52,6 +54,70 @@ def get_optional_float(packet: dict[str, Any], field_name: str, default: float =
return parsed_value return parsed_value
def get_optional_game_time(packet: dict[str, Any], field_name: str, default: float = 0.0) -> float:
value = packet.get(field_name, default)
try:
parsed_value = float(value)
except (TypeError, ValueError):
return default
if not math.isfinite(parsed_value):
return default
return parsed_value
def update_host_world_state(packet: dict[str, Any]) -> None:
global host_world_state
host_world_state = {
"gameHour": get_optional_game_time(packet, "gameHour"),
"gameDaysPassed": get_optional_game_time(packet, "gameDaysPassed"),
"weatherFormId": packet.get("weatherFormId", ""),
"clientTime": packet.get("clientTime", 0.0),
"serverTime": packet.get("serverTime", 0.0),
"lastReceivedLocalTime": time.time(),
}
log("Updated host world state")
print_host_world_state(host_world_state)
def print_host_world_state(world_state: dict[str, Any]) -> None:
client_time = world_state["clientTime"] if world_state["clientTime"] is not None else "missing from packet"
server_time = world_state["serverTime"] if world_state["serverTime"] is not None else "missing from packet"
weather_form_id = world_state["weatherFormId"] or "<none>"
log(
"\n".join(
[
f"Game Hour: {world_state['gameHour']:.3f}",
f"Game Days Passed: {world_state['gameDaysPassed']:.3f}",
f"Weather Form ID: {weather_form_id}",
f"Client Time: {client_time}",
f"Last Server Time: {server_time}",
f"Last Received Local Time: {world_state['lastReceivedLocalTime']}",
]
)
)
def update_world_state_host(packet: dict[str, Any]) -> None:
global world_state_host_player_id
host_id = packet.get("worldStateHostPlayerId")
if host_id is None:
warn(f"Ignoring worldStateHost packet without worldStateHostPlayerId: {packet}")
return
try:
world_state_host_player_id = int(host_id)
except (TypeError, ValueError):
warn(f"Ignoring worldStateHost packet with invalid worldStateHostPlayerId: {packet}")
return
log(f"World-state host reassigned to player {world_state_host_player_id}")
def update_remote_player_state(packet: dict[str, Any]) -> None: def update_remote_player_state(packet: dict[str, Any]) -> None:
required_fields = ( required_fields = (
"type", "type",
@@ -176,10 +242,25 @@ def handle_disconnect_packet(packet: dict[str, Any]) -> None:
def handle_packet(packet: dict[str, Any]) -> None: def handle_packet(packet: dict[str, Any]) -> None:
packet_type = packet.get("type") packet_type = packet.get("type")
if packet_type == "welcome": if packet_type == "welcome":
log(f"Welcome packet: {packet}") host_id = packet.get("worldStateHostPlayerId")
if host_id is not None:
try:
global world_state_host_player_id
world_state_host_player_id = int(host_id)
log(f"Welcome packet: playerId={packet.get('playerId')}, worldStateHostPlayerId={world_state_host_player_id}")
except (TypeError, ValueError):
log(f"Welcome packet: {packet}")
else:
log(f"Welcome packet: {packet}")
elif packet_type == "transform": elif packet_type == "transform":
log(f"Received transform: {packet}") log(f"Received transform: {packet}")
update_remote_player_state(packet) update_remote_player_state(packet)
elif packet_type == "worldState":
log(f"Received worldState: {packet}")
update_host_world_state(packet)
elif packet_type == "worldStateHost":
log(f"Received worldStateHost: {packet}")
update_world_state_host(packet)
elif packet_type == "disconnect": elif packet_type == "disconnect":
handle_disconnect_packet(packet) handle_disconnect_packet(packet)
else: else:
+127 -9
View File
@@ -41,6 +41,7 @@ class FalloutTogetherServer:
self._running = False self._running = False
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._stats: dict[str, int] = { self._stats: dict[str, int] = {
"clientsConnected": 0, "clientsConnected": 0,
@@ -50,6 +51,9 @@ class FalloutTogetherServer:
"packetsBroadcast": 0, "packetsBroadcast": 0,
"transformPacketsReceived": 0, "transformPacketsReceived": 0,
"transformPacketsBroadcast": 0, "transformPacketsBroadcast": 0,
"worldStatePacketsReceived": 0,
"worldStatePacketsBroadcast": 0,
"worldStateHostPacketsBroadcast": 0,
"disconnectPacketsBroadcast": 0, "disconnectPacketsBroadcast": 0,
} }
@@ -220,6 +224,8 @@ class FalloutTogetherServer:
self._next_player_id += 1 self._next_player_id += 1
self._clients[connection] = client self._clients[connection] = client
self._stats["clientsConnected"] += 1 self._stats["clientsConnected"] += 1
if self._world_state_host_player_id is None:
self._world_state_host_player_id = client.player_id
return client return client
@@ -238,14 +244,18 @@ class FalloutTogetherServer:
buffer = "" buffer = ""
try: try:
self._send_packet( with self._lock:
client, world_state_host_player_id = self._world_state_host_player_id
{
"type": "welcome", welcome_packet: dict[str, Any] = {
"playerId": client.player_id, "type": "welcome",
"serverTime": time.time(), "playerId": client.player_id,
}, "serverTime": time.time(),
) }
if world_state_host_player_id is not None:
welcome_packet["worldStateHostPlayerId"] = world_state_host_player_id
self._send_packet(client, welcome_packet)
while self.is_running(): while self.is_running():
chunk = connection.recv(4096) chunk = connection.recv(4096)
@@ -291,10 +301,28 @@ class FalloutTogetherServer:
with self._lock: with self._lock:
self._stats["transformPacketsReceived"] += 1 self._stats["transformPacketsReceived"] += 1
if packet.get("type") == "worldState":
with self._lock:
world_state_host_player_id = self._world_state_host_player_id
if world_state_host_player_id is None or client.player_id != world_state_host_player_id:
self._log(
f"Ignoring worldState from non-host {client.label} "
f"(player {client.player_id}, host is player {world_state_host_player_id})."
)
return
packet["playerId"] = client.player_id
packet["serverTime"] = time.time()
with self._lock:
self._stats["worldStatePacketsReceived"] += 1
self._print_packet(client, packet) self._print_packet(client, packet)
if packet.get("type") == "transform": if packet.get("type") == "transform":
self._broadcast_transform(client, packet) self._broadcast_transform(client, packet)
elif packet.get("type") == "worldState":
self._broadcast_world_state(client, packet)
def _send_packet(self, client: ClientSession, packet: dict[str, Any], *, broadcast: bool = False) -> None: def _send_packet(self, client: ClientSession, packet: dict[str, Any], *, broadcast: bool = False) -> None:
encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n" encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n"
@@ -307,6 +335,9 @@ class FalloutTogetherServer:
self._stats["packetsBroadcast"] += 1 self._stats["packetsBroadcast"] += 1
def _disconnect_client(self, client: ClientSession) -> None: def _disconnect_client(self, client: ClientSession) -> None:
with self._lock:
was_world_state_host = client.player_id == self._world_state_host_player_id
if not self._remove_client(client): if not self._remove_client(client):
return return
@@ -314,6 +345,9 @@ class FalloutTogetherServer:
self._log(f"Client disconnected: {client.label} (player {client.player_id})") self._log(f"Client disconnected: {client.label} (player {client.player_id})")
self._broadcast_disconnect(client) self._broadcast_disconnect(client)
if was_world_state_host:
self._reassign_world_state_host()
def _close_client_socket(self, client: ClientSession) -> None: def _close_client_socket(self, client: ClientSession) -> None:
try: try:
client.connection.close() client.connection.close()
@@ -343,6 +377,71 @@ class FalloutTogetherServer:
self._log(f"Broadcast transform from player {sender.player_id} to {successful_sends} other client(s)") self._log(f"Broadcast transform from player {sender.player_id} to {successful_sends} other client(s)")
def _broadcast_world_state(self, sender: ClientSession, packet: dict[str, Any]) -> None:
with self._lock:
recipients = [client for client in self._clients.values() if client.connection != sender.connection]
failed_recipients: list[ClientSession] = []
successful_sends = 0
for recipient in recipients:
try:
self._send_packet(recipient, packet, broadcast=True)
successful_sends += 1
except OSError:
failed_recipients.append(recipient)
with self._lock:
self._stats["worldStatePacketsBroadcast"] += successful_sends
for recipient in failed_recipients:
self._disconnect_client(recipient)
game_hour = packet.get("gameHour", "?")
game_days_passed = packet.get("gameDaysPassed", "?")
weather_form_id = packet.get("weatherFormId", "")
weather_detail = f", weatherFormId={weather_form_id}" if weather_form_id else ""
self._log(
f"Broadcast worldState from player {sender.player_id} to {successful_sends} other client(s): "
f"gameHour={game_hour}, gameDaysPassed={game_days_passed}{weather_detail}"
)
def _reassign_world_state_host(self) -> None:
with self._lock:
if not self._clients:
self._world_state_host_player_id = None
self._log("World-state host cleared; no clients remain.")
return
new_host_player_id = min(client.player_id for client in self._clients.values())
self._world_state_host_player_id = new_host_player_id
recipients = list(self._clients.values())
packet = {
"type": "worldStateHost",
"worldStateHostPlayerId": new_host_player_id,
"serverTime": time.time(),
}
failed_recipients: list[ClientSession] = []
successful_sends = 0
for recipient in recipients:
try:
self._send_packet(recipient, packet, broadcast=True)
successful_sends += 1
except OSError:
failed_recipients.append(recipient)
with self._lock:
self._stats["worldStateHostPacketsBroadcast"] += successful_sends
for recipient in failed_recipients:
self._disconnect_client(recipient)
self._log(
f"Reassigned world-state host to player {new_host_player_id} "
f"and notified {successful_sends} client(s)."
)
def _broadcast_disconnect(self, disconnected_client: ClientSession) -> None: def _broadcast_disconnect(self, disconnected_client: ClientSession) -> None:
packet = { packet = {
"type": "disconnect", "type": "disconnect",
@@ -374,7 +473,26 @@ class FalloutTogetherServer:
) )
def _print_packet(self, client: ClientSession, packet: dict[str, Any]) -> None: def _print_packet(self, client: ClientSession, packet: dict[str, Any]) -> None:
if packet.get("type") != "transform": packet_type = packet.get("type")
if packet_type == "worldState":
game_hour = packet.get("gameHour", "?")
game_days_passed = packet.get("gameDaysPassed", "?")
weather_form_id = packet.get("weatherFormId", "")
extra_fields = []
for field_name in ("playerId", "clientTime", "serverTime"):
if field_name in packet:
extra_fields.append(f"{field_name}={packet[field_name]}")
extra_details = ""
if extra_fields:
extra_details = ", " + ", ".join(extra_fields)
weather_detail = f", weatherFormId={weather_form_id}" if weather_form_id else ""
self._log(
f"WorldState from {client.label}: gameHour={game_hour}, "
f"gameDaysPassed={game_days_passed}{weather_detail}{extra_details}"
)
return
if packet_type != "transform":
self._log(f"Packet from {client.label}: {packet}") self._log(f"Packet from {client.label}: {packet}")
return return