chunk generation

This commit is contained in:
Connor
2026-02-22 11:57:39 +09:00
parent 9b0c9a87fa
commit 7b80eda561
12 changed files with 1533 additions and 417 deletions

View File

@@ -86,6 +86,7 @@ set(SOURCES
src/Core/WorldInstance.cpp
src/WorldGraph/WorldGraph.cpp
src/WorldGraph/WorldGraphSerializer.cpp
src/WorldGraph/WorldGraphChunk.cpp
)
add_library(factory-hole-core ${SOURCES})

View File

@@ -6,6 +6,7 @@
#include <optional>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace WorldGraph {
@@ -74,6 +75,10 @@ public:
/// dependencies) is connected.
bool IsValid(NodeID outputNode) const;
/// Returns all node IDs reachable from (and including) \p outputNode.
/// Useful for inspecting query-node offsets to compute padding requirements.
std::vector<NodeID> GetDependencies(NodeID outputNode) const;
private:
friend class GraphSerializer;

View File

@@ -0,0 +1,116 @@
#pragma once
#include "WorldGraph/WorldGraph.h"
#include <cstdint>
#include <vector>
namespace WorldGraph {
// ─────────────────────────────── TileGrid ────────────────────────────────────
/// A 2D tile-ID array covering a contiguous world region.
///
/// Storage is row-major: data[ly * width + lx]
/// where lx = worldX - originX, ly = worldY - originY.
///
/// worldY increases "upward" (positive = sky, negative = underground).
struct TileGrid {
std::vector<int32_t> data;
int32_t originX { 0 };
int32_t originY { 0 };
int32_t width { 0 };
int32_t height { 0 };
TileGrid() = default;
TileGrid(int32_t ox, int32_t oy, int32_t w, int32_t h)
: data(static_cast<size_t>(w) * h, 0)
, originX(ox), originY(oy), width(w), height(h) {}
bool Contains(int32_t worldX, int32_t worldY) const noexcept;
/// Returns the tile ID at (worldX, worldY), or 0 if out of bounds.
int32_t Get(int32_t worldX, int32_t worldY) const noexcept;
/// Sets the tile at (worldX, worldY). No-op if out of bounds.
void Set(int32_t worldX, int32_t worldY, int32_t id) noexcept;
/// Build an EvalContext for cell (wx, wy) backed by this grid as prev-pass data.
EvalContext MakeEvalContext(int32_t wx, int32_t wy, uint64_t seed) const noexcept;
};
// ─────────────────────────────── PaddingBounds ───────────────────────────────
/// Extra tiles a pass needs beyond its output region from the previous pass,
/// in each compass direction.
struct PaddingBounds {
int32_t negX { 0 }; ///< Extra tiles needed toward X.
int32_t posX { 0 }; ///< Extra tiles needed toward +X.
int32_t negY { 0 }; ///< Extra tiles needed toward Y.
int32_t posY { 0 }; ///< Extra tiles needed toward +Y.
/// Expand to accommodate a single relative offset (dx, dy).
void Include(int32_t dx, int32_t dy) noexcept;
/// Expand to be at least as large as another PaddingBounds.
void Include(const PaddingBounds& o) noexcept;
int32_t TotalX() const noexcept { return negX + posX; }
int32_t TotalY() const noexcept { return negY + posY; }
bool IsZero() const noexcept { return negX == 0 && posX == 0 && negY == 0 && posY == 0; }
};
/// Walk the subgraph rooted at \p outputNode and return the maximum query offsets
/// found in any QueryTile / QueryRange / QueryDistance node.
///
/// This tells you how much border the PREVIOUS pass must generate so that every
/// query within this pass can be satisfied.
PaddingBounds ComputeRequiredPadding(const Graph& graph, Graph::NodeID outputNode);
// ─────────────────────────────── Generation API ──────────────────────────────
/// One pass in a multi-pass world-generation pipeline.
struct GenerationPass {
const Graph& graph;
Graph::NodeID outputNode;
};
/// Generate a tile grid covering [originX .. originX+width-1] × [originY .. originY+height-1].
///
/// For each cell the graph is evaluated with a context that provides:
/// - The cell's world position.
/// - The full contents of \p prevPassData as the previous-pass tile source
/// (nullptr for the first pass).
///
/// Semantics of the return value:
/// - Non-zero → set that tile to the returned ID.
/// - Zero → "no change": if prevPassData is available the cell keeps its
/// previous-pass value; otherwise it stays 0 (AIR).
TileGrid GenerateRegion(
const Graph& graph,
Graph::NodeID outputNode,
int32_t originX, int32_t originY,
int32_t width, int32_t height,
const TileGrid* prevPassData,
uint64_t seed);
/// Run multiple passes over a chunk, automatically deriving the padded regions
/// each pass needs.
///
/// Algorithm
/// ─────────
/// 1. passes[last] generates exactly [chunkOriginX, chunkOriginY, W × H].
/// 2. For every earlier pass i, ComputeRequiredPadding(passes[i+1]) determines
/// how much larger passes[i]'s output must be so passes[i+1] can query it.
/// This expansion accumulates from last to first.
/// 3. Passes execute in forward order; each feeds its output to the next.
///
/// The returned TileGrid covers the final chunk region only.
TileGrid GenerateChunk(
const std::vector<GenerationPass>& passes,
int32_t chunkOriginX, int32_t chunkOriginY,
int32_t chunkWidth, int32_t chunkHeight,
uint64_t seed);
} // namespace WorldGraph

View File

@@ -3,7 +3,9 @@
#include "WorldGraph/WorldGraphTypes.h"
#include "config.h"
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <string>
#include <vector>
@@ -209,6 +211,131 @@ public:
}
};
// ─────────────────────────────── More math nodes ─────────────────────────────
/// sin(a) in radians (Float)
class SinNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return { Type::Float }; }
std::string GetName() const override { return "Sin"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 1);
return Value::MakeFloat(std::sin(in[0].AsFloat()));
}
};
/// cos(a) in radians (Float)
class CosNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return { Type::Float }; }
std::string GetName() const override { return "Cos"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 1);
return Value::MakeFloat(std::cos(in[0].AsFloat()));
}
};
/// fmod(a, b) — returns 0 when b == 0 (Float)
class ModuloNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return { Type::Float, Type::Float }; }
std::string GetName() const override { return "Modulo"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
float b = in[1].AsFloat();
if (b == 0.0f) return Value::MakeFloat(0.0f);
return Value::MakeFloat(std::fmod(in[0].AsFloat(), b));
}
};
// ─────────────────────────────── Tile query nodes ────────────────────────────
//
// Query nodes read from the PREVIOUS generation pass via EvalContext::GetPrevTile().
// Their offsets are baked into the node so the chunk generator can compute the
// required border padding BEFORE evaluation begins.
//
// Returns 0 (AIR / no data) for any out-of-bounds access.
/// Returns true when the previous-pass tile at (worldX+offsetX, worldY+offsetY)
/// equals expectedID.
class QueryTileNode : public Node {
public:
int32_t offsetX { 0 };
int32_t offsetY { 0 };
int32_t expectedID { 0 };
QueryTileNode() = default;
QueryTileNode(int32_t ox, int32_t oy, int32_t id)
: offsetX(ox), offsetY(oy), expectedID(id) {}
Type GetOutputType() const override { return Type::Bool; }
std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "QueryTile"; }
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override {
return Value::MakeBool(
ctx.GetPrevTile(ctx.worldX + offsetX, ctx.worldY + offsetY) == expectedID);
}
};
/// Counts tiles matching tileID within the rectangle
/// [worldX+minX .. worldX+maxX] × [worldY+minY .. worldY+maxY] (inclusive).
class QueryRangeNode : public Node {
public:
int32_t minX { 0 };
int32_t minY { 1 };
int32_t maxX { 0 };
int32_t maxY { 4 };
int32_t tileID { 0 };
QueryRangeNode() = default;
QueryRangeNode(int32_t mnX, int32_t mnY, int32_t mxX, int32_t mxY, int32_t id)
: minX(mnX), minY(mnY), maxX(mxX), maxY(mxY), tileID(id) {}
Type GetOutputType() const override { return Type::Int; }
std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "QueryRange"; }
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override {
int32_t count = 0;
for (int32_t dy = minY; dy <= maxY; ++dy)
for (int32_t dx = minX; dx <= maxX; ++dx)
if (ctx.GetPrevTile(ctx.worldX + dx, ctx.worldY + dy) == tileID)
++count;
return Value::MakeInt(count);
}
};
/// Returns the Chebyshev distance (max(|dx|,|dy|)) to the nearest tile of
/// tileID within maxDistance tiles. Returns maxDistance + 1 if none found.
/// The current cell (distance 0) is never considered a match.
class QueryDistanceNode : public Node {
public:
int32_t tileID { 0 };
int32_t maxDistance { 4 };
QueryDistanceNode() = default;
QueryDistanceNode(int32_t id, int32_t maxDist)
: tileID(id), maxDistance(maxDist) {}
Type GetOutputType() const override { return Type::Int; }
std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "QueryDistance"; }
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override {
int32_t best = maxDistance + 1;
for (int32_t dy = -maxDistance; dy <= maxDistance; ++dy) {
for (int32_t dx = -maxDistance; dx <= maxDistance; ++dx) {
int32_t d = (std::abs(dx) > std::abs(dy)) ? std::abs(dx) : std::abs(dy);
if (d == 0 || d >= best) continue;
if (ctx.GetPrevTile(ctx.worldX + dx, ctx.worldY + dy) == tileID)
best = d;
}
}
return Value::MakeInt(best);
}
};
// ─────────────────────────────── Source / constant nodes ─────────────────────
/// Outputs a fixed floating-point constant (no inputs).

