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
+305 -62

No files matched your search

+9 -3
View File
@@ -147,10 +147,12 @@ Snapshots have a monotonically increasing revision. `InternalGameServer` caches
- Trigger-zone state. - Trigger-zone state.
- Quest-definition reloads. - Quest-definition reloads.
The remote implementation currently serializes only The remote implementation serializes destination intent from clients, while
`SetCharacterDestinationCommand` as player intent. The server derives the the server emits connection-lifecycle presence commands. The server derives the
character and location from the assigned connection slot; arbitrary state character and location from the assigned connection slot; arbitrary state
mutation commands are not accepted from the network. 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 ### 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: Protocol v2 uses server-sequenced deterministic lockstep:
- The server assigns a connection to a preconfigured campaign player slot. - 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 - Connection receives one authoritative state at a completed tick plus its
assigned player ID, tick duration, input delay, and compatibility fingerprint. assigned player ID, tick duration, input delay, and compatibility fingerprint.
- Destination input is normalized and scheduled two ticks in the future. - Destination input is normalized and scheduled two ticks in the future.
@@ -424,7 +429,8 @@ there is no prediction or rollback.
Current boundaries remain: 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 - player characters must already exist in the initial state and campaign slot
list; dynamic spawning and reconnect history are not implemented; list; dynamic spawning and reconnect history are not implemented;
- authentication and automatic checkpoint resynchronization are absent; - 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 The Windows WebSocket transport uses Boost.Asio and Boost.Beast to run a
server-sequenced deterministic lockstep simulation. A client receives one server-sequenced deterministic lockstep simulation. A client receives one
authoritative state at a completed tick boundary. After that, the server sends authoritative state at a completed tick boundary. After that, the server sends
only numbered tick messages containing ordered player commands (or an empty only numbered tick messages containing ordered destination and presence
command list). commands (or an empty command list).
The current command surface is intentionally limited to Client intent is intentionally limited to `SetCharacterDestinationCommand`.
`SetCharacterDestinationCommand`. The server also sends `SetCharacterEnabledCommand` in tick bundles when a
configured player slot connects or disconnects.
## Runtime Architecture ## 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 - Each accepted connection is assigned the first free character ID from the
campaign's `networkPlayerSlots`; the connection is rejected when all slots campaign's `networkPlayerSlots`; the connection is rejected when all slots
are occupied. 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 - The initial message contains the state, completed snapshot tick, assigned
player ID, tick duration, two-tick input delay, and compatibility fingerprint. player ID, tick duration, two-tick input delay, and compatibility fingerprint.
- Destination inputs cross from the Asio thread through a queue. The simulation - 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; - `initial_state` (server to client): one state snapshot and lockstep metadata;
- `destination_input` (client to server): monotonic client sequence and a - `destination_input` (client to server): monotonic client sequence and a
centimetre-normalized finite target; centimetre-normalized finite target;
- `tick` (server to client): tick ID, globally sequenced commands, and - `tick` (server to client): tick ID, globally sequenced destination and
post-tick diagnostic state hash; character-presence commands, and post-tick diagnostic state hash;
- `error` (server to client): invalid input, incompatibility, or full server. - `error` (server to client): invalid input, incompatibility, or full server.
Protocol v1 is not compatible. Malformed payloads, non-finite or out-of-range 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, CTest covers protocol-v2 round trips and validation, input-delay ordering,
fixed-tick replica behavior, campaign slot metadata, initial connection and 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 ## Current Limitations
@@ -162,7 +162,7 @@
"battle_state": 0, "battle_state": 0,
"currentState": 0, "currentState": 0,
"currentWaypointIndex": 0, "currentWaypointIndex": 0,
"enabled": true, "enabled": false,
"faceTargetId": "", "faceTargetId": "",
"facingAngle": 0.0, "facingAngle": 0.0,
"homeDriftCheckTimer": 5.0, "homeDriftCheckTimer": 5.0,
@@ -199,7 +199,7 @@
"battle_state": 0, "battle_state": 0,
"currentState": 0, "currentState": 0,
"currentWaypointIndex": 0, "currentWaypointIndex": 0,
"enabled": true, "enabled": false,
"faceTargetId": "", "faceTargetId": "",
"facingAngle": 0.0, "facingAngle": 0.0,
"homeDriftCheckTimer": 5.0, "homeDriftCheckTimer": 5.0,
+43
View File
@@ -135,6 +135,18 @@ int main()
FRG::PlayerStateMap noPlayers; FRG::PlayerStateMap noPlayers;
state.update(50, noPlayers, false); 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; FRG::CampaignConfig battleZone;
battleZone.id = "battle_zone"; battleZone.id = "battle_zone";
@@ -165,6 +177,8 @@ int main()
return fail("Network campaign shared player was not created"); return fail("Network campaign shared player was not created");
if (networkSnapshot->players.count("player2") != 0) if (networkSnapshot->players.count("player2") != 0)
return fail("Network campaign unexpectedly created the test bot"); 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; nlohmann::json loadedNetworkState;
networkServer.save(loadedNetworkState); networkServer.save(loadedNetworkState);
@@ -186,6 +200,35 @@ int main()
Eigen::Vector3f(4.f, 0.f, 5.f))) { Eigen::Vector3f(4.f, 0.f, 5.f))) {
return fail("Loaded multiplayer slot position was overwritten"); 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; return EXIT_SUCCESS;
} }
+21 -4
View File
@@ -62,7 +62,10 @@ int main()
FRG::TickMessage tick; FRG::TickMessage tick;
tick.tickId = 43; 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"; tick.stateHash = "0123456789abcdef";
FRG::TickMessage parsedTick; FRG::TickMessage parsedTick;
require( require(
@@ -70,7 +73,11 @@ int main()
FRG::makeTickMessage(tick), parsedTick, error), FRG::makeTickMessage(tick), parsedTick, error),
"tick message did not round trip"); "tick message did not round trip");
require( 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"); "tick contents did not round trip");
require( require(
@@ -120,9 +127,19 @@ int main()
const FRG::TickMessage commandTick = timeline.advance(); const FRG::TickMessage commandTick = timeline.advance();
require( require(
commandTick.tickId == 2 && commandTick.commands.size() == 2 && commandTick.tickId == 2 && commandTick.commands.size() == 2 &&
commandTick.commands[0].commandSequence == firstSequence && std::get<FRG::ScheduledDestinationCommand>(
commandTick.commands[1].commandSequence == secondSequence, commandTick.commands[0]).commandSequence == firstSequence &&
std::get<FRG::ScheduledDestinationCommand>(
commandTick.commands[1]).commandSequence == secondSequence,
"scheduled commands were not finalized in stable order"); "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; return EXIT_SUCCESS;
} }
+49 -2
View File
@@ -34,11 +34,16 @@ int main()
{ {
FRG::CampaignConfig campaign; FRG::CampaignConfig campaign;
campaign.id = "test_lockstep"; campaign.id = "test_lockstep";
campaign.networkPlayerSlots = {"player", "player2"};
FRG::InternalGameServer authoritative; FRG::InternalGameServer authoritative;
authoritative.startCampaign(campaign, {}, true, false); authoritative.startCampaign(campaign, {}, true, false);
nlohmann::json initialState;
authoritative.save(initialState);
initialState["players"]["player2"] = initialState["players"]["player"];
authoritative.load(initialState);
FRG::WebsocketGameServer server( FRG::WebsocketGameServer server(
authoritative, {"player"}, "lockstep-v2:test_lockstep", authoritative, {"player", "player2"}, "lockstep-v2:test_lockstep",
"127.0.0.1", 0); "127.0.0.1", 0);
auto client = std::make_unique<FRG::WebsocketGameClient>( auto client = std::make_unique<FRG::WebsocketGameClient>(
"127.0.0.1", std::to_string(server.port()), "/"); "127.0.0.1", std::to_string(server.port()), "/");
@@ -54,6 +59,18 @@ int main()
"client did not receive initial state"); "client did not receive initial state");
require(client->localPlayerId() == "player", "wrong player slot assigned"); 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( FRG::WebsocketGameClient overflow(
"127.0.0.1", std::to_string(server.port()), "/"); "127.0.0.1", std::to_string(server.port()), "/");
require( require(
@@ -63,6 +80,18 @@ int main()
}), }),
"connection beyond configured slots was not rejected"); "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{ client->submit(FRG::SetCharacterDestinationCommand{
{}, "spoofed", Eigen::Vector3f(1.234f, 0.f, 2.345f), "ignored"}); {}, "spoofed", Eigen::Vector3f(1.234f, 0.f, 2.345f), "ignored"});
std::this_thread::sleep_for(std::chrono::milliseconds(20)); std::this_thread::sleep_for(std::chrono::milliseconds(20));
@@ -75,8 +104,17 @@ int main()
}), }),
"client did not consume finalized lockstep ticks"); "client did not consume finalized lockstep ticks");
client.reset(); secondClient.reset();
std::this_thread::sleep_for(std::chrono::milliseconds(50)); 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( FRG::WebsocketGameClient replacement(
"127.0.0.1", std::to_string(server.port()), "/"); "127.0.0.1", std::to_string(server.port()), "/");
require( require(
@@ -85,6 +123,15 @@ int main()
return replacement.initialized(); return replacement.initialized();
}), }),
"disconnected player slot was not reusable"); "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; return EXIT_SUCCESS;
} }
+1
View File
@@ -3,6 +3,7 @@
#include "external/nlohmann/json.hpp" #include "external/nlohmann/json.hpp"
#include <iostream> #include <iostream>
namespace FRG { namespace FRG {
using json = nlohmann::json; 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) { 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); renderer.RenderUniform1f("uAlpha", 1.0f);
glDisable(GL_BLEND); glDisable(GL_BLEND);
@@ -279,6 +275,9 @@ void Character::draw(Renderer& renderer) {
if (!stateSnapshot) return; if (!stateSnapshot) return;
CharacterState state = interpolatedState(*stateSnapshot); CharacterState state = interpolatedState(*stateSnapshot);
if (!state.enabled) return; if (!state.enabled) return;
if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) {
hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false);
}
updateAnimationForRender(state); updateAnimationForRender(state);
if (state.useGpuSkinning) { if (state.useGpuSkinning) {
+1 -1
View File
@@ -63,7 +63,7 @@ public:
// --- Identity --- // --- Identity ---
std::string npcName; std::string npcName;
bool isPlayer = false; bool isPlayer = false;
bool enabled = true; bool enabled = false;
bool canAttack = false; bool canAttack = false;
// --- Position / orientation --- // --- Position / orientation ---
+6 -4
View File
@@ -1902,7 +1902,8 @@ namespace FRG
if (player) { if (player) {
player->update(delta, eventFor(gameServer_.localPlayerId())); player->update(delta, eventFor(gameServer_.localPlayerId()));
auto playerState = player->stateSnapshot(); auto playerState = player->stateSnapshot();
if (playerState) updateTriggerZones(playerState->position); if (playerState && playerState->enabled)
updateTriggerZones(playerState->position);
} }
for (auto& [id, remote] : remotePlayers_) for (auto& [id, remote] : remotePlayers_)
if (remote) remote->update(delta, eventFor(id)); if (remote) remote->update(delta, eventFor(id));
@@ -1937,7 +1938,8 @@ namespace FRG
} }
auto playerState = player ? player->stateSnapshot() : nullptr; 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(); float dist = (playerState->position - tz->position).norm();
if (dist <= tz->radius) { if (dist <= tz->radius) {
logger() << "[TELEPORT] Player reached teleport zone '" << tz->id << "'" << std::endl; 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) { for (auto& tz : teleportZones) {
if (!tz.active || !tz.automatic) continue; if (!tz.active || !tz.automatic) continue;
const float autoR = tz.automaticRadius > 0.0f ? tz.automaticRadius : tz.radius; const float autoR = tz.automaticRadius > 0.0f ? tz.automaticRadius : tz.radius;
@@ -1990,7 +1992,7 @@ namespace FRG
} }
auto playerState = player ? player->stateSnapshot() : nullptr; auto playerState = player ? player->stateSnapshot() : nullptr;
if (!playerState) return; if (!playerState || !playerState->enabled) return;
auto locationState = stateSnapshot(); auto locationState = stateSnapshot();
if (!locationState) return; if (!locationState) return;
const std::string lastPlayerAttackTarget = playerState->attackTargetId; const std::string lastPlayerAttackTarget = playerState->attackTargetId;
+9 -7
View File
@@ -255,11 +255,11 @@ LocationSimulationEvents LocationGameState::update(
// after every state has advanced, so vector order cannot suppress an attack. // after every state has advanced, so vector order cannot suppress an attack.
std::unordered_map<std::string, CharacterObservation> observations; std::unordered_map<std::string, CharacterObservation> observations;
for (const auto& [id, player] : players) for (const auto& [id, player] : players)
if (player) if (player && player->enabled)
observations.emplace( observations.emplace(
id, CharacterObservation{player->position, player->hp}); id, CharacterObservation{player->position, player->hp});
for (const auto& [id, npc] : npcs) for (const auto& [id, npc] : npcs)
if (npc) if (npc && npc->enabled)
observations.emplace( observations.emplace(
id, CharacterObservation{npc->position, npc->hp}); id, CharacterObservation{npc->position, npc->hp});
@@ -282,19 +282,21 @@ LocationSimulationEvents LocationGameState::update(
if (player) playerIds.push_back(id); if (player) playerIds.push_back(id);
std::sort(playerIds.begin(), playerIds.end()); std::sort(playerIds.begin(), playerIds.end());
for (const std::string& id : playerIds) for (const std::string& id : playerIds)
advance(id, *players.at(id)); if (players.at(id)->enabled)
advance(id, *players.at(id));
std::vector<std::string> npcIds; std::vector<std::string> npcIds;
npcIds.reserve(npcs.size()); npcIds.reserve(npcs.size());
for (const auto& [id, npc] : npcs) for (const auto& [id, npc] : npcs)
if (npc) npcIds.push_back(id); if (npc) npcIds.push_back(id);
std::sort(npcIds.begin(), npcIds.end()); std::sort(npcIds.begin(), npcIds.end());
for (const std::string& id : npcIds) 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) { for (const CharacterSimulationEvent& source : result.characters) {
if (!source.events.attackImpact) continue; if (!source.events.attackImpact) continue;
CharacterState* target = findCharacter(source.events.attackTargetId, players); CharacterState* target = findCharacter(source.events.attackTargetId, players);
if (!target) continue; if (!target || !target->enabled) continue;
constexpr float damage = 10.f; constexpr float damage = 10.f;
target->setHp(target->hp - damage); target->setHp(target->hp - damage);
result.damage.push_back({ result.damage.push_back({
@@ -309,7 +311,7 @@ LocationSimulationEvents LocationGameState::update(
auto player = players.find("player"); auto player = players.find("player");
if (!interactiveObjectsLocked && if (!interactiveObjectsLocked &&
!location->targetInteractiveObjectId.empty() && !location->targetInteractiveObjectId.empty() &&
player != players.end() && player->second) { player != players.end() && player->second && player->second->enabled) {
auto target = interactiveObjects.find(location->targetInteractiveObjectId); auto target = interactiveObjects.find(location->targetInteractiveObjectId);
const auto object = const auto object =
target == interactiveObjects.end() ? nullptr : target->second; target == interactiveObjects.end() ? nullptr : target->second;
@@ -326,7 +328,7 @@ LocationSimulationEvents LocationGameState::update(
} }
if (!location->targetInteractNpcId.empty() && if (!location->targetInteractNpcId.empty() &&
player != players.end() && player->second && player != players.end() && player->second && player->second->enabled &&
npcs.count(location->targetInteractNpcId) != 0 && npcs.count(location->targetInteractNpcId) != 0 &&
npcs.at(location->targetInteractNpcId)) { npcs.at(location->targetInteractNpcId)) {
const auto& npc = 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]; auto& bot = state_.players[kBattleZonePlayer2Id];
if (!bot) bot = std::make_shared<CharacterState>(); if (!bot) bot = std::make_shared<CharacterState>();
bot->configureAsPlayer(); bot->configureAsPlayer();
bot->enabled = true;
configurePlayerAnimations(*bot, resourceZipFile_); configurePlayerAnimations(*bot, resourceZipFile_);
bot->npcName = "Test Bot"; bot->npcName = "Test Bot";
} }
@@ -104,6 +105,7 @@ void InternalGameServer::startCampaign(
if (createLocalPlayer) { if (createLocalPlayer) {
auto player = std::make_shared<CharacterState>(); auto player = std::make_shared<CharacterState>();
player->configureAsPlayer(); player->configureAsPlayer();
player->enabled = campaign.networkPlayerSlots.empty();
configurePlayerAnimations(*player, zipFile); configurePlayerAnimations(*player, zipFile);
state_.players.emplace(localPlayerId_, std::move(player)); state_.players.emplace(localPlayerId_, std::move(player));
} }
@@ -415,7 +417,10 @@ void InternalGameServer::submit(const GameCommand& command)
character->attack = 0; character->attack = 0;
character->attack_cooldown = 0; character->attack_cooldown = 0;
character->currentState = AnimationState::STAND; 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->attackTargetId = "player";
character->battle_state = 0; character->battle_state = 0;
} }
@@ -544,6 +549,7 @@ void InternalGameServer::load(const nlohmann::json& in)
if (player == state_.players.end() || !player->second) continue; if (player == state_.players.end() || !player->second) continue;
player->second->configureAsPlayer(); player->second->configureAsPlayer();
configurePlayerAnimations(*player->second, resourceZipFile_); configurePlayerAnimations(*player->second, resourceZipFile_);
//player->second->enabled = false;
} }
battleZoneTestBotElapsedMs_ = 0; battleZoneTestBotElapsedMs_ = 0;
ensureBattleZoneTestBot(); ensureBattleZoneTestBot();
+77 -16
View File
@@ -4,6 +4,7 @@
#include <iomanip> #include <iomanip>
#include <limits> #include <limits>
#include <sstream> #include <sstream>
#include <type_traits>
namespace FRG { namespace FRG {
@@ -143,7 +144,18 @@ std::uint64_t LockstepTimeline::schedule(
{ {
const std::uint64_t sequence = nextCommandSequence_++; const std::uint64_t sequence = nextCommandSequence_++;
pending_[currentTick_ + inputDelayTicks_].push_back( 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; return sequence;
} }
@@ -182,12 +194,26 @@ bool parseDestinationInputMessage(
std::string makeTickMessage(const TickMessage& message) std::string makeTickMessage(const TickMessage& message)
{ {
nlohmann::json commands = nlohmann::json::array(); nlohmann::json commands = nlohmann::json::array();
for (const ScheduledDestinationCommand& command : message.commands) { for (const ScheduledCommand& scheduled : message.commands) {
commands.push_back({ std::visit([&commands](const auto& command) {
{"commandSequence", command.commandSequence}, using T = std::decay_t<decltype(command)>;
{"playerId", command.playerId}, if constexpr (std::is_same_v<T, ScheduledDestinationCommand>) {
{"target", targetJson(command.target)} 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", { return envelope("tick", {
{"tickId", message.tickId}, {"tickId", message.tickId},
@@ -209,22 +235,57 @@ bool parseTickMessage(
message.commands.clear(); message.commands.clear();
std::uint64_t previousSequence = 0; std::uint64_t previousSequence = 0;
for (const nlohmann::json& value : payload["commands"]) { for (const nlohmann::json& value : payload["commands"]) {
ScheduledDestinationCommand command;
if (!value.is_object() || if (!value.is_object() ||
!readTick(value, "commandSequence", command.commandSequence, error) || !readTick(value, "commandSequence", previousSequence, error)) {
!value.contains("playerId") || !value["playerId"].is_string() ||
!value.contains("target") ||
!readTarget(value["target"], command.target, error)) {
if (error.empty()) error = "invalid scheduled command"; if (error.empty()) error = "invalid scheduled command";
return false; return false;
} }
command.playerId = value["playerId"].get<std::string>(); const std::string type = value.value("type", "destination");
if (command.playerId.empty() || command.commandSequence == 0 || ScheduledCommand command;
command.commandSequence <= previousSequence) { 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"; error = "commands must have unique increasing sequences";
return false; return false;
} }
previousSequence = command.commandSequence;
message.commands.push_back(std::move(command)); message.commands.push_back(std::move(command));
} }
message.stateHash = payload.value("stateHash", std::string()); message.stateHash = payload.value("stateHash", std::string());
+17 -2
View File
@@ -5,6 +5,7 @@
#include <cstdint> #include <cstdint>
#include <map> #include <map>
#include <string> #include <string>
#include <variant>
#include <vector> #include <vector>
namespace FRG { namespace FRG {
@@ -33,9 +34,20 @@ struct ScheduledDestinationCommand {
Eigen::Vector3f target = Eigen::Vector3f::Zero(); 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 { struct TickMessage {
std::uint64_t tickId = 0; std::uint64_t tickId = 0;
std::vector<ScheduledDestinationCommand> commands; std::vector<ScheduledCommand> commands;
std::string stateHash; std::string stateHash;
}; };
@@ -48,13 +60,16 @@ public:
std::uint64_t currentTick() const { return currentTick_; } std::uint64_t currentTick() const { return currentTick_; }
std::uint64_t schedule( std::uint64_t schedule(
const std::string& playerId, const Eigen::Vector3f& target); const std::string& playerId, const Eigen::Vector3f& target);
std::uint64_t scheduleCharacterEnabled(
const std::string& locationId, const std::string& characterId,
bool enabled);
TickMessage advance(); TickMessage advance();
private: private:
std::uint32_t inputDelayTicks_; std::uint32_t inputDelayTicks_;
std::uint64_t currentTick_ = 0; std::uint64_t currentTick_ = 0;
std::uint64_t nextCommandSequence_ = 1; 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); std::string makeInitialStateMessage(const InitialStateMessage& message);
+12 -3
View File
@@ -7,6 +7,7 @@
#include <iostream> #include <iostream>
#include <mutex> #include <mutex>
#include <thread> #include <thread>
#include <type_traits>
#include <utility> #include <utility>
#include <vector> #include <vector>
@@ -252,9 +253,17 @@ void WebsocketGameClient::update(std::int64_t)
pendingTicks_.erase(next); pendingTicks_.erase(next);
const std::string locationId = const std::string locationId =
replica_.snapshot()->currentLocationId; replica_.snapshot()->currentLocationId;
for (const ScheduledDestinationCommand& command : tick.commands) { for (const ScheduledCommand& scheduled : tick.commands) {
replica_.submit(SetCharacterDestinationCommand{ std::visit([&](const auto& command) {
locationId, command.playerId, command.target, {}}); 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); replica_.update(kSimulationTickMs);
completedSimulationTicks_.push_back({ completedSimulationTicks_.push_back({
+36 -6
View File
@@ -10,6 +10,7 @@
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <thread> #include <thread>
#include <type_traits>
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
@@ -179,6 +180,14 @@ public:
return result; 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( void publishTick(
std::string tick, nlohmann::json latestState, std::uint64_t tickId) std::string tick, nlohmann::json latestState, std::uint64_t tickId)
{ {
@@ -237,7 +246,8 @@ private:
<< "' disconnected" << std::endl; << "' disconnected" << std::endl;
assignedSlots_.erase(source->playerId()); assignedSlots_.erase(source->playerId());
std::lock_guard<std::mutex> lock(inputsMutex_); std::lock_guard<std::mutex> lock(inputsMutex_);
releasedPlayers_.push_back(source->playerId()); if (!source->playerId().empty())
releasedPlayers_.push_back(source->playerId());
}); });
if (playerId.empty()) { if (playerId.empty()) {
std::cout << "[network] Rejecting connection: no player slot" std::cout << "[network] Rejecting connection: no player slot"
@@ -251,6 +261,10 @@ private:
<< playerId << "' at snapshot tick " << playerId << "' at snapshot tick "
<< latestTick_ << std::endl; << latestTick_ << std::endl;
sessions_.push_back(session); sessions_.push_back(session);
{
std::lock_guard<std::mutex> lock(inputsMutex_);
connectedPlayers_.push_back(playerId);
}
InitialStateMessage initial; InitialStateMessage initial;
initial.state = latestState_; initial.state = latestState_;
initial.assignedPlayerId = playerId; initial.assignedPlayerId = playerId;
@@ -280,6 +294,7 @@ private:
std::uint64_t latestTick_ = 0; std::uint64_t latestTick_ = 0;
std::mutex inputsMutex_; std::mutex inputsMutex_;
std::vector<PendingInput> pendingInputs_; std::vector<PendingInput> pendingInputs_;
std::vector<std::string> connectedPlayers_;
std::vector<std::string> releasedPlayers_; std::vector<std::string> releasedPlayers_;
std::uint16_t port_ = 0; std::uint16_t port_ = 0;
}; };
@@ -303,7 +318,14 @@ WebsocketGameServer::~WebsocketGameServer() = default;
void WebsocketGameServer::update() 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); lastClientSequences_.erase(playerId);
for (const Impl::PendingInput& pending : impl_->takeInputs()) { for (const Impl::PendingInput& pending : impl_->takeInputs()) {
std::uint64_t& previous = lastClientSequences_[pending.playerId]; std::uint64_t& previous = lastClientSequences_[pending.playerId];
@@ -313,10 +335,18 @@ void WebsocketGameServer::update()
} }
TickMessage tick = timeline_.advance(); TickMessage tick = timeline_.advance();
for (const ScheduledDestinationCommand& command : tick.commands) { for (const ScheduledCommand& scheduled : tick.commands) {
gameServer_.submit(SetCharacterDestinationCommand{ std::visit([this](const auto& command) {
gameServer_.snapshot()->currentLocationId, using T = std::decay_t<decltype(command)>;
command.playerId, command.target, {}}); 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); gameServer_.update(kSimulationTickMs);
nlohmann::json state; nlohmann::json state;