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:
+21
-15
@@ -27,23 +27,29 @@ both send and receive paths:
|
||||
9. The server broadcasts `disconnect` packets when clients disconnect.
|
||||
10. The plugin and fake client remove disconnected players from their remote
|
||||
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
|
||||
sorted by `playerId`, and only remote players with valid transform data in
|
||||
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
|
||||
`ObjectRefHandle`, lifecycle state, PlaceAtMe-backed spawn/candidate state,
|
||||
movement smoothing state, and data-only animation debug state. Stage 4 caps runtime
|
||||
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
|
||||
representation, and only one PlaceAtMe spawn attempt may run at a time so
|
||||
candidate isolation remains unambiguous. Stage 4 captures the remote
|
||||
player's current valid transform before spawning and moves the isolated
|
||||
runtime actor to that transform immediately, so normal remote representation
|
||||
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
|
||||
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
|
||||
@@ -53,60 +59,60 @@ both send and receive paths:
|
||||
alarm, bump, or package movement intent is observed. It does not disable,
|
||||
hide, delete, despawn, alpha-fade, or force animation graph events on the
|
||||
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`,
|
||||
and `teleport` snap directly. Movement and animation-related remote state is
|
||||
still observed and logged only; no visual crouch/sneak, jump, weapon drawn,
|
||||
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
|
||||
`F4TTestCell01`. Runtime actors are not disabled or deleted. Connected
|
||||
players who leave the cell keep their slot reserved, while disconnected
|
||||
held slots become reusable only after the configured grace period. Runtime
|
||||
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.
|
||||
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
|
||||
selected remote player, the fallback can represent that one player only; it
|
||||
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
|
||||
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
|
||||
helper logs, active source logs, and throttled post-movement diagnostics.
|
||||
The diagnostic mode can force the runtime proxy near the local player to
|
||||
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,
|
||||
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
|
||||
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
|
||||
that actor base into the same runtime spawn, handle validation, near-player
|
||||
hold, and visibility diagnostic path. This isolates custom actor-base issues
|
||||
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
|
||||
existing placed Codsworth reference near the local player as a visibility
|
||||
control. This separates runtime actor creation/init problems from vanilla
|
||||
actor-base or placed-reference movement problems and does not change the
|
||||
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
|
||||
Codsworth in `F4TTestCell01`. The plugin executes that console placement path
|
||||
from the local player, resolves the newly created Codsworth actor near the
|
||||
player, and holds/logs it separately from the `CreateReferenceAtLocation`
|
||||
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
|
||||
only a newly isolated Codsworth ref for the primary diagnostic, and preserves
|
||||
that actor at the original console-spawned location during a short settle
|
||||
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
|
||||
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
|
||||
resolved runtime FormID for `F4T_RemotePlayerProxy` when available, falls
|
||||
back to Codsworth only for diagnostics, defers active proxy selection during
|
||||
|
||||
@@ -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
|
||||
|
||||
### Summary
|
||||
|
||||
@@ -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
|
||||
|
||||
**Important:** The protocol is **backward compatible**. Existing clients that don't send Phase 2 fields will continue to work:
|
||||
|
||||
@@ -14,6 +14,8 @@ Long-term versions may move to a binary protocol if performance becomes a proble
|
||||
|
||||
- `welcome`
|
||||
- `transform`
|
||||
- `worldState`
|
||||
- `worldStateHost`
|
||||
- `disconnect`
|
||||
- `ping`
|
||||
- `pong`
|
||||
|
||||
@@ -29,4 +29,9 @@ namespace F4T::Networking
|
||||
bool a_weaponDrawn = false,
|
||||
float a_movementSpeed = 0.0F,
|
||||
float a_animationGraphSpeed = -1.0F);
|
||||
bool SendWorldStatePacket(
|
||||
float a_gameHour,
|
||||
float a_gameDaysPassed,
|
||||
bool a_hasWeatherFormId,
|
||||
std::uint32_t a_weatherFormId);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "F4TNetworking.h"
|
||||
#include "F4TRemotePlayerState.h"
|
||||
#include "F4TWorldStateSync.h"
|
||||
|
||||
#include <WinSock2.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()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
@@ -306,7 +307,109 @@ namespace
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -494,6 +597,10 @@ namespace
|
||||
HandleWelcomePacket(packet);
|
||||
} else if (*packetType == "transform") {
|
||||
HandleTransformPacket(packet);
|
||||
} else if (*packetType == "worldState") {
|
||||
HandleWorldStatePacket(packet);
|
||||
} else if (*packetType == "worldStateHost") {
|
||||
HandleWorldStateHostPacket(packet);
|
||||
} else if (*packetType == "disconnect") {
|
||||
HandleDisconnectPacket(packet);
|
||||
} else {
|
||||
@@ -657,6 +764,7 @@ namespace F4T::Networking
|
||||
|
||||
F4T::RemotePlayerState::ClearAssignedPlayerId();
|
||||
F4T::RemotePlayerState::ClearRemotePlayers();
|
||||
F4T::WorldStateSync::Reset();
|
||||
g_lastRemoteUpdateLogTimes.clear();
|
||||
g_lastRemoteMovementStateLogTimes.clear();
|
||||
StartReceiveThread();
|
||||
@@ -713,6 +821,7 @@ namespace F4T::Networking
|
||||
StopReceiveThread();
|
||||
F4T::RemotePlayerState::ClearAssignedPlayerId();
|
||||
F4T::RemotePlayerState::ClearRemotePlayers();
|
||||
F4T::WorldStateSync::Reset();
|
||||
g_lastRemoteUpdateLogTimes.clear();
|
||||
g_lastRemoteMovementStateLogTimes.clear();
|
||||
|
||||
@@ -860,4 +969,76 @@ namespace F4T::Networking
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "F4TProxyActorController.h"
|
||||
#include "F4TProxyAnimationSync.h"
|
||||
#include "F4TProxyPuppet.h"
|
||||
#include "F4TWorldStateSync.h"
|
||||
|
||||
#include "RE/A/ACTOR_STANCE.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.
|
||||
taskInterface->AddTaskPermanent([]() {
|
||||
PollLocalPlayerTransform();
|
||||
F4T::WorldStateSync::PollHostCapture();
|
||||
F4T::WorldStateSync::ApplyPendingState();
|
||||
F4T::ProxyActorController::Update();
|
||||
F4T::SessionLaunch::Update();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ The first protocol should be simple and focused on player transform sync.
|
||||
|
||||
- Welcome packet
|
||||
- Transform packet
|
||||
- World state packet (host time and weather) — see [`world-state.md`](world-state.md)
|
||||
- Disconnect packet
|
||||
|
||||
## Future Packet Types
|
||||
|
||||
@@ -9,6 +9,8 @@ These message types are currently implemented:
|
||||
```text
|
||||
welcome
|
||||
transform
|
||||
worldState
|
||||
worldStateHost
|
||||
disconnect
|
||||
```
|
||||
|
||||
@@ -17,11 +19,16 @@ Current behavior:
|
||||
- `welcome` is sent by the server after assigning a `playerId`.
|
||||
- `transform` is sent by the Fallout 4 plugin, processed by the server, and
|
||||
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.
|
||||
|
||||
The Fallout 4 plugin sends transform packets and receives `welcome`,
|
||||
`transform`, and `disconnect` packets. Received transforms are stored as plain
|
||||
remote-player state only.
|
||||
`transform`, `worldState`, and `disconnect` packets. Received transforms are
|
||||
stored as plain remote-player state. Non-host clients apply received world state
|
||||
on the game thread.
|
||||
|
||||
## Current Receivers
|
||||
|
||||
@@ -30,11 +37,14 @@ The Fallout 4 plugin and `server/fake_client.py` currently receive:
|
||||
```text
|
||||
welcome
|
||||
transform
|
||||
worldState
|
||||
worldStateHost
|
||||
disconnect
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
@@ -1,9 +1,17 @@
|
||||
# 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.
|
||||
|
||||
## 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 can include:
|
||||
|
||||
+82
-1
@@ -11,6 +11,8 @@ HOST = "127.0.0.1"
|
||||
PORT = 7777
|
||||
|
||||
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:
|
||||
@@ -52,6 +54,70 @@ def get_optional_float(packet: dict[str, Any], field_name: str, default: float =
|
||||
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:
|
||||
required_fields = (
|
||||
"type",
|
||||
@@ -176,10 +242,25 @@ def handle_disconnect_packet(packet: dict[str, Any]) -> None:
|
||||
def handle_packet(packet: dict[str, Any]) -> None:
|
||||
packet_type = packet.get("type")
|
||||
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":
|
||||
log(f"Received transform: {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":
|
||||
handle_disconnect_packet(packet)
|
||||
else:
|
||||
|
||||
+127
-9
@@ -41,6 +41,7 @@ class FalloutTogetherServer:
|
||||
self._running = False
|
||||
self._started_at: float | None = None
|
||||
self._next_player_id = 1
|
||||
self._world_state_host_player_id: int | None = None
|
||||
|
||||
self._stats: dict[str, int] = {
|
||||
"clientsConnected": 0,
|
||||
@@ -50,6 +51,9 @@ class FalloutTogetherServer:
|
||||
"packetsBroadcast": 0,
|
||||
"transformPacketsReceived": 0,
|
||||
"transformPacketsBroadcast": 0,
|
||||
"worldStatePacketsReceived": 0,
|
||||
"worldStatePacketsBroadcast": 0,
|
||||
"worldStateHostPacketsBroadcast": 0,
|
||||
"disconnectPacketsBroadcast": 0,
|
||||
}
|
||||
|
||||
@@ -220,6 +224,8 @@ class FalloutTogetherServer:
|
||||
self._next_player_id += 1
|
||||
self._clients[connection] = client
|
||||
self._stats["clientsConnected"] += 1
|
||||
if self._world_state_host_player_id is None:
|
||||
self._world_state_host_player_id = client.player_id
|
||||
|
||||
return client
|
||||
|
||||
@@ -238,14 +244,18 @@ class FalloutTogetherServer:
|
||||
buffer = ""
|
||||
|
||||
try:
|
||||
self._send_packet(
|
||||
client,
|
||||
{
|
||||
"type": "welcome",
|
||||
"playerId": client.player_id,
|
||||
"serverTime": time.time(),
|
||||
},
|
||||
)
|
||||
with self._lock:
|
||||
world_state_host_player_id = self._world_state_host_player_id
|
||||
|
||||
welcome_packet: dict[str, Any] = {
|
||||
"type": "welcome",
|
||||
"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():
|
||||
chunk = connection.recv(4096)
|
||||
@@ -291,10 +301,28 @@ class FalloutTogetherServer:
|
||||
with self._lock:
|
||||
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)
|
||||
|
||||
if packet.get("type") == "transform":
|
||||
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:
|
||||
encoded = json.dumps(packet, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
@@ -307,6 +335,9 @@ class FalloutTogetherServer:
|
||||
self._stats["packetsBroadcast"] += 1
|
||||
|
||||
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):
|
||||
return
|
||||
|
||||
@@ -314,6 +345,9 @@ class FalloutTogetherServer:
|
||||
self._log(f"Client disconnected: {client.label} (player {client.player_id})")
|
||||
self._broadcast_disconnect(client)
|
||||
|
||||
if was_world_state_host:
|
||||
self._reassign_world_state_host()
|
||||
|
||||
def _close_client_socket(self, client: ClientSession) -> None:
|
||||
try:
|
||||
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)")
|
||||
|
||||
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:
|
||||
packet = {
|
||||
"type": "disconnect",
|
||||
@@ -374,7 +473,26 @@ class FalloutTogetherServer:
|
||||
)
|
||||
|
||||
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}")
|
||||
return
|
||||
|
||||
|
||||
Reference in New Issue
Block a user