Set up basic deterministic lockstep

This commit is contained in:
Vladislav Khorev committed 2026-11-05 23:02:53 +06:00
1 parent 28c6e83eda
commit 23fef6f349
26 files changed
+1172 -239

No files matched your search

+73 -52
View File
@@ -2,19 +2,21 @@
## Purpose ## Purpose
The game now separates authoritative gameplay state from rendering and runtime resources. The current implementation is an in-process server, but public consumers use a snapshot-and-command API designed to support a future network client. The game separates authoritative gameplay state from rendering and runtime
resources. The same snapshot-and-command boundary supports the in-process
This refactor does **not** add multiplayer networking. It establishes the state ownership and API boundaries needed before networking, replication, multiple local player representations, or server-side synchronization are implemented. single-player server and a WebSocket deterministic-lockstep client/server.
## Architecture Overview ## Architecture Overview
The architecture has five layers: The architecture has six layers:
1. `GameState` is the aggregate root for persistent, authoritative state. 1. `GameState` is the aggregate root for persistent, authoritative state.
2. `InternalGameServer` owns `GameState`, advances the local simulation, handles commands, and produces immutable snapshots. 2. `InternalGameServer` owns `GameState`, advances the local simulation, handles commands, and produces immutable snapshots.
3. `ClientLocationState` owns per-client `LocationLocalState` records, including camera orientation. Graphical hosts own this state; it is never replicated. 3. `ClientLocationState` owns per-client `LocationLocalState` records, including camera orientation. Graphical hosts own this state; it is never replicated.
4. Runtime objects hold rendering resources. `Character` and `Location` read immutable server snapshots by stable ID, while `InteractiveObject` still uses a transitional non-owning binding to server-owned state. `Location` also translates local input, scripts, and collision results into semantic server commands. 4. Runtime objects hold rendering resources. `Character` and `Location` read immutable server snapshots by stable ID, while `InteractiveObject` still uses a transitional non-owning binding to server-owned state. `Location` also translates local input, scripts, and collision results into semantic server commands.
5. Client-facing systems such as `MenuManager` read immutable snapshots and submit typed commands through `IGameServer`. 5. Client-facing systems such as `MenuManager` read immutable snapshots and submit typed commands through `IGameServer`.
6. Protocol-v2 WebSocket hosts assign preconfigured player slots and exchange
one initial state followed by ordered 100 ms tick bundles.
```text ```text
Input / UI Input / UI
@@ -103,7 +105,7 @@ array positions.
- `snapshot()` for an immutable, revisioned `GameSnapshot`. - `snapshot()` for an immutable, revisioned `GameSnapshot`.
- `submit()` for typed `GameCommand` values. - `submit()` for typed `GameCommand` values.
- `update()` for advancing or polling the implementation. - `update()` for advancing a local fixed tick or polling a remote implementation.
- `save()` and `load()` for server-side persistence. - `save()` and `load()` for server-side persistence.
No public method returns a mutable state pointer or reference. No public method returns a mutable state pointer or reference.
@@ -143,7 +145,10 @@ Snapshots have a monotonically increasing revision. `InternalGameServer` caches
- Trigger-zone state. - Trigger-zone state.
- Quest-definition reloads. - Quest-definition reloads.
A future network implementation can serialize equivalent requests instead of mutating local state. The remote implementation currently serializes only
`SetCharacterDestinationCommand` as player intent. The server derives the
character and location from the assigned connection slot; arbitrary state
mutation commands are not accepted from the network.
### Presentation events ### Presentation events
@@ -159,7 +164,7 @@ Loading and persistence restoration clear pending events and remain silent.
- Exclusively owns the aggregate `GameState`. - Exclusively owns the aggregate `GameState`.
- Executes typed commands against authoritative state. - Executes typed commands against authoritative state.
- Advances the active location simulation through `update(deltaMs)`. - Advances the active location simulation in exact 100 ms ticks.
- Builds and caches immutable snapshots. - Builds and caches immutable snapshots.
- Delegates save/load serialization to the aggregate state model. - Delegates save/load serialization to the aggregate state model.
@@ -184,9 +189,12 @@ Runtime `Character` construction only loads models for presentation and does not
submit metadata mutations back to the server. submit metadata mutations back to the server.
`CharacterState::update()` returns value-only events containing arrival Lua function `CharacterState::update()` returns value-only events containing arrival Lua function
names, attack impacts, navigation requests, and death-animation completion. `LocationGameState` applies names, attack impacts, navigation requests, and death-animation completion.
queued damage after every character has advanced. Runtime `Character` objects only `LocationGameState` applies queued damage after every character has advanced.
pose loaded models and produce audio, particles, and presentation callbacks. Navigation replans are consumed by `InternalGameServer` as deterministic
simulation consequences rather than being resubmitted by presentation code.
Runtime `Character` objects only pose loaded models and produce audio, particles,
and presentation callbacks.
### Character state operations ### Character state operations
@@ -263,26 +271,23 @@ Configuration remains the source of visual assets, animation paths, meshes, text
## State Update Flow ## State Update Flow
The normal frame flow is: Render frames and simulation ticks are separate:
1. SDL input is processed. 1. SDL input and UI presentation use the real elapsed render-frame time.
2. UI code submits semantic commands through `IGameServer`. 2. A local host accumulates `steady_clock` elapsed time and runs zero or more
3. The internal server freezes location character observations and calls exact 100 ms simulation ticks. A remote host polls transport every frame but
`CharacterState::update()` for every present player and NPC. An empty player advances only from contiguous server tick messages.
collection is valid. 3. Commands assigned to tick `N` are applied in server sequence order before
4. Attack impacts are queued, then damage is applied after all characters advance. simulation tick `N`.
5. The runtime location consumes value-only events. Navigation requests are sent 4. The server freezes location character observations and updates every player,
back as semantic destination commands, whose server handler calls NPC, taxi, and interactive object with exactly 100 ms. An empty player
`CharacterState::setTarget()` with the authoritative `LocationNetworkState`; arrival collection remains valid.
function names are resolved through `ScriptEngine`. 5. Attack impacts are queued and applied only after all characters advance.
6. The server invalidates its cached snapshot and advances the revision so runtime Navigation recovery is applied inside the deterministic state path.
presentation can observe the completed simulation tick. 6. The server invalidates its cached snapshot and advances its revision.
7. Runtime characters read one immutable snapshot per operation, apply the 7. Runtime locations consume the completed tick's value-only events and perform
authoritative animation frame to loaded models, and rendering, audio, particles, and supported presentation callbacks.
play audio, particles, and presentation callbacks. 8. Rendering can occur between ticks and never changes authoritative tick time.
8. Auxiliary dialogue, Lua, trigger, and teleport state is captured into `LocationGameState`.
9. The server publishes those auxiliary changes in a later revision.
10. Rendering, UI, and external-style consumers read immutable snapshots.
Commands that update bound character or object state do not reload the whole location. This avoids replaying dialogue-node effects or Lua callbacks. Commands that update bound character or object state do not reload the whole location. This avoids replaying dialogue-node effects or Lua callbacks.
@@ -335,7 +340,8 @@ Authoritative persistence uses one strict, versioned schema. `schemaVersion` is
required and unsupported versions are rejected rather than migrated. A required and unsupported versions are rejected rather than migrated. A
single-player save is a separate versioned envelope containing `networkState` single-player save is a separate versioned envelope containing `networkState`
(the authoritative `GameState` JSON) and `localState` (the client-local location (the authoritative `GameState` JSON) and `localState` (the client-local location
map). WebSocket replication sends only the authoritative document. map). Lockstep sends that authoritative document only in `initial_state`; later
messages contain numbered command bundles.
- Campaign players are stored once in an ID-keyed `players` object. - Campaign players are stored once in an ID-keyed `players` object.
- Locations are keyed by location ID. - Locations are keyed by location ID.
@@ -394,29 +400,35 @@ These callback-free state systems remain part of `GameState` for single-player
compatibility. UI access, mutations, and presentation notifications cross the compatibility. UI access, mutations, and presentation notifications cross the
server interface. They can remain empty or disabled in multiplayer mode as planned. server interface. They can remain empty or disabled in multiplayer mode as planned.
## Multiplayer Readiness ## Multiplayer Lockstep
The refactor provides: Protocol v2 uses server-sequenced deterministic lockstep:
- One authoritative aggregate state owner. - The server assigns a connection to a preconfigured campaign player slot.
- Multiple player slots keyed by stable ID. - Connection receives one authoritative state at a completed tick plus its
- Stable location, character, object, and zone identities. assigned player ID, tick duration, input delay, and compatibility fingerprint.
- Immutable state snapshots suitable for replication. - Destination input is normalized and scheduled two ticks in the future.
- Semantic commands suitable for request serialization. - The server broadcasts every numbered tick, including empty ticks. Clients
- A replaceable public `IGameServer` contract. buffer out-of-order messages and advance only contiguous tick IDs.
- A single active-location model matching the initial multiplayer scope. - All replicas apply the same globally sequenced commands before one exact
100 ms update.
- Post-tick canonical hashes exclude persistence timestamps and local
presentation state. A mismatch is diagnosed but not automatically repaired.
Still required for real multiplayer: No wall-clock timestamp participates in simulation. `steady_clock` paces the
server and local render accumulator only, so different machine clocks do not
affect command placement or state. The current missing-packet policy is to stall;
there is no prediction or rollback.
- Network transport and command serialization. Current boundaries remain:
- Authentication and player/session assignment.
- Snapshot delta generation or other replication strategy.
- Latency handling, interpolation, reconciliation, and prediction.
- Command validation, authorization, and anti-cheat rules.
- Multiplayer spawning/despawning and location membership.
- Presentation events delivered over the network.
The current local implementation intentionally preserves synchronous Lua and animation behavior. A remote implementation must define which of those behaviors remain server-authoritative and which become client presentation. - only `SetCharacterDestinationCommand` crosses the network;
- player characters must already exist in the initial state and campaign slot
list; dynamic spawning and reconnect history are not implemented;
- authentication and automatic checkpoint resynchronization are absent;
- multiplayer Lua/dialogue shared behavior is unsupported;
- deterministic compatibility targets identical executable and content builds.
Floating-point simulation is not guaranteed bit-identical across platforms.
## Platform Build Integration ## Platform Build Integration
@@ -432,8 +444,9 @@ The refactor uses portable C++17 and introduces no Windows-only gameplay code.
The standalone target lives under `server/`. It links the authoritative state, The standalone target lives under `server/`. It links the authoritative state,
campaign, content-definition, and portable resource sources only; SDL, OpenGL, campaign, content-definition, and portable resource sources only; SDL, OpenGL,
audio, Lua, dialogue presentation, and rendering sources are absent. It can prepare audio, Lua, dialogue presentation, and rendering sources are absent. It can prepare
a selected campaign/location with zero players and run the 50 ms fixed-step loop. a selected campaign/location with zero players. The Windows server adds
Networking remains outside this milestone. Boost.Beast transport and runs a fixed 100 ms lockstep loop; other platforms
continue to compile the portable state core without that transport.
The Windows Debug target was verified with: The Windows Debug target was verified with:
@@ -455,8 +468,11 @@ cmake --build proj-windows/build --config Debug --target ShadowOverBishkekDemo -
auxiliary-state synchronization without making `Game` a server friend. auxiliary-state synchronization without making `Game` a server friend.
- Empty player maps are valid. Player-specific interactions remain inactive until - Empty player maps are valid. Player-specific interactions remain inactive until
a player entity is added. a player entity is added.
- A remote client still needs a client-side runtime-location factory driven by - Pathfinding priority queues use cell index as a stable secondary key.
configuration/definitions and replicated snapshots. - Gameplay randomness must be seeded and serialized; the `random_device` battle
bot is disabled for multiplayer sessions.
- A lockstep compatibility fingerprint must match before the client accepts the
initial state.
## Main Files ## Main Files
@@ -469,6 +485,11 @@ cmake --build proj-windows/build --config Debug --target ShadowOverBishkekDemo -
- `src/game_server/IGameServerLifecycle.h` — graphics-free campaign/location lifecycle. - `src/game_server/IGameServerLifecycle.h` — graphics-free campaign/location lifecycle.
- `src/game_server/InternalGameServer.h` / `.cpp` — in-process authoritative implementation. - `src/game_server/InternalGameServer.h` / `.cpp` — in-process authoritative implementation.
- `src/game_server/LocalGameServerHost.h` / `.cpp` — graphical-client runtime adapter. - `src/game_server/LocalGameServerHost.h` / `.cpp` — graphical-client runtime adapter.
- `src/game_server/NetworkProtocol.h` / `.cpp` — protocol-v2 messages,
lockstep timeline, validation, and canonical hashes.
- `src/game_server/WebsocketGameServer.h` / `.cpp` and
`WebsocketGameClient.h` / `.cpp` — slot assignment, input scheduling, tick
broadcasting, buffering, and replica advancement.
- `src/Game.h` / `src/Game.cpp` — application host, campaign lifecycle, and runtime wiring. - `src/Game.h` / `src/Game.cpp` — application host, campaign lifecycle, and runtime wiring.
- `src/Location.h` / `src/Location.cpp` — loaded location runtime, command routing, - `src/Location.h` / `src/Location.cpp` — loaded location runtime, command routing,
interactions, collisions, scripts, and presentation-event consumption. interactions, collisions, scripts, and presentation-event consumption.
+69 -50
View File
@@ -1,13 +1,15 @@
# WebSocket Networking: First Iteration # WebSocket Deterministic Lockstep Networking
## Scope ## Scope
The first networking iteration adds a Windows-only WebSocket transport using The Windows WebSocket transport uses Boost.Asio and Boost.Beast to run a
Boost.Asio and Boost.Beast. server-sequenced deterministic lockstep simulation. A client receives one
authoritative state at a completed tick boundary. After that, the server sends
only numbered tick messages containing ordered player commands (or an empty
command list).
The goal of this iteration is connection and replication stability. It does not The current command surface is intentionally limited to
yet implement multiplayer commands, authentication, per-connection players, `SetCharacterDestinationCommand`.
state deltas, or presentation-event replication.
## Runtime Architecture ## Runtime Architecture
@@ -15,16 +17,17 @@ state deltas, or presentation-event replication.
`WebsocketGameServer` wraps the headless `InternalGameServer`. `WebsocketGameServer` wraps the headless `InternalGameServer`.
- It accepts any WebSocket connection. - The simulation tick is fixed at 100 ms.
- It sends the current complete game state immediately after connection. - Each accepted connection is assigned the first free character ID from the
- It broadcasts the current complete state every five seconds. campaign's `networkPlayerSlots`; the connection is rejected when all slots
- Incoming WebSocket messages are read and discarded. are occupied.
- The authoritative simulation remains on the main server thread. - The initial message contains the state, completed snapshot tick, assigned
- Network sessions only receive serialized state and never access player ID, tick duration, two-tick input delay, and compatibility fingerprint.
`InternalGameServer` directly. - Destination inputs cross from the Asio thread through a queue. The simulation
thread validates client sequence numbers, replaces client identity with the
The standalone server creates the existing fixed player ID `"player"` and does assigned slot, and schedules commands two ticks in the future.
not enable the battle-zone test bot. - Every tick is broadcast, including ticks with no commands. No periodic state
snapshots are sent.
### Client ### Client
@@ -37,14 +40,19 @@ not enable the battle-zone test bot.
consumers and transitional runtime-state bindings continue to work. consumers and transitional runtime-state bindings continue to work.
- Camera orientation lives in host-owned `LocationLocalState`, so incoming - Camera orientation lives in host-owned `LocationLocalState`, so incoming
authoritative snapshots cannot overwrite camera movement. authoritative snapshots cannot overwrite camera movement.
- `submit()` intentionally discards all commands. - `submit()` serializes destination intent only. Other command variants remain
- `drainEvents()` returns no events. local no-ops for a remote connection.
- Tick bundles are buffered by ID. The replica advances only while the next
contiguous tick is available, applying its commands before one exact 100 ms
simulation step.
- Completed simulation events are passed to the runtime `Location` for
presentation, and ordinary server events are drainable through `IGameServer`.
- Client-side save and load calls are ignored because persistence remains - Client-side save and load calls are ignored because persistence remains
server-side. server-side.
`RemoteGameServerHost` prepares local presentation resources and runtime `RemoteGameServerHost` prepares local presentation resources and runtime
locations against the replicated state. It does not advance authoritative locations against the replica and presents completed-tick events. It never
simulation or write Lua/dialogue auxiliary state back to the server. advances simulation from a render-frame delta.
## Campaign Routing ## Campaign Routing
@@ -63,37 +71,53 @@ multiplayer Battle Zone entries share location content and the start-state path,
so the state can be regenerated through the single-player entry. At startup, the so the state can be regenerated through the single-player entry. At startup, the
standalone server loads the authoritative half of that shared save and rewrites standalone server loads the authoritative half of that shared save and rewrites
its campaign ID to `battle_zone_multiplayer`; each remote client restores only its campaign ID to `battle_zone_multiplayer`; each remote client restores only
the local presentation half. the local presentation half. The multiplayer campaign currently declares the
pre-existing `player` and `player2` state entries as connection
slots; the random test-bot controller is disabled.
The campaign's `networkCompatibility` token is the fingerprint; release builds
must bump it whenever deterministic content changes.
If DNS resolution, TCP connection, the WebSocket handshake, or the active socket If DNS resolution, TCP connection, the WebSocket handshake, or the active socket
fails, the client unloads the remote campaign and returns to the campaign fails, the client unloads the remote campaign and returns to the campaign
selection screen. selection screen.
## JSON Protocol ## JSON Protocol v2
Messages use a versioned JSON envelope: Every message uses a versioned JSON envelope:
```json ```json
{ {
"protocolVersion": 1, "protocolVersion": 2,
"type": "state", "type": "tick",
"payload": { "payload": {
"schemaVersion": 2 "tickId": 81,
"commands": [],
"stateHash": "0123456789abcdef"
} }
} }
``` ```
The payload uses the authoritative `GameState::save()` representation and excludes Message types are:
the client-local location map, including camera orientation.
The client applies it through `InternalGameServer::load()`.
Malformed JSON, unsupported protocol versions, unsupported message types, and - `initial_state` (server to client): one state snapshot and lockstep metadata;
invalid state payloads are logged and ignored. - `destination_input` (client to server): monotonic client sequence and a
centimetre-normalized finite target;
- `tick` (server to client): tick ID, globally sequenced commands, and
post-tick diagnostic state hash;
- `error` (server to client): invalid input, incompatibility, or full server.
Single-player persistence uses a separate envelope that combines this authoritative Protocol v1 is not compatible. Malformed payloads, non-finite or out-of-range
document with client-local location state. The temporary full-state network format coordinates, duplicate client sequences, and unsupported commands cannot enter
is still expected to be replaced by a dedicated replication schema or deltas as the authoritative command stream.
command handling and production replication are implemented.
WebSocket ordering is not used as simulation time. Integer tick IDs are the
only authoritative clock. The server uses `steady_clock` only to pace its loop,
and clients may have different wall clocks without affecting the result. A
client stalls on a missing tick instead of predicting or rolling back.
Single-player persistence remains a separate envelope combining authoritative
and client-local state. `savedAt`, camera state, and presentation objects are
excluded from the diagnostic deterministic hash.
## Build Configuration ## Build Configuration
@@ -135,23 +159,18 @@ Launch the Windows game and select `battle_zone_multiplayer`. The client connect
## Verification ## Verification
The implementation was verified with: CTest covers protocol-v2 round trips and validation, input-delay ordering,
fixed-tick replica behavior, campaign slot metadata, initial connection and
- A successful Windows graphical-game build. slot assignment, destination submission, and contiguous tick consumption.
- A successful Windows headless-server build.
- Server state and protocol tests.
- A WebSocket smoke test receiving the initial state and the next five-second
broadcast.
- A check that the shared `"player"` exists and the battle-zone test bot is not
created for the network server.
## Current Limitations ## Current Limitations
- All clients share the fixed player ID `"player"`. - Only preconfigured player slots can connect; dynamic join/spawn commands are
- Client commands are not sent to the server. not implemented.
- The server does not process commands. - There is no authentication or reconnect/replay history.
- State is sent as a complete snapshot rather than a delta. - A hash mismatch is logged but does not yet trigger checkpoint resynchronization.
- There is no authentication, authorization, reconnect loop, or session identity.
- Presentation events are not replicated.
- The endpoint is hardcoded in the graphical client. - The endpoint is hardcoded in the graphical client.
- Multiplayer Lua and dialogue behavior remains unsupported. - Multiplayer Lua and dialogue behavior remains unsupported.
- Determinism is supported for identical executable/content builds. The
floating-point simulation is not guaranteed bit-identical across platforms
or compilers.
+2
View File
@@ -39,6 +39,8 @@
"startState": "resources/config/start_state_battle_zone.json", "startState": "resources/config/start_state_battle_zone.json",
"quests": "resources/config/quests_battle_zone.json", "quests": "resources/config/quests_battle_zone.json",
"tutorial": false, "tutorial": false,
"networkPlayerSlots": ["player", "player2"],
"networkCompatibility": "battle-zone-lockstep-v2",
"loadingScreen": "resources/w/ui/screen_loading.json" "loadingScreen": "resources/w/ui/screen_loading.json"
} }
] ]
@@ -155,7 +155,7 @@
"playerHp": 200.0, "playerHp": 200.0,
"playerMaxHp": 200.0, "playerMaxHp": 200.0,
"players": { "players": {
"battle_zone_test_bot": { "player2": {
"animationFrame": 0.0, "animationFrame": 0.0,
"attackTargetId": "", "attackTargetId": "",
"attack_cooldown": 0.0, "attack_cooldown": 0.0,
+17
View File
@@ -144,6 +144,23 @@ if(BUILD_TESTING)
add_test( add_test(
NAME GameServer.NetworkProtocol NAME GameServer.NetworkProtocol
COMMAND GameServerNetworkProtocolTests) COMMAND GameServerNetworkProtocolTests)
add_executable(
GameServerWebsocketLockstepTests
tests/WebsocketLockstepTests.cpp
"${GAME_ROOT}/src/game_server/NetworkProtocol.cpp"
"${GAME_ROOT}/src/game_server/WebsocketGameClient.cpp"
"${GAME_ROOT}/src/game_server/WebsocketGameServer.cpp")
target_include_directories(
GameServerWebsocketLockstepTests PRIVATE "${BOOST_BUNDLED_DIR}")
target_compile_definitions(
GameServerWebsocketLockstepTests PRIVATE
NETWORK WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00)
target_link_libraries(
GameServerWebsocketLockstepTests PRIVATE
game_server_core ws2_32 mswsock)
add_test(
NAME GameServer.WebsocketLockstep
COMMAND GameServerWebsocketLockstepTests)
endif() endif()
add_test( add_test(
NAME GameServer.IdleSmoke NAME GameServer.IdleSmoke
+29 -11
View File
@@ -1,8 +1,8 @@
# Headless GameServer # Headless GameServer
This target runs the authoritative `InternalGameServer` without SDL, OpenGL, This target runs the authoritative `InternalGameServer` without SDL, OpenGL,
audio, Lua, or rendering dependencies. On Windows it exposes the state through audio, Lua, or rendering dependencies. On Windows it exposes a deterministic
a Boost.Beast WebSocket listener. lockstep session through a Boost.Beast WebSocket listener.
## Build ## Build
@@ -39,7 +39,7 @@ For a multi-configuration Windows build, the executable is normally
For smoke tests, `--ticks N` limits execution and `--no-sleep` skips the For smoke tests, `--ticks N` limits execution and `--no-sleep` skips the
real-time delay. real-time delay.
Start the first multiplayer transport iteration: Start the multiplayer lockstep server:
```powershell ```powershell
.\server\build\Release\GameServer.exe --campaign battle_zone_multiplayer --location uni_exterior .\server\build\Release\GameServer.exe --campaign battle_zone_multiplayer --location uni_exterior
@@ -48,8 +48,11 @@ Start the first multiplayer transport iteration:
The listener defaults to `ws://0.0.0.0:8080`. Launch the Windows graphical game The listener defaults to `ws://0.0.0.0:8080`. Launch the Windows graphical game
afterward and select `battle_zone_multiplayer`; that campaign connects to afterward and select `battle_zone_multiplayer`; that campaign connects to
`ws://127.0.0.1:8080`. Other campaigns continue to use the in-process server. `ws://127.0.0.1:8080`. Other campaigns continue to use the in-process server.
The server broadcasts an initial full state and then one every five seconds.
Client commands are intentionally discarded in this iteration. The server sends one state snapshot when a player connects, then broadcasts one
numbered command bundle every 100 ms. Bundles may be empty. Destination input is
scheduled two ticks (200 ms) in the future. Tick IDs, rather than server or
client wall-clock values, define simulation time.
If `--location` is omitted, the campaign's configured start location is used. If `--location` is omitted, the campaign's configured start location is used.
`--campaigns PATH` selects a different campaign registry. Content paths are `--campaigns PATH` selects a different campaign registry. Content paths are
@@ -64,17 +67,24 @@ Available startup options:
--campaigns PATH --campaigns PATH
--address IP --address IP
--port N --port N
--tick-ms N --tick-ms 100
--ticks N --ticks N
--no-sleep --no-sleep
``` ```
The graphical client executes Lua/dialogue behavior after building its runtime Network mode requires a 100 ms tick; another `--tick-ms` value is rejected.
The selected campaign's `networkPlayerSlots` lists pre-existing character IDs
assigned to connections in order. `networkCompatibility` is a release token
that must be changed whenever deterministic content changes; clients reject a
different token. The bundled multiplayer Battle Zone has two slots (`player`
and `player2`) and rejects additional clients. Disconnecting
releases the slot. The bot's random autonomous controller is disabled in
multiplayer.
The graphical client executes presentation behavior after building its runtime
`Location`; the headless content pass loads authoritative navigation, NPC, `Location`; the headless content pass loads authoritative navigation, NPC,
interactive-object, trigger, teleport, taxi, and animation metadata only. interactive-object, trigger, teleport, taxi, and animation metadata. Lua and
The standalone WebSocket lifecycle creates the existing shared `"player"` so dialogue-driven shared multiplayer behavior is not yet supported.
the graphical client can render its local character. It does not create the
battle-zone test bot.
## Tests ## Tests
@@ -82,5 +92,13 @@ CTest covers:
- repeated `LocationGameState::setup()` preserving mutable runtime state; - repeated `LocationGameState::setup()` preserving mutable runtime state;
- ticking a prepared location with zero players; - ticking a prepared location with zero players;
- deterministic fixed-100 ms replay behavior;
- protocol-v2 parsing, validation, target normalization, state hashing, and
two-tick command ordering;
- a WebSocket initial-state/slot/destination/tick integration test;
- idle server startup; - idle server startup;
- headless loading of `battle_zone_multiplayer/uni_exterior`. - headless loading of `battle_zone_multiplayer/uni_exterior`.
The lockstep implementation currently targets identical executable and content
builds. Diagnostic state hashes detect divergence, but there is no automatic
checkpoint resynchronization or reconnect input history yet.
+24 -6
View File
@@ -1,4 +1,5 @@
#include "game_server/InternalGameServer.h" #include "game_server/InternalGameServer.h"
#include "game_server/NetworkProtocol.h"
#if defined(NETWORK) #if defined(NETWORK)
#include "game_server/WebsocketGameServer.h" #include "game_server/WebsocketGameServer.h"
#endif #endif
@@ -15,6 +16,7 @@
#include <memory> #include <memory>
#include <string> #include <string>
#include <thread> #include <thread>
#include <vector>
namespace { namespace {
@@ -44,7 +46,7 @@ std::int64_t parsePositiveNumber(const char* value, const char* option)
int main(int argc, char** argv) int main(int argc, char** argv)
{ {
std::int64_t tickMs = 500; std::int64_t tickMs = FRG::kSimulationTickMs;
std::int64_t maxTicks = (std::numeric_limits<std::int64_t>::max)(); std::int64_t maxTicks = (std::numeric_limits<std::int64_t>::max)();
bool sleepBetweenTicks = true; bool sleepBetweenTicks = true;
std::string campaignId; std::string campaignId;
@@ -89,14 +91,18 @@ int main(int argc, char** argv)
return EXIT_FAILURE; return EXIT_FAILURE;
} }
//Temporary override: if (tickMs != FRG::kSimulationTickMs) {
campaignId = "battle_zone_multiplayer"; std::cerr << "Network lockstep requires --tick-ms "
locationId = "uni_exterior"; << FRG::kSimulationTickMs << '\n';
return EXIT_FAILURE;
}
std::signal(SIGINT, requestStop); std::signal(SIGINT, requestStop);
std::signal(SIGTERM, requestStop); std::signal(SIGTERM, requestStop);
FRG::InternalGameServer server; FRG::InternalGameServer server;
std::vector<std::string> playerSlots;
std::string compatibilityFingerprint = "lockstep-v2";
if (!campaignId.empty()) { if (!campaignId.empty()) {
try { try {
FRG::CampaignRegistry::instance().loadFromJson(campaignsPath); FRG::CampaignRegistry::instance().loadFromJson(campaignsPath);
@@ -138,6 +144,17 @@ int main(int argc, char** argv)
} }
} }
server.activateLocation(selectedLocation); server.activateLocation(selectedLocation);
playerSlots = campaign->networkPlayerSlots;
for (const std::string& slot : playerSlots) {
if (server.snapshot()->players.count(slot) == 0)
throw std::runtime_error(
"Multiplayer slot '" + slot +
"' is missing from the initial state");
}
compatibilityFingerprint =
campaign->networkCompatibility.empty()
? "lockstep-v2:" + campaignId
: campaign->networkCompatibility;
if (!campaign->tutorial) { if (!campaign->tutorial) {
server.submit(FRG::SetTutorialStepCommand{ server.submit(FRG::SetTutorialStepCommand{
FRG::TutorialStep::Completed}); FRG::TutorialStep::Completed});
@@ -158,7 +175,8 @@ int main(int argc, char** argv)
std::unique_ptr<FRG::WebsocketGameServer> websocketServer; std::unique_ptr<FRG::WebsocketGameServer> websocketServer;
try { try {
websocketServer = std::make_unique<FRG::WebsocketGameServer>( websocketServer = std::make_unique<FRG::WebsocketGameServer>(
server, listenAddress, static_cast<std::uint16_t>(listenPort)); server, playerSlots, compatibilityFingerprint,
listenAddress, static_cast<std::uint16_t>(listenPort));
} catch (const std::exception& error) { } catch (const std::exception& error) {
std::cerr << "WebSocket listener setup failed: " std::cerr << "WebSocket listener setup failed: "
<< error.what() << '\n'; << error.what() << '\n';
@@ -174,7 +192,7 @@ int main(int argc, char** argv)
tick < maxTicks && !stopRequested.load(); ++tick) { tick < maxTicks && !stopRequested.load(); ++tick) {
nextTick += tickDuration; nextTick += tickDuration;
#if defined(NETWORK) #if defined(NETWORK)
websocketServer->update(tickMs); websocketServer->update();
#else #else
server.update(tickMs); server.update(tickMs);
#endif #endif
+33 -3
View File
@@ -8,7 +8,7 @@ namespace {
int fail(const char* message) int fail(const char* message)
{ {
std::cerr << message << '\n'; std::cout << "LocationGameStateTests: " << message << std::endl;
return EXIT_FAILURE; return EXIT_FAILURE;
} }
@@ -78,6 +78,13 @@ int main()
multiplayerBattleZoneConfig->startStatePath) { multiplayerBattleZoneConfig->startStatePath) {
return fail("Battle Zone modes do not share their initial state"); return fail("Battle Zone modes do not share their initial state");
} }
if (multiplayerBattleZoneConfig->networkPlayerSlots.size() != 2 ||
multiplayerBattleZoneConfig->networkPlayerSlots[0] != "player" ||
multiplayerBattleZoneConfig->networkPlayerSlots[1] !=
"player2" ||
multiplayerBattleZoneConfig->networkCompatibility.empty()) {
return fail("Multiplayer player slots were not loaded");
}
FRG::LocationDefinition definition; FRG::LocationDefinition definition;
definition.config.id = "test_location"; definition.config.id = "test_location";
@@ -135,7 +142,7 @@ int main()
FRG::InternalGameServer server; FRG::InternalGameServer server;
server.startCampaign(battleZone, {}, false, true); server.startCampaign(battleZone, {}, false, true);
const auto snapshot = server.snapshot(); const auto snapshot = server.snapshot();
const auto bot = snapshot->players.find("battle_zone_test_bot"); const auto bot = snapshot->players.find("player2");
if (bot == snapshot->players.end()) if (bot == snapshot->players.end())
return fail("Battle Zone test bot was not created"); return fail("Battle Zone test bot was not created");
const auto stand = static_cast<std::size_t>(FRG::AnimationState::STAND); const auto stand = static_cast<std::size_t>(FRG::AnimationState::STAND);
@@ -148,14 +155,37 @@ int main()
FRG::InternalGameServer networkServer; FRG::InternalGameServer networkServer;
FRG::CampaignConfig multiplayerBattleZone = battleZone; FRG::CampaignConfig multiplayerBattleZone = battleZone;
multiplayerBattleZone.id = "battle_zone_multiplayer"; multiplayerBattleZone.id = "battle_zone_multiplayer";
multiplayerBattleZone.networkPlayerSlots = {
"player", "player2"};
networkServer.startCampaign(multiplayerBattleZone, {}, true, false); networkServer.startCampaign(multiplayerBattleZone, {}, true, false);
const auto networkSnapshot = networkServer.snapshot(); const auto networkSnapshot = networkServer.snapshot();
if (networkSnapshot->campaignId != "battle_zone_multiplayer") if (networkSnapshot->campaignId != "battle_zone_multiplayer")
return fail("Network campaign ID was not retained"); return fail("Network campaign ID was not retained");
if (networkSnapshot->players.count("player") != 1) if (networkSnapshot->players.count("player") != 1)
return fail("Network campaign shared player was not created"); return fail("Network campaign shared player was not created");
if (networkSnapshot->players.count("battle_zone_test_bot") != 0) if (networkSnapshot->players.count("player2") != 0)
return fail("Network campaign unexpectedly created the test bot"); return fail("Network campaign unexpectedly created the test bot");
nlohmann::json loadedNetworkState;
networkServer.save(loadedNetworkState);
loadedNetworkState["players"]["player2"] =
loadedNetworkState["players"]["player"];
loadedNetworkState["players"]["player2"]["position"] =
{4.f, 0.f, 5.f};
networkServer.load(loadedNetworkState);
const auto loadedNetworkSnapshot = networkServer.snapshot();
const auto loadedNetworkBot =
loadedNetworkSnapshot->players.find("player2");
if (loadedNetworkBot == loadedNetworkSnapshot->players.end() ||
loadedNetworkBot->second.state.animationFrameCounts[stand] <= 1 ||
loadedNetworkBot->second.state.animationFrameCounts[walk] <= 1) {
return fail(
"Loaded multiplayer slot did not restore animation metadata");
}
if (!loadedNetworkBot->second.state.position.isApprox(
Eigen::Vector3f(4.f, 0.f, 5.f))) {
return fail("Loaded multiplayer slot position was overwritten");
}
return EXIT_SUCCESS; return EXIT_SUCCESS;
} }
+85 -13
View File
@@ -26,31 +26,103 @@ int main()
{"players", {{"player", {{"hp", 200.0}}}}}, {"players", {{"player", {{"hp", 200.0}}}}},
{"locations", {{"uni_exterior", {{"state", savedLocationState}}}}} {"locations", {{"uni_exterior", {{"state", savedLocationState}}}}}
}; };
const std::string message = FRG::makeStateMessage(original); FRG::InitialStateMessage initial;
nlohmann::json parsed; initial.state = original;
initial.assignedPlayerId = "player";
initial.snapshotTick = 42;
initial.compatibilityFingerprint = "lockstep-v2:battle_zone_multiplayer";
const std::string message = FRG::makeInitialStateMessage(initial);
FRG::InitialStateMessage parsed;
std::string error; std::string error;
require( require(
FRG::parseStateMessage(message, parsed, error), FRG::parseInitialStateMessage(message, parsed, error),
"valid state envelope was rejected"); "valid initial-state envelope was rejected");
require(parsed == original, "state payload did not round trip"); require(parsed.state == original, "state payload did not round trip");
require(parsed.snapshotTick == 42, "snapshot tick did not round trip");
require( require(
!parsed["locations"]["uni_exterior"]["state"].contains("cameraAzimuth") && parsed.assignedPlayerId == "player",
!parsed["locations"]["uni_exterior"]["state"].contains("cameraInclination"), "assigned player did not round trip");
require(
!parsed.state["locations"]["uni_exterior"]["state"].contains("cameraAzimuth") &&
!parsed.state["locations"]["uni_exterior"]["state"].contains("cameraInclination"),
"local camera state leaked into the network payload"); "local camera state leaked into the network payload");
FRG::DestinationInputMessage input;
input.clientSequence = 7;
input.target = Eigen::Vector3f(1.234f, 0.f, -5.678f);
FRG::DestinationInputMessage parsedInput;
require( require(
!FRG::parseStateMessage( FRG::parseDestinationInputMessage(
R"({"protocolVersion":2,"type":"state","payload":{}})", FRG::makeDestinationInputMessage(input), parsedInput, error),
"destination input did not round trip");
require(
parsedInput.clientSequence == 7 &&
parsedInput.target.isApprox(Eigen::Vector3f(1.23f, 0.f, -5.68f)),
"destination input was not normalized");
FRG::TickMessage tick;
tick.tickId = 43;
tick.commands.push_back({1, "player", parsedInput.target});
tick.stateHash = "0123456789abcdef";
FRG::TickMessage parsedTick;
require(
FRG::parseTickMessage(
FRG::makeTickMessage(tick), parsedTick, error),
"tick message did not round trip");
require(
parsedTick.tickId == 43 && parsedTick.commands.size() == 1,
"tick contents did not round trip");
require(
!FRG::parseInitialStateMessage(
R"({"protocolVersion":1,"type":"initial_state","payload":{}})",
parsed, error), parsed, error),
"unknown protocol version was accepted"); "unknown protocol version was accepted");
require( require(
!FRG::parseStateMessage( !FRG::parseDestinationInputMessage(
R"({"protocolVersion":1,"type":"command","payload":{}})", R"({"protocolVersion":2,"type":"command","payload":{}})",
parsed, error), parsedInput, error),
"unknown message type was accepted"); "unknown message type was accepted");
require( require(
!FRG::parseStateMessage("not json", parsed, error), !FRG::parseInitialStateMessage("not json", parsed, error),
"malformed JSON was accepted"); "malformed JSON was accepted");
require(
!FRG::parseDestinationInputMessage(
R"({"protocolVersion":2,"type":"destination_input","payload":{"clientSequence":1,"target":[1e999,0,0]}})",
parsedInput, error),
"non-finite destination was accepted");
std::string errorCode;
std::string errorDetail;
require(
FRG::parseErrorMessage(
FRG::makeErrorMessage("server_full", "No slot"),
errorCode, errorDetail) &&
errorCode == "server_full" && errorDetail == "No slot",
"error message did not round trip");
nlohmann::json hashState = original;
hashState["savedAt"] = "first";
const std::string firstHash = FRG::canonicalStateHash(hashState);
hashState["savedAt"] = "second";
require(
firstHash == FRG::canonicalStateHash(hashState),
"persistence timestamp changed deterministic hash");
FRG::LockstepTimeline timeline(2);
const std::uint64_t firstSequence =
timeline.schedule("player", Eigen::Vector3f(1.f, 0.f, 1.f));
const std::uint64_t secondSequence =
timeline.schedule("player_2", Eigen::Vector3f(2.f, 0.f, 2.f));
const FRG::TickMessage emptyTick = timeline.advance();
require(
emptyTick.tickId == 1 && emptyTick.commands.empty(),
"input-delay tick was not empty");
const FRG::TickMessage commandTick = timeline.advance();
require(
commandTick.tickId == 2 && commandTick.commands.size() == 2 &&
commandTick.commands[0].commandSequence == firstSequence &&
commandTick.commands[1].commandSequence == secondSequence,
"scheduled commands were not finalized in stable order");
return EXIT_SUCCESS; return EXIT_SUCCESS;
} }
+90
View File
@@ -0,0 +1,90 @@
#include "game_server/InternalGameServer.h"
#include "game_server/WebsocketGameClient.h"
#include "game_server/WebsocketGameServer.h"
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <memory>
#include <thread>
namespace {
void require(bool condition, const char* message)
{
if (condition) return;
std::cerr << "WebsocketLockstepTests: " << message << '\n';
std::exit(EXIT_FAILURE);
}
template <class Predicate>
bool waitFor(Predicate predicate)
{
const auto deadline =
std::chrono::steady_clock::now() + std::chrono::seconds(3);
while (std::chrono::steady_clock::now() < deadline) {
if (predicate()) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return false;
}
} // namespace
int main()
{
FRG::CampaignConfig campaign;
campaign.id = "test_lockstep";
FRG::InternalGameServer authoritative;
authoritative.startCampaign(campaign, {}, true, false);
FRG::WebsocketGameServer server(
authoritative, {"player"}, "lockstep-v2:test_lockstep",
"127.0.0.1", 0);
auto client = std::make_unique<FRG::WebsocketGameClient>(
"127.0.0.1", std::to_string(server.port()), "/");
require(
waitFor([&]() { return client->connected(); }),
"client did not connect");
require(
waitFor([&]() {
client->update(0);
return client->initialized();
}),
"client did not receive initial state");
require(client->localPlayerId() == "player", "wrong player slot assigned");
FRG::WebsocketGameClient overflow(
"127.0.0.1", std::to_string(server.port()), "/");
require(
waitFor([&]() {
overflow.update(0);
return overflow.rejected();
}),
"connection beyond configured slots was not rejected");
client->submit(FRG::SetCharacterDestinationCommand{
{}, "spoofed", Eigen::Vector3f(1.234f, 0.f, 2.345f), "ignored"});
std::this_thread::sleep_for(std::chrono::milliseconds(20));
server.update();
server.update();
require(
waitFor([&]() {
client->update(0);
return client->snapshot()->revision > 1;
}),
"client did not consume finalized lockstep ticks");
client.reset();
std::this_thread::sleep_for(std::chrono::milliseconds(50));
FRG::WebsocketGameClient replacement(
"127.0.0.1", std::to_string(server.port()), "/");
require(
waitFor([&]() {
replacement.update(0);
return replacement.initialized();
}),
"disconnected player slot was not reusable");
return EXIT_SUCCESS;
}
+4
View File
@@ -109,6 +109,10 @@ namespace FRG {
c.startStatePath = entry.value("startState", ""); c.startStatePath = entry.value("startState", "");
c.questsPath = entry.value("quests", ""); c.questsPath = entry.value("quests", "");
c.tutorial = entry.value("tutorial", false); c.tutorial = entry.value("tutorial", false);
c.networkPlayerSlots =
entry.value("networkPlayerSlots", std::vector<std::string>{});
c.networkCompatibility =
entry.value("networkCompatibility", std::string());
c.loadingScreenPath = entry.value("loadingScreen", std::string(CampaignConfig::kDefaultLoadingScreen)); c.loadingScreenPath = entry.value("loadingScreen", std::string(CampaignConfig::kDefaultLoadingScreen));
for (const std::string& path : c.locationConfigPaths) { for (const std::string& path : c.locationConfigPaths) {
LocationConfig loc; LocationConfig loc;
+4
View File
@@ -55,6 +55,10 @@ namespace FRG {
std::string startStatePath; std::string startStatePath;
std::string questsPath; std::string questsPath;
bool tutorial = false; bool tutorial = false;
// Existing character IDs assigned to multiplayer connections in order.
std::vector<std::string> networkPlayerSlots;
// Version token shared by clients with identical deterministic content.
std::string networkCompatibility;
// UI layout shown while the campaign loads (preloaded with the rest of the UI). // UI layout shown while the campaign loads (preloaded with the rest of the UI).
std::string loadingScreenPath = kDefaultLoadingScreen; std::string loadingScreenPath = kDefaultLoadingScreen;
+2 -2
View File
@@ -21,8 +21,8 @@ int Environment::width = CONST_DEFAULT_WIDTH;
int Environment::height = CONST_DEFAULT_HEIGHT; int Environment::height = CONST_DEFAULT_HEIGHT;
float Environment::zoom = DEFAULT_ZOOM; float Environment::zoom = DEFAULT_ZOOM;
//bool Environment::enableLogging = false; bool Environment::enableLogging = false;
bool Environment::enableLogging = true; //bool Environment::enableLogging = true;
SDL_Window* Environment::window = nullptr; SDL_Window* Environment::window = nullptr;
+53 -17
View File
@@ -1,4 +1,5 @@
#include "Game.h" #include "Game.h"
#include "game_server/NetworkProtocol.h"
#include "AnimatedModel.h" #include "AnimatedModel.h"
#include "Localization.h" #include "Localization.h"
#include "utils/Utils.h" #include "utils/Utils.h"
@@ -89,7 +90,6 @@ namespace FRG
, gameServer(internalGameServer) , gameServer(internalGameServer)
, gameServerHost(localGameServerHost) , gameServerHost(localGameServerHost)
, menuManager(renderer, gameServer, *audioPlayer) , menuManager(renderer, gameServer, *audioPlayer)
, newTickCount(0)
, lastTickCount(0) , lastTickCount(0)
{ {
} }
@@ -553,18 +553,29 @@ namespace FRG
if (!campaign) return; if (!campaign) return;
#if defined(NETWORK) #if defined(NETWORK)
remoteCampaignActive = campaignId == "battle_zone_multiplayer"; remoteCampaignActive = campaignId == "battle_zone_multiplayer";
remoteInitialStateBound = false;
if (remoteCampaignActive) { if (remoteCampaignActive) {
logger() << "[network] Campaign '" << campaignId
<< "' selected; creating WebSocket client" << std::endl;
websocketGameClient = std::make_unique<WebsocketGameClient>(); websocketGameClient = std::make_unique<WebsocketGameClient>();
remoteGameServerHost = remoteGameServerHost =
std::make_unique<RemoteGameServerHost>(*websocketGameClient); std::make_unique<RemoteGameServerHost>(*websocketGameClient);
gameServer.use(*websocketGameClient); gameServer.use(*websocketGameClient);
gameServerHost.use(*remoteGameServerHost); gameServerHost.use(*remoteGameServerHost);
logger() << "[network] Remote game server routes installed"
<< std::endl;
} else { } else {
logger() << "[network] Campaign '" << campaignId
<< "' uses the in-process server" << std::endl;
gameServer.use(internalGameServer); gameServer.use(internalGameServer);
gameServerHost.use(localGameServerHost); gameServerHost.use(localGameServerHost);
} }
#else #else
remoteCampaignActive = false; remoteCampaignActive = false;
remoteInitialStateBound = false;
logger() << "[network] Networking was not compiled into this client; "
"campaign '" << campaignId << "' uses the in-process server"
<< std::endl;
#endif #endif
gameServerHost.initializeCampaign( gameServerHost.initializeCampaign(
campaignId, campaign->questsPath, CONST_ZIP_FILE); campaignId, campaign->questsPath, CONST_ZIP_FILE);
@@ -588,6 +599,7 @@ namespace FRG
websocketGameClient.reset(); websocketGameClient.reset();
#endif #endif
remoteCampaignActive = false; remoteCampaignActive = false;
remoteInitialStateBound = false;
renderer.textureManager.UnloadUnused(); // the player's textures renderer.textureManager.UnloadUnused(); // the player's textures
} }
@@ -1120,7 +1132,7 @@ namespace FRG
int64_t Game::getSyncTimeMs() { int64_t Game::getSyncTimeMs() {
int64_t localNow = std::chrono::duration_cast<std::chrono::milliseconds>( int64_t localNow = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count(); std::chrono::steady_clock::now().time_since_epoch()).count();
return localNow; return localNow;
} }
@@ -1129,21 +1141,15 @@ namespace FRG
if (lastTickCount == 0) { if (lastTickCount == 0) {
lastTickCount = getSyncTimeMs(); lastTickCount = getSyncTimeMs();
lastTickCount = (lastTickCount / 50) * 50;
return; return;
} }
newTickCount = getSyncTimeMs(); const int64_t now = getSyncTimeMs();
const int64_t delta =
newTickCount = (newTickCount / 50) * 50; (std::min)(now - lastTickCount,
static_cast<int64_t>(CONST_MAX_TIME_INTERVAL));
if (newTickCount - lastTickCount > CONST_TIMER_INTERVAL) { lastTickCount = now;
if (delta > 0) {
int64_t delta = newTickCount - lastTickCount;
lastTickCount = newTickCount;
updateDarklandsFlash(delta); updateDarklandsFlash(delta);
menuManager.uiManager.update(static_cast<float>(delta)); menuManager.uiManager.update(static_cast<float>(delta));
@@ -1155,9 +1161,39 @@ namespace FRG
if (loadingCompleted) if (loadingCompleted)
{ {
// The authoritative implementation advances simulation; a future if (remoteCampaignActive) {
// network client can use the same call to poll/apply server updates. // Remote authoritative time comes only from contiguous server ticks.
gameServerHost.update(delta); gameServerHost.update(delta);
#if defined(NETWORK)
// The connection-assigned slot is known only after initial_state.
// Always rebuild once: replica_.load() replaces authoritative
// location state even when the assigned ID remains "player".
if (websocketGameClient && websocketGameClient->initialized() &&
(!remoteInitialStateBound || !player ||
player->entityId() != server().localPlayerId())) {
const auto assignedState = stateSnapshot();
const std::string locationId = assignedState
? assignedState->currentLocationId : std::string();
unloadLocation(false);
player = Location::createPlayer(
renderer, audioPlayer.get(), server(),
server().localPlayerId());
if (player)
player->onDeathAnimComplete =
[this]() { startDarklandsTransition(); };
if (!locationId.empty()) loadLocation(locationId);
remoteInitialStateBound = true;
logger() << "[network] Runtime rebound to assigned player '"
<< server().localPlayerId() << "'" << std::endl;
}
#endif
} else {
simulationAccumulatorMs += delta;
while (simulationAccumulatorMs >= kSimulationTickMs) {
gameServerHost.update(kSimulationTickMs);
simulationAccumulatorMs -= kSimulationTickMs;
}
}
#if defined(NETWORK) #if defined(NETWORK)
if (remoteCampaignActive && websocketGameClient && if (remoteCampaignActive && websocketGameClient &&
websocketGameClient->connectionFailed()) { websocketGameClient->connectionFailed()) {
+2 -2
View File
@@ -98,6 +98,7 @@ namespace FRG {
GameServerRouter gameServer; GameServerRouter gameServer;
GameServerHostRouter gameServerHost; GameServerHostRouter gameServerHost;
bool remoteCampaignActive = false; bool remoteCampaignActive = false;
bool remoteInitialStateBound = false;
public: public:
IGameServer& server() { return gameServer; } IGameServer& server() { return gameServer; }
@@ -252,13 +253,12 @@ namespace FRG {
*/ */
//void drawMobilePortraitOverlay(); //void drawMobilePortraitOverlay();
int64_t newTickCount;
int64_t lastTickCount; int64_t lastTickCount;
int64_t simulationAccumulatorMs = 0;
int lastDrawableW = -1; int lastDrawableW = -1;
int lastDrawableH = -1; int lastDrawableH = -1;
static const size_t CONST_TIMER_INTERVAL = 10;
static const size_t CONST_MAX_TIME_INTERVAL = 1000; static const size_t CONST_MAX_TIME_INTERVAL = 1000;
}; };
-2
View File
@@ -1837,8 +1837,6 @@ namespace FRG
for (const CharacterSimulationEvent& event : events.characters) { for (const CharacterSimulationEvent& event : events.characters) {
Character* character = findRuntimeCharacter(event.characterId); Character* character = findRuntimeCharacter(event.characterId);
if (character && event.events.navigationRequested)
setCharacterTarget(*character, event.events.navigationTarget);
if (!event.events.arrivedCallbackName.empty()) { if (!event.events.arrivedCallbackName.empty()) {
try { try {
scriptEngine.callFunction(event.events.arrivedCallbackName); scriptEngine.callFunction(event.events.arrivedCallbackName);
+26 -5
View File
@@ -9,7 +9,7 @@ namespace FRG {
namespace { namespace {
constexpr const char* kBattleZoneCampaignId = "battle_zone"; constexpr const char* kBattleZoneCampaignId = "battle_zone";
constexpr const char* kBattleZoneTestBotId = "battle_zone_test_bot"; constexpr const char* kBattleZonePlayer2Id = "player2";
constexpr std::int64_t kBattleZoneTestBotIntervalMs = 5000; constexpr std::int64_t kBattleZoneTestBotIntervalMs = 5000;
void configurePlayerAnimations( void configurePlayerAnimations(
@@ -44,11 +44,11 @@ void InternalGameServer::ensureBattleZoneTestBot()
{ {
if (state_.campaignId != kBattleZoneCampaignId) return; if (state_.campaignId != kBattleZoneCampaignId) return;
if (!battleZoneTestBotEnabled_) { if (!battleZoneTestBotEnabled_) {
state_.players.erase(kBattleZoneTestBotId); state_.players.erase(kBattleZonePlayer2Id);
return; return;
} }
auto& bot = state_.players[kBattleZoneTestBotId]; auto& bot = state_.players[kBattleZonePlayer2Id];
if (!bot) bot = std::make_shared<CharacterState>(); if (!bot) bot = std::make_shared<CharacterState>();
bot->configureAsPlayer(); bot->configureAsPlayer();
configurePlayerAnimations(*bot, resourceZipFile_); configurePlayerAnimations(*bot, resourceZipFile_);
@@ -60,7 +60,7 @@ void InternalGameServer::updateBattleZoneTestBot(std::int64_t deltaMs)
if (!battleZoneTestBotEnabled_ || if (!battleZoneTestBotEnabled_ ||
state_.campaignId != kBattleZoneCampaignId || state_.campaignId != kBattleZoneCampaignId ||
state_.currentLocationName.empty() || state_.currentLocationName.empty() ||
state_.players.find(kBattleZoneTestBotId) == state_.players.end()) { state_.players.find(kBattleZonePlayer2Id) == state_.players.end()) {
return; return;
} }
@@ -72,7 +72,7 @@ void InternalGameServer::updateBattleZoneTestBot(std::int64_t deltaMs)
static std::uniform_real_distribution<float> coordinate(-10.0f, 10.0f); static std::uniform_real_distribution<float> coordinate(-10.0f, 10.0f);
submit(SetCharacterDestinationCommand{ submit(SetCharacterDestinationCommand{
state_.currentLocationName, state_.currentLocationName,
kBattleZoneTestBotId, kBattleZonePlayer2Id,
Eigen::Vector3f(coordinate(randomEngine), 0.f, coordinate(randomEngine)), Eigen::Vector3f(coordinate(randomEngine), 0.f, coordinate(randomEngine)),
std::string()}); std::string()});
} }
@@ -92,6 +92,7 @@ void InternalGameServer::startCampaign(
bool createLocalPlayer, bool enableTestBot) bool createLocalPlayer, bool enableTestBot)
{ {
resourceZipFile_ = zipFile; resourceZipFile_ = zipFile;
networkPlayerSlots_ = campaign.networkPlayerSlots;
battleZoneTestBotEnabled_ = enableTestBot; battleZoneTestBotEnabled_ = enableTestBot;
state_.campaignId = campaign.id; state_.campaignId = campaign.id;
state_.currentLocationName.clear(); state_.currentLocationName.clear();
@@ -125,6 +126,7 @@ void InternalGameServer::clearCampaign()
state_.currentLocationName.clear(); state_.currentLocationName.clear();
state_.players.clear(); state_.players.clear();
state_.locations.clear(); state_.locations.clear();
networkPlayerSlots_.clear();
battleZoneTestBotElapsedMs_ = 0; battleZoneTestBotElapsedMs_ = 0;
publishMutation(); publishMutation();
} }
@@ -487,6 +489,19 @@ void InternalGameServer::update(std::int64_t deltaMs)
pendingSimulationLocationId_ = state_.currentLocationName; pendingSimulationLocationId_ = state_.currentLocationName;
pendingSimulationEvents_ = pendingSimulationEvents_ =
stored->second.update(deltaMs, state_.players, interactiveObjectsLocked); stored->second.update(deltaMs, state_.players, interactiveObjectsLocked);
// Navigation recovery is a simulation consequence, not presentation input.
// Applying it here makes every lockstep replica produce the same follow-up.
for (const CharacterSimulationEvent& event :
pendingSimulationEvents_.characters) {
if (!event.events.navigationRequested) continue;
CharacterState* character = state_.findCharacter(
state_.currentLocationName, event.characterId);
if (character && stored->second.location) {
character->setTarget(
event.events.navigationTarget, {},
*stored->second.location, event.characterId);
}
}
publishMutation(); publishMutation();
} }
@@ -499,6 +514,12 @@ void InternalGameServer::load(const nlohmann::json& in)
{ {
pendingEvents_.clear(); pendingEvents_.clear();
state_.load(in); state_.load(in);
for (const std::string& playerId : networkPlayerSlots_) {
auto player = state_.players.find(playerId);
if (player == state_.players.end() || !player->second) continue;
player->second->configureAsPlayer();
configurePlayerAnimations(*player->second, resourceZipFile_);
}
battleZoneTestBotElapsedMs_ = 0; battleZoneTestBotElapsedMs_ = 0;
ensureBattleZoneTestBot(); ensureBattleZoneTestBot();
publishMutation(); publishMutation();
+5
View File
@@ -23,6 +23,9 @@ public:
void update(std::int64_t deltaMs) override; void update(std::int64_t deltaMs) override;
void save(nlohmann::json& out) const override; void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override; void load(const nlohmann::json& in) override;
void setLocalPlayerId(std::string playerId) {
localPlayerId_ = std::move(playerId);
}
void startCampaign( void startCampaign(
const CampaignConfig& campaign, const std::string& zipFile, const CampaignConfig& campaign, const std::string& zipFile,
bool createLocalPlayer, bool enableTestBot) override; bool createLocalPlayer, bool enableTestBot) override;
@@ -32,6 +35,7 @@ public:
private: private:
friend class LocalGameServerHost; friend class LocalGameServerHost;
friend class RemoteGameServerHost; friend class RemoteGameServerHost;
friend class WebsocketGameClient;
// Host-only lifecycle operations. They never expose authoritative state and // Host-only lifecycle operations. They never expose authoritative state and
// are intentionally absent from IGameServer. // are intentionally absent from IGameServer.
@@ -49,6 +53,7 @@ private:
GameState state_; GameState state_;
std::string localPlayerId_ = "player"; std::string localPlayerId_ = "player";
std::string resourceZipFile_; std::string resourceZipFile_;
std::vector<std::string> networkPlayerSlots_;
std::int64_t battleZoneTestBotElapsedMs_ = 0; std::int64_t battleZoneTestBotElapsedMs_ = 0;
bool battleZoneTestBotEnabled_ = false; bool battleZoneTestBotEnabled_ = false;
std::string pendingSimulationLocationId_; std::string pendingSimulationLocationId_;
+237 -16
View File
@@ -1,24 +1,20 @@
#include "NetworkProtocol.h" #include "NetworkProtocol.h"
#include <cmath>
#include <exception> #include <exception>
#include <iomanip>
#include <limits>
#include <sstream>
namespace FRG { namespace FRG {
namespace {
std::string makeStateMessage(const nlohmann::json& state) bool parseEnvelope(
{ const std::string& text, const char* expectedType,
return nlohmann::json{ nlohmann::json& payload, std::string& error)
{"protocolVersion", kGameProtocolVersion},
{"type", "state"},
{"payload", state}
}.dump();
}
bool parseStateMessage(
const std::string& message, nlohmann::json& state,
std::string& error)
{ {
try { try {
const nlohmann::json envelope = nlohmann::json::parse(message); const nlohmann::json envelope = nlohmann::json::parse(text);
if (!envelope.is_object()) { if (!envelope.is_object()) {
error = "message must be a JSON object"; error = "message must be a JSON object";
return false; return false;
@@ -27,15 +23,15 @@ bool parseStateMessage(
error = "unsupported protocol version"; error = "unsupported protocol version";
return false; return false;
} }
if (envelope.value("type", std::string()) != "state") { if (envelope.value("type", std::string()) != expectedType) {
error = "unsupported message type"; error = "unsupported message type";
return false; return false;
} }
if (!envelope.contains("payload") || !envelope["payload"].is_object()) { if (!envelope.contains("payload") || !envelope["payload"].is_object()) {
error = "state payload must be an object"; error = "message payload must be an object";
return false; return false;
} }
state = envelope["payload"]; payload = envelope["payload"];
error.clear(); error.clear();
return true; return true;
} catch (const std::exception& exception) { } catch (const std::exception& exception) {
@@ -44,4 +40,229 @@ bool parseStateMessage(
} }
} }
std::string envelope(const char* type, nlohmann::json payload)
{
return nlohmann::json{
{"protocolVersion", kGameProtocolVersion},
{"type", type},
{"payload", std::move(payload)}
}.dump();
}
bool readTick(
const nlohmann::json& value, const char* name,
std::uint64_t& out, std::string& error)
{
if (!value.contains(name) ||
(!value[name].is_number_unsigned() && !value[name].is_number_integer())) {
error = std::string(name) + " must be a non-negative integer";
return false;
}
const auto signedValue = value[name].get<std::int64_t>();
if (signedValue < 0) {
error = std::string(name) + " must be non-negative";
return false;
}
out = static_cast<std::uint64_t>(signedValue);
return true;
}
bool readTarget(
const nlohmann::json& value, Eigen::Vector3f& target, std::string& error)
{
if (!value.is_array() || value.size() != 3) {
error = "target must contain three coordinates";
return false;
}
for (std::size_t index = 0; index < 3; ++index) {
if (!value[index].is_number()) {
error = "target coordinates must be numbers";
return false;
}
const float coordinate = value[index].get<float>();
if (!std::isfinite(coordinate) || std::abs(coordinate) > 100000.f) {
error = "target coordinate is not finite or is out of range";
return false;
}
target[static_cast<Eigen::Index>(index)] =
std::round(coordinate * 100.f) / 100.f;
}
return true;
}
nlohmann::json targetJson(const Eigen::Vector3f& target)
{
return nlohmann::json::array({target.x(), target.y(), target.z()});
}
} // namespace
std::string makeInitialStateMessage(const InitialStateMessage& message)
{
return envelope("initial_state", {
{"state", message.state},
{"assignedPlayerId", message.assignedPlayerId},
{"snapshotTick", message.snapshotTick},
{"tickMs", message.tickMs},
{"inputDelayTicks", message.inputDelayTicks},
{"compatibilityFingerprint", message.compatibilityFingerprint}
});
}
bool parseInitialStateMessage(
const std::string& text, InitialStateMessage& message, std::string& error)
{
nlohmann::json payload;
if (!parseEnvelope(text, "initial_state", payload, error)) return false;
if (!payload.contains("state") || !payload["state"].is_object() ||
!payload.contains("assignedPlayerId") ||
!payload["assignedPlayerId"].is_string() ||
!payload.contains("compatibilityFingerprint") ||
!payload["compatibilityFingerprint"].is_string()) {
error = "invalid initial-state payload";
return false;
}
if (!readTick(payload, "snapshotTick", message.snapshotTick, error))
return false;
message.tickMs = payload.value("tickMs", std::int64_t{0});
message.inputDelayTicks =
payload.value("inputDelayTicks", std::uint32_t{0});
if (message.tickMs != kSimulationTickMs || message.inputDelayTicks == 0) {
error = "unsupported lockstep timing";
return false;
}
message.state = payload["state"];
message.assignedPlayerId = payload["assignedPlayerId"].get<std::string>();
message.compatibilityFingerprint =
payload["compatibilityFingerprint"].get<std::string>();
return !message.assignedPlayerId.empty();
}
std::uint64_t LockstepTimeline::schedule(
const std::string& playerId, const Eigen::Vector3f& target)
{
const std::uint64_t sequence = nextCommandSequence_++;
pending_[currentTick_ + inputDelayTicks_].push_back(
{sequence, playerId, target});
return sequence;
}
TickMessage LockstepTimeline::advance()
{
TickMessage result;
result.tickId = ++currentTick_;
auto commands = pending_.find(result.tickId);
if (commands != pending_.end()) {
result.commands = std::move(commands->second);
pending_.erase(commands);
}
return result;
}
std::string makeDestinationInputMessage(const DestinationInputMessage& message)
{
return envelope("destination_input", {
{"clientSequence", message.clientSequence},
{"target", targetJson(message.target)}
});
}
bool parseDestinationInputMessage(
const std::string& text, DestinationInputMessage& message, std::string& error)
{
nlohmann::json payload;
if (!parseEnvelope(text, "destination_input", payload, error)) return false;
if (!readTick(payload, "clientSequence", message.clientSequence, error))
return false;
if (!payload.contains("target"))
return error = "target is required", false;
return readTarget(payload["target"], message.target, error);
}
std::string makeTickMessage(const TickMessage& message)
{
nlohmann::json commands = nlohmann::json::array();
for (const ScheduledDestinationCommand& command : message.commands) {
commands.push_back({
{"commandSequence", command.commandSequence},
{"playerId", command.playerId},
{"target", targetJson(command.target)}
});
}
return envelope("tick", {
{"tickId", message.tickId},
{"commands", std::move(commands)},
{"stateHash", message.stateHash}
});
}
bool parseTickMessage(
const std::string& text, TickMessage& message, std::string& error)
{
nlohmann::json payload;
if (!parseEnvelope(text, "tick", payload, error)) return false;
if (!readTick(payload, "tickId", message.tickId, error)) return false;
if (!payload.contains("commands") || !payload["commands"].is_array()) {
error = "commands must be an array";
return false;
}
message.commands.clear();
std::uint64_t previousSequence = 0;
for (const nlohmann::json& value : payload["commands"]) {
ScheduledDestinationCommand command;
if (!value.is_object() ||
!readTick(value, "commandSequence", command.commandSequence, error) ||
!value.contains("playerId") || !value["playerId"].is_string() ||
!value.contains("target") ||
!readTarget(value["target"], command.target, error)) {
if (error.empty()) error = "invalid scheduled command";
return false;
}
command.playerId = value["playerId"].get<std::string>();
if (command.playerId.empty() || command.commandSequence == 0 ||
command.commandSequence <= previousSequence) {
error = "commands must have unique increasing sequences";
return false;
}
previousSequence = command.commandSequence;
message.commands.push_back(std::move(command));
}
message.stateHash = payload.value("stateHash", std::string());
return true;
}
std::string makeErrorMessage(const std::string& code, const std::string& detail)
{
return envelope("error", {{"code", code}, {"detail", detail}});
}
bool parseErrorMessage(
const std::string& text, std::string& code, std::string& detail)
{
nlohmann::json payload;
std::string error;
if (!parseEnvelope(text, "error", payload, error) ||
!payload.contains("code") || !payload["code"].is_string() ||
!payload.contains("detail") || !payload["detail"].is_string()) {
return false;
}
code = payload["code"].get<std::string>();
detail = payload["detail"].get<std::string>();
return true;
}
std::string canonicalStateHash(nlohmann::json state)
{
state.erase("savedAt");
const std::string bytes = state.dump();
std::uint64_t hash = 1469598103934665603ull;
for (const unsigned char value : bytes) {
hash ^= value;
hash *= 1099511628211ull;
}
std::ostringstream output;
output << std::hex << std::setfill('0') << std::setw(16) << hash;
return output.str();
}
} // namespace FRG } // namespace FRG
+67 -5
View File
@@ -1,15 +1,77 @@
#pragma once #pragma once
#include "../external/nlohmann/json.hpp" #include "../external/nlohmann/json.hpp"
#include <Eigen/Dense>
#include <cstdint>
#include <map>
#include <string> #include <string>
#include <vector>
namespace FRG { namespace FRG {
inline constexpr int kGameProtocolVersion = 1; inline constexpr int kGameProtocolVersion = 2;
inline constexpr std::int64_t kSimulationTickMs = 100;
inline constexpr std::uint32_t kDefaultInputDelayTicks = 2;
std::string makeStateMessage(const nlohmann::json& state); struct InitialStateMessage {
bool parseStateMessage( nlohmann::json state;
const std::string& message, nlohmann::json& state, std::string assignedPlayerId;
std::string& error); std::uint64_t snapshotTick = 0;
std::int64_t tickMs = kSimulationTickMs;
std::uint32_t inputDelayTicks = kDefaultInputDelayTicks;
std::string compatibilityFingerprint;
};
struct DestinationInputMessage {
std::uint64_t clientSequence = 0;
Eigen::Vector3f target = Eigen::Vector3f::Zero();
};
struct ScheduledDestinationCommand {
std::uint64_t commandSequence = 0;
std::string playerId;
Eigen::Vector3f target = Eigen::Vector3f::Zero();
};
struct TickMessage {
std::uint64_t tickId = 0;
std::vector<ScheduledDestinationCommand> commands;
std::string stateHash;
};
class LockstepTimeline {
public:
explicit LockstepTimeline(
std::uint32_t inputDelayTicks = kDefaultInputDelayTicks)
: inputDelayTicks_(inputDelayTicks) {}
std::uint64_t currentTick() const { return currentTick_; }
std::uint64_t schedule(
const std::string& playerId, const Eigen::Vector3f& target);
TickMessage advance();
private:
std::uint32_t inputDelayTicks_;
std::uint64_t currentTick_ = 0;
std::uint64_t nextCommandSequence_ = 1;
std::map<std::uint64_t, std::vector<ScheduledDestinationCommand>> pending_;
};
std::string makeInitialStateMessage(const InitialStateMessage& message);
bool parseInitialStateMessage(
const std::string& text, InitialStateMessage& message, std::string& error);
std::string makeDestinationInputMessage(const DestinationInputMessage& message);
bool parseDestinationInputMessage(
const std::string& text, DestinationInputMessage& message, std::string& error);
std::string makeTickMessage(const TickMessage& message);
bool parseTickMessage(
const std::string& text, TickMessage& message, std::string& error);
std::string makeErrorMessage(const std::string& code, const std::string& detail);
bool parseErrorMessage(
const std::string& text, std::string& code, std::string& detail);
// Stable for the same executable/content build. Persistence-only wall-clock
// fields are removed before hashing.
std::string canonicalStateHash(nlohmann::json state);
} // namespace FRG } // namespace FRG
+19 -5
View File
@@ -20,6 +20,10 @@ void RemoteGameServerHost::initializeCampaign(
CampaignRegistry::instance().find(campaignId); CampaignRegistry::instance().find(campaignId);
if (!campaign) if (!campaign)
throw std::runtime_error("Unknown remote campaign '" + campaignId + "'"); throw std::runtime_error("Unknown remote campaign '" + campaignId + "'");
client_.expectCompatibility(
campaign->networkCompatibility.empty()
? "lockstep-v2:" + campaignId
: campaign->networkCompatibility);
client_.replica_.startCampaign(*campaign, zipFile, true, false); client_.replica_.startCampaign(*campaign, zipFile, true, false);
} }
@@ -38,6 +42,15 @@ void RemoteGameServerHost::resetCampaign(
bool RemoteGameServerHost::prepareLocation(const LocationConfig& config) bool RemoteGameServerHost::prepareLocation(const LocationConfig& config)
{ {
auto existing = client_.replica_.state_.locations.find(config.id);
if (existing != client_.replica_.state_.locations.end() &&
existing->second.definition) {
// The location was prepared before initial_state. Loading the
// authoritative snapshot overlays mutable state while retaining this
// definition; preparing it again would reset the assigned player's
// position to LocationConfig::playerPosition.
return false;
}
return client_.replica_.prepareLocation(config); return client_.replica_.prepareLocation(config);
} }
@@ -92,11 +105,12 @@ void RemoteGameServerHost::bindRuntimeLocation(
void RemoteGameServerHost::update(std::int64_t deltaMs) void RemoteGameServerHost::update(std::int64_t deltaMs)
{ {
client_.update(deltaMs); client_.update(deltaMs);
const auto state = client_.snapshot(); for (auto& completed : client_.takeCompletedSimulationTicks()) {
if (!state || state->currentLocationId.empty()) return; if (completed.locationId.empty()) continue;
auto runtime = runtimeLocations_.find(state->currentLocationId); auto runtime = runtimeLocations_.find(completed.locationId);
if (runtime == runtimeLocations_.end() || !runtime->second) return; if (runtime == runtimeLocations_.end() || !runtime->second) continue;
runtime->second->update(deltaMs, LocationSimulationEvents{}); runtime->second->update(kSimulationTickMs, completed.events);
}
} }
void RemoteGameServerHost::save(nlohmann::json&) void RemoteGameServerHost::save(nlohmann::json&)
+131 -18
View File
@@ -3,6 +3,7 @@
#include <boost/asio.hpp> #include <boost/asio.hpp>
#include <boost/beast.hpp> #include <boost/beast.hpp>
#include <atomic> #include <atomic>
#include <deque>
#include <iostream> #include <iostream>
#include <mutex> #include <mutex>
#include <thread> #include <thread>
@@ -28,6 +29,8 @@ public:
, port_(std::move(port)) , port_(std::move(port))
, path_(std::move(path)) , path_(std::move(path))
{ {
std::cout << "[network] Resolving ws://" << host_ << ':' << port_
<< path_ << std::endl;
resolve(); resolve();
thread_ = std::thread([this]() { io_.run(); }); thread_ = std::thread([this]() { io_.run(); });
} }
@@ -49,6 +52,16 @@ public:
bool connected() const { return connected_.load(); } bool connected() const { return connected_.load(); }
bool connectionFailed() const { return connectionFailed_.load(); } bool connectionFailed() const { return connectionFailed_.load(); }
void send(std::string message)
{
asio::post(io_, [this, message = std::move(message)]() mutable {
if (!connected_.load()) return;
const bool writeInProgress = !outbound_.empty();
outbound_.push_back(std::move(message));
if (!writeInProgress) write();
});
}
private: private:
void resolve() void resolve()
{ {
@@ -56,11 +69,15 @@ private:
host_, port_, host_, port_,
[this](beast::error_code error, tcp::resolver::results_type results) { [this](beast::error_code error, tcp::resolver::results_type results) {
if (error) return fail("resolve", error); if (error) return fail("resolve", error);
std::cout << "[network] WebSocket host resolved; connecting"
<< std::endl;
asio::async_connect( asio::async_connect(
socket_.next_layer(), results, socket_.next_layer(), results,
[this](beast::error_code connectError, [this](beast::error_code connectError,
const tcp::endpoint&) { const tcp::endpoint&) {
if (connectError) return fail("connect", connectError); if (connectError) return fail("connect", connectError);
std::cout << "[network] TCP connected; starting WebSocket handshake"
<< std::endl;
socket_.set_option( socket_.set_option(
websocket::stream_base::timeout::suggested( websocket::stream_base::timeout::suggested(
beast::role_type::client)); beast::role_type::client));
@@ -72,7 +89,7 @@ private:
connected_.store(true); connected_.store(true);
std::cout std::cout
<< "[network] Connected to ws://" << "[network] Connected to ws://"
<< host_ << ':' << port_ << path_ << '\n'; << host_ << ':' << port_ << path_ << std::endl;
read(); read();
}); });
}); });
@@ -99,6 +116,17 @@ private:
}); });
} }
void write()
{
socket_.async_write(
asio::buffer(outbound_.front()),
[this](beast::error_code error, std::size_t) {
if (error) return fail("write", error);
outbound_.pop_front();
if (!outbound_.empty()) write();
});
}
void fail(const char* operation, beast::error_code error) void fail(const char* operation, beast::error_code error)
{ {
connected_.store(false); connected_.store(false);
@@ -119,6 +147,7 @@ private:
std::atomic_bool connectionFailed_{false}; std::atomic_bool connectionFailed_{false};
std::mutex messagesMutex_; std::mutex messagesMutex_;
std::vector<std::string> messages_; std::vector<std::string> messages_;
std::deque<std::string> outbound_;
}; };
WebsocketGameClient::WebsocketGameClient( WebsocketGameClient::WebsocketGameClient(
@@ -140,38 +169,122 @@ const std::string& WebsocketGameClient::localPlayerId() const
return replica_.localPlayerId(); return replica_.localPlayerId();
} }
void WebsocketGameClient::submit(const GameCommand&) void WebsocketGameClient::submit(const GameCommand& command)
{ {
// Commands intentionally remain local no-ops in the first iteration. if (!initialized_) return;
if (const auto* destination =
std::get_if<SetCharacterDestinationCommand>(&command)) {
impl_->send(makeDestinationInputMessage(
{nextClientSequence_++, destination->target}));
}
} }
std::vector<GameServerEvent> WebsocketGameClient::drainEvents() std::vector<GameServerEvent> WebsocketGameClient::drainEvents()
{ {
return {}; std::vector<GameServerEvent> result;
result.swap(pendingEvents_);
return result;
} }
void WebsocketGameClient::update(std::int64_t) void WebsocketGameClient::update(std::int64_t)
{ {
std::vector<std::string> messages = impl_->takeMessages(); std::vector<std::string> messages = impl_->takeMessages();
if (messages.empty()) return; for (const std::string& text : messages) {
// State messages are complete snapshots; only the newest queued one matters.
for (auto it = messages.rbegin(); it != messages.rend(); ++it) {
nlohmann::json state;
std::string error; std::string error;
if (!parseStateMessage(*it, state, error)) { InitialStateMessage initial;
std::cerr << "[network] Ignoring WebSocket message: " if (parseInitialStateMessage(text, initial, error)) {
<< error << '\n'; if (!expectedCompatibilityFingerprint_.empty() &&
initial.compatibilityFingerprint !=
expectedCompatibilityFingerprint_) {
std::cerr << "[network] Incompatible server content: expected "
<< expectedCompatibilityFingerprint_ << ", received "
<< initial.compatibilityFingerprint << '\n';
continue;
}
try {
replica_.load(initial.state);
replica_.setLocalPlayerId(initial.assignedPlayerId);
nextExpectedTick_ = initial.snapshotTick + 1;
compatibilityFingerprint_ =
std::move(initial.compatibilityFingerprint);
pendingTicks_.clear();
initialized_ = true;
std::cout << "[network] Applied initial state at tick "
<< initial.snapshotTick << " as player '"
<< initial.assignedPlayerId << "' (delay "
<< initial.inputDelayTicks << " ticks)" << std::endl;
} catch (const std::exception& exception) {
std::cerr << "[network] Invalid initial state: "
<< exception.what() << '\n';
}
continue; continue;
} }
try { TickMessage tick;
replica_.load(state); if (parseTickMessage(text, tick, error)) {
} catch (const std::exception& exception) { if (initialized_ && tick.tickId >= nextExpectedTick_) {
std::cerr << "[network] Ignoring invalid game state: " if (!loggedFirstTick_) {
<< exception.what() << '\n'; std::cout << "[network] Received first lockstep tick "
<< tick.tickId << std::endl;
loggedFirstTick_ = true;
}
pendingTicks_.emplace(tick.tickId, std::move(tick));
}
continue;
} }
break; std::string errorCode;
std::string errorDetail;
if (parseErrorMessage(text, errorCode, errorDetail)) {
std::cerr << "[network] Server rejected request (" << errorCode
<< "): " << errorDetail << '\n';
if (errorCode == "server_full" ||
errorCode == "incompatible")
rejected_ = true;
continue;
}
std::cerr << "[network] Ignoring WebSocket message: "
<< error << '\n';
} }
while (initialized_) {
auto next = pendingTicks_.find(nextExpectedTick_);
if (next == pendingTicks_.end()) break;
TickMessage tick = std::move(next->second);
pendingTicks_.erase(next);
const std::string locationId =
replica_.snapshot()->currentLocationId;
for (const ScheduledDestinationCommand& command : tick.commands) {
replica_.submit(SetCharacterDestinationCommand{
locationId, command.playerId, command.target, {}});
}
replica_.update(kSimulationTickMs);
completedSimulationTicks_.push_back({
replica_.pendingSimulationLocationId_,
replica_.pendingSimulationEvents_});
std::vector<GameServerEvent> events = replica_.drainEvents();
pendingEvents_.insert(
pendingEvents_.end(),
std::make_move_iterator(events.begin()),
std::make_move_iterator(events.end()));
if (!tick.stateHash.empty()) {
nlohmann::json state;
replica_.save(state);
const std::string localHash = canonicalStateHash(std::move(state));
if (localHash != tick.stateHash) {
std::cerr << "[network] Lockstep divergence at tick "
<< tick.tickId << ": server=" << tick.stateHash
<< ", client=" << localHash << '\n';
}
}
++nextExpectedTick_;
}
}
std::vector<WebsocketGameClient::CompletedSimulationTick>
WebsocketGameClient::takeCompletedSimulationTicks()
{
std::vector<CompletedSimulationTick> result;
result.swap(completedSimulationTicks_);
return result;
} }
void WebsocketGameClient::save(nlohmann::json&) const void WebsocketGameClient::save(nlohmann::json&) const
+22
View File
@@ -2,6 +2,8 @@
#include "IGameServer.h" #include "IGameServer.h"
#include "InternalGameServer.h" #include "InternalGameServer.h"
#include "NetworkProtocol.h"
#include <map>
#include <memory> #include <memory>
#include <string> #include <string>
@@ -30,12 +32,32 @@ public:
bool connected() const; bool connected() const;
bool connectionFailed() const; bool connectionFailed() const;
bool initialized() const { return initialized_; }
bool rejected() const { return rejected_; }
private: private:
friend class RemoteGameServerHost; friend class RemoteGameServerHost;
struct CompletedSimulationTick {
std::string locationId;
LocationSimulationEvents events;
};
std::vector<CompletedSimulationTick> takeCompletedSimulationTicks();
void expectCompatibility(std::string fingerprint) {
expectedCompatibilityFingerprint_ = std::move(fingerprint);
}
class Impl; class Impl;
InternalGameServer replica_; InternalGameServer replica_;
std::unique_ptr<Impl> impl_; std::unique_ptr<Impl> impl_;
std::map<std::uint64_t, TickMessage> pendingTicks_;
std::vector<CompletedSimulationTick> completedSimulationTicks_;
std::vector<GameServerEvent> pendingEvents_;
std::uint64_t nextExpectedTick_ = 0;
std::uint64_t nextClientSequence_ = 1;
std::string compatibilityFingerprint_;
std::string expectedCompatibilityFingerprint_;
bool initialized_ = false;
bool rejected_ = false;
bool loggedFirstTick_ = false;
}; };
} // namespace FRG } // namespace FRG
+161 -25
View File
@@ -3,10 +3,14 @@
#include "NetworkProtocol.h" #include "NetworkProtocol.h"
#include <boost/asio.hpp> #include <boost/asio.hpp>
#include <boost/beast.hpp> #include <boost/beast.hpp>
#include <algorithm>
#include <deque> #include <deque>
#include <iostream> #include <iostream>
#include <map>
#include <memory> #include <memory>
#include <mutex>
#include <thread> #include <thread>
#include <unordered_map>
#include <vector> #include <vector>
namespace FRG { namespace FRG {
@@ -19,13 +23,26 @@ using tcp = asio::ip::tcp;
class ServerSession : public std::enable_shared_from_this<ServerSession> { class ServerSession : public std::enable_shared_from_this<ServerSession> {
public: public:
explicit ServerSession(tcp::socket socket) using InputHandler = std::function<void(
const std::shared_ptr<ServerSession>&, const std::string&)>;
using CloseHandler =
std::function<void(const std::shared_ptr<ServerSession>&)>;
ServerSession(
tcp::socket socket, std::string playerId,
InputHandler inputHandler, CloseHandler closeHandler)
: socket_(std::move(socket)) : socket_(std::move(socket))
, playerId_(std::move(playerId))
, inputHandler_(std::move(inputHandler))
, closeHandler_(std::move(closeHandler))
{ {
} }
void start(std::string initialState) const std::string& playerId() const { return playerId_; }
void start(std::string initialState, bool closeAfterWrite = false)
{ {
closeAfterWrite_ = closeAfterWrite;
socket_.set_option( socket_.set_option(
websocket::stream_base::timeout::suggested(beast::role_type::server)); websocket::stream_base::timeout::suggested(beast::role_type::server));
socket_.async_accept( socket_.async_accept(
@@ -38,7 +55,7 @@ public:
} }
self->socket_.text(true); self->socket_.text(true);
self->send(std::move(initialState)); self->send(std::move(initialState));
self->read(); if (!self->closeAfterWrite_) self->read();
}); });
} }
@@ -59,10 +76,13 @@ private:
if (error != websocket::error::closed) if (error != websocket::error::closed)
std::cerr << "[network] WebSocket read failed: " std::cerr << "[network] WebSocket read failed: "
<< error.message() << '\n'; << error.message() << '\n';
self->notifyClosed();
return; return;
} }
// Commands are intentionally ignored in the first iteration. const std::string message =
beast::buffers_to_string(self->inbound_.data());
self->inbound_.consume(self->inbound_.size()); self->inbound_.consume(self->inbound_.size());
self->inputHandler_(self, message);
self->read(); self->read();
}); });
} }
@@ -78,22 +98,50 @@ private:
return; return;
} }
self->outbound_.pop_front(); self->outbound_.pop_front();
if (!self->outbound_.empty()) self->write(); if (!self->outbound_.empty()) {
self->write();
} else if (self->closeAfterWrite_) {
self->socket_.async_close(
websocket::close_code::policy_error,
[self](beast::error_code) { self->notifyClosed(); });
}
}); });
} }
void notifyClosed()
{
if (closed_) return;
closed_ = true;
if (closeHandler_) closeHandler_(shared_from_this());
}
websocket::stream<tcp::socket> socket_; websocket::stream<tcp::socket> socket_;
beast::flat_buffer inbound_; beast::flat_buffer inbound_;
std::deque<std::string> outbound_; std::deque<std::string> outbound_;
std::string playerId_;
InputHandler inputHandler_;
CloseHandler closeHandler_;
bool closeAfterWrite_ = false;
bool closed_ = false;
}; };
} // namespace } // namespace
class WebsocketGameServer::Impl { class WebsocketGameServer::Impl {
public: public:
Impl(const std::string& address, std::uint16_t port, std::string initialState) struct PendingInput {
std::string playerId;
DestinationInputMessage input;
};
Impl(
const std::string& address, std::uint16_t port,
std::vector<std::string> playerSlots, nlohmann::json initialState,
std::string compatibilityFingerprint)
: acceptor_(io_) : acceptor_(io_)
, playerSlots_(std::move(playerSlots))
, latestState_(std::move(initialState)) , latestState_(std::move(initialState))
, compatibilityFingerprint_(std::move(compatibilityFingerprint))
{ {
const tcp::endpoint endpoint(asio::ip::make_address(address), port); const tcp::endpoint endpoint(asio::ip::make_address(address), port);
acceptor_.open(endpoint.protocol()); acceptor_.open(endpoint.protocol());
@@ -115,14 +163,33 @@ public:
if (thread_.joinable()) thread_.join(); if (thread_.joinable()) thread_.join();
} }
void broadcast(std::string state) std::vector<PendingInput> takeInputs()
{ {
asio::post(io_, [this, state = std::move(state)]() mutable { std::lock_guard<std::mutex> lock(inputsMutex_);
latestState_ = std::move(state); std::vector<PendingInput> result;
result.swap(pendingInputs_);
return result;
}
std::vector<std::string> takeReleasedPlayers()
{
std::lock_guard<std::mutex> lock(inputsMutex_);
std::vector<std::string> result;
result.swap(releasedPlayers_);
return result;
}
void publishTick(
std::string tick, nlohmann::json latestState, std::uint64_t tickId)
{
asio::post(io_, [this, tick = std::move(tick),
latestState = std::move(latestState), tickId]() mutable {
latestState_ = std::move(latestState);
latestTick_ = tickId;
auto output = sessions_.begin(); auto output = sessions_.begin();
for (auto input = sessions_.begin(); input != sessions_.end(); ++input) { for (auto input = sessions_.begin(); input != sessions_.end(); ++input) {
if (auto session = input->lock()) { if (auto session = input->lock()) {
session->send(latestState_); session->send(tick);
*output++ = *input; *output++ = *input;
} }
} }
@@ -138,10 +205,62 @@ private:
acceptor_.async_accept( acceptor_.async_accept(
[this](beast::error_code error, tcp::socket socket) { [this](beast::error_code error, tcp::socket socket) {
if (!error) { if (!error) {
std::string playerId;
for (const std::string& candidate : playerSlots_) {
if (assignedSlots_.count(candidate) == 0) {
playerId = candidate;
assignedSlots_[candidate] = true;
break;
}
}
auto session = auto session =
std::make_shared<ServerSession>(std::move(socket)); std::make_shared<ServerSession>(
sessions_.push_back(session); std::move(socket), playerId,
session->start(latestState_); [this](const std::shared_ptr<ServerSession>& source,
const std::string& message) {
DestinationInputMessage parsed;
std::string parseError;
if (!parseDestinationInputMessage(
message, parsed, parseError)) {
source->send(makeErrorMessage(
"invalid_input", parseError));
return;
}
std::lock_guard<std::mutex> lock(inputsMutex_);
pendingInputs_.push_back(
{source->playerId(), std::move(parsed)});
},
[this](const std::shared_ptr<ServerSession>& source) {
if (!source->playerId().empty())
std::cout << "[network] Player slot '"
<< source->playerId()
<< "' disconnected" << std::endl;
assignedSlots_.erase(source->playerId());
std::lock_guard<std::mutex> lock(inputsMutex_);
releasedPlayers_.push_back(source->playerId());
});
if (playerId.empty()) {
std::cout << "[network] Rejecting connection: no player slot"
<< std::endl;
session->start(
makeErrorMessage(
"server_full", "No multiplayer player slot is available"),
true);
} else {
std::cout << "[network] Accepted connection as player slot '"
<< playerId << "' at snapshot tick "
<< latestTick_ << std::endl;
sessions_.push_back(session);
InitialStateMessage initial;
initial.state = latestState_;
initial.assignedPlayerId = playerId;
initial.snapshotTick = latestTick_;
initial.compatibilityFingerprint =
compatibilityFingerprint_;
session->start(makeInitialStateMessage(initial));
std::cout << "[network] Initial state queued for player '"
<< playerId << "'" << std::endl;
}
} else if (error != asio::error::operation_aborted) { } else if (error != asio::error::operation_aborted) {
std::cerr << "[network] TCP accept failed: " std::cerr << "[network] TCP accept failed: "
<< error.message() << '\n'; << error.message() << '\n';
@@ -154,12 +273,21 @@ private:
tcp::acceptor acceptor_; tcp::acceptor acceptor_;
std::thread thread_; std::thread thread_;
std::vector<std::weak_ptr<ServerSession>> sessions_; std::vector<std::weak_ptr<ServerSession>> sessions_;
std::string latestState_; std::vector<std::string> playerSlots_;
std::unordered_map<std::string, bool> assignedSlots_;
nlohmann::json latestState_;
std::string compatibilityFingerprint_;
std::uint64_t latestTick_ = 0;
std::mutex inputsMutex_;
std::vector<PendingInput> pendingInputs_;
std::vector<std::string> releasedPlayers_;
std::uint16_t port_ = 0; std::uint16_t port_ = 0;
}; };
WebsocketGameServer::WebsocketGameServer( WebsocketGameServer::WebsocketGameServer(
InternalGameServer& gameServer, InternalGameServer& gameServer,
std::vector<std::string> playerSlots,
std::string compatibilityFingerprint,
const std::string& address, const std::string& address,
std::uint16_t port) std::uint16_t port)
: gameServer_(gameServer) : gameServer_(gameServer)
@@ -167,26 +295,34 @@ WebsocketGameServer::WebsocketGameServer(
nlohmann::json state; nlohmann::json state;
gameServer_.save(state); gameServer_.save(state);
impl_ = std::make_unique<Impl>( impl_ = std::make_unique<Impl>(
address, port, makeStateMessage(state)); address, port, std::move(playerSlots), std::move(state),
std::move(compatibilityFingerprint));
} }
WebsocketGameServer::~WebsocketGameServer() = default; WebsocketGameServer::~WebsocketGameServer() = default;
void WebsocketGameServer::update(std::int64_t deltaMs) void WebsocketGameServer::update()
{ {
gameServer_.update(deltaMs); for (const std::string& playerId : impl_->takeReleasedPlayers())
broadcastElapsedMs_ += deltaMs; lastClientSequences_.erase(playerId);
if (broadcastElapsedMs_ >= 5000) { for (const Impl::PendingInput& pending : impl_->takeInputs()) {
broadcastElapsedMs_ %= 5000; std::uint64_t& previous = lastClientSequences_[pending.playerId];
broadcastState(); if (pending.input.clientSequence <= previous) continue;
previous = pending.input.clientSequence;
timeline_.schedule(pending.playerId, pending.input.target);
} }
}
void WebsocketGameServer::broadcastState() TickMessage tick = timeline_.advance();
{ for (const ScheduledDestinationCommand& command : tick.commands) {
gameServer_.submit(SetCharacterDestinationCommand{
gameServer_.snapshot()->currentLocationId,
command.playerId, command.target, {}});
}
gameServer_.update(kSimulationTickMs);
nlohmann::json state; nlohmann::json state;
gameServer_.save(state); gameServer_.save(state);
impl_->broadcast(makeStateMessage(state)); tick.stateHash = canonicalStateHash(state);
impl_->publishTick(makeTickMessage(tick), std::move(state), tick.tickId);
} }
std::uint16_t WebsocketGameServer::port() const std::uint16_t WebsocketGameServer::port() const
+8 -3
View File
@@ -3,6 +3,9 @@
#include <cstdint> #include <cstdint>
#include <memory> #include <memory>
#include <string> #include <string>
#include <unordered_map>
#include <vector>
#include "NetworkProtocol.h"
namespace FRG { namespace FRG {
@@ -12,6 +15,8 @@ class WebsocketGameServer {
public: public:
WebsocketGameServer( WebsocketGameServer(
InternalGameServer& gameServer, InternalGameServer& gameServer,
std::vector<std::string> playerSlots,
std::string compatibilityFingerprint,
const std::string& address = "0.0.0.0", const std::string& address = "0.0.0.0",
std::uint16_t port = 8080); std::uint16_t port = 8080);
~WebsocketGameServer(); ~WebsocketGameServer();
@@ -19,15 +24,15 @@ public:
WebsocketGameServer(const WebsocketGameServer&) = delete; WebsocketGameServer(const WebsocketGameServer&) = delete;
WebsocketGameServer& operator=(const WebsocketGameServer&) = delete; WebsocketGameServer& operator=(const WebsocketGameServer&) = delete;
void update(std::int64_t deltaMs); void update();
void broadcastState();
std::uint16_t port() const; std::uint16_t port() const;
private: private:
class Impl; class Impl;
InternalGameServer& gameServer_; InternalGameServer& gameServer_;
std::unique_ptr<Impl> impl_; std::unique_ptr<Impl> impl_;
std::int64_t broadcastElapsedMs_ = 0; LockstepTimeline timeline_;
std::unordered_map<std::string, std::uint64_t> lastClientSequences_;
}; };
} // namespace FRG } // namespace FRG
+8 -3
View File
@@ -148,7 +148,8 @@ std::vector<Eigen::Vector3f> PathFinder::findPath(const Eigen::Vector3f& start,
bool operator<(const QueueNode& other) const bool operator<(const QueueNode& other) const
{ {
return priority > other.priority; if (priority != other.priority) return priority > other.priority;
return index > other.index;
} }
}; };
@@ -302,7 +303,8 @@ std::vector<Eigen::Vector3f> PathFinder::findPath(const Eigen::Vector3f& start,
bool operator<(const QueueNode& other) const bool operator<(const QueueNode& other) const
{ {
return priority > other.priority; if (priority != other.priority) return priority > other.priority;
return index > other.index;
} }
}; };
@@ -412,7 +414,10 @@ std::vector<Eigen::Vector3f> PathFinder::findNearestReachableImpl(
struct QueueNode { struct QueueNode {
int index = 0; int index = 0;
float priority = 0.0f; float priority = 0.0f;
bool operator<(const QueueNode& other) const { return priority > other.priority; } bool operator<(const QueueNode& other) const {
if (priority != other.priority) return priority > other.priority;
return index > other.index;
}
}; };
const int cellCount = gridWidth * gridDepth; const int cellCount = gridWidth * gridDepth;