player connect-disconnect

This commit is contained in:
Vladislav Khorev committed 2026-11-06 22:07:47 +06:00
1 parent 3f68a8ba07
commit 2e6b93f3ac
16 files changed
+292 -49

No files matched your search

+9 -3
View File
@@ -147,10 +147,12 @@ Snapshots have a monotonically increasing revision. `InternalGameServer` caches
- Trigger-zone state.
- Quest-definition reloads.
The remote implementation currently serializes only
`SetCharacterDestinationCommand` as player intent. The server derives the
The remote implementation serializes destination intent from clients, while
the server emits connection-lifecycle presence commands. The server derives the
character and location from the assigned connection slot; arbitrary state
mutation commands are not accepted from the network.
`SetCharacterEnabledCommand` is consumed by the server and replicated to every
client in the ordered tick bundle when a slot connects or disconnects.
### Presentation events
@@ -407,6 +409,9 @@ server interface. They can remain empty or disabled in multiplayer mode as plann
Protocol v2 uses server-sequenced deterministic lockstep:
- The server assigns a connection to a preconfigured campaign player slot.
- Player slots are disabled in the authoritative initial state. A successful
connection schedules `SetCharacterEnabledCommand(enabled=true)` for the next
lockstep tick; disconnect schedules the corresponding `enabled=false` command.
- 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.
@@ -424,7 +429,8 @@ there is no prediction or rollback.
Current boundaries remain:
- only `SetCharacterDestinationCommand` crosses the network;
- client intent is limited to `SetCharacterDestinationCommand`; server presence
changes use `SetCharacterEnabledCommand` in tick bundles;
- 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;
+12 -7
View File
@@ -5,11 +5,12 @@
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).
only numbered tick messages containing ordered destination and presence
commands (or an empty command list).
The current command surface is intentionally limited to
`SetCharacterDestinationCommand`.
Client intent is intentionally limited to `SetCharacterDestinationCommand`.
The server also sends `SetCharacterEnabledCommand` in tick bundles when a
configured player slot connects or disconnects.
## Runtime Architecture
@@ -21,6 +22,9 @@ The current command surface is intentionally limited to
- Each accepted connection is assigned the first free character ID from the
campaign's `networkPlayerSlots`; the connection is rejected when all slots
are occupied.
- Player slots start disabled. The server consumes and broadcasts an enabled
command on the next tick for each connection and disconnection, including to
the newly connected client.
- 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
@@ -102,8 +106,8 @@ Message types are:
- `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;
- `tick` (server to client): tick ID, globally sequenced destination and
character-presence commands, and post-tick diagnostic state hash;
- `error` (server to client): invalid input, incompatibility, or full server.
Protocol v1 is not compatible. Malformed payloads, non-finite or out-of-range
@@ -161,7 +165,8 @@ Launch the Windows game and select `battle_zone_multiplayer`. The client connect
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.
slot assignment, connection/disconnection presence propagation, destination
submission, and contiguous tick consumption.
## Current Limitations
@@ -162,7 +162,7 @@
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
@@ -199,7 +199,7 @@
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"enabled": false,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
+43
View File
@@ -135,6 +135,18 @@ int main()
FRG::PlayerStateMap noPlayers;
state.update(50, noPlayers, false);
auto disabledPlayer = std::make_shared<FRG::CharacterState>();
disabledPlayer->configureAsPlayer();
disabledPlayer->enabled = false;
state.location->targetInteractiveObjectId = "object";
state.location->targetInteractNpcId = "npc";
FRG::PlayerStateMap disabledPlayers{{"player", disabledPlayer}};
const FRG::LocationSimulationEvents disabledEvents =
state.update(50, disabledPlayers, false);
if (!disabledEvents.interactiveObjects.empty() ||
!disabledEvents.npcInteractionId.empty()) {
return fail("Disabled player interacted with location entities");
}
FRG::CampaignConfig battleZone;
battleZone.id = "battle_zone";
@@ -165,6 +177,8 @@ int main()
return fail("Network campaign shared player was not created");
if (networkSnapshot->players.count("player2") != 0)
return fail("Network campaign unexpectedly created the test bot");
if (networkSnapshot->players.at("player").state.enabled)
return fail("Network campaign player slot was enabled before connection");
nlohmann::json loadedNetworkState;
networkServer.save(loadedNetworkState);
@@ -186,6 +200,35 @@ int main()
Eigen::Vector3f(4.f, 0.f, 5.f))) {
return fail("Loaded multiplayer slot position was overwritten");
}
if (loadedNetworkBot->second.state.enabled)
return fail("Loaded multiplayer slot was enabled before connection");
const std::string locationId = loadedNetworkSnapshot->currentLocationId;
networkServer.submit(FRG::SetCharacterPositionCommand{
locationId, "player2", Eigen::Vector3f(7.f, 0.f, 8.f), true});
networkServer.submit(FRG::SetCharacterFacingAngleCommand{
locationId, "player2", 1.5f, true});
networkServer.submit(FRG::SetCharacterFaceTargetCommand{
locationId, "player2", "player"});
networkServer.submit(FRG::SetCharacterEnabledCommand{
locationId, "player2", false});
const auto commandSnapshot = networkServer.snapshot();
const auto commandCharacter = commandSnapshot->players.at("player2").state;
if (!commandCharacter.position.isApprox(Eigen::Vector3f(7.f, 0.f, 8.f)) ||
!commandCharacter.homePosition.isApprox(Eigen::Vector3f(7.f, 0.f, 8.f)) ||
commandCharacter.facingAngle != 1.5f ||
commandCharacter.targetFacingAngle != 1.5f ||
!commandCharacter.faceTargetId.empty() ||
commandCharacter.enabled) {
return fail("Semantic character commands were not applied");
}
networkServer.submit(FRG::SetCharacterEnabledCommand{
locationId, "player2", true});
const auto enabledPlayer =
networkServer.snapshot()->players.at("player2").state;
if (!enabledPlayer.enabled || !enabledPlayer.attackTargetId.empty()) {
return fail("Enabled player acquired an autonomous attack target");
}
return EXIT_SUCCESS;
}
+21 -4
View File
@@ -62,7 +62,10 @@ int main()
FRG::TickMessage tick;
tick.tickId = 43;
tick.commands.push_back({1, "player", parsedInput.target});
tick.commands.push_back(FRG::ScheduledDestinationCommand{
1, "player", parsedInput.target});
tick.commands.push_back(FRG::ScheduledCharacterEnabledCommand{
2, "uni_exterior", "player2", true});
tick.stateHash = "0123456789abcdef";
FRG::TickMessage parsedTick;
require(
@@ -70,7 +73,11 @@ int main()
FRG::makeTickMessage(tick), parsedTick, error),
"tick message did not round trip");
require(
parsedTick.tickId == 43 && parsedTick.commands.size() == 1,
parsedTick.tickId == 43 && parsedTick.commands.size() == 2 &&
std::holds_alternative<FRG::ScheduledCharacterEnabledCommand>(
parsedTick.commands[1]) &&
std::get<FRG::ScheduledCharacterEnabledCommand>(
parsedTick.commands[1]).enabled,
"tick contents did not round trip");
require(
@@ -120,9 +127,19 @@ int main()
const FRG::TickMessage commandTick = timeline.advance();
require(
commandTick.tickId == 2 && commandTick.commands.size() == 2 &&
commandTick.commands[0].commandSequence == firstSequence &&
commandTick.commands[1].commandSequence == secondSequence,
std::get<FRG::ScheduledDestinationCommand>(
commandTick.commands[0]).commandSequence == firstSequence &&
std::get<FRG::ScheduledDestinationCommand>(
commandTick.commands[1]).commandSequence == secondSequence,
"scheduled commands were not finalized in stable order");
timeline.scheduleCharacterEnabled("uni_exterior", "player2", true);
const FRG::TickMessage presenceTick = timeline.advance();
require(
presenceTick.commands.size() == 1 &&
std::holds_alternative<FRG::ScheduledCharacterEnabledCommand>(
presenceTick.commands[0]),
"character enabled command was not scheduled for the next tick");
return EXIT_SUCCESS;
}
+49 -2
View File
@@ -34,11 +34,16 @@ int main()
{
FRG::CampaignConfig campaign;
campaign.id = "test_lockstep";
campaign.networkPlayerSlots = {"player", "player2"};
FRG::InternalGameServer authoritative;
authoritative.startCampaign(campaign, {}, true, false);
nlohmann::json initialState;
authoritative.save(initialState);
initialState["players"]["player2"] = initialState["players"]["player"];
authoritative.load(initialState);
FRG::WebsocketGameServer server(
authoritative, {"player"}, "lockstep-v2:test_lockstep",
authoritative, {"player", "player2"}, "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()), "/");
@@ -54,6 +59,18 @@ int main()
"client did not receive initial state");
require(client->localPlayerId() == "player", "wrong player slot assigned");
auto secondClient = std::make_unique<FRG::WebsocketGameClient>(
"127.0.0.1", std::to_string(server.port()), "/");
require(
waitFor([&]() {
secondClient->update(0);
return secondClient->initialized();
}),
"second client did not receive initial state");
require(
secondClient->localPlayerId() == "player2",
"second client received the wrong player slot");
FRG::WebsocketGameClient overflow(
"127.0.0.1", std::to_string(server.port()), "/");
require(
@@ -63,6 +80,18 @@ int main()
}),
"connection beyond configured slots was not rejected");
server.update();
require(
waitFor([&]() {
client->update(0);
secondClient->update(0);
return client->snapshot()->players.at("player2").state.enabled &&
secondClient->snapshot()->players.at("player").state.enabled;
}) &&
authoritative.snapshot()->players.at("player").state.enabled &&
authoritative.snapshot()->players.at("player2").state.enabled,
"connection presence was not broadcast to all replicas");
client->submit(FRG::SetCharacterDestinationCommand{
{}, "spoofed", Eigen::Vector3f(1.234f, 0.f, 2.345f), "ignored"});
std::this_thread::sleep_for(std::chrono::milliseconds(20));
@@ -75,8 +104,17 @@ int main()
}),
"client did not consume finalized lockstep ticks");
client.reset();
secondClient.reset();
std::this_thread::sleep_for(std::chrono::milliseconds(50));
server.update();
require(
waitFor([&]() {
client->update(0);
return !client->snapshot()->players.at("player2").state.enabled;
}) &&
!authoritative.snapshot()->players.at("player2").state.enabled,
"disconnect presence was not propagated");
FRG::WebsocketGameClient replacement(
"127.0.0.1", std::to_string(server.port()), "/");
require(
@@ -85,6 +123,15 @@ int main()
return replacement.initialized();
}),
"disconnected player slot was not reusable");
server.update();
require(
waitFor([&]() {
client->update(0);
replacement.update(0);
return client->snapshot()->players.at("player2").state.enabled;
}) &&
authoritative.snapshot()->players.at("player2").state.enabled,
"reconnected player was not enabled");
return EXIT_SUCCESS;
}
+1
View File
@@ -3,6 +3,7 @@
#include "external/nlohmann/json.hpp"
#include <iostream>
namespace FRG {
using json = nlohmann::json;
+3 -4
View File
@@ -268,10 +268,6 @@ void Character::update(int64_t deltaMs, const CharacterUpdateEvents* events) {
}
void Character::draw(Renderer& renderer) {
if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) {
hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false);
}
renderer.RenderUniform1f("uAlpha", 1.0f);
glDisable(GL_BLEND);
@@ -279,6 +275,9 @@ void Character::draw(Renderer& renderer) {
if (!stateSnapshot) return;
CharacterState state = interpolatedState(*stateSnapshot);
if (!state.enabled) return;
if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) {
hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false);
}
updateAnimationForRender(state);
if (state.useGpuSkinning) {
+1 -1
View File
@@ -63,7 +63,7 @@ public:
// --- Identity ---
std::string npcName;
bool isPlayer = false;
bool enabled = true;
bool enabled = false;
bool canAttack = false;
// --- Position / orientation ---
+6 -4
View File
@@ -1902,7 +1902,8 @@ namespace FRG
if (player) {
player->update(delta, eventFor(gameServer_.localPlayerId()));
auto playerState = player->stateSnapshot();
if (playerState) updateTriggerZones(playerState->position);
if (playerState && playerState->enabled)
updateTriggerZones(playerState->position);
}
for (auto& [id, remote] : remotePlayers_)
if (remote) remote->update(delta, eventFor(id));
@@ -1937,7 +1938,8 @@ namespace FRG
}
auto playerState = player ? player->stateSnapshot() : nullptr;
if (auto* tz = getTargetTeleportZone(*locationState); tz && playerState) {
if (auto* tz = getTargetTeleportZone(*locationState);
tz && playerState && playerState->enabled) {
float dist = (playerState->position - tz->position).norm();
if (dist <= tz->radius) {
logger() << "[TELEPORT] Player reached teleport zone '" << tz->id << "'" << std::endl;
@@ -1951,7 +1953,7 @@ namespace FRG
}
}
if (playerState) {
if (playerState && playerState->enabled) {
for (auto& tz : teleportZones) {
if (!tz.active || !tz.automatic) continue;
const float autoR = tz.automaticRadius > 0.0f ? tz.automaticRadius : tz.radius;
@@ -1990,7 +1992,7 @@ namespace FRG
}
auto playerState = player ? player->stateSnapshot() : nullptr;
if (!playerState) return;
if (!playerState || !playerState->enabled) return;
auto locationState = stateSnapshot();
if (!locationState) return;
const std::string lastPlayerAttackTarget = playerState->attackTargetId;
+7 -5
View File
@@ -255,11 +255,11 @@ LocationSimulationEvents LocationGameState::update(
// after every state has advanced, so vector order cannot suppress an attack.
std::unordered_map<std::string, CharacterObservation> observations;
for (const auto& [id, player] : players)
if (player)
if (player && player->enabled)
observations.emplace(
id, CharacterObservation{player->position, player->hp});
for (const auto& [id, npc] : npcs)
if (npc)
if (npc && npc->enabled)
observations.emplace(
id, CharacterObservation{npc->position, npc->hp});
@@ -282,6 +282,7 @@ LocationSimulationEvents LocationGameState::update(
if (player) playerIds.push_back(id);
std::sort(playerIds.begin(), playerIds.end());
for (const std::string& id : playerIds)
if (players.at(id)->enabled)
advance(id, *players.at(id));
std::vector<std::string> npcIds;
npcIds.reserve(npcs.size());
@@ -289,12 +290,13 @@ LocationSimulationEvents LocationGameState::update(
if (npc) npcIds.push_back(id);
std::sort(npcIds.begin(), npcIds.end());
for (const std::string& id : npcIds)
if (npcs.at(id)->enabled)
advance(id, *npcs.at(id));
for (const CharacterSimulationEvent& source : result.characters) {
if (!source.events.attackImpact) continue;
CharacterState* target = findCharacter(source.events.attackTargetId, players);
if (!target) continue;
if (!target || !target->enabled) continue;
constexpr float damage = 10.f;
target->setHp(target->hp - damage);
result.damage.push_back({
@@ -309,7 +311,7 @@ LocationSimulationEvents LocationGameState::update(
auto player = players.find("player");
if (!interactiveObjectsLocked &&
!location->targetInteractiveObjectId.empty() &&
player != players.end() && player->second) {
player != players.end() && player->second && player->second->enabled) {
auto target = interactiveObjects.find(location->targetInteractiveObjectId);
const auto object =
target == interactiveObjects.end() ? nullptr : target->second;
@@ -326,7 +328,7 @@ LocationSimulationEvents LocationGameState::update(
}
if (!location->targetInteractNpcId.empty() &&
player != players.end() && player->second &&
player != players.end() && player->second && player->second->enabled &&
npcs.count(location->targetInteractNpcId) != 0 &&
npcs.at(location->targetInteractNpcId)) {
const auto& npc = npcs.at(location->targetInteractNpcId);
+7 -1
View File
@@ -51,6 +51,7 @@ void InternalGameServer::ensureBattleZoneTestBot()
auto& bot = state_.players[kBattleZonePlayer2Id];
if (!bot) bot = std::make_shared<CharacterState>();
bot->configureAsPlayer();
bot->enabled = true;
configurePlayerAnimations(*bot, resourceZipFile_);
bot->npcName = "Test Bot";
}
@@ -104,6 +105,7 @@ void InternalGameServer::startCampaign(
if (createLocalPlayer) {
auto player = std::make_shared<CharacterState>();
player->configureAsPlayer();
player->enabled = campaign.networkPlayerSlots.empty();
configurePlayerAnimations(*player, zipFile);
state_.players.emplace(localPlayerId_, std::move(player));
}
@@ -415,7 +417,10 @@ void InternalGameServer::submit(const GameCommand& command)
character->attack = 0;
character->attack_cooldown = 0;
character->currentState = AnimationState::STAND;
if (character->canAttack)
character->stopInPlace();
character->faceTargetId.clear();
character->attackTargetId.clear();
if (value.enabled && !character->isPlayer && character->canAttack)
character->attackTargetId = "player";
character->battle_state = 0;
}
@@ -544,6 +549,7 @@ void InternalGameServer::load(const nlohmann::json& in)
if (player == state_.players.end() || !player->second) continue;
player->second->configureAsPlayer();
configurePlayerAnimations(*player->second, resourceZipFile_);
//player->second->enabled = false;
}
battleZoneTestBotElapsedMs_ = 0;
ensureBattleZoneTestBot();
+72 -11
View File
@@ -4,6 +4,7 @@
#include <iomanip>
#include <limits>
#include <sstream>
#include <type_traits>
namespace FRG {
@@ -143,7 +144,18 @@ std::uint64_t LockstepTimeline::schedule(
{
const std::uint64_t sequence = nextCommandSequence_++;
pending_[currentTick_ + inputDelayTicks_].push_back(
{sequence, playerId, target});
ScheduledDestinationCommand{sequence, playerId, target});
return sequence;
}
std::uint64_t LockstepTimeline::scheduleCharacterEnabled(
const std::string& locationId, const std::string& characterId,
bool enabled)
{
const std::uint64_t sequence = nextCommandSequence_++;
pending_[currentTick_ + 1].push_back(
ScheduledCharacterEnabledCommand{
sequence, locationId, characterId, enabled});
return sequence;
}
@@ -182,12 +194,26 @@ bool parseDestinationInputMessage(
std::string makeTickMessage(const TickMessage& message)
{
nlohmann::json commands = nlohmann::json::array();
for (const ScheduledDestinationCommand& command : message.commands) {
for (const ScheduledCommand& scheduled : message.commands) {
std::visit([&commands](const auto& command) {
using T = std::decay_t<decltype(command)>;
if constexpr (std::is_same_v<T, ScheduledDestinationCommand>) {
commands.push_back({
{"type", "destination"},
{"commandSequence", command.commandSequence},
{"playerId", command.playerId},
{"target", targetJson(command.target)}
});
} else {
commands.push_back({
{"type", "character_enabled"},
{"commandSequence", command.commandSequence},
{"locationId", command.locationId},
{"characterId", command.characterId},
{"enabled", command.enabled}
});
}
}, scheduled);
}
return envelope("tick", {
{"tickId", message.tickId},
@@ -209,22 +235,57 @@ bool parseTickMessage(
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)) {
!readTick(value, "commandSequence", previousSequence, 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) {
const std::string type = value.value("type", "destination");
ScheduledCommand command;
if (type == "destination") {
ScheduledDestinationCommand destination;
destination.commandSequence = previousSequence;
if (!value.contains("playerId") || !value["playerId"].is_string() ||
!value.contains("target") ||
!readTarget(value["target"], destination.target, error)) {
if (error.empty()) error = "invalid destination command";
return false;
}
destination.playerId = value["playerId"].get<std::string>();
if (destination.playerId.empty()) {
error = "destination command playerId must not be empty";
return false;
}
command = std::move(destination);
} else if (type == "character_enabled") {
ScheduledCharacterEnabledCommand enabled;
enabled.commandSequence = previousSequence;
if (!value.contains("locationId") || !value["locationId"].is_string() ||
!value.contains("characterId") || !value["characterId"].is_string() ||
!value.contains("enabled") || !value["enabled"].is_boolean()) {
error = "invalid character enabled command";
return false;
}
enabled.locationId = value["locationId"].get<std::string>();
enabled.characterId = value["characterId"].get<std::string>();
enabled.enabled = value["enabled"].get<bool>();
if (enabled.locationId.empty() || enabled.characterId.empty()) {
error = "character enabled command IDs must not be empty";
return false;
}
command = std::move(enabled);
} else {
error = "unsupported scheduled command type";
return false;
}
if (previousSequence == 0 ||
(!message.commands.empty() &&
previousSequence <= std::visit(
[](const auto& item) { return item.commandSequence; },
message.commands.back()))) {
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());
+17 -2
View File
@@ -5,6 +5,7 @@
#include <cstdint>
#include <map>
#include <string>
#include <variant>
#include <vector>
namespace FRG {
@@ -33,9 +34,20 @@ struct ScheduledDestinationCommand {
Eigen::Vector3f target = Eigen::Vector3f::Zero();
};
struct ScheduledCharacterEnabledCommand {
std::uint64_t commandSequence = 0;
std::string locationId;
std::string characterId;
bool enabled = false;
};
using ScheduledCommand = std::variant<
ScheduledDestinationCommand,
ScheduledCharacterEnabledCommand>;
struct TickMessage {
std::uint64_t tickId = 0;
std::vector<ScheduledDestinationCommand> commands;
std::vector<ScheduledCommand> commands;
std::string stateHash;
};
@@ -48,13 +60,16 @@ public:
std::uint64_t currentTick() const { return currentTick_; }
std::uint64_t schedule(
const std::string& playerId, const Eigen::Vector3f& target);
std::uint64_t scheduleCharacterEnabled(
const std::string& locationId, const std::string& characterId,
bool enabled);
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::map<std::uint64_t, std::vector<ScheduledCommand>> pending_;
};
std::string makeInitialStateMessage(const InitialStateMessage& message);
+10 -1
View File
@@ -7,6 +7,7 @@
#include <iostream>
#include <mutex>
#include <thread>
#include <type_traits>
#include <utility>
#include <vector>
@@ -252,9 +253,17 @@ void WebsocketGameClient::update(std::int64_t)
pendingTicks_.erase(next);
const std::string locationId =
replica_.snapshot()->currentLocationId;
for (const ScheduledDestinationCommand& command : tick.commands) {
for (const ScheduledCommand& scheduled : tick.commands) {
std::visit([&](const auto& command) {
using T = std::decay_t<decltype(command)>;
if constexpr (std::is_same_v<T, ScheduledDestinationCommand>) {
replica_.submit(SetCharacterDestinationCommand{
locationId, command.playerId, command.target, {}});
} else {
replica_.submit(SetCharacterEnabledCommand{
command.locationId, command.characterId, command.enabled});
}
}, scheduled);
}
replica_.update(kSimulationTickMs);
completedSimulationTicks_.push_back({
+32 -2
View File
@@ -10,6 +10,7 @@
#include <memory>
#include <mutex>
#include <thread>
#include <type_traits>
#include <unordered_map>
#include <vector>
@@ -179,6 +180,14 @@ public:
return result;
}
std::vector<std::string> takeConnectedPlayers()
{
std::lock_guard<std::mutex> lock(inputsMutex_);
std::vector<std::string> result;
result.swap(connectedPlayers_);
return result;
}
void publishTick(
std::string tick, nlohmann::json latestState, std::uint64_t tickId)
{
@@ -237,6 +246,7 @@ private:
<< "' disconnected" << std::endl;
assignedSlots_.erase(source->playerId());
std::lock_guard<std::mutex> lock(inputsMutex_);
if (!source->playerId().empty())
releasedPlayers_.push_back(source->playerId());
});
if (playerId.empty()) {
@@ -251,6 +261,10 @@ private:
<< playerId << "' at snapshot tick "
<< latestTick_ << std::endl;
sessions_.push_back(session);
{
std::lock_guard<std::mutex> lock(inputsMutex_);
connectedPlayers_.push_back(playerId);
}
InitialStateMessage initial;
initial.state = latestState_;
initial.assignedPlayerId = playerId;
@@ -280,6 +294,7 @@ private:
std::uint64_t latestTick_ = 0;
std::mutex inputsMutex_;
std::vector<PendingInput> pendingInputs_;
std::vector<std::string> connectedPlayers_;
std::vector<std::string> releasedPlayers_;
std::uint16_t port_ = 0;
};
@@ -303,7 +318,14 @@ WebsocketGameServer::~WebsocketGameServer() = default;
void WebsocketGameServer::update()
{
for (const std::string& playerId : impl_->takeReleasedPlayers())
const std::string locationId = gameServer_.snapshot()->currentLocationId;
for (const std::string& playerId : impl_->takeConnectedPlayers())
timeline_.scheduleCharacterEnabled(locationId, playerId, true);
const std::vector<std::string> releasedPlayers =
impl_->takeReleasedPlayers();
for (const std::string& playerId : releasedPlayers)
timeline_.scheduleCharacterEnabled(locationId, playerId, false);
for (const std::string& playerId : releasedPlayers)
lastClientSequences_.erase(playerId);
for (const Impl::PendingInput& pending : impl_->takeInputs()) {
std::uint64_t& previous = lastClientSequences_[pending.playerId];
@@ -313,10 +335,18 @@ void WebsocketGameServer::update()
}
TickMessage tick = timeline_.advance();
for (const ScheduledDestinationCommand& command : tick.commands) {
for (const ScheduledCommand& scheduled : tick.commands) {
std::visit([this](const auto& command) {
using T = std::decay_t<decltype(command)>;
if constexpr (std::is_same_v<T, ScheduledDestinationCommand>) {
gameServer_.submit(SetCharacterDestinationCommand{
gameServer_.snapshot()->currentLocationId,
command.playerId, command.target, {}});
} else {
gameServer_.submit(SetCharacterEnabledCommand{
command.locationId, command.characterId, command.enabled});
}
}, scheduled);
}
gameServer_.update(kSimulationTickMs);
nlohmann::json state;