Set up basic deterministic lockstep
This commit is contained in:
1 parent
28c6e83eda
commit
23fef6f349
26 files changed
+1167
-234
No files matched your search
+73
-52
@@ -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
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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&)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user