diff --git a/GAME_STATE_ARCHITECTURE.md b/GAME_STATE_ARCHITECTURE.md index d370901..21b811c 100644 --- a/GAME_STATE_ARCHITECTURE.md +++ b/GAME_STATE_ARCHITECTURE.md @@ -2,19 +2,21 @@ ## 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. - -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. +The game separates authoritative gameplay state from rendering and runtime +resources. The same snapshot-and-command boundary supports the in-process +single-player server and a WebSocket deterministic-lockstep client/server. ## Architecture Overview -The architecture has five layers: +The architecture has six layers: 1. `GameState` is the aggregate root for persistent, authoritative state. 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. 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`. +6. Protocol-v2 WebSocket hosts assign preconfigured player slots and exchange + one initial state followed by ordered 100 ms tick bundles. ```text Input / UI @@ -103,7 +105,7 @@ array positions. - `snapshot()` for an immutable, revisioned `GameSnapshot`. - `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. 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. - 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 @@ -159,7 +164,7 @@ Loading and persistence restoration clear pending events and remain silent. - Exclusively owns the aggregate `GameState`. - 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. - 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. `CharacterState::update()` returns value-only events containing arrival Lua function -names, attack impacts, navigation requests, and death-animation completion. `LocationGameState` applies -queued damage after every character has advanced. Runtime `Character` objects only -pose loaded models and produce audio, particles, and presentation callbacks. +names, attack impacts, navigation requests, and death-animation completion. +`LocationGameState` applies queued damage after every character has advanced. +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 @@ -263,26 +271,23 @@ Configuration remains the source of visual assets, animation paths, meshes, text ## State Update Flow -The normal frame flow is: +Render frames and simulation ticks are separate: -1. SDL input is processed. -2. UI code submits semantic commands through `IGameServer`. -3. The internal server freezes location character observations and calls - `CharacterState::update()` for every present player and NPC. An empty player - collection is valid. -4. Attack impacts are queued, then damage is applied after all characters advance. -5. The runtime location consumes value-only events. Navigation requests are sent - back as semantic destination commands, whose server handler calls - `CharacterState::setTarget()` with the authoritative `LocationNetworkState`; arrival - function names are resolved through `ScriptEngine`. -6. The server invalidates its cached snapshot and advances the revision so runtime - presentation can observe the completed simulation tick. -7. Runtime characters read one immutable snapshot per operation, apply the - authoritative animation frame to loaded models, and - play audio, particles, and presentation callbacks. -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. +1. SDL input and UI presentation use the real elapsed render-frame time. +2. A local host accumulates `steady_clock` elapsed time and runs zero or more + exact 100 ms simulation ticks. A remote host polls transport every frame but + advances only from contiguous server tick messages. +3. Commands assigned to tick `N` are applied in server sequence order before + simulation tick `N`. +4. The server freezes location character observations and updates every player, + NPC, taxi, and interactive object with exactly 100 ms. An empty player + collection remains valid. +5. Attack impacts are queued and applied only after all characters advance. + Navigation recovery is applied inside the deterministic state path. +6. The server invalidates its cached snapshot and advances its revision. +7. Runtime locations consume the completed tick's value-only events and perform + rendering, audio, particles, and supported presentation callbacks. +8. Rendering can occur between ticks and never changes authoritative tick time. 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 single-player save is a separate versioned envelope containing `networkState` (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. - 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 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. -- Multiple player slots keyed by stable ID. -- Stable location, character, object, and zone identities. -- Immutable state snapshots suitable for replication. -- Semantic commands suitable for request serialization. -- A replaceable public `IGameServer` contract. -- A single active-location model matching the initial multiplayer scope. +- The server assigns a connection to a preconfigured campaign player slot. +- Connection receives one authoritative state at a completed tick plus its + assigned player ID, tick duration, input delay, and compatibility fingerprint. +- Destination input is normalized and scheduled two ticks in the future. +- The server broadcasts every numbered tick, including empty ticks. Clients + buffer out-of-order messages and advance only contiguous tick IDs. +- 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. -- 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. +Current boundaries remain: -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 @@ -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, campaign, content-definition, and portable resource sources only; SDL, OpenGL, 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. -Networking remains outside this milestone. +a selected campaign/location with zero players. The Windows server adds +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: @@ -455,8 +468,11 @@ cmake --build proj-windows/build --config Debug --target ShadowOverBishkekDemo - auxiliary-state synchronization without making `Game` a server friend. - Empty player maps are valid. Player-specific interactions remain inactive until a player entity is added. -- A remote client still needs a client-side runtime-location factory driven by - configuration/definitions and replicated snapshots. +- Pathfinding priority queues use cell index as a stable secondary key. +- 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 @@ -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/InternalGameServer.h` / `.cpp` — in-process authoritative implementation. - `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/Location.h` / `src/Location.cpp` — loaded location runtime, command routing, interactions, collisions, scripts, and presentation-event consumption. diff --git a/WEBSOCKET_NETWORKING.md b/WEBSOCKET_NETWORKING.md index 4325ac8..4498842 100644 --- a/WEBSOCKET_NETWORKING.md +++ b/WEBSOCKET_NETWORKING.md @@ -1,13 +1,15 @@ -# WebSocket Networking: First Iteration +# WebSocket Deterministic Lockstep Networking ## Scope -The first networking iteration adds a Windows-only WebSocket transport using -Boost.Asio and Boost.Beast. +The Windows WebSocket transport uses Boost.Asio and Boost.Beast to run a +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 -yet implement multiplayer commands, authentication, per-connection players, -state deltas, or presentation-event replication. +The current command surface is intentionally limited to +`SetCharacterDestinationCommand`. ## Runtime Architecture @@ -15,16 +17,17 @@ state deltas, or presentation-event replication. `WebsocketGameServer` wraps the headless `InternalGameServer`. -- It accepts any WebSocket connection. -- It sends the current complete game state immediately after connection. -- It broadcasts the current complete state every five seconds. -- Incoming WebSocket messages are read and discarded. -- The authoritative simulation remains on the main server thread. -- Network sessions only receive serialized state and never access - `InternalGameServer` directly. - -The standalone server creates the existing fixed player ID `"player"` and does -not enable the battle-zone test bot. +- The simulation tick is fixed at 100 ms. +- Each accepted connection is assigned the first free character ID from the + campaign's `networkPlayerSlots`; the connection is rejected when all slots + are occupied. +- The initial message contains the state, completed snapshot tick, assigned + player ID, tick duration, two-tick input delay, and compatibility fingerprint. +- Destination inputs cross from the Asio thread through a queue. The simulation + thread validates client sequence numbers, replaces client identity with the + assigned slot, and schedules commands two ticks in the future. +- Every tick is broadcast, including ticks with no commands. No periodic state + snapshots are sent. ### Client @@ -37,14 +40,19 @@ not enable the battle-zone test bot. consumers and transitional runtime-state bindings continue to work. - Camera orientation lives in host-owned `LocationLocalState`, so incoming authoritative snapshots cannot overwrite camera movement. -- `submit()` intentionally discards all commands. -- `drainEvents()` returns no events. +- `submit()` serializes destination intent only. Other command variants remain + 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 server-side. `RemoteGameServerHost` prepares local presentation resources and runtime -locations against the replicated state. It does not advance authoritative -simulation or write Lua/dialogue auxiliary state back to the server. +locations against the replica and presents completed-tick events. It never +advances simulation from a render-frame delta. ## 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 standalone server loads the authoritative half of that shared save and rewrites 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 fails, the client unloads the remote campaign and returns to the campaign selection screen. -## JSON Protocol +## JSON Protocol v2 -Messages use a versioned JSON envelope: +Every message uses a versioned JSON envelope: ```json { - "protocolVersion": 1, - "type": "state", + "protocolVersion": 2, + "type": "tick", "payload": { - "schemaVersion": 2 + "tickId": 81, + "commands": [], + "stateHash": "0123456789abcdef" } } ``` -The payload uses the authoritative `GameState::save()` representation and excludes -the client-local location map, including camera orientation. -The client applies it through `InternalGameServer::load()`. +Message types are: -Malformed JSON, unsupported protocol versions, unsupported message types, and -invalid state payloads are logged and ignored. +- `initial_state` (server to client): one state snapshot and lockstep metadata; +- `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 -document with client-local location state. The temporary full-state network format -is still expected to be replaced by a dedicated replication schema or deltas as -command handling and production replication are implemented. +Protocol v1 is not compatible. Malformed payloads, non-finite or out-of-range +coordinates, duplicate client sequences, and unsupported commands cannot enter +the authoritative command stream. + +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 @@ -135,23 +159,18 @@ Launch the Windows game and select `battle_zone_multiplayer`. The client connect ## Verification -The implementation was verified with: - -- A successful Windows graphical-game build. -- 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. +CTest covers protocol-v2 round trips and validation, input-delay ordering, +fixed-tick replica behavior, campaign slot metadata, initial connection and +slot assignment, destination submission, and contiguous tick consumption. ## Current Limitations -- All clients share the fixed player ID `"player"`. -- Client commands are not sent to the server. -- The server does not process commands. -- State is sent as a complete snapshot rather than a delta. -- There is no authentication, authorization, reconnect loop, or session identity. -- Presentation events are not replicated. +- Only preconfigured player slots can connect; dynamic join/spawn commands are + not implemented. +- There is no authentication or reconnect/replay history. +- A hash mismatch is logged but does not yet trigger checkpoint resynchronization. - The endpoint is hardcoded in the graphical client. - 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. diff --git a/resources/config/campaigns.json b/resources/config/campaigns.json index 566d0a7..3e4e187 100644 --- a/resources/config/campaigns.json +++ b/resources/config/campaigns.json @@ -39,6 +39,8 @@ "startState": "resources/config/start_state_battle_zone.json", "quests": "resources/config/quests_battle_zone.json", "tutorial": false, + "networkPlayerSlots": ["player", "player2"], + "networkCompatibility": "battle-zone-lockstep-v2", "loadingScreen": "resources/w/ui/screen_loading.json" } ] diff --git a/resources/config/start_state_battle_zone.json b/resources/config/start_state_battle_zone.json index cb20780..e591422 100644 --- a/resources/config/start_state_battle_zone.json +++ b/resources/config/start_state_battle_zone.json @@ -155,7 +155,7 @@ "playerHp": 200.0, "playerMaxHp": 200.0, "players": { - "battle_zone_test_bot": { + "player2": { "animationFrame": 0.0, "attackTargetId": "", "attack_cooldown": 0.0, diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 94bceb5..44eac06 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -144,6 +144,23 @@ if(BUILD_TESTING) add_test( NAME GameServer.NetworkProtocol 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() add_test( NAME GameServer.IdleSmoke diff --git a/server/README.md b/server/README.md index 028240f..772c4f5 100644 --- a/server/README.md +++ b/server/README.md @@ -1,8 +1,8 @@ # Headless GameServer This target runs the authoritative `InternalGameServer` without SDL, OpenGL, -audio, Lua, or rendering dependencies. On Windows it exposes the state through -a Boost.Beast WebSocket listener. +audio, Lua, or rendering dependencies. On Windows it exposes a deterministic +lockstep session through a Boost.Beast WebSocket listener. ## 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 real-time delay. -Start the first multiplayer transport iteration: +Start the multiplayer lockstep server: ```powershell .\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 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. -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. `--campaigns PATH` selects a different campaign registry. Content paths are @@ -64,17 +67,24 @@ Available startup options: --campaigns PATH --address IP --port N ---tick-ms N +--tick-ms 100 --ticks N --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, -interactive-object, trigger, teleport, taxi, and animation metadata only. -The standalone WebSocket lifecycle creates the existing shared `"player"` so -the graphical client can render its local character. It does not create the -battle-zone test bot. +interactive-object, trigger, teleport, taxi, and animation metadata. Lua and +dialogue-driven shared multiplayer behavior is not yet supported. ## Tests @@ -82,5 +92,13 @@ CTest covers: - repeated `LocationGameState::setup()` preserving mutable runtime state; - 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; - 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. diff --git a/server/src/main.cpp b/server/src/main.cpp index b6fff9a..9686747 100644 --- a/server/src/main.cpp +++ b/server/src/main.cpp @@ -1,4 +1,5 @@ #include "game_server/InternalGameServer.h" +#include "game_server/NetworkProtocol.h" #if defined(NETWORK) #include "game_server/WebsocketGameServer.h" #endif @@ -15,6 +16,7 @@ #include #include #include +#include namespace { @@ -44,7 +46,7 @@ std::int64_t parsePositiveNumber(const char* value, const char* option) int main(int argc, char** argv) { - std::int64_t tickMs = 500; + std::int64_t tickMs = FRG::kSimulationTickMs; std::int64_t maxTicks = (std::numeric_limits::max)(); bool sleepBetweenTicks = true; std::string campaignId; @@ -89,14 +91,18 @@ int main(int argc, char** argv) return EXIT_FAILURE; } - //Temporary override: - campaignId = "battle_zone_multiplayer"; - locationId = "uni_exterior"; + if (tickMs != FRG::kSimulationTickMs) { + std::cerr << "Network lockstep requires --tick-ms " + << FRG::kSimulationTickMs << '\n'; + return EXIT_FAILURE; + } std::signal(SIGINT, requestStop); std::signal(SIGTERM, requestStop); FRG::InternalGameServer server; + std::vector playerSlots; + std::string compatibilityFingerprint = "lockstep-v2"; if (!campaignId.empty()) { try { FRG::CampaignRegistry::instance().loadFromJson(campaignsPath); @@ -138,6 +144,17 @@ int main(int argc, char** argv) } } 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) { server.submit(FRG::SetTutorialStepCommand{ FRG::TutorialStep::Completed}); @@ -158,7 +175,8 @@ int main(int argc, char** argv) std::unique_ptr websocketServer; try { websocketServer = std::make_unique( - server, listenAddress, static_cast(listenPort)); + server, playerSlots, compatibilityFingerprint, + listenAddress, static_cast(listenPort)); } catch (const std::exception& error) { std::cerr << "WebSocket listener setup failed: " << error.what() << '\n'; @@ -174,7 +192,7 @@ int main(int argc, char** argv) tick < maxTicks && !stopRequested.load(); ++tick) { nextTick += tickDuration; #if defined(NETWORK) - websocketServer->update(tickMs); + websocketServer->update(); #else server.update(tickMs); #endif diff --git a/server/tests/LocationGameStateTests.cpp b/server/tests/LocationGameStateTests.cpp index 348ed3a..602b72c 100644 --- a/server/tests/LocationGameStateTests.cpp +++ b/server/tests/LocationGameStateTests.cpp @@ -8,7 +8,7 @@ namespace { int fail(const char* message) { - std::cerr << message << '\n'; + std::cout << "LocationGameStateTests: " << message << std::endl; return EXIT_FAILURE; } @@ -78,6 +78,13 @@ int main() multiplayerBattleZoneConfig->startStatePath) { 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; definition.config.id = "test_location"; @@ -135,7 +142,7 @@ int main() FRG::InternalGameServer server; server.startCampaign(battleZone, {}, false, true); 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()) return fail("Battle Zone test bot was not created"); const auto stand = static_cast(FRG::AnimationState::STAND); @@ -148,14 +155,37 @@ int main() FRG::InternalGameServer networkServer; FRG::CampaignConfig multiplayerBattleZone = battleZone; multiplayerBattleZone.id = "battle_zone_multiplayer"; + multiplayerBattleZone.networkPlayerSlots = { + "player", "player2"}; networkServer.startCampaign(multiplayerBattleZone, {}, true, false); const auto networkSnapshot = networkServer.snapshot(); if (networkSnapshot->campaignId != "battle_zone_multiplayer") return fail("Network campaign ID was not retained"); if (networkSnapshot->players.count("player") != 1) 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"); + 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; } diff --git a/server/tests/NetworkProtocolTests.cpp b/server/tests/NetworkProtocolTests.cpp index 8802f5c..e6c9997 100644 --- a/server/tests/NetworkProtocolTests.cpp +++ b/server/tests/NetworkProtocolTests.cpp @@ -26,31 +26,103 @@ int main() {"players", {{"player", {{"hp", 200.0}}}}}, {"locations", {{"uni_exterior", {{"state", savedLocationState}}}}} }; - const std::string message = FRG::makeStateMessage(original); - nlohmann::json parsed; + FRG::InitialStateMessage initial; + 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; require( - FRG::parseStateMessage(message, parsed, error), - "valid state envelope was rejected"); - require(parsed == original, "state payload did not round trip"); + FRG::parseInitialStateMessage(message, parsed, error), + "valid initial-state envelope was rejected"); + require(parsed.state == original, "state payload did not round trip"); + require(parsed.snapshotTick == 42, "snapshot tick did not round trip"); require( - !parsed["locations"]["uni_exterior"]["state"].contains("cameraAzimuth") && - !parsed["locations"]["uni_exterior"]["state"].contains("cameraInclination"), + parsed.assignedPlayerId == "player", + "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"); + FRG::DestinationInputMessage input; + input.clientSequence = 7; + input.target = Eigen::Vector3f(1.234f, 0.f, -5.678f); + FRG::DestinationInputMessage parsedInput; require( - !FRG::parseStateMessage( - R"({"protocolVersion":2,"type":"state","payload":{}})", + FRG::parseDestinationInputMessage( + 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), "unknown protocol version was accepted"); require( - !FRG::parseStateMessage( - R"({"protocolVersion":1,"type":"command","payload":{}})", - parsed, error), + !FRG::parseDestinationInputMessage( + R"({"protocolVersion":2,"type":"command","payload":{}})", + parsedInput, error), "unknown message type was accepted"); require( - !FRG::parseStateMessage("not json", parsed, error), + !FRG::parseInitialStateMessage("not json", parsed, error), "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; } diff --git a/server/tests/WebsocketLockstepTests.cpp b/server/tests/WebsocketLockstepTests.cpp new file mode 100644 index 0000000..92c88bf --- /dev/null +++ b/server/tests/WebsocketLockstepTests.cpp @@ -0,0 +1,90 @@ +#include "game_server/InternalGameServer.h" +#include "game_server/WebsocketGameClient.h" +#include "game_server/WebsocketGameServer.h" +#include +#include +#include +#include +#include + +namespace { + +void require(bool condition, const char* message) +{ + if (condition) return; + std::cerr << "WebsocketLockstepTests: " << message << '\n'; + std::exit(EXIT_FAILURE); +} + +template +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( + "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; +} diff --git a/src/Campaign.cpp b/src/Campaign.cpp index 811a564..3a34943 100644 --- a/src/Campaign.cpp +++ b/src/Campaign.cpp @@ -109,6 +109,10 @@ namespace FRG { c.startStatePath = entry.value("startState", ""); c.questsPath = entry.value("quests", ""); c.tutorial = entry.value("tutorial", false); + c.networkPlayerSlots = + entry.value("networkPlayerSlots", std::vector{}); + c.networkCompatibility = + entry.value("networkCompatibility", std::string()); c.loadingScreenPath = entry.value("loadingScreen", std::string(CampaignConfig::kDefaultLoadingScreen)); for (const std::string& path : c.locationConfigPaths) { LocationConfig loc; diff --git a/src/Campaign.h b/src/Campaign.h index 973e1d2..62371ba 100644 --- a/src/Campaign.h +++ b/src/Campaign.h @@ -55,6 +55,10 @@ namespace FRG { std::string startStatePath; std::string questsPath; bool tutorial = false; + // Existing character IDs assigned to multiplayer connections in order. + std::vector 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). std::string loadingScreenPath = kDefaultLoadingScreen; diff --git a/src/Environment.cpp b/src/Environment.cpp index 422d948..5f40823 100644 --- a/src/Environment.cpp +++ b/src/Environment.cpp @@ -21,8 +21,8 @@ int Environment::width = CONST_DEFAULT_WIDTH; int Environment::height = CONST_DEFAULT_HEIGHT; float Environment::zoom = DEFAULT_ZOOM; -//bool Environment::enableLogging = false; -bool Environment::enableLogging = true; +bool Environment::enableLogging = false; +//bool Environment::enableLogging = true; SDL_Window* Environment::window = nullptr; diff --git a/src/Game.cpp b/src/Game.cpp index fd9376c..d94a361 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -1,4 +1,5 @@ #include "Game.h" +#include "game_server/NetworkProtocol.h" #include "AnimatedModel.h" #include "Localization.h" #include "utils/Utils.h" @@ -89,7 +90,6 @@ namespace FRG , gameServer(internalGameServer) , gameServerHost(localGameServerHost) , menuManager(renderer, gameServer, *audioPlayer) - , newTickCount(0) , lastTickCount(0) { } @@ -553,18 +553,29 @@ namespace FRG if (!campaign) return; #if defined(NETWORK) remoteCampaignActive = campaignId == "battle_zone_multiplayer"; + remoteInitialStateBound = false; if (remoteCampaignActive) { + logger() << "[network] Campaign '" << campaignId + << "' selected; creating WebSocket client" << std::endl; websocketGameClient = std::make_unique(); remoteGameServerHost = std::make_unique(*websocketGameClient); gameServer.use(*websocketGameClient); gameServerHost.use(*remoteGameServerHost); + logger() << "[network] Remote game server routes installed" + << std::endl; } else { + logger() << "[network] Campaign '" << campaignId + << "' uses the in-process server" << std::endl; gameServer.use(internalGameServer); gameServerHost.use(localGameServerHost); } #else remoteCampaignActive = false; + remoteInitialStateBound = false; + logger() << "[network] Networking was not compiled into this client; " + "campaign '" << campaignId << "' uses the in-process server" + << std::endl; #endif gameServerHost.initializeCampaign( campaignId, campaign->questsPath, CONST_ZIP_FILE); @@ -588,6 +599,7 @@ namespace FRG websocketGameClient.reset(); #endif remoteCampaignActive = false; + remoteInitialStateBound = false; renderer.textureManager.UnloadUnused(); // the player's textures } @@ -1120,7 +1132,7 @@ namespace FRG int64_t Game::getSyncTimeMs() { int64_t localNow = std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()).count(); + std::chrono::steady_clock::now().time_since_epoch()).count(); return localNow; } @@ -1129,21 +1141,15 @@ namespace FRG if (lastTickCount == 0) { lastTickCount = getSyncTimeMs(); - - lastTickCount = (lastTickCount / 50) * 50; - return; } - newTickCount = getSyncTimeMs(); - - newTickCount = (newTickCount / 50) * 50; - - if (newTickCount - lastTickCount > CONST_TIMER_INTERVAL) { - - int64_t delta = newTickCount - lastTickCount; - - lastTickCount = newTickCount; + const int64_t now = getSyncTimeMs(); + const int64_t delta = + (std::min)(now - lastTickCount, + static_cast(CONST_MAX_TIME_INTERVAL)); + lastTickCount = now; + if (delta > 0) { updateDarklandsFlash(delta); menuManager.uiManager.update(static_cast(delta)); @@ -1155,9 +1161,39 @@ namespace FRG if (loadingCompleted) { - // The authoritative implementation advances simulation; a future - // network client can use the same call to poll/apply server updates. - gameServerHost.update(delta); + if (remoteCampaignActive) { + // Remote authoritative time comes only from contiguous server ticks. + 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 (remoteCampaignActive && websocketGameClient && websocketGameClient->connectionFailed()) { diff --git a/src/Game.h b/src/Game.h index 4923186..04ad440 100644 --- a/src/Game.h +++ b/src/Game.h @@ -98,6 +98,7 @@ namespace FRG { GameServerRouter gameServer; GameServerHostRouter gameServerHost; bool remoteCampaignActive = false; + bool remoteInitialStateBound = false; public: IGameServer& server() { return gameServer; } @@ -252,13 +253,12 @@ namespace FRG { */ //void drawMobilePortraitOverlay(); - int64_t newTickCount; int64_t lastTickCount; + int64_t simulationAccumulatorMs = 0; int lastDrawableW = -1; int lastDrawableH = -1; - static const size_t CONST_TIMER_INTERVAL = 10; static const size_t CONST_MAX_TIME_INTERVAL = 1000; }; diff --git a/src/Location.cpp b/src/Location.cpp index eb9f01c..0d13276 100644 --- a/src/Location.cpp +++ b/src/Location.cpp @@ -1837,8 +1837,6 @@ namespace FRG for (const CharacterSimulationEvent& event : events.characters) { Character* character = findRuntimeCharacter(event.characterId); - if (character && event.events.navigationRequested) - setCharacterTarget(*character, event.events.navigationTarget); if (!event.events.arrivedCallbackName.empty()) { try { scriptEngine.callFunction(event.events.arrivedCallbackName); diff --git a/src/game_server/InternalGameServer.cpp b/src/game_server/InternalGameServer.cpp index 2603a89..7aee684 100644 --- a/src/game_server/InternalGameServer.cpp +++ b/src/game_server/InternalGameServer.cpp @@ -9,7 +9,7 @@ namespace FRG { namespace { 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; void configurePlayerAnimations( @@ -44,11 +44,11 @@ void InternalGameServer::ensureBattleZoneTestBot() { if (state_.campaignId != kBattleZoneCampaignId) return; if (!battleZoneTestBotEnabled_) { - state_.players.erase(kBattleZoneTestBotId); + state_.players.erase(kBattleZonePlayer2Id); return; } - auto& bot = state_.players[kBattleZoneTestBotId]; + auto& bot = state_.players[kBattleZonePlayer2Id]; if (!bot) bot = std::make_shared(); bot->configureAsPlayer(); configurePlayerAnimations(*bot, resourceZipFile_); @@ -60,7 +60,7 @@ void InternalGameServer::updateBattleZoneTestBot(std::int64_t deltaMs) if (!battleZoneTestBotEnabled_ || state_.campaignId != kBattleZoneCampaignId || state_.currentLocationName.empty() || - state_.players.find(kBattleZoneTestBotId) == state_.players.end()) { + state_.players.find(kBattleZonePlayer2Id) == state_.players.end()) { return; } @@ -72,7 +72,7 @@ void InternalGameServer::updateBattleZoneTestBot(std::int64_t deltaMs) static std::uniform_real_distribution coordinate(-10.0f, 10.0f); submit(SetCharacterDestinationCommand{ state_.currentLocationName, - kBattleZoneTestBotId, + kBattleZonePlayer2Id, Eigen::Vector3f(coordinate(randomEngine), 0.f, coordinate(randomEngine)), std::string()}); } @@ -92,6 +92,7 @@ void InternalGameServer::startCampaign( bool createLocalPlayer, bool enableTestBot) { resourceZipFile_ = zipFile; + networkPlayerSlots_ = campaign.networkPlayerSlots; battleZoneTestBotEnabled_ = enableTestBot; state_.campaignId = campaign.id; state_.currentLocationName.clear(); @@ -125,6 +126,7 @@ void InternalGameServer::clearCampaign() state_.currentLocationName.clear(); state_.players.clear(); state_.locations.clear(); + networkPlayerSlots_.clear(); battleZoneTestBotElapsedMs_ = 0; publishMutation(); } @@ -487,6 +489,19 @@ void InternalGameServer::update(std::int64_t deltaMs) pendingSimulationLocationId_ = state_.currentLocationName; pendingSimulationEvents_ = 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(); } @@ -499,6 +514,12 @@ void InternalGameServer::load(const nlohmann::json& in) { pendingEvents_.clear(); 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; ensureBattleZoneTestBot(); publishMutation(); diff --git a/src/game_server/InternalGameServer.h b/src/game_server/InternalGameServer.h index 3372eee..19ae069 100644 --- a/src/game_server/InternalGameServer.h +++ b/src/game_server/InternalGameServer.h @@ -23,6 +23,9 @@ public: void update(std::int64_t deltaMs) override; void save(nlohmann::json& out) const override; void load(const nlohmann::json& in) override; + void setLocalPlayerId(std::string playerId) { + localPlayerId_ = std::move(playerId); + } void startCampaign( const CampaignConfig& campaign, const std::string& zipFile, bool createLocalPlayer, bool enableTestBot) override; @@ -32,6 +35,7 @@ public: private: friend class LocalGameServerHost; friend class RemoteGameServerHost; + friend class WebsocketGameClient; // Host-only lifecycle operations. They never expose authoritative state and // are intentionally absent from IGameServer. @@ -49,6 +53,7 @@ private: GameState state_; std::string localPlayerId_ = "player"; std::string resourceZipFile_; + std::vector networkPlayerSlots_; std::int64_t battleZoneTestBotElapsedMs_ = 0; bool battleZoneTestBotEnabled_ = false; std::string pendingSimulationLocationId_; diff --git a/src/game_server/NetworkProtocol.cpp b/src/game_server/NetworkProtocol.cpp index 82728ac..ec9b60f 100644 --- a/src/game_server/NetworkProtocol.cpp +++ b/src/game_server/NetworkProtocol.cpp @@ -1,24 +1,20 @@ #include "NetworkProtocol.h" +#include #include +#include +#include +#include namespace FRG { +namespace { -std::string makeStateMessage(const nlohmann::json& state) -{ - return nlohmann::json{ - {"protocolVersion", kGameProtocolVersion}, - {"type", "state"}, - {"payload", state} - }.dump(); -} - -bool parseStateMessage( - const std::string& message, nlohmann::json& state, - std::string& error) +bool parseEnvelope( + const std::string& text, const char* expectedType, + nlohmann::json& payload, std::string& error) { try { - const nlohmann::json envelope = nlohmann::json::parse(message); + const nlohmann::json envelope = nlohmann::json::parse(text); if (!envelope.is_object()) { error = "message must be a JSON object"; return false; @@ -27,15 +23,15 @@ bool parseStateMessage( error = "unsupported protocol version"; return false; } - if (envelope.value("type", std::string()) != "state") { + if (envelope.value("type", std::string()) != expectedType) { error = "unsupported message type"; return false; } if (!envelope.contains("payload") || !envelope["payload"].is_object()) { - error = "state payload must be an object"; + error = "message payload must be an object"; return false; } - state = envelope["payload"]; + payload = envelope["payload"]; error.clear(); return true; } 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(); + if (signedValue < 0) { + error = std::string(name) + " must be non-negative"; + return false; + } + out = static_cast(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(); + 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(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(); + message.compatibilityFingerprint = + payload["compatibilityFingerprint"].get(); + 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(); + 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(); + detail = payload["detail"].get(); + 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 diff --git a/src/game_server/NetworkProtocol.h b/src/game_server/NetworkProtocol.h index d49764d..7aad0aa 100644 --- a/src/game_server/NetworkProtocol.h +++ b/src/game_server/NetworkProtocol.h @@ -1,15 +1,77 @@ #pragma once #include "../external/nlohmann/json.hpp" +#include +#include +#include #include +#include 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); -bool parseStateMessage( - const std::string& message, nlohmann::json& state, - std::string& error); +struct InitialStateMessage { + nlohmann::json state; + std::string assignedPlayerId; + 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 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> 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 diff --git a/src/game_server/RemoteGameServerHost.cpp b/src/game_server/RemoteGameServerHost.cpp index 7bc251f..ae7870d 100644 --- a/src/game_server/RemoteGameServerHost.cpp +++ b/src/game_server/RemoteGameServerHost.cpp @@ -20,6 +20,10 @@ void RemoteGameServerHost::initializeCampaign( CampaignRegistry::instance().find(campaignId); if (!campaign) 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); } @@ -38,6 +42,15 @@ void RemoteGameServerHost::resetCampaign( 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); } @@ -92,11 +105,12 @@ void RemoteGameServerHost::bindRuntimeLocation( void RemoteGameServerHost::update(std::int64_t deltaMs) { client_.update(deltaMs); - const auto state = client_.snapshot(); - if (!state || state->currentLocationId.empty()) return; - auto runtime = runtimeLocations_.find(state->currentLocationId); - if (runtime == runtimeLocations_.end() || !runtime->second) return; - runtime->second->update(deltaMs, LocationSimulationEvents{}); + for (auto& completed : client_.takeCompletedSimulationTicks()) { + if (completed.locationId.empty()) continue; + auto runtime = runtimeLocations_.find(completed.locationId); + if (runtime == runtimeLocations_.end() || !runtime->second) continue; + runtime->second->update(kSimulationTickMs, completed.events); + } } void RemoteGameServerHost::save(nlohmann::json&) diff --git a/src/game_server/WebsocketGameClient.cpp b/src/game_server/WebsocketGameClient.cpp index 9732b05..4d567e4 100644 --- a/src/game_server/WebsocketGameClient.cpp +++ b/src/game_server/WebsocketGameClient.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +29,8 @@ public: , port_(std::move(port)) , path_(std::move(path)) { + std::cout << "[network] Resolving ws://" << host_ << ':' << port_ + << path_ << std::endl; resolve(); thread_ = std::thread([this]() { io_.run(); }); } @@ -49,6 +52,16 @@ public: bool connected() const { return connected_.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: void resolve() { @@ -56,11 +69,15 @@ private: host_, port_, [this](beast::error_code error, tcp::resolver::results_type results) { if (error) return fail("resolve", error); + std::cout << "[network] WebSocket host resolved; connecting" + << std::endl; asio::async_connect( socket_.next_layer(), results, [this](beast::error_code connectError, const tcp::endpoint&) { if (connectError) return fail("connect", connectError); + std::cout << "[network] TCP connected; starting WebSocket handshake" + << std::endl; socket_.set_option( websocket::stream_base::timeout::suggested( beast::role_type::client)); @@ -72,7 +89,7 @@ private: connected_.store(true); std::cout << "[network] Connected to ws://" - << host_ << ':' << port_ << path_ << '\n'; + << host_ << ':' << port_ << path_ << std::endl; 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) { connected_.store(false); @@ -119,6 +147,7 @@ private: std::atomic_bool connectionFailed_{false}; std::mutex messagesMutex_; std::vector messages_; + std::deque outbound_; }; WebsocketGameClient::WebsocketGameClient( @@ -140,38 +169,122 @@ const std::string& WebsocketGameClient::localPlayerId() const 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(&command)) { + impl_->send(makeDestinationInputMessage( + {nextClientSequence_++, destination->target})); + } } std::vector WebsocketGameClient::drainEvents() { - return {}; + std::vector result; + result.swap(pendingEvents_); + return result; } void WebsocketGameClient::update(std::int64_t) { std::vector messages = impl_->takeMessages(); - if (messages.empty()) return; - - // State messages are complete snapshots; only the newest queued one matters. - for (auto it = messages.rbegin(); it != messages.rend(); ++it) { - nlohmann::json state; + for (const std::string& text : messages) { std::string error; - if (!parseStateMessage(*it, state, error)) { - std::cerr << "[network] Ignoring WebSocket message: " - << error << '\n'; + InitialStateMessage initial; + if (parseInitialStateMessage(text, initial, error)) { + 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; } - try { - replica_.load(state); - } catch (const std::exception& exception) { - std::cerr << "[network] Ignoring invalid game state: " - << exception.what() << '\n'; + TickMessage tick; + if (parseTickMessage(text, tick, error)) { + if (initialized_ && tick.tickId >= nextExpectedTick_) { + if (!loggedFirstTick_) { + 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 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::takeCompletedSimulationTicks() +{ + std::vector result; + result.swap(completedSimulationTicks_); + return result; } void WebsocketGameClient::save(nlohmann::json&) const diff --git a/src/game_server/WebsocketGameClient.h b/src/game_server/WebsocketGameClient.h index c656e95..beb0ecf 100644 --- a/src/game_server/WebsocketGameClient.h +++ b/src/game_server/WebsocketGameClient.h @@ -2,6 +2,8 @@ #include "IGameServer.h" #include "InternalGameServer.h" +#include "NetworkProtocol.h" +#include #include #include @@ -30,12 +32,32 @@ public: bool connected() const; bool connectionFailed() const; + bool initialized() const { return initialized_; } + bool rejected() const { return rejected_; } private: friend class RemoteGameServerHost; + struct CompletedSimulationTick { + std::string locationId; + LocationSimulationEvents events; + }; + std::vector takeCompletedSimulationTicks(); + void expectCompatibility(std::string fingerprint) { + expectedCompatibilityFingerprint_ = std::move(fingerprint); + } class Impl; InternalGameServer replica_; std::unique_ptr impl_; + std::map pendingTicks_; + std::vector completedSimulationTicks_; + std::vector 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 diff --git a/src/game_server/WebsocketGameServer.cpp b/src/game_server/WebsocketGameServer.cpp index 45f0f9f..905b4ce 100644 --- a/src/game_server/WebsocketGameServer.cpp +++ b/src/game_server/WebsocketGameServer.cpp @@ -3,10 +3,14 @@ #include "NetworkProtocol.h" #include #include +#include #include #include +#include #include +#include #include +#include #include namespace FRG { @@ -19,13 +23,26 @@ using tcp = asio::ip::tcp; class ServerSession : public std::enable_shared_from_this { public: - explicit ServerSession(tcp::socket socket) + using InputHandler = std::function&, const std::string&)>; + using CloseHandler = + std::function&)>; + + ServerSession( + tcp::socket socket, std::string playerId, + InputHandler inputHandler, CloseHandler closeHandler) : 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( websocket::stream_base::timeout::suggested(beast::role_type::server)); socket_.async_accept( @@ -38,7 +55,7 @@ public: } self->socket_.text(true); self->send(std::move(initialState)); - self->read(); + if (!self->closeAfterWrite_) self->read(); }); } @@ -59,10 +76,13 @@ private: if (error != websocket::error::closed) std::cerr << "[network] WebSocket read failed: " << error.message() << '\n'; + self->notifyClosed(); 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->inputHandler_(self, message); self->read(); }); } @@ -78,22 +98,50 @@ private: return; } 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 socket_; beast::flat_buffer inbound_; std::deque outbound_; + std::string playerId_; + InputHandler inputHandler_; + CloseHandler closeHandler_; + bool closeAfterWrite_ = false; + bool closed_ = false; }; } // namespace class WebsocketGameServer::Impl { 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 playerSlots, nlohmann::json initialState, + std::string compatibilityFingerprint) : acceptor_(io_) + , playerSlots_(std::move(playerSlots)) , latestState_(std::move(initialState)) + , compatibilityFingerprint_(std::move(compatibilityFingerprint)) { const tcp::endpoint endpoint(asio::ip::make_address(address), port); acceptor_.open(endpoint.protocol()); @@ -115,14 +163,33 @@ public: if (thread_.joinable()) thread_.join(); } - void broadcast(std::string state) + std::vector takeInputs() { - asio::post(io_, [this, state = std::move(state)]() mutable { - latestState_ = std::move(state); + std::lock_guard lock(inputsMutex_); + std::vector result; + result.swap(pendingInputs_); + return result; + } + + std::vector takeReleasedPlayers() + { + std::lock_guard lock(inputsMutex_); + std::vector 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(); for (auto input = sessions_.begin(); input != sessions_.end(); ++input) { if (auto session = input->lock()) { - session->send(latestState_); + session->send(tick); *output++ = *input; } } @@ -138,10 +205,62 @@ private: acceptor_.async_accept( [this](beast::error_code error, tcp::socket socket) { if (!error) { + std::string playerId; + for (const std::string& candidate : playerSlots_) { + if (assignedSlots_.count(candidate) == 0) { + playerId = candidate; + assignedSlots_[candidate] = true; + break; + } + } auto session = - std::make_shared(std::move(socket)); - sessions_.push_back(session); - session->start(latestState_); + std::make_shared( + std::move(socket), playerId, + [this](const std::shared_ptr& 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 lock(inputsMutex_); + pendingInputs_.push_back( + {source->playerId(), std::move(parsed)}); + }, + [this](const std::shared_ptr& source) { + if (!source->playerId().empty()) + std::cout << "[network] Player slot '" + << source->playerId() + << "' disconnected" << std::endl; + assignedSlots_.erase(source->playerId()); + std::lock_guard 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) { std::cerr << "[network] TCP accept failed: " << error.message() << '\n'; @@ -154,12 +273,21 @@ private: tcp::acceptor acceptor_; std::thread thread_; std::vector> sessions_; - std::string latestState_; + std::vector playerSlots_; + std::unordered_map assignedSlots_; + nlohmann::json latestState_; + std::string compatibilityFingerprint_; + std::uint64_t latestTick_ = 0; + std::mutex inputsMutex_; + std::vector pendingInputs_; + std::vector releasedPlayers_; std::uint16_t port_ = 0; }; WebsocketGameServer::WebsocketGameServer( InternalGameServer& gameServer, + std::vector playerSlots, + std::string compatibilityFingerprint, const std::string& address, std::uint16_t port) : gameServer_(gameServer) @@ -167,26 +295,34 @@ WebsocketGameServer::WebsocketGameServer( nlohmann::json state; gameServer_.save(state); impl_ = std::make_unique( - address, port, makeStateMessage(state)); + address, port, std::move(playerSlots), std::move(state), + std::move(compatibilityFingerprint)); } WebsocketGameServer::~WebsocketGameServer() = default; -void WebsocketGameServer::update(std::int64_t deltaMs) +void WebsocketGameServer::update() { - gameServer_.update(deltaMs); - broadcastElapsedMs_ += deltaMs; - if (broadcastElapsedMs_ >= 5000) { - broadcastElapsedMs_ %= 5000; - broadcastState(); + for (const std::string& playerId : impl_->takeReleasedPlayers()) + lastClientSequences_.erase(playerId); + for (const Impl::PendingInput& pending : impl_->takeInputs()) { + std::uint64_t& previous = lastClientSequences_[pending.playerId]; + 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; 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 diff --git a/src/game_server/WebsocketGameServer.h b/src/game_server/WebsocketGameServer.h index 8bf4d83..f1f76af 100644 --- a/src/game_server/WebsocketGameServer.h +++ b/src/game_server/WebsocketGameServer.h @@ -3,6 +3,9 @@ #include #include #include +#include +#include +#include "NetworkProtocol.h" namespace FRG { @@ -12,6 +15,8 @@ class WebsocketGameServer { public: WebsocketGameServer( InternalGameServer& gameServer, + std::vector playerSlots, + std::string compatibilityFingerprint, const std::string& address = "0.0.0.0", std::uint16_t port = 8080); ~WebsocketGameServer(); @@ -19,15 +24,15 @@ public: WebsocketGameServer(const WebsocketGameServer&) = delete; WebsocketGameServer& operator=(const WebsocketGameServer&) = delete; - void update(std::int64_t deltaMs); - void broadcastState(); + void update(); std::uint16_t port() const; private: class Impl; InternalGameServer& gameServer_; std::unique_ptr impl_; - std::int64_t broadcastElapsedMs_ = 0; + LockstepTimeline timeline_; + std::unordered_map lastClientSequences_; }; } // namespace FRG diff --git a/src/navigation/PathFinder.cpp b/src/navigation/PathFinder.cpp index c54f8e7..4963826 100644 --- a/src/navigation/PathFinder.cpp +++ b/src/navigation/PathFinder.cpp @@ -148,7 +148,8 @@ std::vector PathFinder::findPath(const Eigen::Vector3f& start, 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 PathFinder::findPath(const Eigen::Vector3f& start, 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 PathFinder::findNearestReachableImpl( struct QueueNode { int index = 0; 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;