chunk generation
This commit is contained in:
@@ -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})
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
116
include/WorldGraph/WorldGraphChunk.h
Normal file
116
include/WorldGraph/WorldGraphChunk.h
Normal 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
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
181
src/WorldGraph/WorldGraphChunk.cpp
Normal file
181
src/WorldGraph/WorldGraphChunk.cpp
Normal 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
|
||||
@@ -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));
|
||||
|
||||
170
tests/WorldGraph/test_GraphNodes.cpp
Normal file
170
tests/WorldGraph/test_GraphNodes.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
253
tests/WorldGraph/test_GraphSerialization.cpp
Normal file
253
tests/WorldGraph/test_GraphSerialization.cpp
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
616
tests/WorldGraph/test_WorldGraphChunk.cpp
Normal file
616
tests/WorldGraph/test_WorldGraphChunk.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user