diff --git a/GAME_STATE_ARCHITECTURE.md b/GAME_STATE_ARCHITECTURE.md index 0ea9460..17596c2 100644 --- a/GAME_STATE_ARCHITECTURE.md +++ b/GAME_STATE_ARCHITECTURE.md @@ -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; diff --git a/WEBSOCKET_NETWORKING.md b/WEBSOCKET_NETWORKING.md index ae60d2d..e9e1f46 100644 --- a/WEBSOCKET_NETWORKING.md +++ b/WEBSOCKET_NETWORKING.md @@ -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, diff --git a/server/tests/NetworkProtocolTests.cpp b/server/tests/NetworkProtocolTests.cpp index 9eccdaa..664598f 100644 --- a/server/tests/NetworkProtocolTests.cpp +++ b/server/tests/NetworkProtocolTests.cpp @@ -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( parsedTick.commands[1]) && std::get( - parsedTick.commands[1]).enabled, + parsedTick.commands[1]).enabled && + std::holds_alternative( + parsedTick.commands[2]) && + std::get( + 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( 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( + actionTick.commands[0]), + "character action was not scheduled with input delay"); return EXIT_SUCCESS; } diff --git a/server/tests/WebsocketLockstepTests.cpp b/server/tests/WebsocketLockstepTests.cpp index 8846cee..0c667f1 100644 --- a/server/tests/WebsocketLockstepTests.cpp +++ b/server/tests/WebsocketLockstepTests.cpp @@ -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(); diff --git a/src/game_server/NetworkProtocol.cpp b/src/game_server/NetworkProtocol.cpp index e73b065..c558fbf 100644 --- a/src/game_server/NetworkProtocol.cpp +++ b/src/game_server/NetworkProtocol.cpp @@ -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(); + 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) { + 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(); + action.action = value["action"].get(); + action.targetId = value["targetId"].get(); + 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; diff --git a/src/game_server/NetworkProtocol.h b/src/game_server/NetworkProtocol.h index a476777..521a907 100644 --- a/src/game_server/NetworkProtocol.h +++ b/src/game_server/NetworkProtocol.h @@ -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); diff --git a/src/game_server/WebsocketGameClient.cpp b/src/game_server/WebsocketGameClient.cpp index 8675d1c..84faa71 100644 --- a/src/game_server/WebsocketGameClient.cpp +++ b/src/game_server/WebsocketGameClient.cpp @@ -177,6 +177,16 @@ void WebsocketGameClient::submit(const GameCommand& command) std::get_if(&command)) { impl_->send(makeDestinationInputMessage( {nextClientSequence_++, destination->target})); + } else if (const auto* target = + std::get_if(&command)) { + impl_->send(makeCharacterActionInputMessage( + {nextClientSequence_++, "set_target", target->targetId})); + } else if (std::holds_alternative(command)) { + impl_->send(makeCharacterActionInputMessage( + {nextClientSequence_++, "clear_attack_target", {}})); + } else if (std::holds_alternative(command)) { + impl_->send(makeCharacterActionInputMessage( + {nextClientSequence_++, "try_attack_combo", {}})); } } @@ -259,9 +269,18 @@ void WebsocketGameClient::update(std::int64_t) if constexpr (std::is_same_v) { replica_.submit(SetCharacterDestinationCommand{ locationId, command.playerId, command.target, {}}); - } else { + } else if constexpr (std::is_same_v) { 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); } diff --git a/src/game_server/WebsocketGameServer.cpp b/src/game_server/WebsocketGameServer.cpp index 1fcc03b..3d82568 100644 --- a/src/game_server/WebsocketGameServer.cpp +++ b/src/game_server/WebsocketGameServer.cpp @@ -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& 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 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 lock(inputsMutex_); pendingInputs_.push_back( - {source->playerId(), std::move(parsed)}); + {source->playerId(), std::move(action)}); }, [this](const std::shared_ptr& 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; + if constexpr (std::is_same_v) { + 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) { 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); }