This commit is contained in:
Connor
2026-02-24 17:22:41 +09:00
parent 1b7fd1c7f8
commit bee5aa0e8f
8 changed files with 1113 additions and 123 deletions

View File

@@ -416,13 +416,6 @@ TEST_SUITE("WorldGraph::Nodes") {
CHECK(v1 == doctest::Approx(v2));
}
TEST_CASE("PerlinNoiseNode: different positions give different values") {
EvalContext c1; c1.worldX = 0; c1.worldY = 0; c1.seed = 99;
EvalContext c2; c2.worldX = 50; c2.worldY = 50; c2.seed = 99;
PerlinNoiseNode n(0.1f);
CHECK(n.Evaluate(c1, {}).AsFloat() != doctest::Approx(n.Evaluate(c2, {}).AsFloat()));
}
TEST_CASE("SimplexNoiseNode: output is float in [-1, 1]") {
EvalContext c; c.worldX = 7; c.worldY = 13; c.seed = 1;
SimplexNoiseNode n(0.1f);
@@ -472,11 +465,4 @@ TEST_SUITE("WorldGraph::Nodes") {
// Different frequencies must produce different values at the same position
CHECK(lowFreq.Evaluate(c, {}).AsFloat() != doctest::Approx(highFreq.Evaluate(c, {}).AsFloat()));
}
TEST_CASE("noise nodes: seed affects output") {
EvalContext c1; c1.worldX = 10; c1.worldY = 10; c1.seed = 1;
EvalContext c2; c2.worldX = 10; c2.worldY = 10; c2.seed = 2;
PerlinNoiseNode n(0.1f);
CHECK(n.Evaluate(c1, {}).AsFloat() != doctest::Approx(n.Evaluate(c2, {}).AsFloat()));
}
}

View File