View File

@@ -34,7 +34,21 @@ struct EvalContext {
int32_t worldX { 0 }; ///< World-space tile column.
int32_t worldY { 0 }; ///< World-space tile row.
uint64_t seed { 0 }; ///< World seed for deterministic noise.
// TODO: previous-pass tile accessor for tile-query nodes.
// ── Previous-pass tile data (optional) ────────────────────────────────
// Set by the chunk generator before calling Evaluate().
// Row-major: prevTiles[ly * prevWidth + lx], lx = worldX - prevOriginX.
const int32_t* prevTiles { nullptr };
int32_t prevOriginX { 0 };
int32_t prevOriginY { 0 };
int32_t prevWidth { 0 };
int32_t prevHeight { 0 };
/// Query the previous pass at an absolute world position.
/// Returns 0 (AIR / empty) when no previous pass or position is out of bounds.
inline int32_t GetPrevTile(int32_t x, int32_t y) const noexcept;
inline bool HasPrevPass() const noexcept { return prevTiles != nullptr; }
};
inline float Value::AsFloat() const noexcept {
@@ -64,6 +78,14 @@ inline bool Value::AsBool() const noexcept {
return false;
}
inline int32_t EvalContext::GetPrevTile(int32_t x, int32_t y) const noexcept {
if (!prevTiles) return 0;
int32_t lx = x - prevOriginX;
int32_t ly = y - prevOriginY;
if (lx < 0 || lx >= prevWidth || ly < 0 || ly >= prevHeight) return 0;
return prevTiles[ly * prevWidth + lx];
}
inline bool Value::operator==(const Value& o) const noexcept {
if (type != o.type) return false;
switch (type) {

View File

@@ -106,4 +106,10 @@ bool Graph::IsValid(NodeID outputNode) const {
return true;
}
std::vector<Graph::NodeID> Graph::GetDependencies(NodeID outputNode) const {
std::unordered_set<NodeID> visited;
CollectDependencies(outputNode, visited);
return { visited.begin(), visited.end() };
}
} // namespace WorldGraph

View File

@@ -0,0 +1,181 @@
#include "WorldGraph/WorldGraphChunk.h"
#include "WorldGraph/WorldGraphNode.h" // for dynamic_cast to query node types
#include <algorithm>
#include <cstdlib> // std::abs (integer)
namespace WorldGraph {
// ─────────────────────────────── TileGrid ────────────────────────────────────
bool TileGrid::Contains(int32_t worldX, int32_t worldY) const noexcept {
int32_t lx = worldX - originX;
int32_t ly = worldY - originY;
return lx >= 0 && lx < width && ly >= 0 && ly < height;
}
int32_t TileGrid::Get(int32_t worldX, int32_t worldY) const noexcept {
int32_t lx = worldX - originX;
int32_t ly = worldY - originY;
if (lx < 0 || lx >= width || ly < 0 || ly >= height) return 0;
return data[static_cast<size_t>(ly) * width + lx];
}
void TileGrid::Set(int32_t worldX, int32_t worldY, int32_t id) noexcept {
int32_t lx = worldX - originX;
int32_t ly = worldY - originY;
if (lx < 0 || lx >= width || ly < 0 || ly >= height) return;
data[static_cast<size_t>(ly) * width + lx] = id;
}
EvalContext TileGrid::MakeEvalContext(int32_t wx, int32_t wy, uint64_t seed) const noexcept {
EvalContext ctx;
ctx.worldX = wx;
ctx.worldY = wy;
ctx.seed = seed;
ctx.prevTiles = data.empty() ? nullptr : data.data();
ctx.prevOriginX = originX;
ctx.prevOriginY = originY;
ctx.prevWidth = width;
ctx.prevHeight = height;
return ctx;
}
// ─────────────────────────────── PaddingBounds ───────────────────────────────
void PaddingBounds::Include(int32_t dx, int32_t dy) noexcept {
if (dx < 0) negX = std::max(negX, -dx);
if (dx > 0) posX = std::max(posX, dx);
if (dy < 0) negY = std::max(negY, -dy);
if (dy > 0) posY = std::max(posY, dy);
}
void PaddingBounds::Include(const PaddingBounds& o) noexcept {
negX = std::max(negX, o.negX);
posX = std::max(posX, o.posX);
negY = std::max(negY, o.negY);
posY = std::max(posY, o.posY);
}
// ─────────────────────────────── ComputeRequiredPadding ──────────────────────
PaddingBounds ComputeRequiredPadding(const Graph& graph, Graph::NodeID outputNode) {
PaddingBounds bounds;
for (Graph::NodeID id : graph.GetDependencies(outputNode)) {
const Node* n = graph.GetNode(id);
if (!n) continue;
if (const auto* qt = dynamic_cast<const QueryTileNode*>(n)) {
bounds.Include(qt->offsetX, qt->offsetY);
}
else if (const auto* qr = dynamic_cast<const QueryRangeNode*>(n)) {
bounds.Include(qr->minX, qr->minY);
bounds.Include(qr->maxX, qr->maxY);
}
else if (const auto* qd = dynamic_cast<const QueryDistanceNode*>(n)) {
int32_t d = qd->maxDistance;
bounds.Include(-d, -d);
bounds.Include( d, d);
}
}
return bounds;
}
// ─────────────────────────────── GenerateRegion ──────────────────────────────
TileGrid GenerateRegion(
const Graph& graph,
Graph::NodeID outputNode,
int32_t originX, int32_t originY,
int32_t width, int32_t height,
const TileGrid* prevPassData,
uint64_t seed)
{
TileGrid grid(originX, originY, width, height);
for (int32_t ly = 0; ly < height; ++ly) {
for (int32_t lx = 0; lx < width; ++lx) {
int32_t wx = originX + lx;
int32_t wy = originY + ly;
EvalContext ctx;
if (prevPassData) {
ctx = prevPassData->MakeEvalContext(wx, wy, seed);
} else {
ctx.worldX = wx;
ctx.worldY = wy;
ctx.seed = seed;
}
int32_t tileID = graph.Evaluate(outputNode, ctx).AsInt();
if (tileID != 0) {
grid.Set(wx, wy, tileID);
} else if (prevPassData) {
// 0 = "no change": carry forward the previous pass value.
grid.Set(wx, wy, prevPassData->Get(wx, wy));
}
// else: tileID == 0, no prev data → tile stays 0 (already initialised).
}
}
return grid;
}
// ─────────────────────────────── GenerateChunk ───────────────────────────────
TileGrid GenerateChunk(
const std::vector<GenerationPass>& passes,
int32_t chunkOriginX, int32_t chunkOriginY,
int32_t chunkWidth, int32_t chunkHeight,
uint64_t seed)
{
if (passes.empty())
return TileGrid(chunkOriginX, chunkOriginY, chunkWidth, chunkHeight);
const size_t N = passes.size();
// Compute the output region for each pass (working backwards from the final chunk).
//
// regions[N-1] = final chunk (exact).
// regions[i-1] = regions[i] expanded by the padding that passes[i] needs.
//
// Pass i-1 must generate a region large enough for pass i to query into.
struct Region { int32_t ox, oy, w, h; };
std::vector<Region> regions(N);
regions[N - 1] = { chunkOriginX, chunkOriginY, chunkWidth, chunkHeight };
for (size_t i = N - 1; i > 0; --i) {
const Region& r = regions[i];
PaddingBounds p = ComputeRequiredPadding(passes[i].graph, passes[i].outputNode);
regions[i - 1] = {
r.ox - p.negX,
r.oy - p.negY,
r.w + p.TotalX(),
r.h + p.TotalY()
};
}
// Execute passes in forward order, feeding each output into the next.
TileGrid prev;
bool hasPrev = false;
for (size_t i = 0; i < N; ++i) {
const Region& r = regions[i];
TileGrid next = GenerateRegion(
passes[i].graph, passes[i].outputNode,
r.ox, r.oy, r.w, r.h,
hasPrev ? &prev : nullptr,
seed);
prev = std::move(next);
hasPrev = true;
}
// Trim back to exactly the final chunk region if there is only one pass
// (the last pass already generates the exact region, so no trimming is
// needed in the multi-pass case).
return prev;
}
} // namespace WorldGraph

View File

@@ -26,6 +26,9 @@ std::unique_ptr<Node> GraphSerializer::CreateNode(const std::string& type,
if (type == "Branch") return std::make_unique<BranchNode>();
if (type == "PositionX") return std::make_unique<PositionXNode>();
if (type == "PositionY") return std::make_unique<PositionYNode>();
if (type == "Sin") return std::make_unique<SinNode>();
if (type == "Cos") return std::make_unique<CosNode>();
if (type == "Modulo") return std::make_unique<ModuloNode>();
// Nodes with config data
if (type == "Constant") {
@@ -34,6 +37,25 @@ std::unique_ptr<Node> GraphSerializer::CreateNode(const std::string& type,
if (type == "TileID") {
return std::make_unique<IDNode>(j.value("tileId", 0));
}
if (type == "QueryTile") {
return std::make_unique<QueryTileNode>(
j.value("offsetX", 0),
j.value("offsetY", 0),
j.value("expectedID", 0));
}
if (type == "QueryRange") {
return std::make_unique<QueryRangeNode>(
j.value("minX", 0),
j.value("minY", 1),
j.value("maxX", 0),
j.value("maxY", 4),
j.value("tileId", 0));
}
if (type == "QueryDistance") {
return std::make_unique<QueryDistanceNode>(
j.value("tileId", 0),
j.value("maxDistance", 4));
}
return nullptr; // unrecognised type
}
@@ -64,6 +86,19 @@ nlohmann::json GraphSerializer::ToJson(const Graph& g)
jNode["value"] = cn->value;
} else if (const auto* idn = dynamic_cast<const IDNode*>(node)) {
jNode["tileId"] = idn->tileID;
} else if (const auto* qt = dynamic_cast<const QueryTileNode*>(node)) {
jNode["offsetX"] = qt->offsetX;
jNode["offsetY"] = qt->offsetY;
jNode["expectedID"] = qt->expectedID;
} else if (const auto* qr = dynamic_cast<const QueryRangeNode*>(node)) {
jNode["minX"] = qr->minX;
jNode["minY"] = qr->minY;
jNode["maxX"] = qr->maxX;
jNode["maxY"] = qr->maxY;
jNode["tileId"] = qr->tileID;
} else if (const auto* qd = dynamic_cast<const QueryDistanceNode*>(node)) {
jNode["tileId"] = qd->tileID;
jNode["maxDistance"] = qd->maxDistance;
}
jNodes.push_back(std::move(jNode));

View File

@@ -0,0 +1,170 @@
#include <doctest/doctest.h>
#include "WorldGraph/WorldGraph.h"
#include "WorldGraph/WorldGraphSerializer.h"
using namespace WorldGraph;
TEST_SUITE("WorldGraph::Nodes") {
EvalContext ctx {}; // default zero context
// ── Math ─────────────────────────────────────────────────────────────────
TEST_CASE("AddNode: float + float") {
AddNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(3.0f), Value::MakeFloat(4.0f) }).AsFloat()
== doctest::Approx(7.0f));
}
TEST_CASE("AddNode: coerces Int inputs") {
AddNode n;
CHECK(n.Evaluate(ctx, { Value::MakeInt(2), Value::MakeInt(3) }).AsFloat()
== doctest::Approx(5.0f));
}
TEST_CASE("SubtractNode: positive result") {
SubtractNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(10.0f), Value::MakeFloat(4.0f) }).AsFloat()
== doctest::Approx(6.0f));
}
TEST_CASE("SubtractNode: negative result") {
SubtractNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(2.0f), Value::MakeFloat(5.0f) }).AsFloat()
== doctest::Approx(-3.0f));
}
TEST_CASE("MultiplyNode") {
MultiplyNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(3.0f), Value::MakeFloat(4.0f) }).AsFloat()
== doctest::Approx(12.0f));
}
TEST_CASE("DivideNode: normal division") {
DivideNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(10.0f), Value::MakeFloat(2.0f) }).AsFloat()
== doctest::Approx(5.0f));
}
TEST_CASE("DivideNode: division by zero returns 0") {
DivideNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(5.0f), Value::MakeFloat(0.0f) }).AsFloat()
== doctest::Approx(0.0f));
}
// ── Comparisons ──────────────────────────────────────────────────────────
TEST_CASE("LessNode") {
LessNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-1.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
}
TEST_CASE("GreaterNode") {
GreaterNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-1.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
}
TEST_CASE("LessEqualNode: includes equality") {
LessEqualNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-1.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
}
TEST_CASE("GreaterEqualNode: includes equality") {
GreaterEqualNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-1.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
}
TEST_CASE("EqualNode") {
EqualNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(3.0f), Value::MakeFloat(3.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(3.0f), Value::MakeFloat(4.0f) }).AsBool() == false);
}
// ── Boolean logic ─────────────────────────────────────────────────────────
TEST_CASE("AndNode") {
AndNode n;
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(true) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(false) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeBool(false), Value::MakeBool(false) }).AsBool() == false);
}
TEST_CASE("OrNode") {
OrNode n;
CHECK(n.Evaluate(ctx, { Value::MakeBool(false), Value::MakeBool(false) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(false) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(true) }).AsBool() == true);
}
TEST_CASE("NotNode") {
NotNode n;
CHECK(n.Evaluate(ctx, { Value::MakeBool(true) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeBool(false) }).AsBool() == true);
}
// ── Control flow ──────────────────────────────────────────────────────────
TEST_CASE("BranchNode: selects true branch") {
BranchNode n;
auto r = n.Evaluate(ctx, { Value::MakeBool(true),
Value::MakeFloat(10.0f),
Value::MakeFloat(20.0f) });
CHECK(r.AsFloat() == doctest::Approx(10.0f));
}
TEST_CASE("BranchNode: selects false branch") {
BranchNode n;
auto r = n.Evaluate(ctx, { Value::MakeBool(false),
Value::MakeFloat(10.0f),
Value::MakeFloat(20.0f) });
CHECK(r.AsFloat() == doctest::Approx(20.0f));
}
TEST_CASE("BranchNode: preserves Int type (tile IDs pass through correctly)") {
BranchNode n;
auto r = n.Evaluate(ctx, { Value::MakeBool(true),
Value::MakeInt(7),
Value::MakeInt(0) });
CHECK(r.type == Type::Int);
CHECK(r.AsInt() == 7);
}
// ── Source / constant ─────────────────────────────────────────────────────
TEST_CASE("ConstantNode: returns configured float") {
ConstantNode n(3.5f);
CHECK(n.Evaluate(ctx, {}).AsFloat() == doctest::Approx(3.5f));
}
TEST_CASE("ConstantNode: default is 0") {
ConstantNode n;
CHECK(n.Evaluate(ctx, {}).AsFloat() == doctest::Approx(0.0f));
}
TEST_CASE("IDNode: returns tile ID as Int") {
IDNode n(42);
auto r = n.Evaluate(ctx, {});
CHECK(r.type == Type::Int);
CHECK(r.AsInt() == 42);
}
TEST_CASE("PositionXNode: reads worldX from context") {
EvalContext c; c.worldX = 7;
PositionXNode n;
CHECK(n.Evaluate(c, {}).AsInt() == 7);
}
TEST_CASE("PositionYNode: reads worldY from context") {
EvalContext c; c.worldY = -3;
PositionYNode n;
CHECK(n.Evaluate(c, {}).AsInt() == -3);
}
}

