Files
EVEngine/test/hexmap.cpp
T

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());
}