Added battles in networking

This commit is contained in:
Vladislav Khorev committed 2026-11-07 14:27:22 +06:00
1 parent be8ee31f03
commit 40527f10ac
8 files changed
+302 -36

No files matched your search

+4 -2
View File
@@ -429,8 +429,10 @@ there is no prediction or rollback.
Current boundaries remain:
- client intent is limited to `SetCharacterDestinationCommand`; server presence
changes use `SetCharacterEnabledCommand` in tick bundles;
- client intent is limited to `SetCharacterDestinationCommand`,
`SetCharacterTargetCommand`, `ClearCharacterAttackTargetCommand`, and
`TryCharacterAttackComboCommand`; 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;
+22 -12
View File
@@ -5,10 +5,13 @@
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 destination and presence
commands (or an empty command list).
only numbered tick messages containing ordered destination, combat-intent,
and presence commands (or an empty command list).
Client intent is intentionally limited to `SetCharacterDestinationCommand`.
Client intent is intentionally limited to
`SetCharacterDestinationCommand`, `SetCharacterTargetCommand`,
`ClearCharacterAttackTargetCommand`, and
`TryCharacterAttackComboCommand`.
The server also sends `SetCharacterEnabledCommand` in tick bundles when a
configured player slot connects or disconnects.
@@ -27,9 +30,10 @@ configured player slot connects or disconnects.
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
thread validates client sequence numbers, replaces client identity with the
assigned slot, and schedules commands two ticks in the future.
- Destination and character-action inputs cross from the Asio thread through a
queue. The simulation thread validates client sequence numbers, replaces
client identity with the assigned slot, and schedules commands two ticks in
the future.
- Every tick is broadcast, including ticks with no commands. No periodic state
snapshots are sent.
@@ -44,8 +48,8 @@ configured player slot connects or disconnects.
consumers and transitional runtime-state bindings continue to work.
- Camera orientation lives in host-owned `LocationLocalState`, so incoming
authoritative snapshots cannot overwrite camera movement.
- `submit()` serializes destination intent only. Other command variants remain
local no-ops for a remote connection.
- `submit()` serializes destination and tagged character-action intent. Other
command variants remain local no-ops for a remote connection.
- Tick bundles are buffered by ID. The replica advances only while the next
contiguous tick is available, applying its commands before one exact 100 ms
simulation step.
@@ -106,13 +110,19 @@ 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 destination and
character-presence commands, and post-tick diagnostic state hash;
- `character_action` (client to server): monotonic client sequence, an action
tag (`set_target`, `clear_attack_target`, or `try_attack_combo`), and a
target ID for `set_target`;
- `tick` (server to client): tick ID, globally sequenced destination,
character-action, 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
coordinates, duplicate client sequences, and unsupported commands cannot enter
the authoritative command stream.
coordinates, duplicate client sequences, empty action IDs, and unsupported
commands cannot enter the authoritative command stream. The server derives the
location and player character from the assigned connection slot; clients cannot
use these actions to control another player or an NPC.
WebSocket ordering is not used as simulation time. Integer tick IDs are the
only authoritative clock. The server uses `steady_clock` only to pace its loop,
+40 -2
View File
@@ -60,12 +60,38 @@ int main()
parsedInput.target.isApprox(Eigen::Vector3f(1.23f, 0.f, -5.68f)),
"destination input was not normalized");
FRG::CharacterActionInputMessage actionInput{
8, "set_target", "hostile_npc"};
FRG::CharacterActionInputMessage parsedActionInput;
require(
FRG::parseCharacterActionInputMessage(
FRG::makeCharacterActionInputMessage(actionInput),
parsedActionInput, error),
"character action input did not round trip");
require(
parsedActionInput.clientSequence == 8 &&
parsedActionInput.action == "set_target" &&
parsedActionInput.targetId == "hostile_npc",
"character action input fields did not round trip");
require(
!FRG::parseCharacterActionInputMessage(
R"({"protocolVersion":2,"type":"character_action","payload":{"clientSequence":1,"action":"unknown","targetId":""}})",
parsedActionInput, error),
"unknown character action was accepted");
require(
!FRG::parseCharacterActionInputMessage(
R"({"protocolVersion":2,"type":"character_action","payload":{"clientSequence":1,"action":"set_target","targetId":""}})",
parsedActionInput, error),
"empty attack target was accepted");
FRG::TickMessage tick;
tick.tickId = 43;
tick.commands.push_back(FRG::ScheduledDestinationCommand{
1, "player", parsedInput.target});
tick.commands.push_back(FRG::ScheduledCharacterEnabledCommand{
2, "uni_exterior", "player2", true});
tick.commands.push_back(FRG::ScheduledCharacterActionCommand{
3, "player", "set_target", "hostile_npc"});
tick.stateHash = "0123456789abcdef";
FRG::TickMessage parsedTick;
require(
@@ -73,11 +99,15 @@ int main()
FRG::makeTickMessage(tick), parsedTick, error),
"tick message did not round trip");
require(
parsedTick.tickId == 43 && parsedTick.commands.size() == 2 &&
parsedTick.tickId == 43 && parsedTick.commands.size() == 3 &&
std::holds_alternative<FRG::ScheduledCharacterEnabledCommand>(
parsedTick.commands[1]) &&
std::get<FRG::ScheduledCharacterEnabledCommand>(
parsedTick.commands[1]).enabled,
parsedTick.commands[1]).enabled &&
std::holds_alternative<FRG::ScheduledCharacterActionCommand>(
parsedTick.commands[2]) &&
std::get<FRG::ScheduledCharacterActionCommand>(
parsedTick.commands[2]).targetId == "hostile_npc",
"tick contents did not round trip");
require(
@@ -134,12 +164,20 @@ int main()
"scheduled commands were not finalized in stable order");
timeline.scheduleCharacterEnabled("uni_exterior", "player2", true);
timeline.scheduleCharacterAction("player", "clear_attack_target");
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");
const FRG::TickMessage actionTick =
timeline.advance();
require(
actionTick.commands.size() == 1 &&
std::holds_alternative<FRG::ScheduledCharacterActionCommand>(
actionTick.commands[0]),
"character action was not scheduled with input delay");
return EXIT_SUCCESS;
}
+49
View File
@@ -104,6 +104,55 @@ int main()
}),
"client did not consume finalized lockstep ticks");
client->submit(FRG::SetCharacterTargetCommand{
"stale_location", "spoofed_character", "hostile_npc"});
std::this_thread::sleep_for(std::chrono::milliseconds(20));
server.update();
server.update();
require(
waitFor([&]() {
client->update(0);
secondClient->update(0);
return client->snapshot()->players.at("player").state.attackTargetId ==
"hostile_npc" &&
secondClient->snapshot()->players.at("player").state.attackTargetId ==
"hostile_npc" &&
authoritative.snapshot()->players.at("player").state.attackTargetId ==
"hostile_npc";
}),
"target command was not delayed and rebroadcast");
client->submit(FRG::ClearCharacterAttackTargetCommand{
"stale_location", "spoofed_character"});
std::this_thread::sleep_for(std::chrono::milliseconds(20));
server.update();
server.update();
require(
waitFor([&]() {
client->update(0);
secondClient->update(0);
return client->snapshot()->players.at("player").state.attackTargetId.empty() &&
secondClient->snapshot()->players.at("player").state.attackTargetId.empty() &&
authoritative.snapshot()->players.at("player").state.attackTargetId.empty();
}),
"clear attack target command was not delayed and rebroadcast");
client->submit(FRG::TryCharacterAttackComboCommand{
"stale_location", "spoofed_character"});
std::this_thread::sleep_for(std::chrono::milliseconds(20));
server.update();
server.update();
require(
waitFor([&]() {
client->update(0);
secondClient->update(0);
return client->snapshot()->revision ==
secondClient->snapshot()->revision &&
client->snapshot()->revision ==
authoritative.snapshot()->revision;
}),
"attack combo command was not rebroadcast in lockstep");
secondClient.reset();
std::this_thread::sleep_for(std::chrono::milliseconds(50));
server.update();
+98 -7
View File
@@ -96,6 +96,27 @@ nlohmann::json targetJson(const Eigen::Vector3f& target)
return nlohmann::json::array({target.x(), target.y(), target.z()});
}
bool validCharacterAction(
const std::string& action, const std::string& targetId,
std::string& error)
{
if (action != "set_target" &&
action != "clear_attack_target" &&
action != "try_attack_combo") {
error = "unsupported character action";
return false;
}
if (action == "set_target" && targetId.empty()) {
error = "set_target requires a targetId";
return false;
}
if (action != "set_target" && !targetId.empty()) {
error = "targetId is only valid for set_target";
return false;
}
return true;
}
} // namespace
std::string makeInitialStateMessage(const InitialStateMessage& message)
@@ -159,6 +180,16 @@ std::uint64_t LockstepTimeline::scheduleCharacterEnabled(
return sequence;
}
std::uint64_t LockstepTimeline::scheduleCharacterAction(
const std::string& playerId, std::string action, std::string targetId)
{
const std::uint64_t sequence = nextCommandSequence_++;
pending_[currentTick_ + inputDelayTicks_].push_back(
ScheduledCharacterActionCommand{
sequence, playerId, std::move(action), std::move(targetId)});
return sequence;
}
TickMessage LockstepTimeline::advance()
{
TickMessage result;
@@ -191,6 +222,38 @@ bool parseDestinationInputMessage(
return readTarget(payload["target"], message.target, error);
}
std::string makeCharacterActionInputMessage(
const CharacterActionInputMessage& message)
{
return envelope("character_action", {
{"clientSequence", message.clientSequence},
{"action", message.action},
{"targetId", message.targetId}
});
}
bool parseCharacterActionInputMessage(
const std::string& text, CharacterActionInputMessage& message,
std::string& error)
{
nlohmann::json payload;
if (!parseEnvelope(text, "character_action", payload, error))
return false;
if (!readTick(payload, "clientSequence", message.clientSequence, error))
return false;
if (!payload.contains("action") || !payload["action"].is_string()) {
error = "action is required";
return false;
}
message.action = payload["action"].get<std::string>();
message.targetId = payload.value("targetId", std::string());
if (!payload.contains("targetId") || !payload["targetId"].is_string()) {
error = "targetId must be a string";
return false;
}
return validCharacterAction(message.action, message.targetId, error);
}
std::string makeTickMessage(const TickMessage& message)
{
nlohmann::json commands = nlohmann::json::array();
@@ -205,13 +268,23 @@ std::string makeTickMessage(const TickMessage& message)
{"target", targetJson(command.target)}
});
} else {
commands.push_back({
{"type", "character_enabled"},
{"commandSequence", command.commandSequence},
{"locationId", command.locationId},
{"characterId", command.characterId},
{"enabled", command.enabled}
});
if constexpr (std::is_same_v<T, ScheduledCharacterEnabledCommand>) {
commands.push_back({
{"type", "character_enabled"},
{"commandSequence", command.commandSequence},
{"locationId", command.locationId},
{"characterId", command.characterId},
{"enabled", command.enabled}
});
} else {
commands.push_back({
{"type", "character_action"},
{"commandSequence", command.commandSequence},
{"playerId", command.playerId},
{"action", command.action},
{"targetId", command.targetId}
});
}
}
}, scheduled);
}
@@ -274,6 +347,24 @@ bool parseTickMessage(
return false;
}
command = std::move(enabled);
} else if (type == "character_action") {
ScheduledCharacterActionCommand action;
action.commandSequence = previousSequence;
if (!value.contains("playerId") || !value["playerId"].is_string() ||
!value.contains("action") || !value["action"].is_string() ||
!value.contains("targetId") || !value["targetId"].is_string()) {
error = "invalid character action command";
return false;
}
action.playerId = value["playerId"].get<std::string>();
action.action = value["action"].get<std::string>();
action.targetId = value["targetId"].get<std::string>();
if (action.playerId.empty() ||
!validCharacterAction(action.action, action.targetId, error)) {
if (error.empty()) error = "invalid character action command";
return false;
}
command = std::move(action);
} else {
error = "unsupported scheduled command type";
return false;
+23 -1
View File
@@ -28,6 +28,12 @@ struct DestinationInputMessage {
Eigen::Vector3f target = Eigen::Vector3f::Zero();
};
struct CharacterActionInputMessage {
std::uint64_t clientSequence = 0;
std::string action;
std::string targetId;
};
struct ScheduledDestinationCommand {
std::uint64_t commandSequence = 0;
std::string playerId;
@@ -41,9 +47,17 @@ struct ScheduledCharacterEnabledCommand {
bool enabled = false;
};
struct ScheduledCharacterActionCommand {
std::uint64_t commandSequence = 0;
std::string playerId;
std::string action;
std::string targetId;
};
using ScheduledCommand = std::variant<
ScheduledDestinationCommand,
ScheduledCharacterEnabledCommand>;
ScheduledCharacterEnabledCommand,
ScheduledCharacterActionCommand>;
struct TickMessage {
std::uint64_t tickId = 0;
@@ -63,6 +77,9 @@ public:
std::uint64_t scheduleCharacterEnabled(
const std::string& locationId, const std::string& characterId,
bool enabled);
std::uint64_t scheduleCharacterAction(
const std::string& playerId, std::string action,
std::string targetId = {});
TickMessage advance();
private:
@@ -78,6 +95,11 @@ bool parseInitialStateMessage(
std::string makeDestinationInputMessage(const DestinationInputMessage& message);
bool parseDestinationInputMessage(
const std::string& text, DestinationInputMessage& message, std::string& error);
std::string makeCharacterActionInputMessage(
const CharacterActionInputMessage& message);
bool parseCharacterActionInputMessage(
const std::string& text, CharacterActionInputMessage& message,
std::string& error);
std::string makeTickMessage(const TickMessage& message);
bool parseTickMessage(
const std::string& text, TickMessage& message, std::string& error);
+20 -1
View File
@@ -177,6 +177,16 @@ void WebsocketGameClient::submit(const GameCommand& command)
std::get_if<SetCharacterDestinationCommand>(&command)) {
impl_->send(makeDestinationInputMessage(
{nextClientSequence_++, destination->target}));
} else if (const auto* target =
std::get_if<SetCharacterTargetCommand>(&command)) {
impl_->send(makeCharacterActionInputMessage(
{nextClientSequence_++, "set_target", target->targetId}));
} else if (std::holds_alternative<ClearCharacterAttackTargetCommand>(command)) {
impl_->send(makeCharacterActionInputMessage(
{nextClientSequence_++, "clear_attack_target", {}}));
} else if (std::holds_alternative<TryCharacterAttackComboCommand>(command)) {
impl_->send(makeCharacterActionInputMessage(
{nextClientSequence_++, "try_attack_combo", {}}));
}
}
@@ -259,9 +269,18 @@ void WebsocketGameClient::update(std::int64_t)
if constexpr (std::is_same_v<T, ScheduledDestinationCommand>) {
replica_.submit(SetCharacterDestinationCommand{
locationId, command.playerId, command.target, {}});
} else {
} else if constexpr (std::is_same_v<T, ScheduledCharacterEnabledCommand>) {
replica_.submit(SetCharacterEnabledCommand{
command.locationId, command.characterId, command.enabled});
} else if (command.action == "set_target") {
replica_.submit(SetCharacterTargetCommand{
locationId, command.playerId, command.targetId});
} else if (command.action == "clear_attack_target") {
replica_.submit(ClearCharacterAttackTargetCommand{
locationId, command.playerId});
} else if (command.action == "try_attack_combo") {
replica_.submit(TryCharacterAttackComboCommand{
locationId, command.playerId});
}
}, scheduled);
}
+46 -11
View File
@@ -132,7 +132,9 @@ class WebsocketGameServer::Impl {
public:
struct PendingInput {
std::string playerId;
DestinationInputMessage input;
std::variant<
DestinationInputMessage,
CharacterActionInputMessage> input;
};
Impl(
@@ -227,17 +229,27 @@ private:
std::move(socket), playerId,
[this](const std::shared_ptr<ServerSession>& source,
const std::string& message) {
DestinationInputMessage parsed;
std::string parseError;
if (!parseDestinationInputMessage(
message, parsed, parseError)) {
DestinationInputMessage destination;
std::string destinationError;
if (parseDestinationInputMessage(
message, destination, destinationError)) {
std::lock_guard<std::mutex> lock(inputsMutex_);
pendingInputs_.push_back(
{source->playerId(), std::move(destination)});
return;
}
CharacterActionInputMessage action;
std::string actionError;
if (!parseCharacterActionInputMessage(
message, action, actionError)) {
source->send(makeErrorMessage(
"invalid_input", parseError));
"invalid_input", actionError.empty()
? destinationError : actionError));
return;
}
std::lock_guard<std::mutex> lock(inputsMutex_);
pendingInputs_.push_back(
{source->playerId(), std::move(parsed)});
{source->playerId(), std::move(action)});
},
[this](const std::shared_ptr<ServerSession>& source) {
if (!source->playerId().empty())
@@ -329,9 +341,20 @@ void WebsocketGameServer::update()
lastClientSequences_.erase(playerId);
for (const Impl::PendingInput& pending : impl_->takeInputs()) {
std::uint64_t& previous = lastClientSequences_[pending.playerId];
if (pending.input.clientSequence <= previous) continue;
previous = pending.input.clientSequence;
timeline_.schedule(pending.playerId, pending.input.target);
const std::uint64_t sequence = std::visit(
[](const auto& input) { return input.clientSequence; },
pending.input);
if (sequence <= previous) continue;
previous = sequence;
std::visit([&](const auto& input) {
using T = std::decay_t<decltype(input)>;
if constexpr (std::is_same_v<T, DestinationInputMessage>) {
timeline_.schedule(pending.playerId, input.target);
} else {
timeline_.scheduleCharacterAction(
pending.playerId, input.action, input.targetId);
}
}, pending.input);
}
TickMessage tick = timeline_.advance();
@@ -342,9 +365,21 @@ void WebsocketGameServer::update()
gameServer_.submit(SetCharacterDestinationCommand{
gameServer_.snapshot()->currentLocationId,
command.playerId, command.target, {}});
} else {
} else if constexpr (std::is_same_v<T, ScheduledCharacterEnabledCommand>) {
gameServer_.submit(SetCharacterEnabledCommand{
command.locationId, command.characterId, command.enabled});
} else if (command.action == "set_target") {
gameServer_.submit(SetCharacterTargetCommand{
gameServer_.snapshot()->currentLocationId,
command.playerId, command.targetId});
} else if (command.action == "clear_attack_target") {
gameServer_.submit(ClearCharacterAttackTargetCommand{
gameServer_.snapshot()->currentLocationId,
command.playerId});
} else if (command.action == "try_attack_combo") {
gameServer_.submit(TryCharacterAttackComboCommand{
gameServer_.snapshot()->currentLocationId,
command.playerId});
}
}, scheduled);
}