View File

@@ -0,0 +1,253 @@
#include <doctest/doctest.h>
#include "WorldGraph/WorldGraph.h"
#include "WorldGraph/WorldGraphSerializer.h"
#include <fstream>
using namespace WorldGraph;
TEST_SUITE("WorldGraph::Serialization") {
// Helper: build the flat-world graph used in several tests.
// Branch(Less(PositionY, Constant(0)), IDNode(STONE=1), IDNode(AIR=0))
static Graph MakeFlatWorldGraph(Graph::NodeID& outBranch) {
Graph g;
auto posY = g.AddNode(std::make_unique<PositionYNode>());
auto zero = g.AddNode(std::make_unique<ConstantNode>(0.0f));
auto less = g.AddNode(std::make_unique<LessNode>());
auto stone = g.AddNode(std::make_unique<IDNode>(1));
auto air = g.AddNode(std::make_unique<IDNode>(0));
outBranch = g.AddNode(std::make_unique<BranchNode>());
REQUIRE(g.Connect(posY, less, 0));
REQUIRE(g.Connect(zero, less, 1));
REQUIRE(g.Connect(less, outBranch, 0));
REQUIRE(g.Connect(stone, outBranch, 1));
REQUIRE(g.Connect(air, outBranch, 2));
return g;
}
// ── ToJson / FromJson ─────────────────────────────────────────────────────
TEST_CASE("ToJson: empty graph produces valid structure") {
Graph g;
auto j = GraphSerializer::ToJson(g);
CHECK(j.contains("nextId"));
CHECK(j["nodes"].is_array());
CHECK(j["nodes"].empty());
CHECK(j["connections"].is_array());
CHECK(j["connections"].empty());
}
TEST_CASE("ToJson: node count and type names are correct") {
Graph::NodeID branch;
auto g = MakeFlatWorldGraph(branch);
auto j = GraphSerializer::ToJson(g);
CHECK(j["nodes"].size() == 6);
CHECK(j["connections"].size() == 5);
// Collect type names
std::vector<std::string> types;
for (const auto& n : j["nodes"])
types.push_back(n["type"].get<std::string>());
CHECK(std::count(types.begin(), types.end(), "PositionY") == 1);
CHECK(std::count(types.begin(), types.end(), "Constant") == 1);
CHECK(std::count(types.begin(), types.end(), "Less") == 1);
CHECK(std::count(types.begin(), types.end(), "TileID") == 2);
CHECK(std::count(types.begin(), types.end(), "Branch") == 1);
}
TEST_CASE("ToJson: ConstantNode serialises its value") {
Graph g;
g.AddNode(std::make_unique<ConstantNode>(3.14f));
auto j = GraphSerializer::ToJson(g);
REQUIRE(!j["nodes"].empty());
CHECK(j["nodes"][0]["value"].get<float>() == doctest::Approx(3.14f));
}
TEST_CASE("ToJson: IDNode serialises its tileId") {
Graph g;
g.AddNode(std::make_unique<IDNode>(42));
auto j = GraphSerializer::ToJson(g);
REQUIRE(!j["nodes"].empty());
CHECK(j["nodes"][0]["tileId"].get<int>() == 42);
}
TEST_CASE("ToJson: connections are sorted by (to, slot)") {
Graph::NodeID branch;
auto g = MakeFlatWorldGraph(branch);
auto j = GraphSerializer::ToJson(g);
// Verify the array is ordered by ascending 'to', then 'slot'.
const auto& conns = j["connections"];
for (size_t i = 1; i < conns.size(); ++i) {
auto prevTo = conns[i-1]["to"].get<uint32_t>();
auto currTo = conns[i]["to"].get<uint32_t>();
auto prevSlot = conns[i-1]["slot"].get<int>();
auto currSlot = conns[i]["slot"].get<int>();
CHECK((currTo > prevTo || (currTo == prevTo && currSlot >= prevSlot)));
}
}
TEST_CASE("FromJson: round-trips node count and type") {
Graph::NodeID branch;
auto g = MakeFlatWorldGraph(branch);
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
CHECK(g2->NodeCount() == 6);
}
TEST_CASE("FromJson: round-trips ConstantNode value") {
Graph g;
auto id = g.AddNode(std::make_unique<ConstantNode>(2.71f));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
const auto* cn = dynamic_cast<const ConstantNode*>(g2->GetNode(id));
REQUIRE(cn != nullptr);
CHECK(cn->value == doctest::Approx(2.71f));
}
TEST_CASE("FromJson: round-trips IDNode tileID") {
Graph g;
auto id = g.AddNode(std::make_unique<IDNode>(99));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
const auto* idn = dynamic_cast<const IDNode*>(g2->GetNode(id));
REQUIRE(idn != nullptr);
CHECK(idn->tileID == 99);
}
TEST_CASE("FromJson: round-trips connections") {
Graph g;
auto c1 = g.AddNode(std::make_unique<ConstantNode>(1.0f));
auto c2 = g.AddNode(std::make_unique<ConstantNode>(2.0f));
auto add = g.AddNode(std::make_unique<AddNode>());
REQUIRE(g.Connect(c1, add, 0));
REQUIRE(g.Connect(c2, add, 1));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
CHECK(g2->GetInput(add, 0) == c1);
CHECK(g2->GetInput(add, 1) == c2);
}
TEST_CASE("FromJson: preserves node IDs") {
Graph g;
auto id = g.AddNode(std::make_unique<ConstantNode>(7.0f));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
CHECK(g2->GetNode(id) != nullptr);
}
TEST_CASE("FromJson: preserves nextId so new nodes get fresh IDs") {
Graph g;
auto id1 = g.AddNode(std::make_unique<ConstantNode>(1.0f));
auto id2 = g.AddNode(std::make_unique<ConstantNode>(2.0f));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
// A new node must get an ID not already in use.
auto id3 = g2->AddNode(std::make_unique<ConstantNode>(3.0f));
CHECK(id3 != id1);
CHECK(id3 != id2);
}
TEST_CASE("FromJson: returns nullopt for missing required fields") {
CHECK(!GraphSerializer::FromJson({}).has_value());
CHECK(!GraphSerializer::FromJson(nlohmann::json::object()).has_value());
CHECK(!GraphSerializer::FromJson({ {"nextId", 1}, {"nodes", nullptr}, {"connections", nullptr} }).has_value());
}
TEST_CASE("FromJson: returns nullopt for unknown node type") {
nlohmann::json j;
j["nextId"] = 2;
j["nodes"] = {{ {"id", 1}, {"type", "NonExistentNode"} }};
j["connections"] = nlohmann::json::array();
CHECK(!GraphSerializer::FromJson(j).has_value());
}
// ── Evaluation after round-trip ───────────────────────────────────────────
TEST_CASE("Round-trip: flat world evaluates identically after FromJson") {
Graph::NodeID branch;
auto g = MakeFlatWorldGraph(branch);
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
for (int y : { -5, -1, 0, 1, 5 }) {
INFO("y = " << y);
EvalContext ctx; ctx.worldY = y;
CHECK(g2->Evaluate(branch, ctx).AsInt() == g.Evaluate(branch, ctx).AsInt());
}
}
TEST_CASE("Round-trip: arithmetic graph evaluates identically after FromJson") {
// (3.0 + 4.0) - 2.0 = 5.0
Graph g;
auto c1 = g.AddNode(std::make_unique<ConstantNode>(3.0f));
auto c2 = g.AddNode(std::make_unique<ConstantNode>(4.0f));
auto c3 = g.AddNode(std::make_unique<ConstantNode>(2.0f));
auto add = g.AddNode(std::make_unique<AddNode>());
auto sub = g.AddNode(std::make_unique<SubtractNode>());
REQUIRE(g.Connect(c1, add, 0));
REQUIRE(g.Connect(c2, add, 1));
REQUIRE(g.Connect(add, sub, 0));
REQUIRE(g.Connect(c3, sub, 1));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
CHECK(g2->Evaluate(sub, {}).AsFloat() == doctest::Approx(5.0f));
}
// ── File Save / Load ──────────────────────────────────────────────────────
TEST_CASE("Save + Load: round-trip via file") {
const std::string path = "/tmp/test_worldgraph_save.json";
Graph::NodeID branch;
auto g = MakeFlatWorldGraph(branch);
REQUIRE(GraphSerializer::Save(g, path));
auto g2 = GraphSerializer::Load(path);
REQUIRE(g2.has_value());
CHECK(g2->NodeCount() == g.NodeCount());
for (int y : { -3, 0, 3 }) {
INFO("y = " << y);
EvalContext ctx; ctx.worldY = y;
CHECK(g2->Evaluate(branch, ctx).AsInt() == g.Evaluate(branch, ctx).AsInt());
}
}
TEST_CASE("Save: returns false for an unwritable path") {
Graph g;
g.AddNode(std::make_unique<ConstantNode>(1.0f));
CHECK(!GraphSerializer::Save(g, "/no/such/directory/graph.json"));
}
TEST_CASE("Load: returns nullopt for a missing file") {
CHECK(!GraphSerializer::Load("/no/such/file.json").has_value());
}
TEST_CASE("Load: returns nullopt for malformed JSON") {
const std::string path = "/tmp/test_worldgraph_bad.json";
{
std::ofstream f(path);
f << "{ this is not valid json }";
}
CHECK(!GraphSerializer::Load(path).has_value());
}
}

