From 2e6b93f3acd25aaaeb15478d8c1b55834ba20bc5 Mon Sep 17 00:00:00 2001 From: Vladislav Khorev Date: Fri, 6 Nov 2026 22:07:47 +0600 Subject: [PATCH] player connect-disconnect --- GAME_STATE_ARCHITECTURE.md | 12 ++- WEBSOCKET_NETWORKING.md | 19 ++-- resources/config/start_state_battle_zone.json | 4 +- server/tests/LocationGameStateTests.cpp | 43 +++++++++ server/tests/NetworkProtocolTests.cpp | 25 ++++- server/tests/WebsocketLockstepTests.cpp | 51 +++++++++- src/Campaign.cpp | 1 + src/Character.cpp | 7 +- src/CharacterState.h | 2 +- src/Location.cpp | 10 +- src/LocationGameState.cpp | 16 ++-- src/game_server/InternalGameServer.cpp | 8 +- src/game_server/NetworkProtocol.cpp | 93 +++++++++++++++---- src/game_server/NetworkProtocol.h | 19 +++- src/game_server/WebsocketGameClient.cpp | 15 ++- src/game_server/WebsocketGameServer.cpp | 42 +++++++-- 16 files changed, 305 insertions(+), 62 deletions(-) diff --git a/GAME_STATE_ARCHITECTURE.md b/GAME_STATE_ARCHITECTURE.md index d72cc4e..0ea9460 100644 --- a/GAME_STATE_ARCHITECTURE.md +++ b/GAME_STATE_ARCHITECTURE.md @@ -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; diff --git a/WEBSOCKET_NETWORKING.md b/WEBSOCKET_NETWORKING.md index 4498842..ae60d2d 100644 --- a/WEBSOCKET_NETWORKING.md +++ b/WEBSOCKET_NETWORKING.md @@ -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 diff --git a/resources/config/start_state_battle_zone.json b/resources/config/start_state_battle_zone.json index e591422..7001131 100644 --- a/resources/config/start_state_battle_zone.json +++ b/resources/config/start_state_battle_zone.json @@ -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, diff --git a/server/tests/LocationGameStateTests.cpp b/server/tests/LocationGameStateTests.cpp index 602b72c..7458080 100644 --- a/server/tests/LocationGameStateTests.cpp +++ b/server/tests/LocationGameStateTests.cpp @@ -135,6 +135,18 @@ int main() FRG::PlayerStateMap noPlayers; state.update(50, noPlayers, false); + auto disabledPlayer = std::make_shared(); + 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; } diff --git a/server/tests/NetworkProtocolTests.cpp b/server/tests/NetworkProtocolTests.cpp index e6c9997..9eccdaa 100644 --- a/server/tests/NetworkProtocolTests.cpp +++ b/server/tests/NetworkProtocolTests.cpp @@ -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( + parsedTick.commands[1]) && + std::get( + 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( + commandTick.commands[0]).commandSequence == firstSequence && + std::get( + 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( + presenceTick.commands[0]), + "character enabled command was not scheduled for the next tick"); + return EXIT_SUCCESS; } diff --git a/server/tests/WebsocketLockstepTests.cpp b/server/tests/WebsocketLockstepTests.cpp index 92c88bf..8846cee 100644 --- a/server/tests/WebsocketLockstepTests.cpp +++ b/server/tests/WebsocketLockstepTests.cpp @@ -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( "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( + "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; } diff --git a/src/Campaign.cpp b/src/Campaign.cpp index 3a34943..ed7336c 100644 --- a/src/Campaign.cpp +++ b/src/Campaign.cpp @@ -3,6 +3,7 @@ #include "external/nlohmann/json.hpp" #include + namespace FRG { using json = nlohmann::json; diff --git a/src/Character.cpp b/src/Character.cpp index 3df2861..4dba51e 100644 --- a/src/Character.cpp +++ b/src/Character.cpp @@ -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) { diff --git a/src/CharacterState.h b/src/CharacterState.h index efb7bc5..dbd049b 100644 --- a/src/CharacterState.h +++ b/src/CharacterState.h @@ -63,7 +63,7 @@ public: // --- Identity --- std::string npcName; bool isPlayer = false; - bool enabled = true; + bool enabled = false; bool canAttack = false; // --- Position / orientation --- diff --git a/src/Location.cpp b/src/Location.cpp index 275a342..0814160 100644 --- a/src/Location.cpp +++ b/src/Location.cpp @@ -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; diff --git a/src/LocationGameState.cpp b/src/LocationGameState.cpp index 6bde6bd..a3dd93b 100644 --- a/src/LocationGameState.cpp +++ b/src/LocationGameState.cpp @@ -255,11 +255,11 @@ LocationSimulationEvents LocationGameState::update( // after every state has advanced, so vector order cannot suppress an attack. std::unordered_map 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,19 +282,21 @@ LocationSimulationEvents LocationGameState::update( if (player) playerIds.push_back(id); std::sort(playerIds.begin(), playerIds.end()); for (const std::string& id : playerIds) - advance(id, *players.at(id)); + if (players.at(id)->enabled) + advance(id, *players.at(id)); std::vector npcIds; npcIds.reserve(npcs.size()); for (const auto& [id, npc] : npcs) if (npc) npcIds.push_back(id); std::sort(npcIds.begin(), npcIds.end()); for (const std::string& id : npcIds) - advance(id, *npcs.at(id)); + 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); diff --git a/src/game_server/InternalGameServer.cpp b/src/game_server/InternalGameServer.cpp index b315b3a..0e8bb24 100644 --- a/src/game_server/InternalGameServer.cpp +++ b/src/game_server/InternalGameServer.cpp @@ -51,6 +51,7 @@ void InternalGameServer::ensureBattleZoneTestBot() auto& bot = state_.players[kBattleZonePlayer2Id]; if (!bot) bot = std::make_shared(); 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(); 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(); diff --git a/src/game_server/NetworkProtocol.cpp b/src/game_server/NetworkProtocol.cpp index ec9b60f..e73b065 100644 --- a/src/game_server/NetworkProtocol.cpp +++ b/src/game_server/NetworkProtocol.cpp @@ -4,6 +4,7 @@ #include #include #include +#include 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) { - commands.push_back({ - {"commandSequence", command.commandSequence}, - {"playerId", command.playerId}, - {"target", targetJson(command.target)} - }); + for (const ScheduledCommand& scheduled : message.commands) { + std::visit([&commands](const auto& command) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + 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(); - 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(); + 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(); + enabled.characterId = value["characterId"].get(); + enabled.enabled = value["enabled"].get(); + 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()); diff --git a/src/game_server/NetworkProtocol.h b/src/game_server/NetworkProtocol.h index 7aad0aa..a476777 100644 --- a/src/game_server/NetworkProtocol.h +++ b/src/game_server/NetworkProtocol.h @@ -5,6 +5,7 @@ #include #include #include +#include #include 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 commands; + std::vector 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> pending_; + std::map> pending_; }; std::string makeInitialStateMessage(const InitialStateMessage& message); diff --git a/src/game_server/WebsocketGameClient.cpp b/src/game_server/WebsocketGameClient.cpp index 4d567e4..8675d1c 100644 --- a/src/game_server/WebsocketGameClient.cpp +++ b/src/game_server/WebsocketGameClient.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -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) { - replica_.submit(SetCharacterDestinationCommand{ - locationId, command.playerId, command.target, {}}); + for (const ScheduledCommand& scheduled : tick.commands) { + std::visit([&](const auto& command) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + 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({ diff --git a/src/game_server/WebsocketGameServer.cpp b/src/game_server/WebsocketGameServer.cpp index 905b4ce..1fcc03b 100644 --- a/src/game_server/WebsocketGameServer.cpp +++ b/src/game_server/WebsocketGameServer.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -179,6 +180,14 @@ public: return result; } + std::vector takeConnectedPlayers() + { + std::lock_guard lock(inputsMutex_); + std::vector result; + result.swap(connectedPlayers_); + return result; + } + void publishTick( std::string tick, nlohmann::json latestState, std::uint64_t tickId) { @@ -237,7 +246,8 @@ private: << "' disconnected" << std::endl; assignedSlots_.erase(source->playerId()); std::lock_guard lock(inputsMutex_); - releasedPlayers_.push_back(source->playerId()); + if (!source->playerId().empty()) + releasedPlayers_.push_back(source->playerId()); }); if (playerId.empty()) { std::cout << "[network] Rejecting connection: no player slot" @@ -251,6 +261,10 @@ private: << playerId << "' at snapshot tick " << latestTick_ << std::endl; sessions_.push_back(session); + { + std::lock_guard 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 pendingInputs_; + std::vector connectedPlayers_; std::vector 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 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) { - gameServer_.submit(SetCharacterDestinationCommand{ - gameServer_.snapshot()->currentLocationId, - command.playerId, command.target, {}}); + for (const ScheduledCommand& scheduled : tick.commands) { + std::visit([this](const auto& command) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + 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;