player connect-disconnect
This commit is contained in:
1 parent
3f68a8ba07
commit
2e6b93f3ac
16 files changed
+292
-49
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "external/nlohmann/json.hpp"
|
||||
#include <iostream>
|
||||
|
||||
|
||||
namespace FRG {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
+3
-4
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user