View File

@@ -67,173 +67,6 @@ TEST_SUITE("WorldGraph::Value") {
}
}
// ─────────────────────────── Standalone nodes ────────────────────────────────
TEST_SUITE("WorldGraph::Nodes") {
EvalContext ctx {}; // default zero context
// ── Math ─────────────────────────────────────────────────────────────────
TEST_CASE("AddNode: float + float") {
AddNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(3.0f), Value::MakeFloat(4.0f) }).AsFloat()
== doctest::Approx(7.0f));
}
TEST_CASE("AddNode: coerces Int inputs") {
AddNode n;
CHECK(n.Evaluate(ctx, { Value::MakeInt(2), Value::MakeInt(3) }).AsFloat()
== doctest::Approx(5.0f));
}
TEST_CASE("SubtractNode: positive result") {
SubtractNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(10.0f), Value::MakeFloat(4.0f) }).AsFloat()
== doctest::Approx(6.0f));
}
TEST_CASE("SubtractNode: negative result") {
SubtractNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(2.0f), Value::MakeFloat(5.0f) }).AsFloat()
== doctest::Approx(-3.0f));
}
TEST_CASE("MultiplyNode") {
MultiplyNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(3.0f), Value::MakeFloat(4.0f) }).AsFloat()
== doctest::Approx(12.0f));
}
TEST_CASE("DivideNode: normal division") {
DivideNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(10.0f), Value::MakeFloat(2.0f) }).AsFloat()
== doctest::Approx(5.0f));
}
TEST_CASE("DivideNode: division by zero returns 0") {
DivideNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(5.0f), Value::MakeFloat(0.0f) }).AsFloat()
== doctest::Approx(0.0f));
}
// ── Comparisons ──────────────────────────────────────────────────────────
TEST_CASE("LessNode") {
LessNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-1.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
}
TEST_CASE("GreaterNode") {
GreaterNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-1.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
}
TEST_CASE("LessEqualNode: includes equality") {
LessEqualNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-1.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
}
TEST_CASE("GreaterEqualNode: includes equality") {
GreaterEqualNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-1.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
}
TEST_CASE("EqualNode") {
EqualNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(3.0f), Value::MakeFloat(3.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(3.0f), Value::MakeFloat(4.0f) }).AsBool() == false);
}
// ── Boolean logic ─────────────────────────────────────────────────────────
TEST_CASE("AndNode") {
AndNode n;
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(true) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(false) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeBool(false), Value::MakeBool(false) }).AsBool() == false);
}
TEST_CASE("OrNode") {
OrNode n;
CHECK(n.Evaluate(ctx, { Value::MakeBool(false), Value::MakeBool(false) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(false) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(true) }).AsBool() == true);
}
TEST_CASE("NotNode") {
NotNode n;
CHECK(n.Evaluate(ctx, { Value::MakeBool(true) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeBool(false) }).AsBool() == true);
}
// ── Control flow ──────────────────────────────────────────────────────────
TEST_CASE("BranchNode: selects true branch") {
BranchNode n;
auto r = n.Evaluate(ctx, { Value::MakeBool(true),
Value::MakeFloat(10.0f),
Value::MakeFloat(20.0f) });
CHECK(r.AsFloat() == doctest::Approx(10.0f));
}
TEST_CASE("BranchNode: selects false branch") {
BranchNode n;
auto r = n.Evaluate(ctx, { Value::MakeBool(false),
Value::MakeFloat(10.0f),
Value::MakeFloat(20.0f) });
CHECK(r.AsFloat() == doctest::Approx(20.0f));
}
TEST_CASE("BranchNode: preserves Int type (tile IDs pass through correctly)") {
BranchNode n;
auto r = n.Evaluate(ctx, { Value::MakeBool(true),
Value::MakeInt(7),
Value::MakeInt(0) });
CHECK(r.type == Type::Int);
CHECK(r.AsInt() == 7);
}
// ── Source / constant ─────────────────────────────────────────────────────
TEST_CASE("ConstantNode: returns configured float") {
ConstantNode n(3.5f);
CHECK(n.Evaluate(ctx, {}).AsFloat() == doctest::Approx(3.5f));
}
TEST_CASE("ConstantNode: default is 0") {
ConstantNode n;
CHECK(n.Evaluate(ctx, {}).AsFloat() == doctest::Approx(0.0f));
}
TEST_CASE("IDNode: returns tile ID as Int") {
IDNode n(42);
auto r = n.Evaluate(ctx, {});
CHECK(r.type == Type::Int);
CHECK(r.AsInt() == 42);
}
TEST_CASE("PositionXNode: reads worldX from context") {
EvalContext c; c.worldX = 7;
PositionXNode n;
CHECK(n.Evaluate(c, {}).AsInt() == 7);
}
TEST_CASE("PositionYNode: reads worldY from context") {
EvalContext c; c.worldY = -3;
PositionYNode n;
CHECK(n.Evaluate(c, {}).AsInt() == -3);
}
}
// ─────────────────────────────── Graph ───────────────────────────────────────
TEST_SUITE("WorldGraph::Graph") {
@@ -617,252 +450,3 @@ TEST_SUITE("WorldGraph::Graph") {
CHECK(eval(5) == AIR);
}
}
// ─────────────────────────── Serialization ───────────────────────────────────
TEST_SUITE("WorldGraph::Serialization") {
// Helper: build the flat-world graph used in several tests.
// Branch(Less(PositionY, Constant(0)), IDNode(STONE=1), IDNode(AIR=0))
static Graph MakeFlatWorldGraph(Graph::NodeID& outBranch) {
Graph g;
auto posY = g.AddNode(std::make_unique<PositionYNode>());
auto zero = g.AddNode(std::make_unique<ConstantNode>(0.0f));
auto less = g.AddNode(std::make_unique<LessNode>());
auto stone = g.AddNode(std::make_unique<IDNode>(1));
auto air = g.AddNode(std::make_unique<IDNode>(0));
outBranch = g.AddNode(std::make_unique<BranchNode>());
REQUIRE(g.Connect(posY, less, 0));
REQUIRE(g.Connect(zero, less, 1));
REQUIRE(g.Connect(less, outBranch, 0));
REQUIRE(g.Connect(stone, outBranch, 1));
REQUIRE(g.Connect(air, outBranch, 2));
return g;
}
// ── ToJson / FromJson ─────────────────────────────────────────────────────
TEST_CASE("ToJson: empty graph produces valid structure") {
Graph g;
auto j = GraphSerializer::ToJson(g);
CHECK(j.contains("nextId"));
CHECK(j["nodes"].is_array());
CHECK(j["nodes"].empty());
CHECK(j["connections"].is_array());
CHECK(j["connections"].empty());
}
TEST_CASE("ToJson: node count and type names are correct") {
Graph::NodeID branch;
auto g = MakeFlatWorldGraph(branch);
auto j = GraphSerializer::ToJson(g);
CHECK(j["nodes"].size() == 6);
CHECK(j["connections"].size() == 5);
// Collect type names
std::vector<std::string> types;
for (const auto& n : j["nodes"])
types.push_back(n["type"].get<std::string>());
CHECK(std::count(types.begin(), types.end(), "PositionY") == 1);
CHECK(std::count(types.begin(), types.end(), "Constant") == 1);
CHECK(std::count(types.begin(), types.end(), "Less") == 1);
CHECK(std::count(types.begin(), types.end(), "TileID") == 2);
CHECK(std::count(types.begin(), types.end(), "Branch") == 1);
}
TEST_CASE("ToJson: ConstantNode serialises its value") {
Graph g;
g.AddNode(std::make_unique<ConstantNode>(3.14f));
auto j = GraphSerializer::ToJson(g);
REQUIRE(!j["nodes"].empty());
CHECK(j["nodes"][0]["value"].get<float>() == doctest::Approx(3.14f));
}
TEST_CASE("ToJson: IDNode serialises its tileId") {
Graph g;
g.AddNode(std::make_unique<IDNode>(42));
auto j = GraphSerializer::ToJson(g);
REQUIRE(!j["nodes"].empty());
CHECK(j["nodes"][0]["tileId"].get<int>() == 42);
}
TEST_CASE("ToJson: connections are sorted by (to, slot)") {
Graph::NodeID branch;
auto g = MakeFlatWorldGraph(branch);
auto j = GraphSerializer::ToJson(g);
// Verify the array is ordered by ascending 'to', then 'slot'.
const auto& conns = j["connections"];
for (size_t i = 1; i < conns.size(); ++i) {
auto prevTo = conns[i-1]["to"].get<uint32_t>();
auto currTo = conns[i]["to"].get<uint32_t>();
auto prevSlot = conns[i-1]["slot"].get<int>();
auto currSlot = conns[i]["slot"].get<int>();
CHECK((currTo > prevTo || (currTo == prevTo && currSlot >= prevSlot)));
}
}
TEST_CASE("FromJson: round-trips node count and type") {
Graph::NodeID branch;
auto g = MakeFlatWorldGraph(branch);
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
CHECK(g2->NodeCount() == 6);
}
TEST_CASE("FromJson: round-trips ConstantNode value") {
Graph g;
auto id = g.AddNode(std::make_unique<ConstantNode>(2.71f));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
const auto* cn = dynamic_cast<const ConstantNode*>(g2->GetNode(id));
REQUIRE(cn != nullptr);
CHECK(cn->value == doctest::Approx(2.71f));
}
TEST_CASE("FromJson: round-trips IDNode tileID") {
Graph g;
auto id = g.AddNode(std::make_unique<IDNode>(99));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
const auto* idn = dynamic_cast<const IDNode*>(g2->GetNode(id));
REQUIRE(idn != nullptr);
CHECK(idn->tileID == 99);
}
TEST_CASE("FromJson: round-trips connections") {
Graph g;
auto c1 = g.AddNode(std::make_unique<ConstantNode>(1.0f));
auto c2 = g.AddNode(std::make_unique<ConstantNode>(2.0f));
auto add = g.AddNode(std::make_unique<AddNode>());
REQUIRE(g.Connect(c1, add, 0));
REQUIRE(g.Connect(c2, add, 1));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
CHECK(g2->GetInput(add, 0) == c1);
CHECK(g2->GetInput(add, 1) == c2);
}
TEST_CASE("FromJson: preserves node IDs") {
Graph g;
auto id = g.AddNode(std::make_unique<ConstantNode>(7.0f));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
CHECK(g2->GetNode(id) != nullptr);
}
TEST_CASE("FromJson: preserves nextId so new nodes get fresh IDs") {
Graph g;
auto id1 = g.AddNode(std::make_unique<ConstantNode>(1.0f));
auto id2 = g.AddNode(std::make_unique<ConstantNode>(2.0f));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
// A new node must get an ID not already in use.
auto id3 = g2->AddNode(std::make_unique<ConstantNode>(3.0f));
CHECK(id3 != id1);
CHECK(id3 != id2);
}
TEST_CASE("FromJson: returns nullopt for missing required fields") {
CHECK(!GraphSerializer::FromJson({}).has_value());
CHECK(!GraphSerializer::FromJson(nlohmann::json::object()).has_value());
CHECK(!GraphSerializer::FromJson({ {"nextId", 1}, {"nodes", nullptr}, {"connections", nullptr} }).has_value());
}
TEST_CASE("FromJson: returns nullopt for unknown node type") {
nlohmann::json j;
j["nextId"] = 2;
j["nodes"] = {{ {"id", 1}, {"type", "NonExistentNode"} }};
j["connections"] = nlohmann::json::array();
CHECK(!GraphSerializer::FromJson(j).has_value());
}
// ── Evaluation after round-trip ───────────────────────────────────────────
TEST_CASE("Round-trip: flat world evaluates identically after FromJson") {
Graph::NodeID branch;
auto g = MakeFlatWorldGraph(branch);
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
for (int y : { -5, -1, 0, 1, 5 }) {
INFO("y = " << y);
EvalContext ctx; ctx.worldY = y;
CHECK(g2->Evaluate(branch, ctx).AsInt() == g.Evaluate(branch, ctx).AsInt());
}
}
TEST_CASE("Round-trip: arithmetic graph evaluates identically after FromJson") {
// (3.0 + 4.0) - 2.0 = 5.0
Graph g;
auto c1 = g.AddNode(std::make_unique<ConstantNode>(3.0f));
auto c2 = g.AddNode(std::make_unique<ConstantNode>(4.0f));
auto c3 = g.AddNode(std::make_unique<ConstantNode>(2.0f));
auto add = g.AddNode(std::make_unique<AddNode>());
auto sub = g.AddNode(std::make_unique<SubtractNode>());
REQUIRE(g.Connect(c1, add, 0));
REQUIRE(g.Connect(c2, add, 1));
REQUIRE(g.Connect(add, sub, 0));
REQUIRE(g.Connect(c3, sub, 1));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
CHECK(g2->Evaluate(sub, {}).AsFloat() == doctest::Approx(5.0f));
}
// ── File Save / Load ──────────────────────────────────────────────────────
TEST_CASE("Save + Load: round-trip via file") {
const std::string path = "/tmp/test_worldgraph_save.json";
Graph::NodeID branch;
auto g = MakeFlatWorldGraph(branch);
REQUIRE(GraphSerializer::Save(g, path));
auto g2 = GraphSerializer::Load(path);
REQUIRE(g2.has_value());
CHECK(g2->NodeCount() == g.NodeCount());
for (int y : { -3, 0, 3 }) {
INFO("y = " << y);
EvalContext ctx; ctx.worldY = y;
CHECK(g2->Evaluate(branch, ctx).AsInt() == g.Evaluate(branch, ctx).AsInt());
}
}
TEST_CASE("Save: returns false for an unwritable path") {
Graph g;
g.AddNode(std::make_unique<ConstantNode>(1.0f));
CHECK(!GraphSerializer::Save(g, "/no/such/directory/graph.json"));
}
TEST_CASE("Load: returns nullopt for a missing file") {
CHECK(!GraphSerializer::Load("/no/such/file.json").has_value());
}
TEST_CASE("Load: returns nullopt for malformed JSON") {
const std::string path = "/tmp/test_worldgraph_bad.json";
{
std::ofstream f(path);
f << "{ this is not valid json }";
}
CHECK(!GraphSerializer::Load(path).has_value());
}
}

