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
+1167 -234

No files matched your search

+73 -52
View File
@@ -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.
+69 -50
View File
@@ -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.
+2
View File
@@ -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"
}
]
@@ -155,7 +155,7 @@
"playerHp": 200.0,
"playerMaxHp": 200.0,
"players": {
"battle_zone_test_bot": {
"player2": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
+17
View File
@@ -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
+29 -11
View File
@@ -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.
+24 -6
View File
@@ -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 <memory>
#include <string>
#include <thread>
#include <vector>
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<std::int64_t>::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<std::string> 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<FRG::WebsocketGameServer> websocketServer;
try {
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) {
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
+33 -3
View File
@@ -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<std::size_t>(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;
}
+85 -13
View File
@@ -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;
}
+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.questsPath = entry.value("quests", "");
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));
for (const std::string& path : c.locationConfigPaths) {
LocationConfig loc;
+4
View File
@@ -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<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).
std::string loadingScreenPath = kDefaultLoadingScreen;
+2 -2
View File
@@ -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;
+52 -16
View File
@@ -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<WebsocketGameClient>();
remoteGameServerHost =
std::make_unique<RemoteGameServerHost>(*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::milliseconds>(
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<int64_t>(CONST_MAX_TIME_INTERVAL));
lastTickCount = now;
if (delta > 0) {
updateDarklandsFlash(delta);
menuManager.uiManager.update(static_cast<float>(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.
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()) {
+2 -2
View File
@@ -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;
};
-2
View File
@@ -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);
+26 -5
View File
@@ -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<CharacterState>();
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<float> 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();
+5
View File
@@ -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<std::string> networkPlayerSlots_;
std::int64_t battleZoneTestBotElapsedMs_ = 0;
bool battleZoneTestBotEnabled_ = false;
std::string pendingSimulationLocationId_;
+237 -16
View File
@@ -1,24 +1,20 @@
#include "NetworkProtocol.h"
#include <cmath>
#include <exception>
#include <iomanip>
#include <limits>
#include <sstream>
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<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
+67 -5
View File
@@ -1,15 +1,77 @@
#pragma once
#include "../external/nlohmann/json.hpp"
#include <Eigen/Dense>
#include <cstdint>
#include <map>
#include <string>
#include <vector>
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<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
+19 -5
View File
@@ -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&)
+128 -15
View File
@@ -3,6 +3,7 @@
#include <boost/asio.hpp>
#include <boost/beast.hpp>
#include <atomic>
#include <deque>
#include <iostream>
#include <mutex>
#include <thread>
@@ -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<std::string> messages_;
std::deque<std::string> 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<SetCharacterDestinationCommand>(&command)) {
impl_->send(makeDestinationInputMessage(
{nextClientSequence_++, destination->target}));
}
}
std::vector<GameServerEvent> WebsocketGameClient::drainEvents()
{
return {};
std::vector<GameServerEvent> result;
result.swap(pendingEvents_);
return result;
}
void WebsocketGameClient::update(std::int64_t)
{
std::vector<std::string> 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(state);
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] Ignoring invalid game state: "
std::cerr << "[network] Invalid initial state: "
<< exception.what() << '\n';
}
break;
continue;
}
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;
}
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
+22
View File
@@ -2,6 +2,8 @@
#include "IGameServer.h"
#include "InternalGameServer.h"
#include "NetworkProtocol.h"
#include <map>
#include <memory>
#include <string>
@@ -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<CompletedSimulationTick> takeCompletedSimulationTicks();
void expectCompatibility(std::string fingerprint) {
expectedCompatibilityFingerprint_ = std::move(fingerprint);
}
class Impl;
InternalGameServer replica_;
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
+160 -24
View File
@@ -3,10 +3,14 @@
#include "NetworkProtocol.h"
#include <boost/asio.hpp>
#include <boost/beast.hpp>
#include <algorithm>
#include <deque>
#include <iostream>
#include <map>
#include <memory>
#include <mutex>
#include <thread>
#include <unordered_map>
#include <vector>
namespace FRG {
@@ -19,13 +23,26 @@ using tcp = asio::ip::tcp;
class ServerSession : public std::enable_shared_from_this<ServerSession> {
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))
, 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<tcp::socket> socket_;
beast::flat_buffer inbound_;
std::deque<std::string> 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<std::string> 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<PendingInput> takeInputs()
{
asio::post(io_, [this, state = std::move(state)]() mutable {
latestState_ = std::move(state);
std::lock_guard<std::mutex> lock(inputsMutex_);
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();
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<ServerSession>(std::move(socket));
std::make_shared<ServerSession>(
std::move(socket), playerId,
[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);
session->start(latestState_);
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<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;
};
WebsocketGameServer::WebsocketGameServer(
InternalGameServer& gameServer,
std::vector<std::string> 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<Impl>(
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
+8 -3
View File
@@ -3,6 +3,9 @@
#include <cstdint>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
#include "NetworkProtocol.h"
namespace FRG {
@@ -12,6 +15,8 @@ class WebsocketGameServer {
public:
WebsocketGameServer(
InternalGameServer& gameServer,
std::vector<std::string> 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> impl_;
std::int64_t broadcastElapsedMs_ = 0;
LockstepTimeline timeline_;
std::unordered_map<std::string, std::uint64_t> lastClientSequences_;
};
} // 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
{
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
{
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 {
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;