diff --git a/CMakeLists.txt b/CMakeLists.txt index f68d4d6..4faa921 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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}) diff --git a/include/WorldGraph/WorldGraph.h b/include/WorldGraph/WorldGraph.h index 09d6768..6ea94df 100644 --- a/include/WorldGraph/WorldGraph.h +++ b/include/WorldGraph/WorldGraph.h @@ -6,6 +6,7 @@ #include #include #include +#include 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 GetDependencies(NodeID outputNode) const; + private: friend class GraphSerializer; diff --git a/include/WorldGraph/WorldGraphChunk.h b/include/WorldGraph/WorldGraphChunk.h new file mode 100644 index 0000000..a219fc4 --- /dev/null +++ b/include/WorldGraph/WorldGraphChunk.h @@ -0,0 +1,116 @@ +#pragma once + +#include "WorldGraph/WorldGraph.h" + +#include +#include + +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 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(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& passes, + int32_t chunkOriginX, int32_t chunkOriginY, + int32_t chunkWidth, int32_t chunkHeight, + uint64_t seed); + +} // namespace WorldGraph diff --git a/include/WorldGraph/WorldGraphNode.h b/include/WorldGraph/WorldGraphNode.h index 354e0ea..e38fa2a 100644 --- a/include/WorldGraph/WorldGraphNode.h +++ b/include/WorldGraph/WorldGraphNode.h @@ -3,7 +3,9 @@ #include "WorldGraph/WorldGraphTypes.h" #include "config.h" +#include #include +#include #include #include @@ -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 GetInputTypes() const override { return { Type::Float }; } + std::string GetName() const override { return "Sin"; } + Value Evaluate(const EvalContext&, const std::vector& 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 GetInputTypes() const override { return { Type::Float }; } + std::string GetName() const override { return "Cos"; } + Value Evaluate(const EvalContext&, const std::vector& 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 GetInputTypes() const override { return { Type::Float, Type::Float }; } + std::string GetName() const override { return "Modulo"; } + Value Evaluate(const EvalContext&, const std::vector& 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 GetInputTypes() const override { return {}; } + std::string GetName() const override { return "QueryTile"; } + Value Evaluate(const EvalContext& ctx, const std::vector&) 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 GetInputTypes() const override { return {}; } + std::string GetName() const override { return "QueryRange"; } + Value Evaluate(const EvalContext& ctx, const std::vector&) 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 GetInputTypes() const override { return {}; } + std::string GetName() const override { return "QueryDistance"; } + Value Evaluate(const EvalContext& ctx, const std::vector&) 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). diff --git a/include/WorldGraph/WorldGraphTypes.h b/include/WorldGraph/WorldGraphTypes.h index db9d91d..7640c95 100644 --- a/include/WorldGraph/WorldGraphTypes.h +++ b/include/WorldGraph/WorldGraphTypes.h @@ -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) { diff --git a/src/WorldGraph/WorldGraph.cpp b/src/WorldGraph/WorldGraph.cpp index 497688c..7fc160f 100644 --- a/src/WorldGraph/WorldGraph.cpp +++ b/src/WorldGraph/WorldGraph.cpp @@ -106,4 +106,10 @@ bool Graph::IsValid(NodeID outputNode) const { return true; } +std::vector Graph::GetDependencies(NodeID outputNode) const { + std::unordered_set visited; + CollectDependencies(outputNode, visited); + return { visited.begin(), visited.end() }; +} + } // namespace WorldGraph diff --git a/src/WorldGraph/WorldGraphChunk.cpp b/src/WorldGraph/WorldGraphChunk.cpp new file mode 100644 index 0000000..b9aaae8 --- /dev/null +++ b/src/WorldGraph/WorldGraphChunk.cpp @@ -0,0 +1,181 @@ +#include "WorldGraph/WorldGraphChunk.h" +#include "WorldGraph/WorldGraphNode.h" // for dynamic_cast to query node types + +#include +#include // 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(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(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(n)) { + bounds.Include(qt->offsetX, qt->offsetY); + } + else if (const auto* qr = dynamic_cast(n)) { + bounds.Include(qr->minX, qr->minY); + bounds.Include(qr->maxX, qr->maxY); + } + else if (const auto* qd = dynamic_cast(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& 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 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 diff --git a/src/WorldGraph/WorldGraphSerializer.cpp b/src/WorldGraph/WorldGraphSerializer.cpp index 711b0bf..8426a37 100644 --- a/src/WorldGraph/WorldGraphSerializer.cpp +++ b/src/WorldGraph/WorldGraphSerializer.cpp @@ -26,6 +26,9 @@ std::unique_ptr GraphSerializer::CreateNode(const std::string& type, if (type == "Branch") return std::make_unique(); if (type == "PositionX") return std::make_unique(); if (type == "PositionY") return std::make_unique(); + if (type == "Sin") return std::make_unique(); + if (type == "Cos") return std::make_unique(); + if (type == "Modulo") return std::make_unique(); // Nodes with config data if (type == "Constant") { @@ -34,6 +37,25 @@ std::unique_ptr GraphSerializer::CreateNode(const std::string& type, if (type == "TileID") { return std::make_unique(j.value("tileId", 0)); } + if (type == "QueryTile") { + return std::make_unique( + j.value("offsetX", 0), + j.value("offsetY", 0), + j.value("expectedID", 0)); + } + if (type == "QueryRange") { + return std::make_unique( + 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( + 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(node)) { jNode["tileId"] = idn->tileID; + } else if (const auto* qt = dynamic_cast(node)) { + jNode["offsetX"] = qt->offsetX; + jNode["offsetY"] = qt->offsetY; + jNode["expectedID"] = qt->expectedID; + } else if (const auto* qr = dynamic_cast(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(node)) { + jNode["tileId"] = qd->tileID; + jNode["maxDistance"] = qd->maxDistance; } jNodes.push_back(std::move(jNode)); diff --git a/tests/WorldGraph/test_GraphNodes.cpp b/tests/WorldGraph/test_GraphNodes.cpp new file mode 100644 index 0000000..12caced --- /dev/null +++ b/tests/WorldGraph/test_GraphNodes.cpp @@ -0,0 +1,170 @@ +#include +#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); + } +} \ No newline at end of file diff --git a/tests/WorldGraph/test_GraphSerialization.cpp b/tests/WorldGraph/test_GraphSerialization.cpp new file mode 100644 index 0000000..adb4a08 --- /dev/null +++ b/tests/WorldGraph/test_GraphSerialization.cpp @@ -0,0 +1,253 @@ +#include +#include "WorldGraph/WorldGraph.h" +#include "WorldGraph/WorldGraphSerializer.h" +#include + +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()); + auto zero = g.AddNode(std::make_unique(0.0f)); + auto less = g.AddNode(std::make_unique()); + auto stone = g.AddNode(std::make_unique(1)); + auto air = g.AddNode(std::make_unique(0)); + outBranch = g.AddNode(std::make_unique()); + 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 types; + for (const auto& n : j["nodes"]) + types.push_back(n["type"].get()); + + 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(3.14f)); + auto j = GraphSerializer::ToJson(g); + REQUIRE(!j["nodes"].empty()); + CHECK(j["nodes"][0]["value"].get() == doctest::Approx(3.14f)); + } + + TEST_CASE("ToJson: IDNode serialises its tileId") { + Graph g; + g.AddNode(std::make_unique(42)); + auto j = GraphSerializer::ToJson(g); + REQUIRE(!j["nodes"].empty()); + CHECK(j["nodes"][0]["tileId"].get() == 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(); + auto currTo = conns[i]["to"].get(); + auto prevSlot = conns[i-1]["slot"].get(); + auto currSlot = conns[i]["slot"].get(); + 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(2.71f)); + auto j = GraphSerializer::ToJson(g); + auto g2 = GraphSerializer::FromJson(j); + + REQUIRE(g2.has_value()); + const auto* cn = dynamic_cast(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(99)); + auto j = GraphSerializer::ToJson(g); + auto g2 = GraphSerializer::FromJson(j); + + REQUIRE(g2.has_value()); + const auto* idn = dynamic_cast(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(1.0f)); + auto c2 = g.AddNode(std::make_unique(2.0f)); + auto add = g.AddNode(std::make_unique()); + 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(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(1.0f)); + auto id2 = g.AddNode(std::make_unique(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(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(3.0f)); + auto c2 = g.AddNode(std::make_unique(4.0f)); + auto c3 = g.AddNode(std::make_unique(2.0f)); + auto add = g.AddNode(std::make_unique()); + auto sub = g.AddNode(std::make_unique()); + 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(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()); + } +} \ No newline at end of file diff --git a/tests/WorldGraph/test_WorldGraph.cpp b/tests/WorldGraph/test_WorldGraph.cpp index 496645e..eb705c5 100644 --- a/tests/WorldGraph/test_WorldGraph.cpp +++ b/tests/WorldGraph/test_WorldGraph.cpp @@ -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()); - auto zero = g.AddNode(std::make_unique(0.0f)); - auto less = g.AddNode(std::make_unique()); - auto stone = g.AddNode(std::make_unique(1)); - auto air = g.AddNode(std::make_unique(0)); - outBranch = g.AddNode(std::make_unique()); - 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 types; - for (const auto& n : j["nodes"]) - types.push_back(n["type"].get()); - - 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(3.14f)); - auto j = GraphSerializer::ToJson(g); - REQUIRE(!j["nodes"].empty()); - CHECK(j["nodes"][0]["value"].get() == doctest::Approx(3.14f)); - } - - TEST_CASE("ToJson: IDNode serialises its tileId") { - Graph g; - g.AddNode(std::make_unique(42)); - auto j = GraphSerializer::ToJson(g); - REQUIRE(!j["nodes"].empty()); - CHECK(j["nodes"][0]["tileId"].get() == 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(); - auto currTo = conns[i]["to"].get(); - auto prevSlot = conns[i-1]["slot"].get(); - auto currSlot = conns[i]["slot"].get(); - 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(2.71f)); - auto j = GraphSerializer::ToJson(g); - auto g2 = GraphSerializer::FromJson(j); - - REQUIRE(g2.has_value()); - const auto* cn = dynamic_cast(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(99)); - auto j = GraphSerializer::ToJson(g); - auto g2 = GraphSerializer::FromJson(j); - - REQUIRE(g2.has_value()); - const auto* idn = dynamic_cast(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(1.0f)); - auto c2 = g.AddNode(std::make_unique(2.0f)); - auto add = g.AddNode(std::make_unique()); - 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(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(1.0f)); - auto id2 = g.AddNode(std::make_unique(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(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(3.0f)); - auto c2 = g.AddNode(std::make_unique(4.0f)); - auto c3 = g.AddNode(std::make_unique(2.0f)); - auto add = g.AddNode(std::make_unique()); - auto sub = g.AddNode(std::make_unique()); - 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(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()); - } -} diff --git a/tests/WorldGraph/test_WorldGraphChunk.cpp b/tests/WorldGraph/test_WorldGraphChunk.cpp new file mode 100644 index 0000000..65e8f91 --- /dev/null +++ b/tests/WorldGraph/test_WorldGraphChunk.cpp @@ -0,0 +1,616 @@ +#include +#include "WorldGraph/WorldGraphChunk.h" +#include "WorldGraph/WorldGraphNode.h" + +#include + +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(1.0f)); + CHECK(ComputeRequiredPadding(g, c).IsZero()); + } + + TEST_CASE("QueryTileNode contributes its offset") { + Graph g; + auto qt = g.AddNode(std::make_unique(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(-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(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( 0, 5, 1)); // posY=5 + auto qtFar = g.AddNode(std::make_unique(-4, 0, 1)); // negX=4 + auto branch = g.AddNode(std::make_unique()); + auto id0 = g.AddNode(std::make_unique(0)); + auto id1 = g.AddNode(std::make_unique(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()); + REQUIRE(g.Connect(cond, andN, 0)); + REQUIRE(g.Connect(qtFar, andN, 1)); + auto branch2 = g.AddNode(std::make_unique()); + 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(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()); + auto thresh = g.AddNode(std::make_unique(4.0f)); + auto less = g.AddNode(std::make_unique()); + auto stone = g.AddNode(std::make_unique(STONE)); + auto air = g.AddNode(std::make_unique(0)); + auto branch = g.AddNode(std::make_unique()); + 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(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(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()); + auto posY = g.AddNode(std::make_unique()); + auto freq = g.AddNode(std::make_unique(0.5f)); + auto amp = g.AddNode(std::make_unique(3.0f)); + auto bias = g.AddNode(std::make_unique(8.0f)); + auto mul1 = g.AddNode(std::make_unique()); + auto sinN = g.AddNode(std::make_unique()); + auto mul2 = g.AddNode(std::make_unique()); + auto addN = g.AddNode(std::make_unique()); + auto less = g.AddNode(std::make_unique()); + auto branch = g.AddNode(std::make_unique()); + auto stone = g.AddNode(std::make_unique(STONE)); + auto air = g.AddNode(std::make_unique(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(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(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(0, 1, 0, 4, AIR)); + auto zero = g.AddNode(std::make_unique(0.0f)); + auto hasAirAbove = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(airAboveCount, hasAirAbove, 0)); + REQUIRE(g.Connect(zero, hasAirAbove, 1)); + + // Both conditions must be true. + auto andN = g.AddNode(std::make_unique()); + 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(DIRT)); + auto noChange = g.AddNode(std::make_unique(0)); + auto branch = g.AddNode(std::make_unique()); + 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); + } +}