View File

@@ -0,0 +1,616 @@
#include <doctest/doctest.h>
#include "WorldGraph/WorldGraphChunk.h"
#include "WorldGraph/WorldGraphNode.h"
#include <cmath>
using namespace WorldGraph;
// ─────────────────────────────── TileGrid ────────────────────────────────────
TEST_SUITE("WorldGraph::TileGrid") {
TEST_CASE("Initialises every cell to 0") {
TileGrid g(0, 0, 4, 4);
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x)
CHECK(g.Get(x, y) == 0);
}
TEST_CASE("Get/Set round-trip") {
TileGrid g(0, 0, 8, 8);
g.Set(3, 5, 42);
CHECK(g.Get(3, 5) == 42);
CHECK(g.Get(3, 4) == 0); // neighbour untouched
}
TEST_CASE("Get returns 0 for out-of-bounds") {
TileGrid g(2, 2, 4, 4); // covers (2..5, 2..5)
CHECK(g.Get(1, 3) == 0); // x too small
CHECK(g.Get(6, 3) == 0); // x too large
CHECK(g.Get(3, 1) == 0); // y too small
CHECK(g.Get(3, 6) == 0); // y too large
}
TEST_CASE("Set is a no-op for out-of-bounds") {
TileGrid g(0, 0, 4, 4);
g.Set(-1, 0, 99); // must not crash or corrupt
g.Set(0, -1, 99);
g.Set(4, 0, 99);
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x)
CHECK(g.Get(x, y) == 0);
}
TEST_CASE("Contains") {
TileGrid g(1, 2, 3, 4); // x: 1..3, y: 2..5
CHECK( g.Contains(1, 2));
CHECK( g.Contains(3, 5));
CHECK(!g.Contains(0, 3));
CHECK(!g.Contains(4, 3));
CHECK(!g.Contains(2, 1));
CHECK(!g.Contains(2, 6));
}
TEST_CASE("Origin offset: Set/Get at non-zero origin") {
TileGrid g(-5, -3, 10, 6); // world x: -5..4, y: -3..2
g.Set(-5, -3, 7);
g.Set(4, 2, 8);
CHECK(g.Get(-5, -3) == 7);
CHECK(g.Get( 4, 2) == 8);
CHECK(g.Get( 0, 0) == 0);
}
TEST_CASE("MakeEvalContext sets prev-pass fields") {
TileGrid g(0, 0, 4, 4);
g.Set(1, 2, 5);
EvalContext ctx = g.MakeEvalContext(1, 2, 42u);
CHECK(ctx.worldX == 1);
CHECK(ctx.worldY == 2);
CHECK(ctx.seed == 42u);
CHECK(ctx.prevTiles != nullptr);
CHECK(ctx.prevOriginX == 0);
CHECK(ctx.prevOriginY == 0);
CHECK(ctx.prevWidth == 4);
CHECK(ctx.prevHeight == 4);
CHECK(ctx.GetPrevTile(1, 2) == 5);
CHECK(ctx.GetPrevTile(0, 0) == 0);
}
}
// ─────────────────────────────── PaddingBounds ───────────────────────────────
TEST_SUITE("WorldGraph::PaddingBounds") {
TEST_CASE("Default is all zero") {
PaddingBounds p;
CHECK(p.IsZero());
}
TEST_CASE("Include positive offset expands posX/posY") {
PaddingBounds p;
p.Include(3, 4);
CHECK(p.negX == 0); CHECK(p.posX == 3);
CHECK(p.negY == 0); CHECK(p.posY == 4);
}
TEST_CASE("Include negative offset expands negX/negY") {
PaddingBounds p;
p.Include(-2, -5);
CHECK(p.negX == 2); CHECK(p.posX == 0);
CHECK(p.negY == 5); CHECK(p.posY == 0);
}
TEST_CASE("Include zero changes nothing") {
PaddingBounds p;
p.Include(0, 0);
CHECK(p.IsZero());
}
TEST_CASE("Include keeps max, not last") {
PaddingBounds p;
p.Include(1, 0);
p.Include(3, 0);
p.Include(2, 0);
CHECK(p.posX == 3);
}
TEST_CASE("Include(PaddingBounds) takes element-wise max") {
PaddingBounds a; a.Include(2, 1); a.Include(-3, 0);
PaddingBounds b; b.Include(1, 4); b.Include( 0, -2);
a.Include(b);
CHECK(a.negX == 3); CHECK(a.posX == 2);
CHECK(a.negY == 2); CHECK(a.posY == 4);
}
TEST_CASE("TotalX/TotalY") {
PaddingBounds p;
p.Include(-2, -3);
p.Include( 4, 5);
CHECK(p.TotalX() == 6);
CHECK(p.TotalY() == 8);
}
}
// ─────────────────────────────── ComputeRequiredPadding ──────────────────────
TEST_SUITE("WorldGraph::ComputeRequiredPadding") {
TEST_CASE("Graph with no query nodes → zero padding") {
Graph g;
auto c = g.AddNode(std::make_unique<ConstantNode>(1.0f));
CHECK(ComputeRequiredPadding(g, c).IsZero());
}
TEST_CASE("QueryTileNode contributes its offset") {
Graph g;
auto qt = g.AddNode(std::make_unique<QueryTileNode>(2, -3, 1));
auto p = ComputeRequiredPadding(g, qt);
CHECK(p.posX == 2); CHECK(p.negX == 0);
CHECK(p.negY == 3); CHECK(p.posY == 0);
}
TEST_CASE("QueryRangeNode contributes its extreme corners") {
Graph g;
// range: x in [-1,1], y in [1,4]
auto qr = g.AddNode(std::make_unique<QueryRangeNode>(-1, 1, 1, 4, 0));
auto p = ComputeRequiredPadding(g, qr);
CHECK(p.negX == 1); CHECK(p.posX == 1);
CHECK(p.negY == 0); CHECK(p.posY == 4);
}
TEST_CASE("QueryDistanceNode with maxDistance=3 pads all directions by 3") {
Graph g;
auto qd = g.AddNode(std::make_unique<QueryDistanceNode>(1, 3));
auto p = ComputeRequiredPadding(g, qd);
CHECK(p.negX == 3); CHECK(p.posX == 3);
CHECK(p.negY == 3); CHECK(p.posY == 3);
}
TEST_CASE("Multiple query nodes: takes element-wise max") {
Graph g;
// Outer branch feeds two query nodes; ComputeRequiredPadding walks all deps.
auto cond = g.AddNode(std::make_unique<QueryTileNode>( 0, 5, 1)); // posY=5
auto qtFar = g.AddNode(std::make_unique<QueryTileNode>(-4, 0, 1)); // negX=4
auto branch = 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(cond, branch, 0));
REQUIRE(g.Connect(id1, branch, 1));
REQUIRE(g.Connect(id0, branch, 2));
// qtFar is not connected to branch but IS in the graph.
// ComputeRequiredPadding only walks the subgraph reachable from outputNode.
auto p1 = ComputeRequiredPadding(g, branch);
CHECK(p1.posY == 5); // from cond
CHECK(p1.negX == 0); // qtFar not reachable from branch
// Connect qtFar into the output chain.
auto andN = g.AddNode(std::make_unique<AndNode>());
REQUIRE(g.Connect(cond, andN, 0));
REQUIRE(g.Connect(qtFar, andN, 1));
auto branch2 = g.AddNode(std::make_unique<BranchNode>());
REQUIRE(g.Connect(andN, branch2, 0));
REQUIRE(g.Connect(id1, branch2, 1));
REQUIRE(g.Connect(id0, branch2, 2));
auto p2 = ComputeRequiredPadding(g, branch2);
CHECK(p2.posY == 5);
CHECK(p2.negX == 4);
}
}
// ─────────────────────────────── Query nodes ─────────────────────────────────
TEST_SUITE("WorldGraph::QueryNodes") {
// Helper: build a 4×4 grid filled with a checkerboard of STONE(1) / AIR(0).
static TileGrid MakeCheckerboard(int ox = 0, int oy = 0) {
TileGrid g(ox, oy, 4, 4);
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x)
if ((x + y) % 2 == 0) g.Set(ox + x, oy + y, 1); // STONE
return g;
}
TEST_CASE("QueryTileNode: match at current cell") {
auto grid = MakeCheckerboard();
EvalContext ctx = grid.MakeEvalContext(0, 0, 0); // (0,0) is STONE
QueryTileNode n(0, 0, 1);
CHECK(n.Evaluate(ctx, {}).AsBool() == true);
}
TEST_CASE("QueryTileNode: no match") {
auto grid = MakeCheckerboard();
EvalContext ctx = grid.MakeEvalContext(0, 0, 0); // (0,0) is STONE
QueryTileNode n(0, 0, 0); // looking for AIR at (0,0)
CHECK(n.Evaluate(ctx, {}).AsBool() == false);
}
TEST_CASE("QueryTileNode: relative offset") {
auto grid = MakeCheckerboard();
// (0,0)=STONE (1,0)=AIR (0,1)=AIR (1,1)=STONE
EvalContext ctx = grid.MakeEvalContext(0, 0, 0);
QueryTileNode at1_0(1, 0, 0); // should be AIR
CHECK(at1_0.Evaluate(ctx, {}).AsBool() == true);
QueryTileNode at1_1(1, 1, 1); // should be STONE
CHECK(at1_1.Evaluate(ctx, {}).AsBool() == true);
}
TEST_CASE("QueryTileNode: out-of-bounds returns 0 (AIR)") {
auto grid = MakeCheckerboard();
EvalContext ctx = grid.MakeEvalContext(0, 0, 0);
QueryTileNode oob(10, 10, 0); // offset way out of bounds → GetPrevTile → 0 (AIR)
CHECK(oob.Evaluate(ctx, {}).AsBool() == true);
}
TEST_CASE("QueryRangeNode: count matching tiles in range") {
TileGrid g(0, 0, 5, 5);
// Fill column x=2, y=0..4 with STONE(1)
for (int y = 0; y < 5; ++y) g.Set(2, y, 1);
// At cell (2, 2): range (-1,1) to (1,1) → 3 STONE tiles in column
EvalContext ctx = g.MakeEvalContext(2, 2, 0);
QueryRangeNode qr(-1, -1, 1, 1, 1);
CHECK(qr.Evaluate(ctx, {}).AsInt() == 3); // (2,1),(2,2),(2,3)
}
TEST_CASE("QueryRangeNode: count above — column of AIR above stone") {
TileGrid g(0, 0, 3, 8);
// Stone layer: y=0..3, air above: y=4..7
for (int x = 0; x < 3; ++x)
for (int y = 0; y < 4; ++y)
g.Set(x, y, 1); // STONE
// At cell (1, 3) (top stone): range (0,1..4) should find 4 AIR tiles above
EvalContext ctx = g.MakeEvalContext(1, 3, 0);
QueryRangeNode above(0, 1, 0, 4, 0); // count AIR in y+1..y+4
CHECK(above.Evaluate(ctx, {}).AsInt() == 4);
// At cell (1, 0) (deep stone): only y=1..4 checked, y=1..3 are stone, y=4 is air
EvalContext ctx2 = g.MakeEvalContext(1, 0, 0);
CHECK(above.Evaluate(ctx2, {}).AsInt() == 1); // only y=4 is air
}
TEST_CASE("QueryDistanceNode: finds adjacent tile at distance 1") {
TileGrid g(0, 0, 5, 5);
g.Set(2, 3, 1); // STONE one tile above current cell (1,3) → actually (2,3) is to the right of (1,3)
// Place STONE directly above (1,1) at (1,2)
g.Set(1, 2, 1);
EvalContext ctx = g.MakeEvalContext(1, 1, 0); // current = (1,1)
QueryDistanceNode qd(1, 4);
CHECK(qd.Evaluate(ctx, {}).AsInt() == 1); // (1,2) is Chebyshev distance 1
}
TEST_CASE("QueryDistanceNode: not found returns maxDistance+1") {
TileGrid g(0, 0, 5, 5); // all AIR
EvalContext ctx = g.MakeEvalContext(2, 2, 0);
QueryDistanceNode qd(1 /*STONE*/, 3);
CHECK(qd.Evaluate(ctx, {}).AsInt() == 4); // maxDistance+1
}
TEST_CASE("QueryDistanceNode: no previous pass returns maxDistance+1") {
EvalContext ctx; ctx.worldX = 0; ctx.worldY = 0;
QueryDistanceNode qd(1, 4);
CHECK(qd.Evaluate(ctx, {}).AsInt() == 5);
}
TEST_CASE("QueryDistanceNode: ignores current cell") {
TileGrid g(0, 0, 3, 3);
g.Set(1, 1, 1); // STONE at current cell
EvalContext ctx = g.MakeEvalContext(1, 1, 0);
QueryDistanceNode qd(1, 2); // looking for STONE
// (1,1) is self, must be skipped; no other STONE tiles → maxDistance+1
CHECK(qd.Evaluate(ctx, {}).AsInt() == 3);
}
}
// ─────────────────────────────── GenerateRegion ──────────────────────────────
TEST_SUITE("WorldGraph::GenerateRegion") {
// Constant tile: every cell gets IDNode(7).
TEST_CASE("Single pass, constant tile ID") {
Graph g;
auto out = g.AddNode(std::make_unique<IDNode>(7));
auto grid = GenerateRegion(g, out, 0, 0, 4, 4, nullptr, 0);
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x)
CHECK(grid.Get(x, y) == 7);
}
// Flat stone layer: stone (ID=1) where worldY < 4, air otherwise.
TEST_CASE("Single pass, flat stone layer at y < 4") {
const int32_t STONE = 1;
Graph g;
auto posY = g.AddNode(std::make_unique<PositionYNode>());
auto thresh = g.AddNode(std::make_unique<ConstantNode>(4.0f));
auto less = g.AddNode(std::make_unique<LessNode>());
auto stone = g.AddNode(std::make_unique<IDNode>(STONE));
auto air = g.AddNode(std::make_unique<IDNode>(0));
auto branch = g.AddNode(std::make_unique<BranchNode>());
REQUIRE(g.Connect(posY, less, 0));
REQUIRE(g.Connect(thresh, less, 1));
REQUIRE(g.Connect(less, branch, 0));
REQUIRE(g.Connect(stone, branch, 1));
REQUIRE(g.Connect(air, branch, 2));
auto grid = GenerateRegion(g, branch, 0, 0, 8, 8, nullptr, 0);
CHECK(grid.originX == 0); CHECK(grid.originY == 0);
CHECK(grid.width == 8); CHECK(grid.height == 8);
for (int y = 0; y < 8; ++y) {
for (int x = 0; x < 8; ++x) {
INFO("x=" << x << " y=" << y);
CHECK(grid.Get(x, y) == (y < 4 ? STONE : 0));
}
}
}
// "No change" semantics: pass returns 0 → keep previous pass value.
TEST_CASE("Zero return keeps previous pass value") {
// Previous pass: all STONE (1).
TileGrid prev(0, 0, 4, 4);
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x)
prev.Set(x, y, 1);
// Current pass: always returns 0 (no change).
Graph g;
auto out = g.AddNode(std::make_unique<IDNode>(0));
auto grid = GenerateRegion(g, out, 0, 0, 4, 4, &prev, 0);
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x)
CHECK(grid.Get(x, y) == 1); // kept from prev
}
// Non-zero pass: overrides previous pass.
TEST_CASE("Non-zero return overrides previous pass") {
TileGrid prev(0, 0, 4, 4);
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x)
prev.Set(x, y, 1); // STONE
Graph g;
auto out = g.AddNode(std::make_unique<IDNode>(2)); // always DIRT
auto grid = GenerateRegion(g, out, 0, 0, 4, 4, &prev, 0);
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x)
CHECK(grid.Get(x, y) == 2);
}
}
// ─────────────────────────────── 16×16 chunk tests ───────────────────────────
// Tile IDs used throughout.
static constexpr int32_t AIR = 0;
static constexpr int32_t STONE = 1;
static constexpr int32_t DIRT = 2;
// ─── Sine-wave stone pass ─────────────────────────────────────────────────────
//
// stone if worldY < sin(worldX * 0.5) * 3 + 8.0
//
// Graph: [PosX] → mul(0.5) → Sin → mul(3.0) → add(8.0) → threshold
// [PosY] ──────────────────────────────────────────→ Less(y, threshold)
// → Branch(condition, ID(STONE), ID(AIR))
static Graph::NodeID BuildSineStoneGraph(Graph& g) {
auto posX = g.AddNode(std::make_unique<PositionXNode>());
auto posY = g.AddNode(std::make_unique<PositionYNode>());
auto freq = g.AddNode(std::make_unique<ConstantNode>(0.5f));
auto amp = g.AddNode(std::make_unique<ConstantNode>(3.0f));
auto bias = g.AddNode(std::make_unique<ConstantNode>(8.0f));
auto mul1 = g.AddNode(std::make_unique<MultiplyNode>());
auto sinN = g.AddNode(std::make_unique<SinNode>());
auto mul2 = g.AddNode(std::make_unique<MultiplyNode>());
auto addN = 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, mul1, 0));
REQUIRE(g.Connect(freq, mul1, 1));
REQUIRE(g.Connect(mul1, sinN, 0));
REQUIRE(g.Connect(sinN, mul2, 0));
REQUIRE(g.Connect(amp, mul2, 1));
REQUIRE(g.Connect(mul2, addN, 0));
REQUIRE(g.Connect(bias, addN, 1));
REQUIRE(g.Connect(posY, less, 0));
REQUIRE(g.Connect(addN, less, 1));
REQUIRE(g.Connect(less, branch, 0));
REQUIRE(g.Connect(stone, branch, 1));
REQUIRE(g.Connect(air, branch, 2));
return branch;
}
// Expected stone predicate — mirrors the graph exactly.
static bool IsStoneExpected(int worldX, int worldY) {
float threshold = std::sin(worldX * 0.5f) * 3.0f + 8.0f;
return static_cast<float>(worldY) < threshold;
}
TEST_SUITE("WorldGraph::Chunks_16x16") {
TEST_CASE("Pass 0: sine-wave stone layer matches expected predicate") {
Graph g;
auto branch = BuildSineStoneGraph(g);
auto grid = GenerateRegion(g, branch, 0, 0, 16, 16, nullptr, 0);
for (int y = 0; y < 16; ++y) {
for (int x = 0; x < 16; ++x) {
INFO("x=" << x << " y=" << y);
bool expectedStone = IsStoneExpected(x, y);
CHECK(grid.Get(x, y) == (expectedStone ? STONE : AIR));
}
}
}
TEST_CASE("Pass 0: non-zero-origin chunk — same predicate, different coords") {
Graph g;
auto branch = BuildSineStoneGraph(g);
// Generate at world offset (-8, -8) so tile (i,j) is at world (-8+i, -8+j).
auto grid = GenerateRegion(g, branch, -8, -8, 16, 16, nullptr, 0);
for (int ly = 0; ly < 16; ++ly) {
for (int lx = 0; lx < 16; ++lx) {
int wx = -8 + lx, wy = -8 + ly;
INFO("wx=" << wx << " wy=" << wy);
bool expectedStone = IsStoneExpected(wx, wy);
CHECK(grid.Get(wx, wy) == (expectedStone ? STONE : AIR));
}
}
}
// ─── Two-pass: sine-wave stone + dirt top layer ───────────────────────────
//
// Pass 1 (dirt): for each cell in the chunk
// - QueryTile (0, 0) == STONE (is current cell stone in pass 0?)
// - QueryRange (0, 1..4) counts AIR tiles directly above
// - If both: return DIRT, else: return 0 (no change)
//
// Expected: the topmost N≤4 stone tiles in each column become dirt.
static Graph::NodeID BuildDirtGraph(Graph& g) {
// Condition 1: current cell is STONE in the previous pass.
auto isStone = g.AddNode(std::make_unique<QueryTileNode>(0, 0, STONE));
// Condition 2: any of the 4 tiles directly above are AIR in the previous pass.
auto airAboveCount = g.AddNode(std::make_unique<QueryRangeNode>(0, 1, 0, 4, AIR));
auto zero = g.AddNode(std::make_unique<ConstantNode>(0.0f));
auto hasAirAbove = g.AddNode(std::make_unique<GreaterNode>());
REQUIRE(g.Connect(airAboveCount, hasAirAbove, 0));
REQUIRE(g.Connect(zero, hasAirAbove, 1));
// Both conditions must be true.
auto andN = g.AddNode(std::make_unique<AndNode>());
REQUIRE(g.Connect(isStone, andN, 0));
REQUIRE(g.Connect(hasAirAbove, andN, 1));
// Branch: true → DIRT, false → 0 (no change).
auto dirt = g.AddNode(std::make_unique<IDNode>(DIRT));
auto noChange = g.AddNode(std::make_unique<IDNode>(0));
auto branch = g.AddNode(std::make_unique<BranchNode>());
REQUIRE(g.Connect(andN, branch, 0));
REQUIRE(g.Connect(dirt, branch, 1));
REQUIRE(g.Connect(noChange, branch, 2));
return branch;
}
TEST_CASE("Padding: dirt pass needs 4 tiles of padding above") {
Graph g;
auto branch = BuildDirtGraph(g);
auto p = ComputeRequiredPadding(g, branch);
CHECK(p.negX == 0); CHECK(p.posX == 0);
CHECK(p.negY == 0); CHECK(p.posY == 4); // QueryRange up to +4
}
TEST_CASE("Two-pass GenerateChunk: dirt layer appears on top of stone") {
Graph stoneGraph, dirtGraph;
auto stoneBranch = BuildSineStoneGraph(stoneGraph);
auto dirtBranch = BuildDirtGraph(dirtGraph);
auto chunk = GenerateChunk(
{ { stoneGraph, stoneBranch }, { dirtGraph, dirtBranch } },
0, 0, 16, 16, 0);
CHECK(chunk.originX == 0); CHECK(chunk.originY == 0);
CHECK(chunk.width == 16); CHECK(chunk.height == 16);
for (int x = 0; x < 16; ++x) {
// Find the stone boundary for this column.
// IsStoneExpected(x, y) = (y < sin(x*0.5)*3+8), so the topmost stone row
// is floor(threshold) or threshold-1 depending on fractions.
// Collect per-column stone tiles.
int topStoneY = -1;
for (int y = 15; y >= 0; --y) {
if (IsStoneExpected(x, y)) { topStoneY = y; break; }
}
if (topStoneY < 0) continue; // all air in this column
// Cells y=0 .. max(0, topStoneY-4) should be STONE (too deep for air above).
// Cells y=(topStoneY-3) .. topStoneY should be DIRT (air within 4 above).
// (The exact boundary depends on how many stone tiles sit above the 4-tile window.)
// Simple invariant: the topmost stone tile must be DIRT.
{
INFO("x=" << x << " topStoneY=" << topStoneY);
CHECK(chunk.Get(x, topStoneY) == DIRT);
}
// Verify no DIRT appears in the air zone.
for (int y = topStoneY + 1; y < 16; ++y) {
INFO("air zone x=" << x << " y=" << y);
CHECK(chunk.Get(x, y) == AIR);
}
// Verify tiles well below the surface (>4 below topStoneY) are still STONE.
if (topStoneY > 4) {
INFO("deep stone x=" << x << " y=0");
CHECK(chunk.Get(x, 0) == STONE);
}
}
}
TEST_CASE("Two-pass: stone-pass grid covers padded region for dirt pass") {
// Validate that GenerateChunk computed the right regions by checking
// that the intermediate pass 0 output would cover the needed range.
// We do this by comparing the single-pass stone output for the padded
// region against what GenerateChunk provides.
Graph stoneGraph, dirtGraph;
auto stoneBranch = BuildSineStoneGraph(stoneGraph);
auto dirtBranch = BuildDirtGraph(dirtGraph);
// The dirt graph needs posY = 4 padding → pass 0 must cover y=0..19 for chunk y=0..15.
auto padding = ComputeRequiredPadding(dirtGraph, dirtBranch);
CHECK(padding.posY == 4);
// Generate the padded stone layer manually and verify it covers y=0..19.
auto paddedStone = GenerateRegion(stoneGraph, stoneBranch,
0, 0, 16, 16 + padding.TotalY(), nullptr, 0);
CHECK(paddedStone.height == 20);
CHECK(paddedStone.originY == 0);
// Re-run the dirt pass using our manually padded stone grid.
auto dirtGrid = GenerateRegion(dirtGraph, dirtBranch,
0, 0, 16, 16, &paddedStone, 0);
// And compare against GenerateChunk output.
auto chunkGrid = GenerateChunk(
{ { stoneGraph, stoneBranch }, { dirtGraph, dirtBranch } },
0, 0, 16, 16, 0);
for (int y = 0; y < 16; ++y)
for (int x = 0; x < 16; ++x) {
INFO("x=" << x << " y=" << y);
CHECK(chunkGrid.Get(x, y) == dirtGrid.Get(x, y));
}
}
TEST_CASE("Single-pass GenerateChunk equals GenerateRegion") {
Graph g;
auto branch = BuildSineStoneGraph(g);
auto chunkGrid = GenerateChunk({ { g, branch } }, 0, 0, 16, 16, 0);
auto regionGrid = GenerateRegion(g, branch, 0, 0, 16, 16, nullptr, 0);
for (int y = 0; y < 16; ++y)
for (int x = 0; x < 16; ++x) {
INFO("x=" << x << " y=" << y);
CHECK(chunkGrid.Get(x, y) == regionGrid.Get(x, y));
}
}
TEST_CASE("GenerateChunk: empty pass list returns all-zero grid") {
auto grid = GenerateChunk({}, 5, 10, 16, 16, 0);
CHECK(grid.originX == 5); CHECK(grid.originY == 10);
CHECK(grid.width == 16); CHECK(grid.height == 16);
for (int y = 0; y < 16; ++y)
for (int x = 0; x < 16; ++x)
CHECK(grid.Get(5 + x, 10 + y) == 0);
}
}