diff --git a/src/openrct2/world/Location.hpp b/src/openrct2/world/Location.hpp index cc057d0c25..950a2937c6 100644 --- a/src/openrct2/world/Location.hpp +++ b/src/openrct2/world/Location.hpp @@ -116,6 +116,16 @@ struct TileCoordsXYZ y += rhs.y; return *this; } + + bool operator==(const TileCoordsXYZ& other) const + { + return x == other.x && y == other.y && z == other.z; + } + bool operator!=(const TileCoordsXYZ& other) const + { + return !(*this == other); + } + int32_t x = 0, y = 0, z = 0; }; diff --git a/test/tests/CMakeLists.txt b/test/tests/CMakeLists.txt index 098924659c..25d946547b 100644 --- a/test/tests/CMakeLists.txt +++ b/test/tests/CMakeLists.txt @@ -197,3 +197,10 @@ if (NOT DISABLE_NETWORK) target_link_libraries(test_replays ${GTEST_LIBRARIES} libopenrct2 ${LDL} z) add_test(NAME replay_tests COMMAND test_replays) endif () + +# Pathfinding test +set(PATHFINDING_TEST_SOURCES "${CMAKE_CURRENT_LIST_DIR}/Pathfinding.cpp" + "${CMAKE_CURRENT_LIST_DIR}/TestData.cpp") +add_executable(test_pathfinding ${PATHFINDING_TEST_SOURCES}) +target_link_libraries(test_pathfinding ${GTEST_LIBRARIES} libopenrct2 ${LDL} z) +add_test(NAME pathfinding COMMAND test_pathfinding) diff --git a/test/tests/Pathfinding.cpp b/test/tests/Pathfinding.cpp new file mode 100644 index 0000000000..940c1a8c80 --- /dev/null +++ b/test/tests/Pathfinding.cpp @@ -0,0 +1,259 @@ +#include "TestData.h" +#include "openrct2/core/StringReader.hpp" +#include "openrct2/peep/Peep.h" +#include "openrct2/ride/Station.h" +#include "openrct2/scenario/Scenario.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace OpenRCT2; + +static std::ostream& operator<<(std::ostream& os, const TileCoordsXYZ& coords) +{ + return os << "(" << coords.x << ", " << coords.y << ", " << coords.z << ")"; +} + +class PathfindingTestBase : public testing::Test +{ +public: + static void SetUpTestCase() + { + core_init(); + + gOpenRCT2Headless = true; + gOpenRCT2NoGraphics = true; + _context = CreateContext(); + const bool initialised = _context->Initialise(); + ASSERT_TRUE(initialised); + + std::string parkPath = TestData::GetParkPath("pathfinding-tests.sv6"); + load_from_sv6(parkPath.c_str()); + game_load_init(); + } + + void SetUp() override + { + // Use a consistent random seed in every test + gScenarioSrand0 = 0x12345678; + gScenarioSrand1 = 0x87654321; + } + + static void TearDownTestCase() + { + _context = nullptr; + } + +protected: + static Ride* FindRideByName(const char* name, int32_t* outRideIndex) + { + Ride* ride; + FOR_ALL_RIDES ((*outRideIndex), ride) + { + char thisName[256]; + format_string(thisName, sizeof(thisName), ride->name, &ride->name_arguments); + if (!_strnicmp(thisName, name, sizeof(thisName))) + return ride; + } + + return nullptr; + } + + static bool FindPath(TileCoordsXYZ* pos, const TileCoordsXYZ& goal, int expectedSteps, int targetRideID) + { + // Our start position is in tile coordinates, but we need to give the peep spawn + // position in actual world coords (32 units per tile X/Y, 8 per Z level). + // Add 16 so the peep spawns in the center of the tile. + rct_peep* peep = peep_generate(pos->x * 32 + 16, pos->y * 32 + 16, pos->z * 8); + + // Peeps that are outside of the park use specialized pathfinding which we don't want to + // use here + peep->outside_of_park = 0; + + // An earlier iteration of this code just gave peeps a target position to walk to, but it turns out + // that with no actual ride to head towards, when a peep reaches a junction they use the 'aimless' + // pathfinder instead of pursuing their original pathfinding target. So, we always need to give them + // an actual ride to walk to the entrance of. + peep->guest_heading_to_ride_id = targetRideID; + + // Pick the direction the peep should initially move in, given the goal position. + // This will also store the goal position and initialize pathfinding data for the peep. + gPeepPathFindGoalPosition = goal; + const int32_t moveDir = peep_pathfind_choose_direction(*pos, peep); + if (moveDir < 0) + { + // Couldn't determine a direction to move off in + return false; + } + + // We have already set up the peep's overall pathfinding goal, but we also have to set their initial + // 'destination' which is a close position that they will walk towards in a straight line - in this case, one + // tile away. Stepping the peep will move them towards their destination, and once they reach it, a new + // destination will be picked, to try and get the peep towards the overall pathfinding goal. + peep->direction = moveDir; + peep->destination_x = peep->x + CoordsDirectionDelta[moveDir].x; + peep->destination_y = peep->y + CoordsDirectionDelta[moveDir].y; + peep->destination_tolerance = 2; + + // Repeatedly step the peep, until they reach the target position or until the expected number of steps have + // elapsed. Each step, check that the tile they are standing on is not marked as forbidden in the test data + // (red neon ground type). + int step = 0; + while (!(*pos == goal) && step < expectedSteps) + { + uint8_t pathingResult = 0; + peep->PerformNextAction(pathingResult); + ++step; + + pos->x = peep->x / 32; + pos->y = peep->y / 32; + pos->z = peep->z / 8; + + EXPECT_PRED_FORMAT1(AssertIsNotForbiddenPosition, *pos); + + // Check that the peep is still on a footpath. Use next_z instead of pos->z here because pos->z will change + // when the peep is halfway up a slope, but next_z will not change until they move to the next tile. + EXPECT_NE(map_get_footpath_element(pos->x, pos->y, peep->next_z), nullptr); + } + + // Clean up the peep, because we're reusing this loaded context for all tests. + peep_sprite_remove(peep); + + // Require that the number of steps taken is exactly what we expected. The pathfinder is supposed to be + // deterministic, and we reset the RNG seed for each test, everything should be entirely repeatable; as + // such a change in the number of steps taken on one of these paths needs to be reviewed. For the negative + // tests, we will not have reached the goal but we still expect the loop to have run for the total number + // of steps requested before giving up. + EXPECT_EQ(step, expectedSteps); + + return *pos == goal; + } + + static ::testing::AssertionResult AssertIsStartPosition(const char*, const TileCoordsXYZ& location) + { + const uint32_t expectedSurfaceStyle = 11u; + const uint32_t style = map_get_surface_element_at(location.x, location.y)->AsSurface()->GetSurfaceStyle(); + + if (style != expectedSurfaceStyle) + return ::testing::AssertionFailure() + << "Start location " << location << " should have surface style " << expectedSurfaceStyle + << " but actually has style " << style + << ". Either the test map is not set up correctly, or you got the coordinates wrong."; + + return ::testing::AssertionSuccess(); + } + + static ::testing::AssertionResult AssertIsNotForbiddenPosition(const char*, const TileCoordsXYZ& location) + { + const uint32_t forbiddenSurfaceStyle = 8u; + + const uint32_t style = map_get_surface_element_at(location.x, location.y)->AsSurface()->GetSurfaceStyle(); + + if (style == forbiddenSurfaceStyle) + return ::testing::AssertionFailure() + << "Path traversed location " << location << ", but it is marked as a forbidden location (surface style " + << forbiddenSurfaceStyle << "). Either the map is set up incorrectly, or the pathfinder went the wrong way."; + + return ::testing::AssertionSuccess(); + } + +private: + static std::shared_ptr _context; +}; + +std::shared_ptr PathfindingTestBase::_context; + +struct SimplePathfindingScenario +{ + const char* name; + TileCoordsXYZ start; + uint32_t steps; + + SimplePathfindingScenario(const char* _name, const TileCoordsXYZ& _start, int _steps) + : name(_name) + , start(_start) + , steps(_steps) + { + } + + static std::string ToName(const ::testing::TestParamInfo& param_info) + { + return param_info.param.name; + } +}; + +class SimplePathfindingTest : public PathfindingTestBase, public ::testing::WithParamInterface +{ +}; + +TEST_P(SimplePathfindingTest, CanFindPathFromStartToGoal) +{ + const SimplePathfindingScenario& scenario = GetParam(); + + ASSERT_PRED_FORMAT1(AssertIsStartPosition, scenario.start); + TileCoordsXYZ pos = scenario.start; + + int32_t rideIndex; + Ride* ride = FindRideByName(scenario.name, &rideIndex); + ASSERT_NE(ride, nullptr); + + auto entrancePos = ride_get_entrance_location(ride, 0); + TileCoordsXYZ goal = TileCoordsXYZ( + entrancePos.x - TileDirectionDelta[entrancePos.direction].x, + entrancePos.y - TileDirectionDelta[entrancePos.direction].y, entrancePos.z); + + const auto succeeded = FindPath(&pos, goal, scenario.steps, rideIndex) ? ::testing::AssertionSuccess() + : ::testing::AssertionFailure() + << "Failed to find path from " << scenario.start << " to " << goal << " in " << scenario.steps << " steps; reached " + << pos << " before giving up."; + + EXPECT_TRUE(succeeded); +} + +INSTANTIATE_TEST_CASE_P( + ForScenario, SimplePathfindingTest, + ::testing::Values( + SimplePathfindingScenario("StraightFlat", { 19, 15, 14 }, 24), SimplePathfindingScenario("SBend", { 15, 12, 14 }, 88), + SimplePathfindingScenario("UBend", { 17, 9, 14 }, 86), SimplePathfindingScenario("CBend", { 14, 5, 14 }, 164), + SimplePathfindingScenario("TwoEqualRoutes", { 9, 13, 14 }, 87), + SimplePathfindingScenario("TwoUnequalRoutes", { 3, 13, 14 }, 87), + SimplePathfindingScenario("StraightUpBridge", { 12, 15, 14 }, 24), + SimplePathfindingScenario("StraightUpSlope", { 14, 15, 14 }, 24), + SimplePathfindingScenario("SelfCrossingPath", { 6, 5, 14 }, 213)), + SimplePathfindingScenario::ToName); + +class ImpossiblePathfindingTest : public PathfindingTestBase, public ::testing::WithParamInterface +{ +}; + +TEST_P(ImpossiblePathfindingTest, CannotFindPathFromStartToGoal) +{ + const SimplePathfindingScenario& scenario = GetParam(); + TileCoordsXYZ pos = scenario.start; + ASSERT_PRED_FORMAT1(AssertIsStartPosition, scenario.start); + + int32_t rideIndex; + Ride* ride = FindRideByName(scenario.name, &rideIndex); + ASSERT_NE(ride, nullptr); + + auto entrancePos = ride_get_entrance_location(ride, 0); + TileCoordsXYZ goal = TileCoordsXYZ( + entrancePos.x + TileDirectionDelta[entrancePos.direction].x, + entrancePos.y + TileDirectionDelta[entrancePos.direction].y, entrancePos.z); + + EXPECT_FALSE(FindPath(&pos, goal, 10000, rideIndex)); +} + +INSTANTIATE_TEST_CASE_P( + ForScenario, ImpossiblePathfindingTest, + ::testing::Values( + SimplePathfindingScenario("PathWithGap", { 1, 6, 14 }, 10000), + SimplePathfindingScenario("PathWithFences", { 11, 6, 14 }, 10000), + SimplePathfindingScenario("PathWithCliff", { 7, 17, 14 }, 10000)), + SimplePathfindingScenario::ToName); diff --git a/test/tests/testdata/parks/pathfinding-tests.sv6 b/test/tests/testdata/parks/pathfinding-tests.sv6 new file mode 100644 index 0000000000..2fbb022692 Binary files /dev/null and b/test/tests/testdata/parks/pathfinding-tests.sv6 differ diff --git a/test/tests/tests.vcxproj b/test/tests/tests.vcxproj index 3d194ddff4..adddb17b74 100644 --- a/test/tests/tests.vcxproj +++ b/test/tests/tests.vcxproj @@ -1,4 +1,4 @@ - + ..\..\ @@ -64,6 +64,7 @@ +