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