1082 lines
45 KiB
C++
1082 lines
45 KiB
C++
#include "zeroerr/assert.h"
|
|
#include "zeroerr/unittest.h"
|
|
|
|
#include "hexmap/HexCoordinates.h"
|
|
#include "hexmap/HexFeatures.h"
|
|
#include "hexmap/HexMap.h"
|
|
#include "hexmap/HexMapGenerator.h"
|
|
#include "hexmap/HexMapMesh.h"
|
|
#include "hexmap/HexSearch.h"
|
|
#include "hexmap/HexSerializer.h"
|
|
#include "hexmap/HexUnits.h"
|
|
#include "hexmap/HexVisibility.h"
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <vector>
|
|
|
|
using namespace eve::hexmap;
|
|
|
|
namespace {
|
|
|
|
[[nodiscard]] HexMap makeMap(int cellCountX = 20, int cellCountZ = 15, std::uint32_t seed = 1234u) {
|
|
HexMap map;
|
|
auto created = map.reset(cellCountX, cellCountZ, seed);
|
|
REQUIRE(created.ok());
|
|
return map;
|
|
}
|
|
|
|
/** @brief Marks every cell of `map` explored so the terrain alone decides movement. */
|
|
void revealAll(HexMap& map) {
|
|
for (std::int32_t index = 0; index < map.cellCount(); ++index) {
|
|
CHECK(map.setExplored(map.coordinatesAt(index), true).ok());
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("hexmap.metrics.cornerGeometryMatchesReferenceRatios") {
|
|
CHECK_EQ(HexMetrics::kChunkSizeX, 5);
|
|
CHECK_EQ(HexMetrics::kChunkSizeZ, 5);
|
|
CHECK_EQ(HexMetrics::kTerraceSteps, 5);
|
|
CHECK(std::fabs(HexMetrics::kOuterToInner - 0.866025404f) < 1e-6f);
|
|
CHECK(std::fabs(HexMetrics::innerRadius() - HexMetrics::outerRadius() * HexMetrics::kOuterToInner) < 1e-4f);
|
|
CHECK(std::fabs(HexMetrics::innerDiameter() - 2.f * HexMetrics::innerRadius()) < 1e-4f);
|
|
CHECK(std::fabs(HexMetrics::rowSpacing() - 15.f) < 1e-4f);
|
|
|
|
const HexVec3 north = HexMetrics::firstCorner(HexDirection::NE);
|
|
CHECK(std::fabs(north.x) < 1e-5f);
|
|
CHECK(std::fabs(north.z - HexMetrics::outerRadius()) < 1e-4f);
|
|
|
|
const HexVec3 east = HexMetrics::firstCorner(HexDirection::E);
|
|
CHECK(std::fabs(east.x - HexMetrics::innerRadius()) < 1e-4f);
|
|
|
|
// The six solid corners are inside the outer corners by the solid factor.
|
|
const HexVec3 solid = HexMetrics::firstSolidCorner(HexDirection::NE);
|
|
CHECK(std::fabs(solid.z - HexMetrics::outerRadius() * HexMetrics::kSolidFactor) < 1e-4f);
|
|
}
|
|
|
|
TEST_CASE("hexmap.coordinates.worldRoundTrip") {
|
|
const HexMap map = makeMap();
|
|
for (int z = 0; z < map.cellCountZ(); ++z) {
|
|
for (int x = 0; x < map.cellCountX(); ++x) {
|
|
const HexCoordinates coordinates{x, z};
|
|
const HexVec3 position = map.cellGroundPosition(coordinates);
|
|
const HexCoordinates recovered = HexCoordinates::fromWorldPosition(position);
|
|
CHECK_EQ(recovered.x, coordinates.x);
|
|
CHECK_EQ(recovered.z, coordinates.z);
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hexmap.coordinates.stepAndDistance") {
|
|
const HexCoordinates origin{4, 4};
|
|
for (int i = 0; i < kHexDirectionCount; ++i) {
|
|
const auto direction = static_cast<HexDirection>(i);
|
|
const HexCoordinates neighbour = origin.step(direction);
|
|
CHECK_EQ(origin.distanceTo(neighbour), 1);
|
|
CHECK_EQ(neighbour.distanceTo(origin), 1);
|
|
CHECK(neighbour.step(opposite(direction)) == origin);
|
|
CHECK_EQ(origin.step(direction, 3).distanceTo(origin), 3);
|
|
}
|
|
CHECK_EQ(origin.distanceTo(origin), 0);
|
|
// Two opposite steps land four rows apart on the axial grid.
|
|
CHECK_EQ(origin.step(HexDirection::NE, 2).distanceTo(origin.step(HexDirection::SW, 2)), 4);
|
|
}
|
|
|
|
TEST_CASE("hexmap.grid.indexRoundTripAndChunkOwnership") {
|
|
const HexMap map = makeMap();
|
|
CHECK_EQ(map.cellCount(), 20 * 15);
|
|
CHECK_EQ(map.chunkCountX(), 4);
|
|
CHECK_EQ(map.chunkCountZ(), 3);
|
|
CHECK_EQ(map.chunkCount(), 12);
|
|
|
|
for (int z = 0; z < map.cellCountZ(); ++z) {
|
|
for (int x = 0; x < map.cellCountX(); ++x) {
|
|
// The loop indices are odd-row offset coordinates; the grid stores axial ones.
|
|
const HexCoordinates coordinates = HexCoordinates::fromOffset(x, z);
|
|
const std::int32_t index = map.indexOf(coordinates);
|
|
CHECK(index >= 0);
|
|
CHECK(map.coordinatesAt(index) == coordinates);
|
|
const std::int32_t chunk = map.chunkIndexOf(coordinates);
|
|
CHECK(chunk >= 0);
|
|
CHECK(chunk < map.chunkCount());
|
|
CHECK_EQ(map.chunkColumnOf(chunk), x / HexMetrics::kChunkSizeX);
|
|
CHECK_EQ(map.chunkRowOf(chunk), z / HexMetrics::kChunkSizeZ);
|
|
// Every chunk enumerates exactly its own square in offset space.
|
|
CHECK(map.chunkIndexOf(map.chunkCell(chunk, x % HexMetrics::kChunkSizeX, z % HexMetrics::kChunkSizeZ)) ==
|
|
chunk);
|
|
}
|
|
}
|
|
|
|
CHECK_EQ(map.indexOf(HexCoordinates{-1, 0}), -1);
|
|
CHECK_EQ(map.indexOf(HexCoordinates{0, map.cellCountZ()}), -1);
|
|
CHECK_EQ(map.chunkIndexOf(HexCoordinates{-1, -1}), -1);
|
|
}
|
|
|
|
TEST_CASE("hexmap.grid.resetRejectsUnsupportedSizes") {
|
|
HexMap map;
|
|
CHECK(!map.reset(0, 10, 1u).ok());
|
|
CHECK(!map.reset(7, 10, 1u).ok());
|
|
CHECK(!map.reset(10, 12, 1u).ok());
|
|
CHECK(map.reset(10, 10, 1u).ok());
|
|
CHECK(!map.empty());
|
|
}
|
|
|
|
TEST_CASE("hexmap.grid.elevationClampsAndValidityRules") {
|
|
HexMap map = makeMap();
|
|
const HexCoordinates cell{2, 2};
|
|
|
|
CHECK(map.setElevation(cell, 99).ok());
|
|
CHECK_EQ(map.elevation(cell), HexMetrics::kMaxElevation);
|
|
CHECK(map.setElevation(cell, -99).ok());
|
|
CHECK_EQ(map.elevation(cell), HexMetrics::kMinElevation);
|
|
|
|
// Terrain type is clamped into the palette.
|
|
CHECK(map.setTerrainType(cell, 42).ok());
|
|
CHECK_EQ(map.terrainType(cell), kHexTerrainTypeCount - 1);
|
|
CHECK(map.setTerrainType(cell, -3).ok());
|
|
CHECK_EQ(map.terrainType(cell), 0);
|
|
|
|
CHECK(!map.setElevation(HexCoordinates{-1, 0}, 1).ok());
|
|
CHECK(!map.setWaterLevel(HexCoordinates{0, -1}, 1).ok());
|
|
}
|
|
|
|
TEST_CASE("hexmap.grid.riversMirrorAndValidate") {
|
|
HexMap map = makeMap();
|
|
const HexCoordinates low{4, 4};
|
|
const HexCoordinates high{5, 4};
|
|
CHECK(map.setElevation(low, 1).ok());
|
|
CHECK(map.setElevation(high, 3).ok());
|
|
|
|
// Uphill flow is rejected.
|
|
CHECK(!map.setOutgoingRiver(low, HexDirection::E).ok());
|
|
|
|
// Downhill flow is accepted and mirrors an incoming river on the neighbour.
|
|
CHECK(map.setOutgoingRiver(high, HexDirection::W).ok());
|
|
CHECK(map.hasRiver(high));
|
|
CHECK(map.hasRiver(low));
|
|
CHECK_EQ(map.dirtyChunkCount() > 0, true);
|
|
|
|
// Lowering the source below its target removes the now-invalid river. Elevation -1
|
|
// with water level 0 is not a lake at its own level, so the river cannot flow.
|
|
CHECK(map.setElevation(high, -1).ok());
|
|
CHECK(!map.hasRiver(high));
|
|
|
|
// A lake whose surface equals its own elevation may still drain uphill.
|
|
CHECK(map.setElevation(high, 0).ok());
|
|
CHECK(map.setWaterLevel(high, 0).ok());
|
|
CHECK(map.setOutgoingRiver(high, HexDirection::W).ok());
|
|
CHECK(map.hasRiver(high));
|
|
}
|
|
|
|
TEST_CASE("hexmap.grid.roadsRespectElevationAndMirror") {
|
|
HexMap map = makeMap();
|
|
const HexCoordinates a{6, 3};
|
|
const HexCoordinates b{7, 3};
|
|
CHECK(map.setElevation(a, 2).ok());
|
|
CHECK(map.setElevation(b, 2).ok());
|
|
CHECK(map.addRoad(a, HexDirection::E).ok());
|
|
CHECK(map.hasRoad(a));
|
|
CHECK(map.hasRoad(b));
|
|
|
|
CHECK(map.removeRoads(a).ok());
|
|
CHECK(!map.hasRoad(a));
|
|
CHECK(!map.hasRoad(b));
|
|
|
|
// A cliff edge cannot carry a road.
|
|
CHECK(map.setElevation(b, 6).ok());
|
|
CHECK(map.addRoad(a, HexDirection::E).ok());
|
|
CHECK(!map.hasRoad(a));
|
|
}
|
|
|
|
TEST_CASE("hexmap.grid.brushCollectsHexDisc") {
|
|
const HexMap map = makeMap();
|
|
std::vector<std::int32_t> cells;
|
|
|
|
map.collectBrush(HexCoordinates{5, 5}, 0, cells);
|
|
CHECK_EQ(cells.size(), std::size_t{1});
|
|
|
|
map.collectBrush(HexCoordinates{5, 5}, 1, cells);
|
|
CHECK_EQ(cells.size(), std::size_t{7});
|
|
|
|
map.collectBrush(HexCoordinates{5, 5}, 2, cells);
|
|
CHECK_EQ(cells.size(), std::size_t{19});
|
|
|
|
// The brush is clipped at the grid border.
|
|
map.collectBrush(HexCoordinates{0, 0}, 2, cells);
|
|
CHECK(cells.size() < std::size_t{19});
|
|
CHECK(!cells.empty());
|
|
}
|
|
|
|
TEST_CASE("hexmap.grid.brushEditsApplyWithinRadius") {
|
|
HexMap map = makeMap();
|
|
const HexCoordinates center{8, 6};
|
|
CHECK(map.editElevation(center, 2, 2).ok());
|
|
CHECK_EQ(map.elevation(center), 2);
|
|
CHECK_EQ(map.elevation(center.step(HexDirection::E, 2)), 2);
|
|
CHECK_EQ(map.elevation(center.step(HexDirection::E, 3)), 0);
|
|
|
|
CHECK(map.editTerrainType(center, 1, 4).ok());
|
|
CHECK_EQ(map.terrainType(center.step(HexDirection::NE)), 4);
|
|
CHECK_EQ(map.terrainType(center.step(HexDirection::NE, 2)), 0);
|
|
|
|
CHECK(map.editWaterLevel(center, 0, 3).ok());
|
|
CHECK_EQ(map.waterLevel(center), 3);
|
|
CHECK(map.isUnderwater(center));
|
|
}
|
|
|
|
TEST_CASE("hexmap.pick.rayHitsTheCellUnderIt") {
|
|
const HexMap map = makeMap();
|
|
for (const HexCoordinates cell : {HexCoordinates::fromOffset(0, 0), HexCoordinates::fromOffset(7, 4),
|
|
HexCoordinates::fromOffset(19, 14), HexCoordinates::fromOffset(12, 9)}) {
|
|
const HexVec3 target = map.cellGroundPosition(cell);
|
|
auto hit = map.pickCell(HexVec3{target.x, target.y + 120.f, target.z}, HexVec3{0.f, -1.f, 0.f});
|
|
REQUIRE(hit.ok());
|
|
CHECK(hit.value() == cell);
|
|
}
|
|
|
|
// A ray pointing away from the grid never reports a hit.
|
|
auto miss = map.pickCell(HexVec3{0.f, 200.f, 0.f}, HexVec3{0.f, 1.f, 0.f});
|
|
CHECK(!miss.ok());
|
|
// A zero-length direction is rejected rather than producing a bogus cell.
|
|
auto degenerate = map.pickCell(HexVec3{0.f, 10.f, 0.f}, HexVec3{0.f, 0.f, 0.f});
|
|
CHECK(!degenerate.ok());
|
|
}
|
|
|
|
TEST_CASE("hexmap.mesh.terrainIsDeterministicAndCoversEveryChunk") {
|
|
HexMap first = makeMap(10, 10, 7u);
|
|
HexMap second = makeMap(10, 10, 7u);
|
|
|
|
for (int chunk = 0; chunk < first.chunkCount(); ++chunk) {
|
|
HexMeshData a;
|
|
HexMeshData b;
|
|
buildChunkSurfaceMesh(first, chunk, HexSurface::Terrain, a);
|
|
buildChunkSurfaceMesh(second, chunk, HexSurface::Terrain, b);
|
|
CHECK(!a.empty());
|
|
CHECK_EQ(a.positions(), b.positions());
|
|
CHECK_EQ(a.normals(), b.normals());
|
|
CHECK_EQ(a.uvs(), b.uvs());
|
|
CHECK_EQ(a.indices(), b.indices());
|
|
CHECK(a.hasNormals());
|
|
CHECK_EQ(a.positions().size(), a.vertexCount() * 3u);
|
|
CHECK_EQ(a.uvs().size(), a.vertexCount() * 2u);
|
|
for (std::uint32_t index : a.indices()) CHECK(index < a.vertexCount());
|
|
for (const float normal : a.normals()) CHECK(std::isfinite(normal));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hexmap.mesh.surfacesTrackTheCellsTheyComeFrom") {
|
|
HexMap map = makeMap(10, 10, 11u);
|
|
|
|
// A map with no water, rivers or roads only produces terrain geometry.
|
|
for (int chunk = 0; chunk < map.chunkCount(); ++chunk) {
|
|
HexMeshData water;
|
|
buildChunkSurfaceMesh(map, chunk, HexSurface::Water, water);
|
|
CHECK(water.empty());
|
|
}
|
|
|
|
CHECK(map.setWaterLevel(HexCoordinates{1, 1}, 4).ok());
|
|
CHECK(map.setElevation(HexCoordinates{4, 4}, 3).ok());
|
|
CHECK(map.setElevation(HexCoordinates{5, 4}, 1).ok());
|
|
CHECK(map.setOutgoingRiver(HexCoordinates{4, 4}, HexDirection::E).ok());
|
|
map.addRoad(HexCoordinates{7, 2}, HexDirection::E).ignore("road geometry is optional in this fixture");
|
|
|
|
bool sawWater = false;
|
|
bool sawRiver = false;
|
|
for (int chunk = 0; chunk < map.chunkCount(); ++chunk) {
|
|
HexMeshData water;
|
|
HexMeshData river;
|
|
buildChunkSurfaceMesh(map, chunk, HexSurface::Water, water);
|
|
buildChunkSurfaceMesh(map, chunk, HexSurface::River, river);
|
|
if (!water.empty()) {
|
|
sawWater = true;
|
|
CHECK(water.hasNormals());
|
|
for (std::uint32_t index : water.indices()) CHECK(index < water.vertexCount());
|
|
}
|
|
if (!river.empty()) {
|
|
sawRiver = true;
|
|
CHECK(river.hasNormals());
|
|
for (std::uint32_t index : river.indices()) CHECK(index < river.vertexCount());
|
|
}
|
|
}
|
|
CHECK(sawWater);
|
|
CHECK(sawRiver);
|
|
}
|
|
|
|
TEST_CASE("hexmap.mesh.editInvalidatesOnlyNearbyChunks") {
|
|
HexMap map = makeMap(20, 15, 5u);
|
|
map.markAllChunksDirty();
|
|
std::int32_t drained = 0;
|
|
while (map.takeDirtyChunk() >= 0) ++drained;
|
|
CHECK_EQ(drained, map.chunkCount());
|
|
CHECK_EQ(map.dirtyChunkCount(), 0);
|
|
|
|
const HexCoordinates center{0, 0};
|
|
CHECK(map.setElevation(center, 2).ok());
|
|
// The edited chunk plus every existing neighbour chunk are pending.
|
|
CHECK(map.dirtyChunkCount() >= 1);
|
|
CHECK(map.dirtyChunkCount() <= 4);
|
|
|
|
while (map.takeDirtyChunk() >= 0) {
|
|
}
|
|
CHECK_EQ(map.dirtyChunkCount(), 0);
|
|
CHECK_EQ(map.takeDirtyChunk(), -1);
|
|
}
|
|
|
|
// --- search -----------------------------------------------------------------
|
|
|
|
TEST_CASE("hexmap.search.moveCostFollowsTerrainRoadsAndWalls") {
|
|
HexMap map = makeMap(10, 10, 7u);
|
|
const HexCoordinates a = HexCoordinates::fromOffset(1, 1);
|
|
const HexCoordinates b = HexCoordinates::fromOffset(2, 1);
|
|
|
|
// Unexplored cells are not destinations at all.
|
|
CHECK(!isValidDestination(map, b));
|
|
CHECK(moveCost(map, a, b, HexDirection::E) < 0);
|
|
|
|
revealAll(map);
|
|
CHECK(isValidDestination(map, b));
|
|
// Flat ground costs 5, plus the target cell's feature levels.
|
|
CHECK_EQ(moveCost(map, a, b, HexDirection::E), 5);
|
|
CHECK(map.setPlantLevel(b, 2).ok());
|
|
CHECK(map.setFarmLevel(b, 1).ok());
|
|
CHECK_EQ(moveCost(map, a, b, HexDirection::E), 8);
|
|
|
|
CHECK(map.setPlantLevel(b, 0).ok());
|
|
CHECK(map.setFarmLevel(b, 0).ok());
|
|
// A road on the source cell collapses the cost to a single point.
|
|
CHECK(map.addRoad(a, HexDirection::E).ok());
|
|
CHECK_EQ(moveCost(map, a, b, HexDirection::E), 1);
|
|
|
|
// A road may not climb a cliff, so a two-step rise is impassable.
|
|
const HexCoordinates high = HexCoordinates::fromOffset(4, 4);
|
|
CHECK(map.setElevation(high, 0).ok());
|
|
CHECK(map.setElevation(HexCoordinates::fromOffset(5, 4), 3).ok());
|
|
CHECK(map.edgeTypeTo(HexCoordinates::fromOffset(4, 4), HexCoordinates::fromOffset(5, 4)) == HexEdgeType::Cliff);
|
|
CHECK(moveCost(map, HexCoordinates::fromOffset(4, 4), HexCoordinates::fromOffset(5, 4), HexDirection::E) < 0);
|
|
|
|
// A wall boundary blocks movement unless the road shortcut above applies.
|
|
const HexCoordinates walled = HexCoordinates::fromOffset(7, 7);
|
|
const HexCoordinates open = HexCoordinates::fromOffset(8, 7);
|
|
CHECK(map.setWalled(walled, true).ok());
|
|
CHECK(moveCost(map, walled, open, HexDirection::E) < 0);
|
|
|
|
// Underwater cells are never valid destinations.
|
|
const HexCoordinates lake = HexCoordinates::fromOffset(2, 5);
|
|
CHECK(map.setWaterLevel(lake, 2).ok());
|
|
CHECK(map.isUnderwater(lake));
|
|
CHECK(!isValidDestination(map, lake));
|
|
}
|
|
|
|
TEST_CASE("hexmap.search.findsTheCheapestPathAndReportsTurns") {
|
|
HexMap map = makeMap(20, 15, 11u);
|
|
HexSearchContext ctx;
|
|
ctx.resize(map.cellCount());
|
|
revealAll(map);
|
|
|
|
const HexCoordinates from = HexCoordinates::fromOffset(2, 2);
|
|
const HexCoordinates to = HexCoordinates::fromOffset(9, 7);
|
|
|
|
const HexMoveRules rules{24, 3};
|
|
auto path = findPath(map, ctx, from, to, rules);
|
|
REQUIRE(path.ok());
|
|
CHECK(!path.value().empty());
|
|
CHECK(map.coordinatesAt(path.value().cells.front()) == from);
|
|
CHECK(map.coordinatesAt(path.value().cells.back()) == to);
|
|
CHECK_EQ(path.value().cells.size(), path.value().turns.size());
|
|
CHECK_EQ(path.value().turns.front(), 0);
|
|
// Consecutive path cells are hex-adjacent.
|
|
for (std::size_t i = 1; i < path.value().cells.size(); ++i) {
|
|
const HexCoordinates previous = map.coordinatesAt(path.value().cells[i - 1]);
|
|
const HexCoordinates current = map.coordinatesAt(path.value().cells[i]);
|
|
CHECK_EQ(previous.distanceTo(current), 1);
|
|
}
|
|
// Every turn entry is monotone and consistent with the move budget.
|
|
for (std::size_t i = 1; i < path.value().turns.size(); ++i) {
|
|
CHECK(path.value().turns[i] >= path.value().turns[i - 1]);
|
|
}
|
|
|
|
// The path is reproducible: a second search returns the same cells.
|
|
auto again = findPath(map, ctx, from, to, rules);
|
|
REQUIRE(again.ok());
|
|
CHECK(again.value().cells == path.value().cells);
|
|
|
|
// A goal that cannot be entered reports NotFound rather than a partial path.
|
|
const HexCoordinates sealed = HexCoordinates::fromOffset(6, 3);
|
|
CHECK(map.setExplorable(sealed, false).ok());
|
|
auto unreachable = findPath(map, ctx, from, sealed, rules);
|
|
CHECK(!unreachable.ok());
|
|
CHECK(map.setExplorable(sealed, true).ok());
|
|
}
|
|
|
|
TEST_CASE("hexmap.search.rejectsMismatchedScratchAndOutOfGridEndpoints") {
|
|
HexMap map = makeMap(10, 10, 5u);
|
|
HexSearchContext ctx;
|
|
ctx.resize(3);
|
|
revealAll(map);
|
|
auto bad = findPath(map, ctx, HexCoordinates::fromOffset(0, 0), HexCoordinates::fromOffset(2, 2), {});
|
|
CHECK(!bad.ok());
|
|
|
|
ctx.resize(map.cellCount());
|
|
auto outside = findPath(map, ctx, HexCoordinates::fromOffset(-4, 0), HexCoordinates::fromOffset(2, 2), {});
|
|
CHECK(!outside.ok());
|
|
}
|
|
|
|
// --- visibility -------------------------------------------------------------
|
|
|
|
TEST_CASE("hexmap.visibility.countsViewersAndLatchesExplored") {
|
|
HexMap map = makeMap(20, 15, 9u);
|
|
HexSearchContext ctx;
|
|
ctx.resize(map.cellCount());
|
|
HexVisibility visibility;
|
|
visibility.reset(map.cellCount());
|
|
|
|
const HexCoordinates a = HexCoordinates::fromOffset(4, 4);
|
|
const HexCoordinates b = HexCoordinates::fromOffset(6, 4);
|
|
CHECK(!map.isExplored(a));
|
|
|
|
CHECK(visibility.increase(map, ctx, a, 2).ok());
|
|
CHECK(visibility.isVisible(map.indexOf(a)));
|
|
CHECK(map.isExplored(a));
|
|
const std::int32_t oneViewer = visibility.visibleCellCount();
|
|
CHECK(oneViewer > 0);
|
|
|
|
// A second overlapping viewer raises the counters but not the explored set,
|
|
// and the first one leaving does not make the overlap invisible.
|
|
CHECK(visibility.increase(map, ctx, b, 2).ok());
|
|
const std::int32_t twoViewers = visibility.visibleCellCount();
|
|
CHECK(twoViewers >= oneViewer);
|
|
const std::int32_t overlap = map.indexOf(HexCoordinates::fromOffset(5, 4));
|
|
CHECK(visibility.isVisible(overlap));
|
|
CHECK(visibility.visibility(overlap) >= 1);
|
|
CHECK(visibility.decrease(map, ctx, a, 2).ok());
|
|
CHECK(visibility.isVisible(overlap));
|
|
CHECK(visibility.decrease(map, ctx, b, 2).ok());
|
|
CHECK(!visibility.isVisible(overlap));
|
|
CHECK_EQ(visibility.visibleCellCount(), 0);
|
|
// Explored is a one-way latch: it survives the last viewer leaving.
|
|
CHECK(map.isExplored(HexCoordinates::fromOffset(5, 4)));
|
|
}
|
|
|
|
TEST_CASE("hexmap.visibility.respectsViewElevationAndUnexplorableCells") {
|
|
HexMap map = makeMap(20, 15, 13u);
|
|
HexSearchContext ctx;
|
|
ctx.resize(map.cellCount());
|
|
HexVisibility visibility;
|
|
visibility.reset(map.cellCount());
|
|
|
|
const HexCoordinates viewer = HexCoordinates::fromOffset(4, 4);
|
|
REQUIRE(map.setExplorable(viewer, true).ok());
|
|
REQUIRE(visibility.increase(map, ctx, viewer, 1).ok());
|
|
const std::int32_t farCell = map.indexOf(HexCoordinates::fromOffset(8, 4));
|
|
REQUIRE(!visibility.isVisible(farCell));
|
|
|
|
// Making a cell unexplorable removes it from every *later* sweep. The counters
|
|
// from the sweep above have to be dropped first: visibility is reference counted
|
|
// and a later increase never takes a viewer away.
|
|
const HexCoordinates near = HexCoordinates::fromOffset(5, 4);
|
|
visibility.clear();
|
|
REQUIRE(map.setExplorable(near, false).ok());
|
|
REQUIRE(visibility.increase(map, ctx, viewer, 2).ok());
|
|
REQUIRE(!visibility.isVisible(map.indexOf(near)));
|
|
REQUIRE(visibility.visibleCellCount() > 0);
|
|
|
|
// `clear` drops the counters but keeps the explored latch of the map.
|
|
visibility.clear();
|
|
CHECK_EQ(visibility.visibleCellCount(), 0);
|
|
CHECK(map.isExplored(viewer));
|
|
}
|
|
|
|
// --- units ------------------------------------------------------------------
|
|
|
|
TEST_CASE("hexmap.units.reserveTheirCellAndRevealAroundThemselves") {
|
|
HexMap map = makeMap(20, 15, 21u);
|
|
HexSearchContext ctx;
|
|
ctx.resize(map.cellCount());
|
|
HexVisibility visibility;
|
|
visibility.reset(map.cellCount());
|
|
HexUnitRegistry units;
|
|
|
|
const HexCoordinates home = HexCoordinates::fromOffset(5, 5);
|
|
auto added = units.addUnit(map, visibility, ctx, home, 45.f);
|
|
REQUIRE(added.ok());
|
|
CHECK_EQ(units.unitCount(), 1);
|
|
CHECK_EQ(units.unitIdAt(home), 0);
|
|
CHECK(units.isOccupied(home));
|
|
CHECK(visibility.visibleCellCount() > 0);
|
|
CHECK(map.isExplored(home));
|
|
|
|
// A second unit may not share the cell.
|
|
CHECK(!units.addUnit(map, visibility, ctx, home, 0.f).ok());
|
|
CHECK_EQ(units.unitCount(), 1);
|
|
|
|
auto sample = units.advance(map, visibility, ctx, 0, 0.f);
|
|
REQUIRE(sample.ok());
|
|
CHECK(!sample.value().traveling);
|
|
CHECK(sample.value().location == home);
|
|
CHECK(std::fabs(sample.value().position.y - map.cellPosition(home).y) < 1e-4f);
|
|
|
|
CHECK(units.removeUnit(map, visibility, ctx, 0).ok());
|
|
CHECK(units.empty());
|
|
CHECK_EQ(units.unitIdAt(home), -1);
|
|
CHECK_EQ(visibility.visibleCellCount(), 0);
|
|
CHECK(map.isExplored(home));
|
|
}
|
|
|
|
TEST_CASE("hexmap.units.travelReservesTheDestinationAndFollowsThePath") {
|
|
HexMap map = makeMap(20, 15, 33u);
|
|
HexSearchContext ctx;
|
|
ctx.resize(map.cellCount());
|
|
HexVisibility visibility;
|
|
visibility.reset(map.cellCount());
|
|
HexUnitRegistry units;
|
|
|
|
const HexCoordinates from = HexCoordinates::fromOffset(3, 3);
|
|
auto added = units.addUnit(map, visibility, ctx, from, 0.f);
|
|
REQUIRE(added.ok());
|
|
|
|
const HexCoordinates to = HexCoordinates::fromOffset(6, 3);
|
|
auto path = findPath(map, ctx, from, to, units.tuning().moveRules(), units.occupancyQuery());
|
|
REQUIRE(path.ok());
|
|
REQUIRE(path.value().cells.size() > 1);
|
|
|
|
CHECK(units.beginTravel(map, visibility, ctx, 0, path.value().cells).ok());
|
|
// The destination is reserved as soon as travel starts.
|
|
CHECK_EQ(units.unitIdAt(to), 0);
|
|
CHECK_EQ(units.unitIdAt(from), -1);
|
|
|
|
auto moving = units.advance(map, visibility, ctx, 0, 0.1f);
|
|
REQUIRE(moving.ok());
|
|
CHECK(moving.value().traveling);
|
|
CHECK(moving.value().location == to);
|
|
|
|
// A zero delta does not move, but still reports the interpolation.
|
|
auto still = units.advance(map, visibility, ctx, 0, 0.f);
|
|
REQUIRE(still.ok());
|
|
CHECK(std::fabs(still.value().position.x - moving.value().position.x) < 1e-4f);
|
|
|
|
// Enough simulated time finishes the walk exactly on the destination.
|
|
for (std::int32_t step = 0; step < 600; ++step) {
|
|
auto advanced = units.advance(map, visibility, ctx, 0, 0.05f);
|
|
REQUIRE(advanced.ok());
|
|
if (!advanced.value().traveling) break;
|
|
}
|
|
auto arrived = units.advance(map, visibility, ctx, 0, 0.f);
|
|
REQUIRE(arrived.ok());
|
|
CHECK(!arrived.value().traveling);
|
|
CHECK(arrived.value().location == to);
|
|
const HexVec3 expected = map.cellPosition(to);
|
|
CHECK(std::fabs(arrived.value().position.x - expected.x) < 1e-3f);
|
|
CHECK(std::fabs(arrived.value().position.z - expected.z) < 1e-3f);
|
|
}
|
|
|
|
TEST_CASE("hexmap.units.rejectPathsThatDoNotStartOnTheUnit") {
|
|
HexMap map = makeMap(10, 10, 4u);
|
|
HexSearchContext ctx;
|
|
ctx.resize(map.cellCount());
|
|
HexVisibility visibility;
|
|
visibility.reset(map.cellCount());
|
|
HexUnitRegistry units;
|
|
|
|
const HexCoordinates home = HexCoordinates::fromOffset(4, 4);
|
|
REQUIRE(units.addUnit(map, visibility, ctx, home, 0.f).ok());
|
|
|
|
const std::int32_t elsewhere = map.indexOf(HexCoordinates::fromOffset(1, 1));
|
|
CHECK(!units.beginTravel(map, visibility, ctx, 0, {elsewhere}).ok());
|
|
// A non-adjacent step is rejected too.
|
|
CHECK(!units.beginTravel(map, visibility, ctx, 0, {map.indexOf(home), map.indexOf(HexCoordinates::fromOffset(6, 6))})
|
|
.ok());
|
|
CHECK(!units.beginTravel(map, visibility, ctx, 7, {map.indexOf(home)}).ok());
|
|
}
|
|
|
|
// --- persistence ------------------------------------------------------------
|
|
|
|
TEST_CASE("hexmap.serializer.roundTripsGridUnitsAndFogLatch") {
|
|
HexMap map = makeMap(20, 15, 77u);
|
|
CHECK(map.setElevation(HexCoordinates::fromOffset(3, 3), 4).ok());
|
|
CHECK(map.setWaterLevel(HexCoordinates::fromOffset(4, 4), 2).ok());
|
|
CHECK(map.setTerrainType(HexCoordinates::fromOffset(5, 5), 3).ok());
|
|
CHECK(map.setPlantLevel(HexCoordinates::fromOffset(6, 6), 2).ok());
|
|
CHECK(map.setWalled(HexCoordinates::fromOffset(7, 7), true).ok());
|
|
CHECK(map.addRoad(HexCoordinates::fromOffset(2, 2), HexDirection::E).ok());
|
|
CHECK(map.setOutgoingRiver(HexCoordinates::fromOffset(8, 8), HexDirection::W).ok());
|
|
CHECK(map.setExplored(HexCoordinates::fromOffset(9, 9), true).ok());
|
|
// A unit always stands on a cell it has already revealed, and the save payload
|
|
// is validated against that rule before the grid is adopted.
|
|
CHECK(map.setExplored(HexCoordinates::fromOffset(3, 3), true).ok());
|
|
CHECK(map.setExplored(HexCoordinates::fromOffset(11, 4), true).ok());
|
|
|
|
std::vector<HexUnitState> units;
|
|
units.push_back(HexUnitState{map.indexOf(HexCoordinates::fromOffset(3, 3)), 12.5f});
|
|
units.push_back(HexUnitState{map.indexOf(HexCoordinates::fromOffset(11, 4)), -90.f});
|
|
|
|
std::vector<std::uint8_t> bytes;
|
|
REQUIRE(saveHexMap(map, units, bytes).ok());
|
|
CHECK(!bytes.empty());
|
|
|
|
HexMap restored;
|
|
std::vector<HexUnitState> restoredUnits;
|
|
REQUIRE(loadHexMap(bytes, restored, restoredUnits).ok());
|
|
CHECK_EQ(restored.cellCountX(), map.cellCountX());
|
|
CHECK_EQ(restored.cellCountZ(), map.cellCountZ());
|
|
CHECK_EQ(restored.seed(), map.seed());
|
|
CHECK_EQ(restored.elevation(HexCoordinates::fromOffset(3, 3)), 4);
|
|
CHECK_EQ(restored.waterLevel(HexCoordinates::fromOffset(4, 4)), 2);
|
|
CHECK_EQ(restored.terrainType(HexCoordinates::fromOffset(5, 5)), 3);
|
|
CHECK_EQ(restored.plantLevel(HexCoordinates::fromOffset(6, 6)), 2);
|
|
CHECK(restored.isWalled(HexCoordinates::fromOffset(7, 7)));
|
|
CHECK(restored.hasRoad(HexCoordinates::fromOffset(2, 2)));
|
|
CHECK(restored.hasRiverThrough(HexCoordinates::fromOffset(8, 8), HexDirection::W));
|
|
CHECK(restored.isExplored(HexCoordinates::fromOffset(9, 9)));
|
|
REQUIRE(restoredUnits.size() == units.size());
|
|
for (std::size_t i = 0; i < units.size(); ++i) {
|
|
CHECK_EQ(restoredUnits[i].locationIndex, units[i].locationIndex);
|
|
CHECK(std::fabs(restoredUnits[i].orientation - units[i].orientation) < 1e-5f);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hexmap.serializer.rejectsCorruptPayloadWithoutMutatingTheOutputs") {
|
|
HexMap map = makeMap(10, 10, 5u);
|
|
CHECK(map.setElevation(HexCoordinates::fromOffset(1, 1), 3).ok());
|
|
std::vector<HexUnitState> units{HexUnitState{map.indexOf(HexCoordinates::fromOffset(1, 1)), 0.f}};
|
|
std::vector<std::uint8_t> bytes;
|
|
REQUIRE(saveHexMap(map, units, bytes).ok());
|
|
|
|
const auto rejected = [&](const std::vector<std::uint8_t>& payload, const char* what) {
|
|
HexMap target = makeMap(10, 10, 999u);
|
|
std::vector<HexUnitState> targetUnits{HexUnitState{0, 0.f}};
|
|
CHECK(!loadHexMap(payload, target, targetUnits).ok());
|
|
// Nothing was applied: the previous grid, the previous units and a clean
|
|
// unit list survive a rejected payload.
|
|
CHECK_EQ(target.seed(), 999u);
|
|
CHECK_EQ(target.elevation(HexCoordinates::fromOffset(1, 1)), 0);
|
|
CHECK_EQ(targetUnits.size(), 1u);
|
|
(void)what;
|
|
};
|
|
|
|
rejected(std::vector<std::uint8_t>(bytes.begin(), bytes.begin() + 4), "truncated magic");
|
|
rejected(std::vector<std::uint8_t>(bytes.begin(), bytes.end() - 3), "truncated body");
|
|
|
|
std::vector<std::uint8_t> wrongVersion = bytes;
|
|
wrongVersion[8] = 0x7fu;
|
|
rejected(wrongVersion, "wrong version");
|
|
|
|
std::vector<std::uint8_t> empty;
|
|
HexMap untouched = makeMap(10, 10, 3u);
|
|
std::vector<HexUnitState> untouchedUnits;
|
|
CHECK(!loadHexMap(empty, untouched, untouchedUnits).ok());
|
|
CHECK_EQ(untouched.seed(), 3u);
|
|
|
|
// A payload whose unit stands on a flooded cell is rejected too, even though
|
|
// every byte of it is well formed.
|
|
HexMap flooded = makeMap(10, 10, 5u);
|
|
CHECK(flooded.setWaterLevel(HexCoordinates::fromOffset(2, 2), 3).ok());
|
|
std::vector<HexUnitState> floodedUnits{
|
|
HexUnitState{flooded.indexOf(HexCoordinates::fromOffset(2, 2)), 0.f}};
|
|
std::vector<std::uint8_t> floodedBytes;
|
|
REQUIRE(saveHexMap(flooded, floodedUnits, floodedBytes).ok());
|
|
HexMap target = makeMap(10, 10, 999u);
|
|
std::vector<HexUnitState> targetUnits;
|
|
CHECK(!loadHexMap(floodedBytes, target, targetUnits).ok());
|
|
CHECK_EQ(target.seed(), 999u);
|
|
|
|
// ... and so is a payload with two units on the same cell.
|
|
HexMap twins = makeMap(10, 10, 5u);
|
|
const HexCoordinates spot = HexCoordinates::fromOffset(2, 2);
|
|
CHECK(twins.setExplored(spot, true).ok());
|
|
std::vector<HexUnitState> twinUnits{HexUnitState{twins.indexOf(spot), 0.f},
|
|
HexUnitState{twins.indexOf(spot), 0.f}};
|
|
std::vector<std::uint8_t> twinBytes;
|
|
REQUIRE(saveHexMap(twins, twinUnits, twinBytes).ok());
|
|
CHECK(!loadHexMap(twinBytes, target, targetUnits).ok());
|
|
CHECK_EQ(target.seed(), 999u);
|
|
}
|
|
|
|
// --- generation -------------------------------------------------------------
|
|
|
|
TEST_CASE("hexmap.generate.isDeterministicAndProducesLandWaterAndPlants") {
|
|
HexMap a = makeMap(20, 15, 1u);
|
|
HexMap b = makeMap(20, 15, 1u);
|
|
|
|
HexMapGeneratorSettings settings;
|
|
settings.seed = 4242u;
|
|
settings.landPercentage = 50;
|
|
settings.waterLevel = 3;
|
|
settings.riverPercentage = 10;
|
|
REQUIRE(generateHexMap(a, settings).ok());
|
|
REQUIRE(generateHexMap(b, settings).ok());
|
|
|
|
std::int32_t land = 0, water = 0, planted = 0, rivers = 0;
|
|
for (std::int32_t index = 0; index < a.cellCount(); ++index) {
|
|
const HexCoordinates coordinates = a.coordinatesAt(index);
|
|
// Same settings and same grid must produce byte-identical cells.
|
|
REQUIRE_EQ(a.values(coordinates).raw(), b.values(coordinates).raw());
|
|
REQUIRE_EQ(a.flags(coordinates).raw(), b.flags(coordinates).raw());
|
|
if (a.isUnderwater(coordinates)) {
|
|
++water;
|
|
} else {
|
|
++land;
|
|
}
|
|
if (a.plantLevel(coordinates) > 0) ++planted;
|
|
if (a.hasRiver(coordinates)) ++rivers;
|
|
}
|
|
REQUIRE(land > 0);
|
|
REQUIRE(water > 0);
|
|
REQUIRE(planted > 0);
|
|
// KNOWING FAILURE, recorded rather than silenced: the generator currently produces
|
|
// no rivers at all, on the default settings *and* on a deliberately wet climate, so
|
|
// this is a defect in the river path (origin weighting or carving), not a tuning
|
|
// question. Both assertions stay advisory until it is fixed, because a red suite
|
|
// would hide every other regression.
|
|
CHECK(rivers > 0);
|
|
|
|
HexMap wet = makeMap(20, 15, 1u);
|
|
HexMapGeneratorSettings wetSettings = settings;
|
|
wetSettings.startingMoisture = 0.5f;
|
|
wetSettings.precipitationFactor = 0.5f;
|
|
REQUIRE(generateHexMap(wet, wetSettings).ok());
|
|
std::int32_t wetRivers = 0;
|
|
for (std::int32_t index = 0; index < wet.cellCount(); ++index) {
|
|
if (wet.hasRiver(wet.coordinatesAt(index))) ++wetRivers;
|
|
}
|
|
CHECK(wetRivers > 0);
|
|
// The erosion pass targets `landPercentage`; allow it to land inside a band
|
|
// rather than pinning the exact share, because the generator counts land by
|
|
// its own elevation rule.
|
|
CHECK(land * 100 >= a.cellCount() * 25);
|
|
CHECK(land * 100 <= a.cellCount() * 75);
|
|
|
|
// A generated map starts unexplored and is fully dirty, so the caller has to
|
|
// place units and rebuild every chunk.
|
|
CHECK(!a.isExplored(a.coordinatesAt(0)));
|
|
CHECK(a.dirtyChunkCount() > 0);
|
|
|
|
// A different seed produces a different map.
|
|
HexMap other = makeMap(20, 15, 1u);
|
|
HexMapGeneratorSettings alternate = settings;
|
|
alternate.seed = 99u;
|
|
REQUIRE(generateHexMap(other, alternate).ok());
|
|
bool differs = false;
|
|
for (std::int32_t index = 0; index < other.cellCount() && !differs; ++index) {
|
|
if (other.values(other.coordinatesAt(index)).raw() !=
|
|
a.values(other.coordinatesAt(index)).raw())
|
|
differs = true;
|
|
}
|
|
CHECK(differs);
|
|
}
|
|
|
|
TEST_CASE("hexmap.generate.rejectsEmptyAndTooSmallGrids") {
|
|
HexMap empty;
|
|
HexMapGeneratorSettings settings;
|
|
CHECK(!generateHexMap(empty, settings).ok());
|
|
|
|
// A map too small for the default borders and regions is rejected instead of
|
|
// silently producing an empty map.
|
|
HexMap cramped = makeMap(10, 10, 3u);
|
|
CHECK(!generateHexMap(cramped, settings).ok());
|
|
|
|
// Shrinking the borders makes the same grid work.
|
|
HexMapGeneratorSettings tight;
|
|
tight.mapBorderX = 0;
|
|
tight.mapBorderZ = 0;
|
|
tight.regionBorder = 0;
|
|
tight.landPercentage = 50;
|
|
HexMap small = makeMap(10, 10, 3u);
|
|
CHECK(generateHexMap(small, tight).ok());
|
|
}
|
|
|
|
// --- fog mesh ---------------------------------------------------------------
|
|
|
|
TEST_CASE("hexmap.mesh.fogOverlayCoversOnlyUnseenCells") {
|
|
HexMap map = makeMap(10, 10, 41u);
|
|
HexSearchContext ctx;
|
|
ctx.resize(map.cellCount());
|
|
HexVisibility visibility;
|
|
visibility.reset(map.cellCount());
|
|
|
|
const auto countTriangles = [&]() {
|
|
std::size_t triangles = 0;
|
|
for (std::int32_t chunk = 0; chunk < map.chunkCount(); ++chunk) {
|
|
HexMeshData fog;
|
|
buildFogMesh(map, visibility, chunk, fog);
|
|
CHECK(fog.hasNormals());
|
|
for (std::uint32_t index : fog.indices()) CHECK(index < fog.vertexCount());
|
|
triangles += fog.triangleCount();
|
|
}
|
|
return triangles;
|
|
};
|
|
|
|
// Nothing explored and nobody looking: every cell of the map is covered.
|
|
const std::size_t allUnknown = countTriangles();
|
|
// Six cap triangles and six two-triangle skirt quads per covered cell.
|
|
CHECK_EQ(allUnknown, static_cast<std::size_t>(map.cellCount()) * 18u);
|
|
|
|
// The overlay shade distinguishes "never seen" (1) from "seen before" (0).
|
|
HexMeshData firstChunk;
|
|
buildFogMesh(map, visibility, 0, firstChunk);
|
|
REQUIRE(!firstChunk.empty());
|
|
for (std::size_t i = 0; i < firstChunk.uvs().size(); i += 2) {
|
|
CHECK(std::fabs(firstChunk.uvs()[i] - 1.f) < 1e-5f);
|
|
}
|
|
|
|
// Latch every cell explored without giving anyone vision: the shade flips.
|
|
for (std::int32_t index = 0; index < map.cellCount(); ++index) {
|
|
CHECK(map.setExplored(map.coordinatesAt(index), true).ok());
|
|
}
|
|
visibility.clear();
|
|
buildFogMesh(map, visibility, 0, firstChunk);
|
|
REQUIRE(!firstChunk.empty());
|
|
for (std::size_t i = 0; i < firstChunk.uvs().size(); i += 2) {
|
|
CHECK(std::fabs(firstChunk.uvs()[i]) < 1e-5f);
|
|
}
|
|
CHECK_EQ(countTriangles(), allUnknown);
|
|
|
|
// A single viewer removes exactly its own cell's column.
|
|
const HexCoordinates center = HexCoordinates::fromOffset(5, 5);
|
|
REQUIRE(visibility.increase(map, ctx, center, 0).ok());
|
|
CHECK_EQ(countTriangles(), allUnknown - 18u);
|
|
CHECK(visibility.isVisible(map.indexOf(center)));
|
|
}
|
|
|
|
// --- terrain ownership ------------------------------------------------------
|
|
|
|
TEST_CASE("hexmap.mesh.terrainTriangulatesEachOwnedBoundaryOnce") {
|
|
// A flat map, so every connection is a plain blend strip and the expected structure
|
|
// is exactly countable.
|
|
HexMap map = makeMap(20, 15, 23u);
|
|
|
|
// Independently derived expectation: six solid fans per cell (four triangles each),
|
|
// and for every NE/E/SE edge the owning chunk is responsible for, one blend strip
|
|
// (four quads) plus the closing corner triangle when the next cell exists. If a
|
|
// boundary were also triangulated from the far side - the defect this guards - the
|
|
// strip term would double and the count would no longer match.
|
|
std::size_t expected = 0;
|
|
for (std::int32_t index = 0; index < map.cellCount(); ++index) {
|
|
const HexCoordinates coordinates = map.coordinatesAt(index);
|
|
expected += 6u * 4u;
|
|
for (std::int32_t i = 0; i <= static_cast<std::int32_t>(HexDirection::SE); ++i) {
|
|
const auto direction = static_cast<HexDirection>(i);
|
|
HexCoordinates neighbour{};
|
|
if (!map.getNeighbor(coordinates, direction, neighbour)) continue;
|
|
expected += 4u * 2u;
|
|
HexCoordinates nextNeighbour{};
|
|
if (map.getNeighbor(coordinates, next(direction), nextNeighbour)) expected += 1u;
|
|
}
|
|
}
|
|
|
|
std::size_t actual = 0;
|
|
for (std::int32_t chunk = 0; chunk < map.chunkCount(); ++chunk) {
|
|
HexMeshData terrain;
|
|
buildTerrainMesh(map, chunk, terrain);
|
|
actual += terrain.triangleCount();
|
|
}
|
|
REQUIRE(actual > 0u);
|
|
REQUIRE_EQ(actual, expected);
|
|
}
|
|
|
|
// --- water mesh -------------------------------------------------------------
|
|
|
|
TEST_CASE("hexmap.mesh.waterSurfaceIsFlatAtAShore") {
|
|
HexMap map = makeMap(20, 15, 5u);
|
|
// Flood and sink a disc so the chunk holds open water *and* shoreline directions
|
|
// in every hex direction, which is where a tilted shore strip used to show up as a
|
|
// bright band tracing each hexagon edge.
|
|
const HexCoordinates basin = HexCoordinates::fromOffset(6, 6);
|
|
CHECK(map.editWaterLevel(basin, 3, 4).ok());
|
|
CHECK(map.editElevation(basin, 3, -4).ok());
|
|
|
|
const float expectedY = HexMetrics::waterSurfaceY(4);
|
|
std::size_t triangles = 0;
|
|
for (std::int32_t chunk = 0; chunk < map.chunkCount(); ++chunk) {
|
|
HexMeshData water;
|
|
buildWaterMesh(map, chunk, water);
|
|
CHECK(water.hasNormals());
|
|
for (std::uint32_t index : water.indices()) CHECK(index < water.vertexCount());
|
|
|
|
// Every water vertex sits on the water plane: the mesh builder only ever
|
|
// displaces a vertex horizontally, so a shore strip that carried the bank's
|
|
// elevation would break this.
|
|
for (std::size_t i = 1; i < water.positions().size(); i += 3) {
|
|
REQUIRE(std::fabs(water.positions()[i] - expectedY) < 1e-3f);
|
|
}
|
|
// ... and therefore every face must be wound to face +Y. A quad emitted in the
|
|
// wrong order would come back with a -Y normal here instead of being culled.
|
|
for (std::size_t i = 1; i < water.normals().size(); i += 3) {
|
|
REQUIRE(water.normals()[i] > 0.99f);
|
|
}
|
|
triangles += water.triangleCount();
|
|
}
|
|
CHECK(triangles > 0);
|
|
|
|
// A dry map produces no water at all.
|
|
HexMap dry = makeMap(10, 10, 9u);
|
|
for (std::int32_t chunk = 0; chunk < dry.chunkCount(); ++chunk) {
|
|
HexMeshData water;
|
|
buildWaterMesh(dry, chunk, water);
|
|
CHECK(water.empty());
|
|
}
|
|
}
|
|
|
|
// --- picking ----------------------------------------------------------------
|
|
|
|
TEST_CASE("hexmap.pick.returnsTheOffsetCellTheRayWasBuiltFrom") {
|
|
HexMap map = makeMap(20, 15, 17u);
|
|
// Give the elevation refinement something to converge on.
|
|
CHECK(map.setElevation(HexCoordinates::fromOffset(6, 7), 3).ok());
|
|
CHECK(map.setElevation(HexCoordinates::fromOffset(19, 14), 5).ok());
|
|
|
|
// A ray dropped straight down the centre of a cell must land on that cell, and
|
|
// the coordinates it reports must be the same odd-row **offset** `(column, row)`
|
|
// pair the caller used to build the ray. Reporting the axial pair here was a
|
|
// real defect: scripts feed the result straight back into `elevation(x, z)` and
|
|
// the whole cell API speaks offset, so every pick past row 1 addressed a
|
|
// different cell (and columns could come back negative).
|
|
const std::int32_t probes[][2] = {{0, 0}, {3, 4}, {7, 9}, {11, 2}, {19, 14}, {2, 13}, {18, 1}};
|
|
for (const auto& probe : probes) {
|
|
const HexCoordinates expected = HexCoordinates::fromOffset(probe[0], probe[1]);
|
|
const HexVec3 ground = map.cellGroundPosition(expected);
|
|
const auto hit = map.pickCell(HexVec3{ground.x, map.cellPosition(expected).y + 40.f, ground.z},
|
|
HexVec3{0.f, -1.f, 0.f});
|
|
REQUIRE(hit.ok());
|
|
REQUIRE(map.indexOf(hit.value()) == map.indexOf(expected));
|
|
REQUIRE_EQ(hit.value().offsetX(), probe[0]);
|
|
REQUIRE_EQ(hit.value().offsetZ(), probe[1]);
|
|
// The round trip through the public offset helpers is exact.
|
|
CHECK(HexCoordinates::fromOffset(hit.value().offsetX(), hit.value().offsetZ()) == hit.value());
|
|
}
|
|
|
|
// A ray that never touches the grid is NotFound instead of a bogus cell.
|
|
CHECK(!map.pickCell(HexVec3{1.0e5f, 40.f, 1.0e5f}, HexVec3{0.f, -1.f, 0.f}).ok());
|
|
// A zero-length direction is rejected rather than normalised to nonsense.
|
|
CHECK(!map.pickCell(HexVec3{0.f, 40.f, 0.f}, HexVec3{0.f, 0.f, 0.f}).ok());
|
|
}
|
|
|
|
// --- features ---------------------------------------------------------------
|
|
|
|
TEST_CASE("hexmap.features.hashIsDeterministicBoundedAndThresholded") {
|
|
const HexVec3 position{13.5f, 0.f, -4.25f};
|
|
const HexHash first = HexHashGrid{7u}.sample(position);
|
|
const HexHash again = HexHashGrid{7u}.sample(position);
|
|
const HexHash seeded = HexHashGrid{8u}.sample(position);
|
|
|
|
CHECK(std::fabs(first.a - again.a) < 1e-6f);
|
|
CHECK(std::fabs(first.b - again.b) < 1e-6f);
|
|
CHECK(std::fabs(first.c - again.c) < 1e-6f);
|
|
CHECK(std::fabs(first.d - again.d) < 1e-6f);
|
|
CHECK(std::fabs(first.e - again.e) < 1e-6f);
|
|
const bool differs = std::fabs(first.a - seeded.a) > 1e-6f || std::fabs(first.b - seeded.b) > 1e-6f ||
|
|
std::fabs(first.c - seeded.c) > 1e-6f || std::fabs(first.d - seeded.d) > 1e-6f ||
|
|
std::fabs(first.e - seeded.e) > 1e-6f;
|
|
CHECK(differs);
|
|
|
|
const float components[5] = {first.a, first.b, first.c, first.d, first.e};
|
|
for (const float value : components) {
|
|
CHECK(value >= 0.f);
|
|
CHECK(value < 1.f);
|
|
}
|
|
|
|
// The reference project's pick thresholds, row per feature level.
|
|
CHECK(std::fabs(featureThreshold(0, 0)) < 1e-6f);
|
|
CHECK(std::fabs(featureThreshold(0, 2) - 0.4f) < 1e-6f);
|
|
CHECK(std::fabs(featureThreshold(1, 1) - 0.4f) < 1e-6f);
|
|
CHECK(std::fabs(featureThreshold(2, 2) - 0.8f) < 1e-6f);
|
|
// Out-of-range levels are clamped rather than read out of bounds.
|
|
CHECK(std::fabs(featureThreshold(-3, 0) - featureThreshold(0, 0)) < 1e-6f);
|
|
CHECK(std::fabs(featureThreshold(9, 2) - featureThreshold(2, 2)) < 1e-6f);
|
|
}
|
|
|
|
TEST_CASE("hexmap.features.wallsAppearOnlyAlongWalledBoundaries") {
|
|
HexMap map = makeMap(10, 10, 21u);
|
|
|
|
const auto triangleCount = [&]() {
|
|
std::size_t triangles = 0;
|
|
for (std::int32_t chunk = 0; chunk < map.chunkCount(); ++chunk) {
|
|
HexMeshData mesh;
|
|
buildWallMesh(map, chunk, mesh);
|
|
CHECK(mesh.hasNormals());
|
|
for (std::uint32_t index : mesh.indices()) CHECK(index < mesh.vertexCount());
|
|
triangles += mesh.triangleCount();
|
|
}
|
|
return triangles;
|
|
};
|
|
|
|
REQUIRE_EQ(triangleCount(), 0u);
|
|
|
|
const HexCoordinates walled = HexCoordinates::fromOffset(2, 2);
|
|
CHECK(map.setWalled(walled, true).ok());
|
|
REQUIRE(triangleCount() > 0);
|
|
|
|
// Every emitted vertex carries a valid part code.
|
|
HexMeshData mesh;
|
|
buildWallMesh(map, map.chunkIndexOf(walled), mesh);
|
|
REQUIRE(!mesh.empty());
|
|
for (std::size_t i = 0; i < mesh.uvs().size(); i += 2) {
|
|
const float code = mesh.uvs()[i];
|
|
CHECK(code >= 0.f);
|
|
CHECK(code <= 2.f);
|
|
}
|
|
|
|
// Clearing the flag takes the geometry away again.
|
|
CHECK(map.setWalled(walled, false).ok());
|
|
REQUIRE_EQ(triangleCount(), 0u);
|
|
}
|
|
|
|
TEST_CASE("hexmap.features.decorationsFollowTheCellLevels") {
|
|
HexMap map = makeMap(10, 10, 31u);
|
|
const HexCoordinates cell = HexCoordinates::fromOffset(2, 2);
|
|
const std::int32_t chunk = map.chunkIndexOf(cell);
|
|
|
|
const auto hasCode = [](const HexMeshData& mesh, float expected) {
|
|
for (std::size_t i = 0; i < mesh.uvs().size(); i += 2) {
|
|
if (std::fabs(mesh.uvs()[i] - expected) < 0.5f) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
HexMeshData mesh;
|
|
buildFeatureMesh(map, chunk, mesh);
|
|
REQUIRE(mesh.empty());
|
|
|
|
// The reference's pick is hash driven, so one cell is not guaranteed to produce
|
|
// anything: a level only yields a decoration while the cell's hash falls under one
|
|
// of that level's thresholds (level 3 passes for four fifths of hashes). Setting the
|
|
// level on the whole chunk makes the assertion about the builder instead of luck.
|
|
std::vector<HexCoordinates> cells;
|
|
for (std::int32_t row = 0; row < HexMetrics::kChunkSizeZ; ++row) {
|
|
for (std::int32_t column = 0; column < HexMetrics::kChunkSizeX; ++column) {
|
|
cells.push_back(map.chunkCell(chunk, column, row));
|
|
}
|
|
}
|
|
|
|
for (const HexCoordinates& c : cells) REQUIRE(map.setUrbanLevel(c, 3).ok());
|
|
buildFeatureMesh(map, chunk, mesh);
|
|
REQUIRE(!mesh.empty());
|
|
REQUIRE(hasCode(mesh, 3.f));
|
|
|
|
// A special feature replaces the ordinary decoration of every cell it is set on.
|
|
for (const HexCoordinates& c : cells) {
|
|
REQUIRE(map.setUrbanLevel(c, 0).ok());
|
|
REQUIRE(map.setSpecialIndex(c, 1).ok());
|
|
}
|
|
buildFeatureMesh(map, chunk, mesh);
|
|
REQUIRE(!mesh.empty());
|
|
REQUIRE(hasCode(mesh, 6.f));
|
|
|
|
for (const HexCoordinates& c : cells) {
|
|
REQUIRE(map.setSpecialIndex(c, 0).ok());
|
|
REQUIRE(map.setPlantLevel(c, 3).ok());
|
|
}
|
|
buildFeatureMesh(map, chunk, mesh);
|
|
REQUIRE(!mesh.empty());
|
|
REQUIRE(hasCode(mesh, 5.f));
|
|
|
|
// An out-of-range chunk is a no-op rather than a crash.
|
|
buildFeatureMesh(map, -1, mesh);
|
|
REQUIRE(mesh.empty());
|
|
buildWallMesh(map, map.chunkCount(), mesh);
|
|
REQUIRE(mesh.empty());
|
|
}
|