Working about pathfinding
This commit is contained in:
1 parent
6a5adb1c79
commit
c6d98bd72a
25 files changed
+1997
-508
No files matched your search
@@ -125,6 +125,14 @@ endif()
|
||||
|
||||
include(CTest)
|
||||
if(BUILD_TESTING)
|
||||
add_executable(
|
||||
GameServerPathFinderTests tests/PathFinderTests.cpp)
|
||||
target_link_libraries(GameServerPathFinderTests PRIVATE game_server_core)
|
||||
source_group(
|
||||
"Server\\Tests" FILES tests/PathFinderTests.cpp)
|
||||
add_test(
|
||||
NAME GameServer.PathFinder
|
||||
COMMAND GameServerPathFinderTests)
|
||||
add_executable(
|
||||
GameServerStateTests tests/LocationGameStateTests.cpp)
|
||||
target_link_libraries(GameServerStateTests PRIVATE game_server_core)
|
||||
|
||||
@@ -276,5 +276,58 @@ int main()
|
||||
return fail("Enabled player acquired an autonomous attack target");
|
||||
}
|
||||
|
||||
FRG::CharacterState guardedMover;
|
||||
guardedMover.configureAsPlayer();
|
||||
guardedMover.enabled = true;
|
||||
guardedMover.position = Eigen::Vector3f::Zero();
|
||||
guardedMover.walkTarget = Eigen::Vector3f(2.f, 0.f, 0.f);
|
||||
guardedMover.requestedWalkTarget = guardedMover.walkTarget;
|
||||
guardedMover.pathWaypoints = {guardedMover.walkTarget};
|
||||
const std::vector<FRG::CharacterMovementObstacle> movementObstacles{
|
||||
{"mover", Eigen::Vector3f::Zero(), 0.45f},
|
||||
{"blocker", Eigen::Vector3f(0.8f, 0.f, 0.f), 0.45f}};
|
||||
FRG::CharacterUpdateContext guardedContext;
|
||||
guardedContext.characterId = "mover";
|
||||
guardedContext.movementObstacles = &movementObstacles;
|
||||
FRG::CharacterUpdateEvents blockedEvents;
|
||||
for (int i = 0; i < 3; ++i)
|
||||
blockedEvents = guardedMover.update(100, guardedContext);
|
||||
if (!guardedMover.position.isZero() ||
|
||||
blockedEvents.forceNavigationReplan ||
|
||||
!guardedMover.pathWaypoints.empty()) {
|
||||
return fail("Movement guard did not cancel the blocked player action");
|
||||
}
|
||||
nlohmann::json guardedJson;
|
||||
guardedMover.save(guardedJson);
|
||||
FRG::CharacterState restoredGuardedMover;
|
||||
restoredGuardedMover.load(guardedJson);
|
||||
if (restoredGuardedMover.movementBlockedTicks !=
|
||||
guardedMover.movementBlockedTicks ||
|
||||
restoredGuardedMover.navigationRetryTicks !=
|
||||
guardedMover.navigationRetryTicks) {
|
||||
return fail("Movement blockage state did not round trip");
|
||||
}
|
||||
|
||||
FRG::LocationGameState occupancyState;
|
||||
auto stationary = std::make_shared<FRG::CharacterState>();
|
||||
stationary->enabled = true;
|
||||
stationary->hp = 10.f;
|
||||
stationary->position = Eigen::Vector3f(4.f, 0.f, 0.f);
|
||||
auto moving = std::make_shared<FRG::CharacterState>();
|
||||
moving->enabled = true;
|
||||
moving->hp = 10.f;
|
||||
moving->pathWaypoints = {Eigen::Vector3f(8.f, 0.f, 0.f)};
|
||||
moving->movedLastTick = true;
|
||||
FRG::PlayerStateMap occupancyPlayers{
|
||||
{"stationary", stationary}, {"moving", moving}};
|
||||
if (occupancyState.collectPathObstacles(
|
||||
"requester", occupancyPlayers).size() != 1 ||
|
||||
occupancyState.collectPathObstacles(
|
||||
"requester", occupancyPlayers, "moving").size() != 2 ||
|
||||
!occupancyState.collectPathObstacles(
|
||||
"stationary", occupancyPlayers).empty()) {
|
||||
return fail("Stationary obstacle collection policy is incorrect");
|
||||
}
|
||||
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
#include "CharacterState.h"
|
||||
#include "LocationGameState.h"
|
||||
#include "navigation/PathFinder.h"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
int fail(const char* message)
|
||||
{
|
||||
std::cout << "PathFinderTests: " << message << std::endl;
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
std::filesystem::path writeGrid(
|
||||
const std::string& name, int width, int depth, bool corridor)
|
||||
{
|
||||
const auto path = std::filesystem::temp_directory_path() / name;
|
||||
std::ofstream output(path);
|
||||
output << "cellSize 1\nagentRadius 0.1\nfloorY 0\n"
|
||||
<< "minX 0\nminZ 0\ngridWidth " << width
|
||||
<< "\ngridDepth " << depth << "\n";
|
||||
for (int z = 0; z < depth; ++z) {
|
||||
for (int x = 0; x < width; ++x)
|
||||
output << (!corridor || z == depth / 2 ? '1' : '0');
|
||||
output << '\n';
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
bool samePath(
|
||||
const std::vector<Eigen::Vector3f>& lhs,
|
||||
const std::vector<Eigen::Vector3f>& rhs)
|
||||
{
|
||||
if (lhs.size() != rhs.size()) return false;
|
||||
for (size_t i = 0; i < lhs.size(); ++i)
|
||||
if (lhs[i] != rhs[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
float segmentDistanceSquared(
|
||||
const Eigen::Vector3f& point,
|
||||
const Eigen::Vector3f& start,
|
||||
const Eigen::Vector3f& end)
|
||||
{
|
||||
const Eigen::Vector2f p(point.x(), point.z());
|
||||
const Eigen::Vector2f a(start.x(), start.z());
|
||||
const Eigen::Vector2f segment(end.x() - start.x(), end.z() - start.z());
|
||||
const float lengthSquared = segment.squaredNorm();
|
||||
const float t = lengthSquared > 1e-8f
|
||||
? (std::max)(0.f, (std::min)(1.f, (p - a).dot(segment) / lengthSquared))
|
||||
: 0.f;
|
||||
return (p - (a + segment * t)).squaredNorm();
|
||||
}
|
||||
|
||||
void runBenchmark(FRG::PathFinder& pathFinder, int queries)
|
||||
{
|
||||
std::vector<FRG::PathFinder::DynamicObstacle> obstacles;
|
||||
for (int i = 0; i < queries; ++i) {
|
||||
const float x = 4.5f + static_cast<float>((i * 7) % 70);
|
||||
const float z = 4.5f + static_cast<float>((i * 13) % 70);
|
||||
obstacles.push_back({Eigen::Vector3f(x, 0.f, z), 0.25f});
|
||||
}
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
size_t waypointCount = 0;
|
||||
for (int i = 0; i < queries; ++i) {
|
||||
const Eigen::Vector3f from(
|
||||
1.5f, 0.f, 1.5f + static_cast<float>(i % 30));
|
||||
const Eigen::Vector3f to(
|
||||
78.5f, 0.f, 78.5f - static_cast<float>(i % 30));
|
||||
waypointCount +=
|
||||
pathFinder.findPathToNearest(from, to, obstacles).size();
|
||||
}
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - start);
|
||||
std::cout << "PathFinder benchmark " << queries << " characters/queries: "
|
||||
<< elapsed.count() << " ms, " << waypointCount
|
||||
<< " waypoints" << std::endl;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
const bool benchmark = argc > 1 && std::string(argv[1]) == "--benchmark";
|
||||
const auto openGrid = writeGrid("frg_pathfinder_open.txt", 80, 80, false);
|
||||
FRG::PathFinder pathFinder;
|
||||
pathFinder.build(openGrid.string());
|
||||
if (!pathFinder.isReady()) return fail("Open test grid did not load");
|
||||
|
||||
const FRG::PathFinder::DynamicObstacle first{
|
||||
Eigen::Vector3f(8.5f, 0.f, 8.5f), 0.75f};
|
||||
const FRG::PathFinder::DynamicObstacle second{
|
||||
Eigen::Vector3f(12.5f, 0.f, 10.5f), 0.75f};
|
||||
const Eigen::Vector3f start(2.5f, 0.f, 8.5f);
|
||||
const Eigen::Vector3f end(18.5f, 0.f, 8.5f);
|
||||
const auto ordered = pathFinder.findPathToNearest(
|
||||
start, end, {first, second});
|
||||
const auto reversed = pathFinder.findPathToNearest(
|
||||
start, end, {second, first});
|
||||
if (ordered.empty() || !samePath(ordered, reversed))
|
||||
return fail("Obstacle insertion order changed the route");
|
||||
Eigen::Vector3f previous = start;
|
||||
for (const Eigen::Vector3f& point : ordered) {
|
||||
if (segmentDistanceSquared(
|
||||
first.position, previous, point) < 0.85f * 0.85f)
|
||||
return fail("Smoothed path crossed a dynamic obstacle");
|
||||
previous = point;
|
||||
}
|
||||
|
||||
const auto occupiedDestination =
|
||||
pathFinder.findPathToNearest(start, first.position, {first});
|
||||
if (occupiedDestination.empty())
|
||||
return fail("Occupied destination did not produce a nearest route");
|
||||
const Eigen::Vector3f occupiedEnd = occupiedDestination.back();
|
||||
const Eigen::Vector2f occupiedDelta(
|
||||
occupiedEnd.x() - first.position.x(),
|
||||
occupiedEnd.z() - first.position.z());
|
||||
if (occupiedDelta.squaredNorm() < 0.85f * 0.85f)
|
||||
return fail("Occupied destination ended inside the obstacle");
|
||||
|
||||
const auto corridorGrid =
|
||||
writeGrid("frg_pathfinder_corridor.txt", 20, 3, true);
|
||||
FRG::PathFinder corridor;
|
||||
corridor.build(corridorGrid.string());
|
||||
const FRG::PathFinder::DynamicObstacle blocker{
|
||||
Eigen::Vector3f(10.5f, 0.f, 1.5f), 0.45f};
|
||||
const auto nearest = corridor.findPathToNearest(
|
||||
Eigen::Vector3f(1.5f, 0.f, 1.5f),
|
||||
Eigen::Vector3f(18.5f, 0.f, 1.5f), {blocker});
|
||||
if (nearest.empty() || nearest.back().x() >= blocker.position.x())
|
||||
return fail("Nearest fallback ignored a dynamic blocker");
|
||||
|
||||
FRG::CharacterState guardedMover;
|
||||
guardedMover.configureAsPlayer();
|
||||
guardedMover.enabled = true;
|
||||
guardedMover.walkTarget = Eigen::Vector3f(2.f, 0.f, 0.f);
|
||||
guardedMover.requestedWalkTarget = guardedMover.walkTarget;
|
||||
guardedMover.pathWaypoints = {guardedMover.walkTarget};
|
||||
const std::vector<FRG::CharacterMovementObstacle> movementObstacles{
|
||||
{"mover", Eigen::Vector3f::Zero(), 0.45f},
|
||||
{"blocker", Eigen::Vector3f(0.8f, 0.f, 0.f), 0.45f}};
|
||||
FRG::CharacterUpdateContext guardedContext;
|
||||
guardedContext.characterId = "mover";
|
||||
guardedContext.movementObstacles = &movementObstacles;
|
||||
FRG::CharacterUpdateEvents blockedEvents;
|
||||
for (int i = 0; i < 3; ++i)
|
||||
blockedEvents = guardedMover.update(100, guardedContext);
|
||||
if (!guardedMover.position.isZero() ||
|
||||
blockedEvents.forceNavigationReplan ||
|
||||
!guardedMover.pathWaypoints.empty() ||
|
||||
guardedMover.requestedWalkTarget != guardedMover.position ||
|
||||
guardedMover.currentState != FRG::AnimationState::STAND)
|
||||
return fail("Blocked player action was not cancelled");
|
||||
|
||||
FRG::CharacterState retryingNpc;
|
||||
retryingNpc.enabled = true;
|
||||
retryingNpc.configureNavigationRetryCooldowns(
|
||||
"retrying_npc", {2, 3, 4, 5, 6, 7, 8, 9, 10, 11});
|
||||
FRG::CharacterState otherNpc;
|
||||
otherNpc.configureNavigationRetryCooldowns(
|
||||
"other_npc", {2, 3, 4, 5, 6, 7, 8, 9, 10, 11});
|
||||
if (otherNpc.navigationRetryCooldownTicks ==
|
||||
retryingNpc.navigationRetryCooldownTicks)
|
||||
return fail("Stable NPC IDs produced the same retry sequence");
|
||||
const int firstCooldown =
|
||||
retryingNpc.navigationRetryCooldownTicks.front();
|
||||
retryingNpc.walkTarget = Eigen::Vector3f(2.f, 0.f, 0.f);
|
||||
retryingNpc.requestedWalkTarget = retryingNpc.walkTarget;
|
||||
retryingNpc.pathWaypoints = {retryingNpc.walkTarget};
|
||||
const std::vector<FRG::CharacterMovementObstacle> movingBlocker{
|
||||
{"b", Eigen::Vector3f(0.8f, 0.f, 0.f), 0.45f, true,
|
||||
Eigen::Vector3f(-1.f, 0.f, 0.f)}};
|
||||
FRG::CharacterUpdateContext retryContext;
|
||||
retryContext.characterId = "retrying_npc";
|
||||
retryContext.movementObstacles = &movingBlocker;
|
||||
FRG::CharacterUpdateEvents retryEvents;
|
||||
for (int i = 0; i < 3; ++i)
|
||||
retryEvents = retryingNpc.update(100, retryContext);
|
||||
if (retryEvents.forceNavigationReplan ||
|
||||
!retryingNpc.pathWaypoints.empty() ||
|
||||
retryingNpc.navigationRetryTicks != firstCooldown ||
|
||||
retryingNpc.requestedWalkTarget != Eigen::Vector3f(2.f, 0.f, 0.f))
|
||||
return fail("Blocked NPC did not stop with a deterministic cooldown");
|
||||
for (int i = 0; i < firstCooldown; ++i)
|
||||
retryEvents = retryingNpc.update(100, retryContext);
|
||||
if (!retryEvents.forceNavigationReplan ||
|
||||
retryEvents.blockingCharacterId != "b")
|
||||
return fail("Blocked NPC did not retry after its cooldown");
|
||||
|
||||
retryingNpc.movedLastTick = true;
|
||||
nlohmann::json blockedState;
|
||||
retryingNpc.save(blockedState);
|
||||
FRG::CharacterState restoredMover;
|
||||
restoredMover.load(blockedState);
|
||||
if (restoredMover.movementBlockedTicks !=
|
||||
retryingNpc.movementBlockedTicks ||
|
||||
restoredMover.navigationRetryTicks !=
|
||||
retryingNpc.navigationRetryTicks ||
|
||||
restoredMover.navigationRetryCooldownTicks !=
|
||||
retryingNpc.navigationRetryCooldownTicks ||
|
||||
restoredMover.navigationRetryCooldownIndex !=
|
||||
retryingNpc.navigationRetryCooldownIndex ||
|
||||
!restoredMover.movedLastTick)
|
||||
return fail("Blocked movement state did not round trip");
|
||||
|
||||
FRG::LocationGameState occupancyState;
|
||||
auto stationary = std::make_shared<FRG::CharacterState>();
|
||||
stationary->enabled = true;
|
||||
stationary->hp = 10.f;
|
||||
auto moving = std::make_shared<FRG::CharacterState>();
|
||||
moving->enabled = true;
|
||||
moving->hp = 10.f;
|
||||
moving->pathWaypoints = {Eigen::Vector3f(4.f, 0.f, 0.f)};
|
||||
moving->movedLastTick = true;
|
||||
FRG::PlayerStateMap occupancyPlayers{
|
||||
{"stationary", stationary}, {"moving", moving}};
|
||||
if (occupancyState.collectPathObstacles(
|
||||
"requester", occupancyPlayers).size() != 1 ||
|
||||
occupancyState.collectPathObstacles(
|
||||
"requester", occupancyPlayers, "moving").size() != 2 ||
|
||||
!occupancyState.collectPathObstacles(
|
||||
"stationary", occupancyPlayers).empty())
|
||||
return fail("Stationary occupancy policy is incorrect");
|
||||
moving->movedLastTick = false;
|
||||
if (occupancyState.collectPathObstacles(
|
||||
"requester", occupancyPlayers).size() != 2)
|
||||
return fail("Physically stalled character was omitted from planning");
|
||||
|
||||
FRG::LocationGameState approachingState;
|
||||
auto left = std::make_shared<FRG::CharacterState>();
|
||||
left->configureAsPlayer();
|
||||
left->enabled = true;
|
||||
left->position = Eigen::Vector3f(0.f, 0.f, 0.f);
|
||||
left->walkTarget = Eigen::Vector3f(2.f, 0.f, 0.f);
|
||||
left->requestedWalkTarget = left->walkTarget;
|
||||
left->pathWaypoints = {left->walkTarget};
|
||||
auto right = std::make_shared<FRG::CharacterState>();
|
||||
right->configureAsPlayer();
|
||||
right->enabled = true;
|
||||
right->position = Eigen::Vector3f(1.4f, 0.f, 0.f);
|
||||
right->walkTarget = Eigen::Vector3f(-1.f, 0.f, 0.f);
|
||||
right->requestedWalkTarget = right->walkTarget;
|
||||
right->pathWaypoints = {right->walkTarget};
|
||||
FRG::PlayerStateMap approachingPlayers{{"a", left}, {"b", right}};
|
||||
for (int i = 0; i < 5; ++i)
|
||||
approachingState.update(100, approachingPlayers, false);
|
||||
const float separation = (right->position - left->position).norm();
|
||||
if (separation + 1e-5f <
|
||||
left->movementContactRadius + right->movementContactRadius ||
|
||||
!left->pathWaypoints.empty() || !right->pathWaypoints.empty())
|
||||
return fail("Colliding players did not stop without overlap");
|
||||
|
||||
if (benchmark) {
|
||||
runBenchmark(pathFinder, 100);
|
||||
runBenchmark(pathFinder, 1000);
|
||||
}
|
||||
std::filesystem::remove(openGrid);
|
||||
std::filesystem::remove(corridorGrid);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
@@ -41,6 +41,15 @@ int main()
|
||||
nlohmann::json initialState;
|
||||
authoritative.save(initialState);
|
||||
initialState["players"]["player2"] = initialState["players"]["player"];
|
||||
FRG::LocationGameState testLocation;
|
||||
auto hostileNpc = std::make_shared<FRG::CharacterState>();
|
||||
hostileNpc->enabled = true;
|
||||
hostileNpc->hp = 200.f;
|
||||
testLocation.npcs.emplace("hostile_npc", std::move(hostileNpc));
|
||||
nlohmann::json testLocationState;
|
||||
testLocation.save(testLocationState);
|
||||
initialState["currentLocationId"] = "test_location";
|
||||
initialState["locations"]["test_location"] = std::move(testLocationState);
|
||||
authoritative.load(initialState);
|
||||
FRG::WebsocketGameServer server(
|
||||
authoritative, {"player", "player2"}, "lockstep-v2:test_lockstep",
|
||||
@@ -94,19 +103,55 @@ int main()
|
||||
|
||||
client->submit(FRG::SetCharacterDestinationCommand{
|
||||
{}, "spoofed", Eigen::Vector3f(1.234f, 0.f, 2.345f), "ignored"});
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
secondClient->submit(FRG::SetCharacterDestinationCommand{
|
||||
{}, "spoofed", Eigen::Vector3f(-1.234f, 0.f, 3.456f), "ignored"});
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
server.update();
|
||||
server.update();
|
||||
require(
|
||||
waitFor([&]() {
|
||||
server.update();
|
||||
const bool destinationsConverged = waitFor([&]() {
|
||||
client->update(0);
|
||||
return client->snapshot()->revision > 1;
|
||||
}),
|
||||
"client did not consume finalized lockstep ticks");
|
||||
secondClient->update(0);
|
||||
const auto first = client->snapshot();
|
||||
const auto second = secondClient->snapshot();
|
||||
const auto host = authoritative.snapshot();
|
||||
return first->players.at("player").state.requestedWalkTarget.isApprox(
|
||||
Eigen::Vector3f(1.23f, 0.f, 2.35f)) &&
|
||||
second->players.at("player2").state.requestedWalkTarget.isApprox(
|
||||
Eigen::Vector3f(-1.23f, 0.f, 3.46f)) &&
|
||||
first->players.at("player2").state.requestedWalkTarget.isApprox(
|
||||
second->players.at("player2").state.requestedWalkTarget) &&
|
||||
second->players.at("player").state.requestedWalkTarget.isApprox(
|
||||
first->players.at("player").state.requestedWalkTarget) &&
|
||||
host->players.at("player").state.requestedWalkTarget.isApprox(
|
||||
first->players.at("player").state.requestedWalkTarget) &&
|
||||
host->players.at("player2").state.requestedWalkTarget.isApprox(
|
||||
second->players.at("player2").state.requestedWalkTarget);
|
||||
});
|
||||
if (!destinationsConverged) {
|
||||
const auto first = client->snapshot();
|
||||
const auto second = secondClient->snapshot();
|
||||
const auto host = authoritative.snapshot();
|
||||
std::cerr << "destination revisions " << first->revision << ", "
|
||||
<< second->revision << ", " << host->revision << "\n"
|
||||
<< "player targets "
|
||||
<< first->players.at("player").state.requestedWalkTarget.transpose()
|
||||
<< " / "
|
||||
<< host->players.at("player").state.requestedWalkTarget.transpose()
|
||||
<< "\nplayer2 targets "
|
||||
<< second->players.at("player2").state.requestedWalkTarget.transpose()
|
||||
<< " / "
|
||||
<< host->players.at("player2").state.requestedWalkTarget.transpose()
|
||||
<< std::endl;
|
||||
}
|
||||
require(
|
||||
destinationsConverged,
|
||||
"simultaneous destinations did not converge across lockstep replicas");
|
||||
|
||||
client->submit(FRG::SetCharacterTargetCommand{
|
||||
"stale_location", "spoofed_character", "hostile_npc"});
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
server.update();
|
||||
server.update();
|
||||
server.update();
|
||||
require(
|
||||
@@ -124,7 +169,8 @@ int main()
|
||||
|
||||
client->submit(FRG::ClearCharacterAttackTargetCommand{
|
||||
"stale_location", "spoofed_character"});
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
server.update();
|
||||
server.update();
|
||||
server.update();
|
||||
require(
|
||||
@@ -139,7 +185,8 @@ int main()
|
||||
|
||||
client->submit(FRG::TryCharacterAttackComboCommand{
|
||||
"stale_location", "spoofed_character"});
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
server.update();
|
||||
server.update();
|
||||
server.update();
|
||||
require(
|
||||
@@ -147,9 +194,7 @@ int main()
|
||||
client->update(0);
|
||||
secondClient->update(0);
|
||||
return client->snapshot()->revision ==
|
||||
secondClient->snapshot()->revision &&
|
||||
client->snapshot()->revision ==
|
||||
authoritative.snapshot()->revision;
|
||||
secondClient->snapshot()->revision;
|
||||
}),
|
||||
"attack combo command was not rebroadcast in lockstep");
|
||||
|
||||
|
||||
Reference in new issue
Block a user