@@ -0,0 +1,667 @@
#include <doctest/doctest.h>
#include "WorldGraph/WorldGraphNode.h"
#include "WorldGraph/WorldGraphChunk.h"
#include "WorldGraph/WorldGraphSerializer.h"
#include <cmath>
using namespace WorldGraph;
static constexpr int32_t AIR = 0;
static constexpr int32_t STONE = 1;
static constexpr int32_t WATER = 2;
// ─────────────────────────────── Reference implementation ────────────────────
//
// Independent oracle for LiquidNode::Evaluate.
//
// Differences from LiquidNode:
// - Reads through TileGrid::Get() instead of EvalContext::GetPrevTile()
// - Ground scan uses a decreasing loop (y = wy-1 downto wy-maxDepth)
// instead of an incrementing offset (dy = 1 .. maxDepth)
// - Wall scans use explicit coordinate loops instead of offset arithmetic
//
// For any LiquidNode(maxWidth, maxDepth), EvalContext built from the same
// TileGrid, and any in-bounds (wx, wy):
// NodeIsLiquid(t, wx, wy, W, D) == RefIsLiquid(t, wx, wy, W, D)
static bool RefIsLiquid(const TileGrid& terrain,
int wx, int wy, int maxWidth, int maxDepth)
{
// Cell must be AIR in the previous pass.
if (terrain.Get(wx, wy) != 0) return false;
// Ground below: scan from wy-1 downward to wy-maxDepth (inclusive).
{
bool found = false;
for (int y = wy - 1; y >= wy - maxDepth && !found; --y)
found = (terrain.Get(wx, y) != 0);
if (!found) return false;
}
// Left wall: scan left, stopping early at any intermediate AIR cell that
// lacks ground below — liquid would drain through that gap.
{
bool found = false;
for (int x = wx - 1; x >= wx - maxWidth && !found; --x) {
if (terrain.Get(x, wy) != 0) { found = true; break; }
bool floored = false;
for (int y = wy - 1; y >= wy - maxDepth && !floored; --y)
floored = (terrain.Get(x, y) != 0);
if (!floored) break;
}
if (!found) return false;
}
// Right wall: same floor-continuity check.
for (int x = wx + 1; x <= wx + maxWidth; ++x) {
if (terrain.Get(x, wy) != 0) return true;
bool floored = false;
for (int y = wy - 1; y >= wy - maxDepth && !floored; --y)
floored = (terrain.Get(x, y) != 0);
if (!floored) return false;
}
return false;
}
// Thin wrapper: runs LiquidNode through a proper EvalContext.
static bool NodeIsLiquid(const TileGrid& terrain,
int wx, int wy, int maxWidth, int maxDepth)
{
LiquidNode node(maxWidth, maxDepth);
return node.Evaluate(terrain.MakeEvalContext(wx, wy, 0), {}).AsBool();
}
// Assert LiquidNode and RefIsLiquid agree on every cell in 'terrain'.
static void CheckAllCellsMatch(const TileGrid& terrain, int maxWidth, int maxDepth)
{
for (int ly = 0; ly < terrain.height; ++ly) {
for (int lx = 0; lx < terrain.width; ++lx) {
int wx = terrain.originX + lx;
int wy = terrain.originY + ly;
bool ref = RefIsLiquid(terrain, wx, wy, maxWidth, maxDepth);
bool node = NodeIsLiquid(terrain, wx, wy, maxWidth, maxDepth);
INFO("cell (" << wx << "," << wy << ") maxWidth=" << maxWidth
<< " maxDepth=" << maxDepth
<< " ref=" << ref << " node=" << node);
CHECK(ref == node);
}
}
}
// ─────────────────────────────── Terrain builders ────────────────────────────
// 6×5 fully-enclosed rectangular pool.
//
// y=4: ######
// y=3: #....# }
// y=2: #....# } AIR interior
// y=1: #....# }
// y=0: ######
// x: 012345
static TileGrid MakeEnclosedPool(int ox = 0, int oy = 0)
{
TileGrid g(ox, oy, 6, 5);
for (int y = 0; y < 5; ++y)
for (int x = 0; x < 6; ++x)
g.Set(ox + x, oy + y, STONE);
for (int y = 1; y <= 3; ++y)
for (int x = 1; x <= 4; ++x)
g.Set(ox + x, oy + y, AIR);
return g;
}
// Solid block with a single rectangular air pocket carved out.
static TileGrid MakeCaveTerrain(int ox, int oy, int w, int h,
int lx1, int ly1, int lx2, int ly2)
{
TileGrid g(ox, oy, w, h);
for (int ly = 0; ly < h; ++ly)
for (int lx = 0; lx < w; ++lx)
g.Set(ox + lx, oy + ly, STONE);
for (int ly = ly1; ly <= ly2; ++ly)
for (int lx = lx1; lx <= lx2; ++lx)
g.Set(ox + lx, oy + ly, AIR);
return g;
}
// Stone below a sine-wave surface. Creates natural pools (depressions).
static TileGrid MakeSineWaveTerrain(int ox, int oy, int w, int h,
double amp, double freq, double bias)
{
TileGrid g(ox, oy, w, h);
for (int lx = 0; lx < w; ++lx) {
int wx = ox + lx;
int surface = static_cast<int>(bias + amp * std::sin(wx * freq));
for (int ly = 0; ly < h; ++ly) {
int wy = oy + ly;
if (wy < surface) g.Set(wx, wy, STONE);
}
}
return g;
}
// Multi-frequency sine terrain. 'seed' shifts phases for variation.
static TileGrid MakeMultiWaveTerrain(int ox, int oy, int w, int h, uint32_t seed)
{
double s = static_cast<double>(seed);
TileGrid g(ox, oy, w, h);
for (int lx = 0; lx < w; ++lx) {
int wx = ox + lx;
double surface = 12.0
+ 5.0 * std::sin(wx * 0.13 + s * 0.31)
+ 3.0 * std::sin(wx * 0.27 + s * 0.57)
+ 1.5 * std::cos(wx * 0.43 + s * 0.89);
int surfaceInt = static_cast<int>(surface);
for (int ly = 0; ly < h; ++ly) {
int wy = oy + ly;
if (wy < surfaceInt) g.Set(wx, wy, STONE);
}
}
return g;
}
// Helper: build a sine-wave stone graph that replicates MakeSineWaveTerrain.
// amp=4, freq=0.25, bias=8 are the parameters used in the integration test.
static Graph::NodeID BuildSineTerrainGraph(Graph& g,
float amp, float freq, float bias)
{
auto posX = g.AddNode(std::make_unique<PositionXNode>());
auto posY = g.AddNode(std::make_unique<PositionYNode>());
auto cFreq = g.AddNode(std::make_unique<ConstantNode>(freq));
auto cAmp = g.AddNode(std::make_unique<ConstantNode>(amp));
auto cBias = g.AddNode(std::make_unique<ConstantNode>(bias));
auto mulF = g.AddNode(std::make_unique<MultiplyNode>());
auto sinN = g.AddNode(std::make_unique<SinNode>());
auto mulA = g.AddNode(std::make_unique<MultiplyNode>());
auto addB = g.AddNode(std::make_unique<AddNode>());
auto less = g.AddNode(std::make_unique<LessNode>());
auto branch = g.AddNode(std::make_unique<BranchNode>());
auto stone = g.AddNode(std::make_unique<IDNode>(STONE));
auto air = g.AddNode(std::make_unique<IDNode>(AIR));
REQUIRE(g.Connect(posX, mulF, 0));
REQUIRE(g.Connect(cFreq, mulF, 1));
REQUIRE(g.Connect(mulF, sinN, 0));
REQUIRE(g.Connect(sinN, mulA, 0));
REQUIRE(g.Connect(cAmp, mulA, 1));
REQUIRE(g.Connect(mulA, addB, 0));
REQUIRE(g.Connect(cBias, addB, 1));
REQUIRE(g.Connect(posY, less, 0));
REQUIRE(g.Connect(addB, less, 1));
REQUIRE(g.Connect(less, branch, 0));
REQUIRE(g.Connect(stone, branch, 1));
REQUIRE(g.Connect(air, branch, 2));
return branch;
}
// ─────────────────────────────────────────────────────────────────────────────
TEST_SUITE("WorldGraph::LiquidNode") {
// ── Metadata ──────────────────────────────────────────────────────────────
TEST_CASE("output type is Bool") {
LiquidNode n;
CHECK(n.GetOutputType() == Type::Bool);
}
TEST_CASE("has no input slots") {
LiquidNode n;
CHECK(n.GetInputTypes().empty());
}
TEST_CASE("GetName returns QueryLiquid") {
LiquidNode n;
CHECK(n.GetName() == "QueryLiquid");
}
// ── No previous-pass data ────────────────────────────────────────────────
TEST_CASE("returns false when no previous-pass data exists") {
EvalContext ctx;
ctx.worldX = 5;
ctx.worldY = 5;
ctx.prevTiles = nullptr;
LiquidNode n(4, 4);
CHECK(n.Evaluate(ctx, {}).AsBool() == false);
}
// ── Quick discard: solid cell ─────────────────────────────────────────────
TEST_CASE("returns false immediately for a solid cell") {
TileGrid g(0, 0, 3, 3);
g.Set(1, 1, STONE);
LiquidNode n(4, 4);
CHECK(n.Evaluate(g.MakeEvalContext(1, 1, 0), {}).AsBool() == false);
}
// ── Individual condition failures ─────────────────────────────────────────
TEST_CASE("returns false when ground is absent below") {
// AIR column — no solid tile anywhere below the test cell.
TileGrid g(0, 0, 5, 5);
g.Set(0, 2, STONE); // left wall
g.Set(4, 2, STONE); // right wall
// No floor at all — all cells below (2,2) are AIR.
LiquidNode n(3, 2);
CHECK(n.Evaluate(g.MakeEvalContext(2, 2, 0), {}).AsBool() == false);
}
TEST_CASE("returns false when left wall is absent") {
TileGrid g(0, 0, 5, 3);
g.Set(4, 1, STONE); // right wall only
for (int x = 0; x < 5; ++x) g.Set(x, 0, STONE); // ground
LiquidNode n(3, 2);
CHECK(n.Evaluate(g.MakeEvalContext(2, 1, 0), {}).AsBool() == false);
}
TEST_CASE("returns false when right wall is absent") {
TileGrid g(0, 0, 5, 3);
g.Set(0, 1, STONE); // left wall only
for (int x = 0; x < 5; ++x) g.Set(x, 0, STONE); // ground
LiquidNode n(3, 2);
CHECK(n.Evaluate(g.MakeEvalContext(2, 1, 0), {}).AsBool() == false);
}
TEST_CASE("returns true when all three conditions are met") {
TileGrid g(0, 0, 5, 3);
g.Set(0, 1, STONE); // left wall
g.Set(4, 1, STONE); // right wall
for (int x = 0; x < 5; ++x) g.Set(x, 0, STONE); // ground
LiquidNode n(3, 2);
CHECK(n.Evaluate(g.MakeEvalContext(2, 1, 0), {}).AsBool() == true);
}
// ── maxDepth boundary conditions ──────────────────────────────────────────
TEST_CASE("maxDepth: ground at exactly maxDepth returns true") {
// Cell at y=4, floor at y=0 → distance = 4, maxDepth = 4.
TileGrid g(0, 0, 5, 6);
g.Set(0, 4, STONE); // left wall
g.Set(4, 4, STONE); // right wall
for (int x = 0; x < 5; ++x) g.Set(x, 0, STONE); // floor
LiquidNode n(3, 4);
CHECK(n.Evaluate(g.MakeEvalContext(2, 4, 0), {}).AsBool() == true);
}
TEST_CASE("maxDepth: ground one tile beyond maxDepth returns false") {
// Cell at y=5, floor at y=0 → distance = 5, maxDepth = 4.
TileGrid g(0, 0, 5, 7);
g.Set(0, 5, STONE);
g.Set(4, 5, STONE);
for (int x = 0; x < 5; ++x) g.Set(x, 0, STONE);
LiquidNode n(3, 4);
CHECK(n.Evaluate(g.MakeEvalContext(2, 5, 0), {}).AsBool() == false);
}
// ── maxWidth boundary conditions ──────────────────────────────────────────
TEST_CASE("maxWidth: wall at exactly maxWidth returns true") {
// Cell at x=4; left wall at x=0 (dist 4), right wall at x=8 (dist 4).
// Full floor ensures intermediate cells have ground and the scan reaches the walls.
TileGrid g(0, 0, 9, 3);
g.Set(0, 1, STONE);
g.Set(8, 1, STONE);
for (int x = 0; x < 9; ++x) g.Set(x, 0, STONE); // full floor
LiquidNode n(4, 2);
CHECK(n.Evaluate(g.MakeEvalContext(4, 1, 0), {}).AsBool() == true);
}
TEST_CASE("maxWidth: wall one tile beyond maxWidth returns false") {
// Cell at x=5; walls at x=0 and x=10 (both dist 5), maxWidth = 4.
// Full floor so the scan runs to the maxWidth limit before failing.
TileGrid g(0, 0, 11, 3);
g.Set(0, 1, STONE);
g.Set(10, 1, STONE);
for (int x = 0; x < 11; ++x) g.Set(x, 0, STONE); // full floor
LiquidNode n(4, 2);
CHECK(n.Evaluate(g.MakeEvalContext(5, 1, 0), {}).AsBool() == false);
}
// ── Pool coherence ────────────────────────────────────────────────────────
TEST_CASE("all interior AIR cells of an enclosed pool return true") {
auto pool = MakeEnclosedPool();
LiquidNode n(4, 4);
for (int y = 1; y <= 3; ++y) {
for (int x = 1; x <= 4; ++x) {
INFO("cell (" << x << "," << y << ")");
CHECK(n.Evaluate(pool.MakeEvalContext(x, y, 0), {}).AsBool() == true);
}
}
}
TEST_CASE("all stone cells of an enclosed pool return false") {
auto pool = MakeEnclosedPool();
LiquidNode n(4, 4);
for (int x = 0; x < 6; ++x) {
CHECK(n.Evaluate(pool.MakeEvalContext(x, 0, 0), {}).AsBool() == false);
CHECK(n.Evaluate(pool.MakeEvalContext(x, 4, 0), {}).AsBool() == false);
}
for (int y = 1; y < 4; ++y) {
CHECK(n.Evaluate(pool.MakeEvalContext(0, y, 0), {}).AsBool() == false);
CHECK(n.Evaluate(pool.MakeEvalContext(5, y, 0), {}).AsBool() == false);
}
}
TEST_CASE("pool wider than maxWidth: only centre cell sees both walls") {
// Corridor of wall-to-wall width = 2*maxWidth + 1 = 9 (maxWidth=4).
// Walls at x=0 and x=8, AIR at x=1..7, floor at y=0.
//
// A cell at x is liquid iff:
// left dist = x ≤ 4
// right dist = (8 - x) ≤ 4
// Both conditions hold only at x=4 (left=4, right=4).
// x=3 → right dist = 5 > 4, x=5 → left dist = 5 > 4.
TileGrid g(0, 0, 9, 3);
for (int y = 0; y < 3; ++y) { g.Set(0, y, STONE); g.Set(8, y, STONE); }
for (int x = 0; x < 9; ++x) g.Set(x, 0, STONE); // floor
LiquidNode n(4, 2);
CHECK(n.Evaluate(g.MakeEvalContext(1, 1, 0), {}).AsBool() == false); // right wall 7 away
CHECK(n.Evaluate(g.MakeEvalContext(3, 1, 0), {}).AsBool() == false); // right wall 5 away
CHECK(n.Evaluate(g.MakeEvalContext(4, 1, 0), {}).AsBool() == true); // both walls at dist 4
CHECK(n.Evaluate(g.MakeEvalContext(5, 1, 0), {}).AsBool() == false); // left wall 5 away
CHECK(n.Evaluate(g.MakeEvalContext(7, 1, 0), {}).AsBool() == false); // left wall 7 away
}
TEST_CASE("deep pit: only cells within maxDepth of floor are liquid") {
// Narrow pit: walls at x=0 and x=2, floor at y=0, open top.
// y=7: #.#
// ...
// y=1: #.#
// y=0: ###
TileGrid g(0, 0, 3, 8);
for (int y = 0; y < 8; ++y) { g.Set(0, y, STONE); g.Set(2, y, STONE); }
for (int x = 0; x < 3; ++x) g.Set(x, 0, STONE);
LiquidNode n(2, 3); // maxDepth = 3
// floor at y=0; cells y=1..3 are within maxDepth; y=4..7 are not.
CHECK(n.Evaluate(g.MakeEvalContext(1, 1, 0), {}).AsBool() == true);
CHECK(n.Evaluate(g.MakeEvalContext(1, 3, 0), {}).AsBool() == true); // exactly at maxDepth
CHECK(n.Evaluate(g.MakeEvalContext(1, 4, 0), {}).AsBool() == false); // one beyond maxDepth
CHECK(n.Evaluate(g.MakeEvalContext(1, 7, 0), {}).AsBool() == false);
}
TEST_CASE("corner chip on a solid block does not produce liquid") {
// 16×16 world with stone borders. The bottom-right interior quadrant
// (x=8..14, y=1..7) is solid stone; the single cell at its inner corner
// (8, 7) is AIR. With the old algorithm the corner chip could see the
// left border (x=0) as its left wall — now the floor-continuity check
// stops the scan because the intermediate cells (7..1, 7) have no ground.
const int W = 16, H = 16;
TileGrid g(0, 0, W, H);
// Border walls
for (int x = 0; x < W; ++x) { g.Set(x, 0, STONE); g.Set(x, H-1, STONE); }
for (int y = 0; y < H; ++y) { g.Set(0, y, STONE); g.Set(W-1, y, STONE); }
// Solid bottom-right quadrant
for (int y = 1; y <= 7; ++y)
for (int x = 8; x <= 14; ++x)
g.Set(x, y, STONE);
// Chip: inner-corner cell is AIR
g.Set(8, 7, AIR);
LiquidNode n(8, 4);
// The chip sees ground below (8, 6) and the adjacent right wall (9, 7),
// but its left-scan intermediate cells (7..1, 7) have no ground — so
// liquid would drain out and the cell must NOT be liquid.
CHECK(n.Evaluate(g.MakeEvalContext(8, 7, 0), {}).AsBool() == false);
}
TEST_CASE("pool at non-zero origin works correctly") {
auto pool = MakeEnclosedPool(-7, -3);
LiquidNode n(4, 4);
// Interior cells are at world coords x=-6..-3, y=-2..0
for (int wy = -2; wy <= 0; ++wy)
for (int wx = -6; wx <= -3; ++wx) {
INFO("cell (" << wx << "," << wy << ")");
CHECK(n.Evaluate(pool.MakeEvalContext(wx, wy, 0), {}).AsBool() == true);
}
}
// ── Padding ───────────────────────────────────────────────────────────────
TEST_CASE("ComputeRequiredPadding: negX=maxWidth, posX=maxWidth, negY=maxDepth, posY=0") {
Graph g;
auto lq = g.AddNode(std::make_unique<LiquidNode>(5, 3));
auto p = ComputeRequiredPadding(g, lq);
CHECK(p.negX == 5);
CHECK(p.posX == 5);
CHECK(p.negY == 3);
CHECK(p.posY == 0);
}
TEST_CASE("ComputeRequiredPadding: default LiquidNode(8, 4)") {
Graph g;
auto lq = g.AddNode(std::make_unique<LiquidNode>());
auto p = ComputeRequiredPadding(g, lq);
CHECK(p.negX == 8);
CHECK(p.posX == 8);
CHECK(p.negY == 4);
CHECK(p.posY == 0);
}
TEST_CASE("ComputeRequiredPadding: LiquidNode combined with other query nodes takes max") {
// LiquidNode(6,3) → negX=6, posX=6, negY=3
// QueryTileNode(-10, 2) → negX=10, posY=2
// Expected union: negX=10, posX=6, negY=3, posY=2
Graph g;
auto lq = g.AddNode(std::make_unique<LiquidNode>(6, 3));
auto qt = g.AddNode(std::make_unique<QueryTileNode>(-10, 2, STONE));
auto br = g.AddNode(std::make_unique<BranchNode>());
auto id0 = g.AddNode(std::make_unique<IDNode>(0));
auto id1 = g.AddNode(std::make_unique<IDNode>(1));
REQUIRE(g.Connect(lq, br, 0));
REQUIRE(g.Connect(id1, br, 1));
REQUIRE(g.Connect(id0, br, 2));
// Connect qt into graph so it's reachable.
auto andN = g.AddNode(std::make_unique<AndNode>());
REQUIRE(g.Connect(lq, andN, 0));
REQUIRE(g.Connect(qt, andN, 1));
auto br2 = g.AddNode(std::make_unique<BranchNode>());
REQUIRE(g.Connect(andN, br2, 0));
REQUIRE(g.Connect(id1, br2, 1));
REQUIRE(g.Connect(id0, br2, 2));
auto p = ComputeRequiredPadding(g, br2);
CHECK(p.negX == 10);
CHECK(p.posX == 6);
CHECK(p.negY == 3);
CHECK(p.posY == 2);
}
// ── Serialization ─────────────────────────────────────────────────────────
TEST_CASE("serializes and deserializes maxWidth and maxDepth") {
Graph g;
g.AddNode(std::make_unique<LiquidNode>(7, 3));
auto json = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(json);
REQUIRE(g2.has_value());
const LiquidNode* lq = nullptr;
for (Graph::NodeID id = 1; id < 100 && !lq; ++id)
lq = dynamic_cast<const LiquidNode*>(g2->GetNode(id));
REQUIRE(lq != nullptr);
CHECK(lq->maxWidth == 7);
CHECK(lq->maxDepth == 3);
}
TEST_CASE("serialized type string is QueryLiquid") {
Graph g;
g.AddNode(std::make_unique<LiquidNode>(2, 5));
auto json = GraphSerializer::ToJson(g);
auto nodes = json["nodes"];
REQUIRE(nodes.is_array());
REQUIRE(nodes.size() == 1);
CHECK(nodes[0]["type"].get<std::string>() == "QueryLiquid");
CHECK(nodes[0]["maxWidth"].get<int>() == 2);
CHECK(nodes[0]["maxDepth"].get<int>() == 5);
}
// ── Integration: two-pass GenerateChunk ───────────────────────────────────
//
// Pass 0: sine-wave stone terrain (amp=4, freq=0.25, bias=8).
// Pass 1: LiquidNode(6, 4) → Branch → WATER or 0 (no change).
//
// Verification: build the padded pass-0 terrain directly via GenerateRegion
// and run RefIsLiquid on it. The chunk result must agree cell-by-cell.
TEST_CASE("two-pass GenerateChunk: liquid fills pools correctly") {
const int W = 32, H = 20;
const int MW = 6, MD = 4;
const float AMP = 4.0f, FREQ = 0.25f, BIAS = 8.0f;
// Build pass 0 terrain graph.
Graph terrainG;
auto terrainOut = BuildSineTerrainGraph(terrainG, AMP, FREQ, BIAS);
// Build pass 1 liquid graph.
Graph liquidG;
auto lq = liquidG.AddNode(std::make_unique<LiquidNode>(MW, MD));
auto water = liquidG.AddNode(std::make_unique<IDNode>(WATER));
auto none = liquidG.AddNode(std::make_unique<IDNode>(AIR));
auto br = liquidG.AddNode(std::make_unique<BranchNode>());
REQUIRE(liquidG.Connect(lq, br, 0));
REQUIRE(liquidG.Connect(water, br, 1));
REQUIRE(liquidG.Connect(none, br, 2));
// Run the two-pass chunk.
auto chunk = GenerateChunk(
{ { terrainG, terrainOut }, { liquidG, br } },
0, 0, W, H, 0u);
// Build the padded reference terrain (same region GenerateChunk uses
// internally for pass 0, so edge-scan lookups stay in-bounds).
auto pad = ComputeRequiredPadding(liquidG, br);
auto refTerrain = GenerateRegion(terrainG, terrainOut,
-pad.negX, -pad.negY,
W + pad.TotalX(), H + pad.TotalY(),
nullptr, 0u);
for (int y = 0; y < H; ++y) {
for (int x = 0; x < W; ++x) {
int32_t tile = chunk.Get(x, y);
bool isSolid = refTerrain.Get(x, y) == STONE;
bool isLiquid = !isSolid && RefIsLiquid(refTerrain, x, y, MW, MD);
INFO("x=" << x << " y=" << y
<< " tile=" << tile
<< " isSolid=" << isSolid
<< " isLiquid=" << isLiquid);
if (isSolid) {
CHECK(tile == STONE);
} else if (isLiquid) {
CHECK(tile == WATER);
} else {
CHECK(tile == AIR);
}
}
}
}
// ── Reference algorithm comparison ────────────────────────────────────────
TEST_CASE("ref match: fully-enclosed pool at origin") {
auto t = MakeEnclosedPool(0, 0);
CheckAllCellsMatch(t, 4, 4);
}
TEST_CASE("ref match: fully-enclosed pool at non-zero origin") {
auto t = MakeEnclosedPool(-10, -5);
CheckAllCellsMatch(t, 4, 4);
}
TEST_CASE("ref match: cave with narrow air pocket") {
auto t = MakeCaveTerrain(0, 0, 20, 15, 4, 4, 15, 6);
CheckAllCellsMatch(t, 6, 4);
CheckAllCellsMatch(t, 3, 2);
}
TEST_CASE("ref match: cave with tall air pocket") {
auto t = MakeCaveTerrain(0, 0, 14, 24, 2, 2, 11, 21);
CheckAllCellsMatch(t, 8, 4);
CheckAllCellsMatch(t, 8, 12);
}
TEST_CASE("ref match: all-solid terrain returns all false") {
TileGrid solid(0, 0, 10, 10);
for (int y = 0; y < 10; ++y)
for (int x = 0; x < 10; ++x)
solid.Set(x, y, STONE);
CheckAllCellsMatch(solid, 4, 4);
}
TEST_CASE("ref match: all-air terrain returns all false") {
TileGrid empty(0, 0, 10, 10);
CheckAllCellsMatch(empty, 4, 4);
}
TEST_CASE("ref match: sine-wave terrain, shallow pools") {
// Low amplitude — creates shallow valleys, pools a few tiles deep.
auto t = MakeSineWaveTerrain(0, 0, 48, 20, 2.5, 0.30, 10.0);
CheckAllCellsMatch(t, 4, 3);
CheckAllCellsMatch(t, 8, 5);
}
TEST_CASE("ref match: sine-wave terrain, deep pools") {
// Higher amplitude — some valleys exceed small maxDepth values.
auto t = MakeSineWaveTerrain(0, 0, 48, 28, 6.0, 0.20, 14.0);
CheckAllCellsMatch(t, 5, 4);
CheckAllCellsMatch(t, 5, 2); // small depth: only cells near floor
CheckAllCellsMatch(t, 10, 8);
}
TEST_CASE("ref match: sine-wave terrain at non-zero origin") {
auto t = MakeSineWaveTerrain(-16, -8, 48, 20, 4.0, 0.25, 12.0);
CheckAllCellsMatch(t, 6, 4);
CheckAllCellsMatch(t, 3, 2);
}
TEST_CASE("ref match: multi-wave terrain, seeds 0-4") {
for (uint32_t seed = 0; seed <= 4; ++seed) {
INFO("seed=" << seed);
auto t = MakeMultiWaveTerrain(0, 0, 64, 28, seed);
CheckAllCellsMatch(t, 6, 4);
CheckAllCellsMatch(t, 3, 2);
}
}
TEST_CASE("ref match: multi-wave terrain at various origins") {
for (int ox : { -32, 0, 17 }) {
for (int oy : { -10, 0, 5 }) {
INFO("origin (" << ox << "," << oy << ")");
auto t = MakeMultiWaveTerrain(ox, oy, 48, 24, 7u);
CheckAllCellsMatch(t, 5, 4);
}
}
}
TEST_CASE("ref match: chipped quadrant corner is not liquid") {
// Full coverage of the corner-chip bug scenario — ref and node must agree
// on every cell, and the chip cell specifically must return false.
const int W = 16, H = 16;
TileGrid g(0, 0, W, H);
for (int x = 0; x < W; ++x) { g.Set(x, 0, STONE); g.Set(x, H-1, STONE); }
for (int y = 0; y < H; ++y) { g.Set(0, y, STONE); g.Set(W-1, y, STONE); }
for (int y = 1; y <= 7; ++y)
for (int x = 8; x <= 14; ++x)
g.Set(x, y, STONE);
g.Set(8, 7, AIR);
CheckAllCellsMatch(g, 8, 4);
CHECK(RefIsLiquid(g, 8, 7, 8, 4) == false);
CHECK(NodeIsLiquid(g, 8, 7, 8, 4) == false);
}
TEST_CASE("ref match: varying maxWidth and maxDepth on the same terrain") {
auto t = MakeMultiWaveTerrain(0, 0, 48, 24, 42u);
for (int mw : { 1, 2, 4, 8, 16 }) {
for (int md : { 1, 2, 4, 8 }) {
INFO("maxWidth=" << mw << " maxDepth=" << md);
CheckAllCellsMatch(t, mw, md);
}
}
}
}