Set up basic deterministic lockstep

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

No files matched your search

+17
View File
@@ -144,6 +144,23 @@ if(BUILD_TESTING)
add_test(
NAME GameServer.NetworkProtocol
COMMAND GameServerNetworkProtocolTests)
add_executable(
GameServerWebsocketLockstepTests
tests/WebsocketLockstepTests.cpp
"${GAME_ROOT}/src/game_server/NetworkProtocol.cpp"
"${GAME_ROOT}/src/game_server/WebsocketGameClient.cpp"
"${GAME_ROOT}/src/game_server/WebsocketGameServer.cpp")
target_include_directories(
GameServerWebsocketLockstepTests PRIVATE "${BOOST_BUNDLED_DIR}")
target_compile_definitions(
GameServerWebsocketLockstepTests PRIVATE
NETWORK WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00)
target_link_libraries(
GameServerWebsocketLockstepTests PRIVATE
game_server_core ws2_32 mswsock)
add_test(
NAME GameServer.WebsocketLockstep
COMMAND GameServerWebsocketLockstepTests)
endif()
add_test(
NAME GameServer.IdleSmoke
+29 -11
View File
@@ -1,8 +1,8 @@
# Headless GameServer
This target runs the authoritative `InternalGameServer` without SDL, OpenGL,
audio, Lua, or rendering dependencies. On Windows it exposes the state through
a Boost.Beast WebSocket listener.
audio, Lua, or rendering dependencies. On Windows it exposes a deterministic
lockstep session through a Boost.Beast WebSocket listener.
## Build
@@ -39,7 +39,7 @@ For a multi-configuration Windows build, the executable is normally
For smoke tests, `--ticks N` limits execution and `--no-sleep` skips the
real-time delay.
Start the first multiplayer transport iteration:
Start the multiplayer lockstep server:
```powershell
.\server\build\Release\GameServer.exe --campaign battle_zone_multiplayer --location uni_exterior
@@ -48,8 +48,11 @@ Start the first multiplayer transport iteration:
The listener defaults to `ws://0.0.0.0:8080`. Launch the Windows graphical game
afterward and select `battle_zone_multiplayer`; that campaign connects to
`ws://127.0.0.1:8080`. Other campaigns continue to use the in-process server.
The server broadcasts an initial full state and then one every five seconds.
Client commands are intentionally discarded in this iteration.
The server sends one state snapshot when a player connects, then broadcasts one
numbered command bundle every 100 ms. Bundles may be empty. Destination input is
scheduled two ticks (200 ms) in the future. Tick IDs, rather than server or
client wall-clock values, define simulation time.
If `--location` is omitted, the campaign's configured start location is used.
`--campaigns PATH` selects a different campaign registry. Content paths are
@@ -64,17 +67,24 @@ Available startup options:
--campaigns PATH
--address IP
--port N
--tick-ms N
--tick-ms 100
--ticks N
--no-sleep
```
The graphical client executes Lua/dialogue behavior after building its runtime
Network mode requires a 100 ms tick; another `--tick-ms` value is rejected.
The selected campaign's `networkPlayerSlots` lists pre-existing character IDs
assigned to connections in order. `networkCompatibility` is a release token
that must be changed whenever deterministic content changes; clients reject a
different token. The bundled multiplayer Battle Zone has two slots (`player`
and `player2`) and rejects additional clients. Disconnecting
releases the slot. The bot's random autonomous controller is disabled in
multiplayer.
The graphical client executes presentation behavior after building its runtime
`Location`; the headless content pass loads authoritative navigation, NPC,
interactive-object, trigger, teleport, taxi, and animation metadata only.
The standalone WebSocket lifecycle creates the existing shared `"player"` so
the graphical client can render its local character. It does not create the
battle-zone test bot.
interactive-object, trigger, teleport, taxi, and animation metadata. Lua and
dialogue-driven shared multiplayer behavior is not yet supported.
## Tests
@@ -82,5 +92,13 @@ CTest covers:
- repeated `LocationGameState::setup()` preserving mutable runtime state;
- ticking a prepared location with zero players;
- deterministic fixed-100 ms replay behavior;
- protocol-v2 parsing, validation, target normalization, state hashing, and
two-tick command ordering;
- a WebSocket initial-state/slot/destination/tick integration test;
- idle server startup;
- headless loading of `battle_zone_multiplayer/uni_exterior`.
The lockstep implementation currently targets identical executable and content
builds. Diagnostic state hashes detect divergence, but there is no automatic
checkpoint resynchronization or reconnect input history yet.
+24 -6
View File
@@ -1,4 +1,5 @@
#include "game_server/InternalGameServer.h"
#include "game_server/NetworkProtocol.h"
#if defined(NETWORK)
#include "game_server/WebsocketGameServer.h"
#endif
@@ -15,6 +16,7 @@
#include <memory>
#include <string>
#include <thread>
#include <vector>
namespace {
@@ -44,7 +46,7 @@ std::int64_t parsePositiveNumber(const char* value, const char* option)
int main(int argc, char** argv)
{
std::int64_t tickMs = 500;
std::int64_t tickMs = FRG::kSimulationTickMs;
std::int64_t maxTicks = (std::numeric_limits<std::int64_t>::max)();
bool sleepBetweenTicks = true;
std::string campaignId;
@@ -89,14 +91,18 @@ int main(int argc, char** argv)
return EXIT_FAILURE;
}
//Temporary override:
campaignId = "battle_zone_multiplayer";
locationId = "uni_exterior";
if (tickMs != FRG::kSimulationTickMs) {
std::cerr << "Network lockstep requires --tick-ms "
<< FRG::kSimulationTickMs << '\n';
return EXIT_FAILURE;
}
std::signal(SIGINT, requestStop);
std::signal(SIGTERM, requestStop);
FRG::InternalGameServer server;
std::vector<std::string> playerSlots;
std::string compatibilityFingerprint = "lockstep-v2";
if (!campaignId.empty()) {
try {
FRG::CampaignRegistry::instance().loadFromJson(campaignsPath);
@@ -138,6 +144,17 @@ int main(int argc, char** argv)
}
}
server.activateLocation(selectedLocation);
playerSlots = campaign->networkPlayerSlots;
for (const std::string& slot : playerSlots) {
if (server.snapshot()->players.count(slot) == 0)
throw std::runtime_error(
"Multiplayer slot '" + slot +
"' is missing from the initial state");
}
compatibilityFingerprint =
campaign->networkCompatibility.empty()
? "lockstep-v2:" + campaignId
: campaign->networkCompatibility;
if (!campaign->tutorial) {
server.submit(FRG::SetTutorialStepCommand{
FRG::TutorialStep::Completed});
@@ -158,7 +175,8 @@ int main(int argc, char** argv)
std::unique_ptr<FRG::WebsocketGameServer> websocketServer;
try {
websocketServer = std::make_unique<FRG::WebsocketGameServer>(
server, listenAddress, static_cast<std::uint16_t>(listenPort));
server, playerSlots, compatibilityFingerprint,
listenAddress, static_cast<std::uint16_t>(listenPort));
} catch (const std::exception& error) {
std::cerr << "WebSocket listener setup failed: "
<< error.what() << '\n';
@@ -174,7 +192,7 @@ int main(int argc, char** argv)
tick < maxTicks && !stopRequested.load(); ++tick) {
nextTick += tickDuration;
#if defined(NETWORK)
websocketServer->update(tickMs);
websocketServer->update();
#else
server.update(tickMs);
#endif
+33 -3
View File
@@ -8,7 +8,7 @@ namespace {
int fail(const char* message)
{
std::cerr << message << '\n';
std::cout << "LocationGameStateTests: " << message << std::endl;
return EXIT_FAILURE;
}
@@ -78,6 +78,13 @@ int main()
multiplayerBattleZoneConfig->startStatePath) {
return fail("Battle Zone modes do not share their initial state");
}
if (multiplayerBattleZoneConfig->networkPlayerSlots.size() != 2 ||
multiplayerBattleZoneConfig->networkPlayerSlots[0] != "player" ||
multiplayerBattleZoneConfig->networkPlayerSlots[1] !=
"player2" ||
multiplayerBattleZoneConfig->networkCompatibility.empty()) {
return fail("Multiplayer player slots were not loaded");
}
FRG::LocationDefinition definition;
definition.config.id = "test_location";
@@ -135,7 +142,7 @@ int main()
FRG::InternalGameServer server;
server.startCampaign(battleZone, {}, false, true);
const auto snapshot = server.snapshot();
const auto bot = snapshot->players.find("battle_zone_test_bot");
const auto bot = snapshot->players.find("player2");
if (bot == snapshot->players.end())
return fail("Battle Zone test bot was not created");
const auto stand = static_cast<std::size_t>(FRG::AnimationState::STAND);
@@ -148,14 +155,37 @@ int main()
FRG::InternalGameServer networkServer;
FRG::CampaignConfig multiplayerBattleZone = battleZone;
multiplayerBattleZone.id = "battle_zone_multiplayer";
multiplayerBattleZone.networkPlayerSlots = {
"player", "player2"};
networkServer.startCampaign(multiplayerBattleZone, {}, true, false);
const auto networkSnapshot = networkServer.snapshot();
if (networkSnapshot->campaignId != "battle_zone_multiplayer")
return fail("Network campaign ID was not retained");
if (networkSnapshot->players.count("player") != 1)
return fail("Network campaign shared player was not created");
if (networkSnapshot->players.count("battle_zone_test_bot") != 0)
if (networkSnapshot->players.count("player2") != 0)
return fail("Network campaign unexpectedly created the test bot");
nlohmann::json loadedNetworkState;
networkServer.save(loadedNetworkState);
loadedNetworkState["players"]["player2"] =
loadedNetworkState["players"]["player"];
loadedNetworkState["players"]["player2"]["position"] =
{4.f, 0.f, 5.f};
networkServer.load(loadedNetworkState);
const auto loadedNetworkSnapshot = networkServer.snapshot();
const auto loadedNetworkBot =
loadedNetworkSnapshot->players.find("player2");
if (loadedNetworkBot == loadedNetworkSnapshot->players.end() ||
loadedNetworkBot->second.state.animationFrameCounts[stand] <= 1 ||
loadedNetworkBot->second.state.animationFrameCounts[walk] <= 1) {
return fail(
"Loaded multiplayer slot did not restore animation metadata");
}
if (!loadedNetworkBot->second.state.position.isApprox(
Eigen::Vector3f(4.f, 0.f, 5.f))) {
return fail("Loaded multiplayer slot position was overwritten");
}
return EXIT_SUCCESS;
}
+85 -13
View File
@@ -26,31 +26,103 @@ int main()
{"players", {{"player", {{"hp", 200.0}}}}},
{"locations", {{"uni_exterior", {{"state", savedLocationState}}}}}
};
const std::string message = FRG::makeStateMessage(original);
nlohmann::json parsed;
FRG::InitialStateMessage initial;
initial.state = original;
initial.assignedPlayerId = "player";
initial.snapshotTick = 42;
initial.compatibilityFingerprint = "lockstep-v2:battle_zone_multiplayer";
const std::string message = FRG::makeInitialStateMessage(initial);
FRG::InitialStateMessage parsed;
std::string error;
require(
FRG::parseStateMessage(message, parsed, error),
"valid state envelope was rejected");
require(parsed == original, "state payload did not round trip");
FRG::parseInitialStateMessage(message, parsed, error),
"valid initial-state envelope was rejected");
require(parsed.state == original, "state payload did not round trip");
require(parsed.snapshotTick == 42, "snapshot tick did not round trip");
require(
!parsed["locations"]["uni_exterior"]["state"].contains("cameraAzimuth") &&
!parsed["locations"]["uni_exterior"]["state"].contains("cameraInclination"),
parsed.assignedPlayerId == "player",
"assigned player did not round trip");
require(
!parsed.state["locations"]["uni_exterior"]["state"].contains("cameraAzimuth") &&
!parsed.state["locations"]["uni_exterior"]["state"].contains("cameraInclination"),
"local camera state leaked into the network payload");
FRG::DestinationInputMessage input;
input.clientSequence = 7;
input.target = Eigen::Vector3f(1.234f, 0.f, -5.678f);
FRG::DestinationInputMessage parsedInput;
require(
!FRG::parseStateMessage(
R"({"protocolVersion":2,"type":"state","payload":{}})",
FRG::parseDestinationInputMessage(
FRG::makeDestinationInputMessage(input), parsedInput, error),
"destination input did not round trip");
require(
parsedInput.clientSequence == 7 &&
parsedInput.target.isApprox(Eigen::Vector3f(1.23f, 0.f, -5.68f)),
"destination input was not normalized");
FRG::TickMessage tick;
tick.tickId = 43;
tick.commands.push_back({1, "player", parsedInput.target});
tick.stateHash = "0123456789abcdef";
FRG::TickMessage parsedTick;
require(
FRG::parseTickMessage(
FRG::makeTickMessage(tick), parsedTick, error),
"tick message did not round trip");
require(
parsedTick.tickId == 43 && parsedTick.commands.size() == 1,
"tick contents did not round trip");
require(
!FRG::parseInitialStateMessage(
R"({"protocolVersion":1,"type":"initial_state","payload":{}})",
parsed, error),
"unknown protocol version was accepted");
require(
!FRG::parseStateMessage(
R"({"protocolVersion":1,"type":"command","payload":{}})",
parsed, error),
!FRG::parseDestinationInputMessage(
R"({"protocolVersion":2,"type":"command","payload":{}})",
parsedInput, error),
"unknown message type was accepted");
require(
!FRG::parseStateMessage("not json", parsed, error),
!FRG::parseInitialStateMessage("not json", parsed, error),
"malformed JSON was accepted");
require(
!FRG::parseDestinationInputMessage(
R"({"protocolVersion":2,"type":"destination_input","payload":{"clientSequence":1,"target":[1e999,0,0]}})",
parsedInput, error),
"non-finite destination was accepted");
std::string errorCode;
std::string errorDetail;
require(
FRG::parseErrorMessage(
FRG::makeErrorMessage("server_full", "No slot"),
errorCode, errorDetail) &&
errorCode == "server_full" && errorDetail == "No slot",
"error message did not round trip");
nlohmann::json hashState = original;
hashState["savedAt"] = "first";
const std::string firstHash = FRG::canonicalStateHash(hashState);
hashState["savedAt"] = "second";
require(
firstHash == FRG::canonicalStateHash(hashState),
"persistence timestamp changed deterministic hash");
FRG::LockstepTimeline timeline(2);
const std::uint64_t firstSequence =
timeline.schedule("player", Eigen::Vector3f(1.f, 0.f, 1.f));
const std::uint64_t secondSequence =
timeline.schedule("player_2", Eigen::Vector3f(2.f, 0.f, 2.f));
const FRG::TickMessage emptyTick = timeline.advance();
require(
emptyTick.tickId == 1 && emptyTick.commands.empty(),
"input-delay tick was not empty");
const FRG::TickMessage commandTick = timeline.advance();
require(
commandTick.tickId == 2 && commandTick.commands.size() == 2 &&
commandTick.commands[0].commandSequence == firstSequence &&
commandTick.commands[1].commandSequence == secondSequence,
"scheduled commands were not finalized in stable order");
return EXIT_SUCCESS;
}
+90
View File
@@ -0,0 +1,90 @@
#include "game_server/InternalGameServer.h"
#include "game_server/WebsocketGameClient.h"
#include "game_server/WebsocketGameServer.h"
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <memory>
#include <thread>
namespace {
void require(bool condition, const char* message)
{
if (condition) return;
std::cerr << "WebsocketLockstepTests: " << message << '\n';
std::exit(EXIT_FAILURE);
}
template <class Predicate>
bool waitFor(Predicate predicate)
{
const auto deadline =
std::chrono::steady_clock::now() + std::chrono::seconds(3);
while (std::chrono::steady_clock::now() < deadline) {
if (predicate()) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return false;
}
} // namespace
int main()
{
FRG::CampaignConfig campaign;
campaign.id = "test_lockstep";
FRG::InternalGameServer authoritative;
authoritative.startCampaign(campaign, {}, true, false);
FRG::WebsocketGameServer server(
authoritative, {"player"}, "lockstep-v2:test_lockstep",
"127.0.0.1", 0);
auto client = std::make_unique<FRG::WebsocketGameClient>(
"127.0.0.1", std::to_string(server.port()), "/");
require(
waitFor([&]() { return client->connected(); }),
"client did not connect");
require(
waitFor([&]() {
client->update(0);
return client->initialized();
}),
"client did not receive initial state");
require(client->localPlayerId() == "player", "wrong player slot assigned");
FRG::WebsocketGameClient overflow(
"127.0.0.1", std::to_string(server.port()), "/");
require(
waitFor([&]() {
overflow.update(0);
return overflow.rejected();
}),
"connection beyond configured slots was not rejected");
client->submit(FRG::SetCharacterDestinationCommand{
{}, "spoofed", Eigen::Vector3f(1.234f, 0.f, 2.345f), "ignored"});
std::this_thread::sleep_for(std::chrono::milliseconds(20));
server.update();
server.update();
require(
waitFor([&]() {
client->update(0);
return client->snapshot()->revision > 1;
}),
"client did not consume finalized lockstep ticks");
client.reset();
std::this_thread::sleep_for(std::chrono::milliseconds(50));
FRG::WebsocketGameClient replacement(
"127.0.0.1", std::to_string(server.port()), "/");
require(
waitFor([&]() {
replacement.update(0);
return replacement.initialized();
}),
"disconnected player slot was not reusable");
return EXIT_SUCCESS;
}