330 lines
10 KiB
C++
330 lines
10 KiB
C++
#include "NetworkProtocol.h"
|
|
#include <cmath>
|
|
#include <exception>
|
|
#include <iomanip>
|
|
#include <limits>
|
|
#include <sstream>
|
|
#include <type_traits>
|
|
|
|
|
|
namespace FRG {
|
|
namespace {
|
|
|
|
bool parseEnvelope(
|
|
const std::string& text, const char* expectedType,
|
|
nlohmann::json& payload, std::string& error)
|
|
{
|
|
try {
|
|
const nlohmann::json envelope = nlohmann::json::parse(text);
|
|
if (!envelope.is_object()) {
|
|
error = "message must be a JSON object";
|
|
return false;
|
|
}
|
|
if (envelope.value("protocolVersion", -1) != kGameProtocolVersion) {
|
|
error = "unsupported protocol version";
|
|
return false;
|
|
}
|
|
if (envelope.value("type", std::string()) != expectedType) {
|
|
error = "unsupported message type";
|
|
return false;
|
|
}
|
|
if (!envelope.contains("payload") || !envelope["payload"].is_object()) {
|
|
error = "message payload must be an object";
|
|
return false;
|
|
}
|
|
payload = envelope["payload"];
|
|
error.clear();
|
|
return true;
|
|
} catch (const std::exception& exception) {
|
|
error = exception.what();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
std::string envelope(const char* type, nlohmann::json payload)
|
|
{
|
|
return nlohmann::json{
|
|
{"protocolVersion", kGameProtocolVersion},
|
|
{"type", type},
|
|
{"payload", std::move(payload)}
|
|
}.dump();
|
|
}
|
|
|
|
bool readTick(
|
|
const nlohmann::json& value, const char* name,
|
|
std::uint64_t& out, std::string& error)
|
|
{
|
|
if (!value.contains(name) ||
|
|
(!value[name].is_number_unsigned() && !value[name].is_number_integer())) {
|
|
error = std::string(name) + " must be a non-negative integer";
|
|
return false;
|
|
}
|
|
const auto signedValue = value[name].get<std::int64_t>();
|
|
if (signedValue < 0) {
|
|
error = std::string(name) + " must be non-negative";
|
|
return false;
|
|
}
|
|
out = static_cast<std::uint64_t>(signedValue);
|
|
return true;
|
|
}
|
|
|
|
bool readTarget(
|
|
const nlohmann::json& value, Eigen::Vector3f& target, std::string& error)
|
|
{
|
|
if (!value.is_array() || value.size() != 3) {
|
|
error = "target must contain three coordinates";
|
|
return false;
|
|
}
|
|
for (std::size_t index = 0; index < 3; ++index) {
|
|
if (!value[index].is_number()) {
|
|
error = "target coordinates must be numbers";
|
|
return false;
|
|
}
|
|
const float coordinate = value[index].get<float>();
|
|
if (!std::isfinite(coordinate) || std::abs(coordinate) > 100000.f) {
|
|
error = "target coordinate is not finite or is out of range";
|
|
return false;
|
|
}
|
|
target[static_cast<Eigen::Index>(index)] =
|
|
std::round(coordinate * 100.f) / 100.f;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
nlohmann::json targetJson(const Eigen::Vector3f& target)
|
|
{
|
|
return nlohmann::json::array({target.x(), target.y(), target.z()});
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::string makeInitialStateMessage(const InitialStateMessage& message)
|
|
{
|
|
return envelope("initial_state", {
|
|
{"state", message.state},
|
|
{"assignedPlayerId", message.assignedPlayerId},
|
|
{"snapshotTick", message.snapshotTick},
|
|
{"tickMs", message.tickMs},
|
|
{"inputDelayTicks", message.inputDelayTicks},
|
|
{"compatibilityFingerprint", message.compatibilityFingerprint}
|
|
});
|
|
}
|
|
|
|
bool parseInitialStateMessage(
|
|
const std::string& text, InitialStateMessage& message, std::string& error)
|
|
{
|
|
nlohmann::json payload;
|
|
if (!parseEnvelope(text, "initial_state", payload, error)) return false;
|
|
if (!payload.contains("state") || !payload["state"].is_object() ||
|
|
!payload.contains("assignedPlayerId") ||
|
|
!payload["assignedPlayerId"].is_string() ||
|
|
!payload.contains("compatibilityFingerprint") ||
|
|
!payload["compatibilityFingerprint"].is_string()) {
|
|
error = "invalid initial-state payload";
|
|
return false;
|
|
}
|
|
if (!readTick(payload, "snapshotTick", message.snapshotTick, error))
|
|
return false;
|
|
message.tickMs = payload.value("tickMs", std::int64_t{0});
|
|
message.inputDelayTicks =
|
|
payload.value("inputDelayTicks", std::uint32_t{0});
|
|
if (message.tickMs != kSimulationTickMs || message.inputDelayTicks == 0) {
|
|
error = "unsupported lockstep timing";
|
|
return false;
|
|
}
|
|
message.state = payload["state"];
|
|
message.assignedPlayerId = payload["assignedPlayerId"].get<std::string>();
|
|
message.compatibilityFingerprint =
|
|
payload["compatibilityFingerprint"].get<std::string>();
|
|
return !message.assignedPlayerId.empty();
|
|
}
|
|
|
|
std::uint64_t LockstepTimeline::schedule(
|
|
const std::string& playerId, const Eigen::Vector3f& target)
|
|
{
|
|
const std::uint64_t sequence = nextCommandSequence_++;
|
|
pending_[currentTick_ + inputDelayTicks_].push_back(
|
|
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;
|
|
}
|
|
|
|
TickMessage LockstepTimeline::advance()
|
|
{
|
|
TickMessage result;
|
|
result.tickId = ++currentTick_;
|
|
auto commands = pending_.find(result.tickId);
|
|
if (commands != pending_.end()) {
|
|
result.commands = std::move(commands->second);
|
|
pending_.erase(commands);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
std::string makeDestinationInputMessage(const DestinationInputMessage& message)
|
|
{
|
|
return envelope("destination_input", {
|
|
{"clientSequence", message.clientSequence},
|
|
{"target", targetJson(message.target)}
|
|
});
|
|
}
|
|
|
|
bool parseDestinationInputMessage(
|
|
const std::string& text, DestinationInputMessage& message, std::string& error)
|
|
{
|
|
nlohmann::json payload;
|
|
if (!parseEnvelope(text, "destination_input", payload, error)) return false;
|
|
if (!readTick(payload, "clientSequence", message.clientSequence, error))
|
|
return false;
|
|
if (!payload.contains("target"))
|
|
return error = "target is required", false;
|
|
return readTarget(payload["target"], message.target, error);
|
|
}
|
|
|
|
std::string makeTickMessage(const TickMessage& message)
|
|
{
|
|
nlohmann::json commands = nlohmann::json::array();
|
|
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},
|
|
{"commands", std::move(commands)},
|
|
{"stateHash", message.stateHash}
|
|
});
|
|
}
|
|
|
|
bool parseTickMessage(
|
|
const std::string& text, TickMessage& message, std::string& error)
|
|
{
|
|
nlohmann::json payload;
|
|
if (!parseEnvelope(text, "tick", payload, error)) return false;
|
|
if (!readTick(payload, "tickId", message.tickId, error)) return false;
|
|
if (!payload.contains("commands") || !payload["commands"].is_array()) {
|
|
error = "commands must be an array";
|
|
return false;
|
|
}
|
|
message.commands.clear();
|
|
std::uint64_t previousSequence = 0;
|
|
for (const nlohmann::json& value : payload["commands"]) {
|
|
if (!value.is_object() ||
|
|
!readTick(value, "commandSequence", previousSequence, error)) {
|
|
if (error.empty()) error = "invalid scheduled command";
|
|
return false;
|
|
}
|
|
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;
|
|
}
|
|
message.commands.push_back(std::move(command));
|
|
}
|
|
message.stateHash = payload.value("stateHash", std::string());
|
|
return true;
|
|
}
|
|
|
|
std::string makeErrorMessage(const std::string& code, const std::string& detail)
|
|
{
|
|
return envelope("error", {{"code", code}, {"detail", detail}});
|
|
}
|
|
|
|
bool parseErrorMessage(
|
|
const std::string& text, std::string& code, std::string& detail)
|
|
{
|
|
nlohmann::json payload;
|
|
std::string error;
|
|
if (!parseEnvelope(text, "error", payload, error) ||
|
|
!payload.contains("code") || !payload["code"].is_string() ||
|
|
!payload.contains("detail") || !payload["detail"].is_string()) {
|
|
return false;
|
|
}
|
|
code = payload["code"].get<std::string>();
|
|
detail = payload["detail"].get<std::string>();
|
|
return true;
|
|
}
|
|
|
|
std::string canonicalStateHash(nlohmann::json state)
|
|
{
|
|
state.erase("savedAt");
|
|
const std::string bytes = state.dump();
|
|
std::uint64_t hash = 1469598103934665603ull;
|
|
for (const unsigned char value : bytes) {
|
|
hash ^= value;
|
|
hash *= 1099511628211ull;
|
|
}
|
|
std::ostringstream output;
|
|
output << std::hex << std::setfill('0') << std::setw(16) << hash;
|
|
return output.str();
|
|
}
|
|
|
|
} // namespace FRG
|