diff --git a/CMakeLists.txt b/CMakeLists.txt index 3459336..f68d4d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,12 +1,14 @@ cmake_minimum_required(VERSION 3.20) project(factory-hole-core LANGUAGES CXX) -set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) include(FetchContent) +# ---- Core dependencies ------------------------------------------------------- + # Flecs ECS FetchContent_Declare( flecs @@ -23,21 +25,71 @@ FetchContent_Declare( GIT_SHALLOW TRUE ) -FetchContent_MakeAvailable(flecs doctest) +# GLM – vector/matrix math +FetchContent_Declare( + glm + GIT_REPOSITORY https://github.com/g-truc/glm.git + GIT_TAG 1.0.1 + GIT_SHALLOW TRUE +) + +# nlohmann/json – graph serialisation +FetchContent_Declare( + nlohmann_json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG v3.11.3 + GIT_SHALLOW TRUE +) + +# ---- World-generation dependencies ------------------------------------------ + +# FastNoise2 – noise nodes +FetchContent_Declare( + FastNoise2 + GIT_REPOSITORY https://github.com/Auburn/FastNoise2.git + GIT_TAG master + GIT_SHALLOW TRUE +) + +# ---- Frontend dependencies (used by tools/) ---------------------------------- + +# SDL2 – install via your system package manager, e.g.: +# sudo apt install libsdl2-dev (Debian/Ubuntu) +# brew install sdl2 (macOS) +find_package(SDL2 QUIET) + +# Dear ImGui +FetchContent_Declare( + imgui + GIT_REPOSITORY https://github.com/ocornut/imgui.git + GIT_TAG v1.91.6 + GIT_SHALLOW TRUE +) + +# ImNodes – node-graph editor widget for ImGui +FetchContent_Declare( + imnodes + GIT_REPOSITORY https://github.com/Nelarius/imnodes.git + GIT_TAG v0.5 + GIT_SHALLOW TRUE +) + +# Make available what we need right now. +# imgui / imnodes / SDL2 / FastNoise2 are deferred until tools/ is built. +FetchContent_MakeAvailable(flecs doctest glm nlohmann_json) + +# ---- Core library ------------------------------------------------------------ -# Only compile sources needed for the core library set(SOURCES src/Components/Config/WorldConfig.cpp src/Components/Support.cpp src/Core/WorldInstance.cpp + src/WorldGraph/WorldGraph.cpp + src/WorldGraph/WorldGraphSerializer.cpp ) add_library(factory-hole-core ${SOURCES}) -# Main executable -add_executable(factory-hole-app src/main.cpp) -target_link_libraries(factory-hole-app PRIVATE factory-hole-core) - target_include_directories(factory-hole-core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ) @@ -45,9 +97,17 @@ target_include_directories(factory-hole-core PUBLIC target_link_libraries(factory-hole-core PUBLIC flecs::flecs_static doctest::doctest + glm::glm + nlohmann_json::nlohmann_json ) -# Tests +# ---- Main executable --------------------------------------------------------- + +add_executable(factory-hole-app src/main.cpp) +target_link_libraries(factory-hole-app PRIVATE factory-hole-core) + +# ---- Tests ------------------------------------------------------------------- + enable_testing() file(GLOB_RECURSE TEST_SOURCES tests/*.cpp) diff --git a/include/Types/WorldGraph/WorldGraph.h b/include/Types/WorldGraph/WorldGraph.h deleted file mode 100644 index 465642c..0000000 --- a/include/Types/WorldGraph/WorldGraph.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include "WorldGraphVisualNode.h" - -class WorldGraph final -{ -public: - WorldGraph() = default; - WorldGraph(const Vector>& nodes); - - WorldGraph(const WorldGraph& other); - WorldGraph(WorldGraph&& other) noexcept = default; - - WorldGraph& operator=(const WorldGraph& other); - WorldGraph& operator=(WorldGraph&& other) noexcept = default; - -public: - Variant Execute(Ref node, const WorldNodeParameters& params) const; - WorldNodeBase* GetNode(Ref node) const; - -private: - void Compile(const Vector>& nodes); - std::unique_ptr CopyMemory(HashMap, WorldNodeBase*>& nodeMap) const; - -private: - int MemorySize{}; - std::unique_ptr CompiledData{}; - HashMap, WorldNodeBase*> NodeMap{}; -}; - diff --git a/include/Types/WorldGraph/WorldGraphAllocator.h b/include/Types/WorldGraph/WorldGraphAllocator.h deleted file mode 100644 index 0d95c1f..0000000 --- a/include/Types/WorldGraph/WorldGraphAllocator.h +++ /dev/null @@ -1,70 +0,0 @@ -#pragma once -#include -#include -#include "modules/factory/include/Util/Span.h" - -struct WorldGraphAllocatorBase -{ - virtual ~WorldGraphAllocatorBase() = default; - - virtual void* Allocate(tcb::span data) = 0; - virtual void Clear() = 0; - - template - T* Allocate(const T& val) - { - return static_cast(Allocate(tcb::span(reinterpret_cast(&val), sizeof(T)))); - } -}; - -struct WorldGraphAllocator : public WorldGraphAllocatorBase -{ - virtual ~WorldGraphAllocator() = default; - WorldGraphAllocator() = default; - WorldGraphAllocator(uint32_t totalSize) : Data {std::make_unique(totalSize)}, Size{totalSize} {} - - virtual void* Allocate(tcb::span data) override - { - auto size = (data.size_bytes() + sizeof(void*) - 1) / sizeof(void*) * sizeof(void*); // make sure aligment is 8/4 bytes for pointers - if (CurrentOffset + size > Size) - throw std::exception{}; - - std::memcpy(Data.get() + CurrentOffset, data.data(), data.size_bytes()); - CurrentOffset += size; - - return Data.get() + CurrentOffset - size; - } - - virtual void Clear() override - { - CurrentOffset = 0; - } - - void* GetCurrentAddress() const - { - return Data.get() + CurrentOffset; - } - - std::unique_ptr Data{}; - uint32_t Size{}; - uint32_t CurrentOffset{}; -}; - -struct WorldGraphSizeMeasurer : public WorldGraphAllocatorBase -{ - virtual ~WorldGraphSizeMeasurer() = default; - WorldGraphSizeMeasurer() = default; - - virtual void* Allocate(tcb::span data) override - { - TotalSize += (data.size_bytes() + sizeof(void*) - 1) / sizeof(void*) * sizeof(void*); - return nullptr; - } - - virtual void Clear() override - { - TotalSize = 0; - } - - uint32_t TotalSize{}; -}; \ No newline at end of file diff --git a/include/Types/WorldGraph/WorldGraphNode.h b/include/Types/WorldGraph/WorldGraphNode.h deleted file mode 100644 index fb4ff4b..0000000 --- a/include/Types/WorldGraph/WorldGraphNode.h +++ /dev/null @@ -1,665 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include "WorldGraphAllocator.h" -#include "Util/FastNoiseLite.h" -#include "core/variant/variant.h" -#include "modules/factory/include/Core/Chunk.h" - -// // last bit determines if it is a boolean or a float -// // if it is a float, last bit of precision will be lost (not that bad) -// // if it is a bool, the bool value will be stored on the second bit -// // for floats the last bit is set -// // for bools the last bit is unset -// class BoolFloat -// { -// public: -// BoolFloat() = default; -// BoolFloat(bool val) {} -// BoolFloat(float val) {} - -// public: -// void SetFloat(float val) { Data = reinterpret_cast(val) | 0b1u; } -// void SetBool(bool val) { Data = (val << 1) & (~0b1u); } - -// constexpr float IsFloat() const { return Data & 0b1u; } -// constexpr float IsBool() const { return Data & (~0b1u); } - -// float GetFloat() const { _ASSERT(IsFloat()); return reinterpret_cast(Data); } -// float GetBool() const { _ASSERT(IsBool()); return Data; } - -// public: -// operator float() const { return GetFloat(); } - -// private: -// uint32_t Data{}; -// }; - -struct WorldNodeParameters; - -struct alignas(void*) WorldNodeBase -{ - virtual Variant Evaluate(const WorldNodeParameters& params) const = 0; - virtual Variant::Type GetReturnType() const = 0; - virtual Vector GetInputTypes() const = 0; - virtual bool IsValid() const = 0; - virtual void Allocate(WorldGraphAllocatorBase* Allocator) const = 0; - virtual void SetInput(int index, WorldNodeBase* input) = 0; -}; - -struct WorldNodeParameters -{ - static constexpr int MaxQueryOffset = 4; - static constexpr int PaddedChunkSide = Chunk::ChunkSize + MaxQueryOffset * 2; - static constexpr int PaddedChunkSize = PaddedChunkSide * PaddedChunkSide; - - typedef std::array TileArray; - - int X{ }; - int Y{ }; - int Seed{ }; - float FinalValueSubstract{ }; - - ChunkKey ChunkInfo{ }; - TileArray* GeneratedTiles{ }; - - Tile GetTile(int x, int y) const - { - auto bounds = GetGenerationBounds(); - if (unlikely(!bounds.has_point(Vector2i{x, y}))) - return {}; - // DEV_ASSERT(bounds.has_point(Vector2i{x, y})); - - return (*GeneratedTiles)[(y - bounds.position.y) * PaddedChunkSide + (x - bounds.position.x)]; - } - - static int GetArrayIndex(int x, int y) - { - return (y + MaxQueryOffset) * PaddedChunkSide + (x + MaxQueryOffset); - } - - Rect2i GetGenerationBounds() const - { - return ChunkInfo.GetBounds().grow(MaxQueryOffset); - } -}; - -template -struct TtoVariant -{ - typedef Variant TVariant; -}; - -template -struct WorldNodeTemplated : public WorldNodeBase -{ - std::array InputNodes{}; - - virtual Variant::Type GetReturnType() const override { return Variant::get_type_t(); } - virtual Vector GetInputTypes() const override - { - return { Variant::get_type_t()... }; - }; - virtual bool IsValid() const override - { - bool valid{ true }; - for (auto input : InputNodes) - { - valid = valid && input; - } - return valid; - } - virtual void Allocate(WorldGraphAllocatorBase* Allocator) const override { Allocator->Allocate(*this); } - - virtual Variant Evaluate(const WorldNodeParameters& params) const override - { - std::array results{}; - for (int i{}; i < sizeof...(Inputs); ++i) - { - results[i] = InputNodes[i]->Evaluate(params); - } - - auto EvaluateFunctor = [this](typename TtoVariant::TVariant... args) -> Variant - { - return EvaluateT(args.get_unsafe_t()...); - }; - - return std::apply(EvaluateFunctor, results); - } - - virtual void SetInput(int index, WorldNodeBase* input) override - { - InputNodes[index] = input; - } - - - virtual Return EvaluateT(Inputs...) const = 0; -}; - -struct WorldNode_Add : public WorldNodeTemplated -{ - virtual float EvaluateT(float v0, float v1) const override - { - return v0 + v1; - } -}; - -struct WorldNode_Subtract : public WorldNodeTemplated -{ - virtual float EvaluateT(float v0, float v1) const override - { - return v0 - v1; - } -}; - -struct WorldNode_Multiply : public WorldNodeTemplated -{ - virtual float EvaluateT(float v0, float v1) const override - { - return v0 * v1; - } -}; - -struct WorldNode_Divide : public WorldNodeTemplated -{ - virtual float EvaluateT(float v0, float v1) const override - { - return v0 / v1; - } -}; - -struct WorldNode_Modulo : public WorldNodeTemplated -{ - virtual float EvaluateT(float v0, float v1) const override - { - return fmod(v0, v1); - } -}; - -struct WorldNode_Equal : public WorldNodeTemplated -{ - virtual bool EvaluateT(float v0, float v1) const override - { - return v0 == v1; - } -}; - -struct WorldNode_Smaller : public WorldNodeTemplated -{ - virtual bool EvaluateT(float v0, float v1) const override - { - return v0 < v1; - } -}; - -struct WorldNode_Greater : public WorldNodeTemplated -{ - virtual bool EvaluateT(float v0, float v1) const override - { - return v0 > v1; - } -}; - -struct WorldNode_SmallerEqual : public WorldNodeTemplated -{ - virtual bool EvaluateT(float v0, float v1) const override - { - return v0 <= v1; - } -}; - -struct WorldNode_GreaterEqual : public WorldNodeTemplated -{ - virtual bool EvaluateT(float v0, float v1) const override - { - return v0 >= v1; - } -}; - -struct WorldNode_Negate : public WorldNodeTemplated -{ - virtual float EvaluateT(float v) const override - { - return -v; - } -}; - -struct WorldNode_Abs : public WorldNodeTemplated -{ - virtual float EvaluateT(float v) const override - { - return std::abs(v); - } -}; - -struct WorldNode_Ceil : public WorldNodeTemplated -{ - virtual float EvaluateT(float v) const override - { - return std::ceil(v); - } -}; - -struct WorldNode_Floor : public WorldNodeTemplated -{ - virtual float EvaluateT(float v) const override - { - return std::floor(v); - } -}; - -struct WorldNode_Sin : public WorldNodeTemplated -{ - virtual float EvaluateT(float v) const override - { - return std::sin(v); - } -}; - -struct WorldNode_Cos : public WorldNodeTemplated -{ - virtual float EvaluateT(float v) const override - { - return std::cos(v); - } -}; - -struct WorldNode_Tan : public WorldNodeTemplated -{ - virtual float EvaluateT(float v) const override - { - return std::tan(v); - } -}; - -struct WorldNode_Exp : public WorldNodeTemplated -{ - virtual float EvaluateT(float v) const override - { - return std::exp(v); - } -}; - -struct WorldNode_Pow : public WorldNodeTemplated -{ - virtual float EvaluateT(float v0, float v1) const override - { - return std::pow(v0, v1); - } -}; - -struct WorldNode_Max : public WorldNodeTemplated -{ - virtual float EvaluateT(float v0, float v1) const override - { - return std::max(v0, v1); - } -}; - -struct WorldNode_Min : public WorldNodeTemplated -{ - virtual float EvaluateT(float v0, float v1) const override - { - return std::min(v0, v1); - } -}; - -struct WorldNode_Clamp : public WorldNodeTemplated -{ - virtual float EvaluateT(float v0, float v1, float v2) const override - { - return std::clamp(v0, v1, v2); - } -}; - -struct WorldNode_Round : public WorldNodeTemplated -{ - virtual float EvaluateT(float v) const override - { - return std::round(v); - } -}; - -struct WorldNode_Log : public WorldNodeTemplated -{ - virtual float EvaluateT(float v) const override - { - return std::log(v); - } -}; - -struct WorldNode_Square : public WorldNodeTemplated -{ - virtual float EvaluateT(float v) const override - { - return v * v; - } -}; - -struct WorldNode_Lerp : public WorldNodeTemplated -{ - virtual float EvaluateT(float from, float to, float weight) const override - { - return Math::lerp(from, to, weight); - } -}; - -struct WorldNode_OneMinus : public WorldNodeTemplated -{ - virtual float EvaluateT(float val) const override - { - return 1 - val; - } -}; - -struct WorldNode_And : public WorldNodeTemplated -{ - virtual bool EvaluateT(bool val0, bool val1) const override - { - return val0 && val1; - } -}; - -struct WorldNode_Or : public WorldNodeTemplated -{ - virtual bool EvaluateT(bool val0, bool val1) const override - { - return val0 || val1; - } -}; - -struct WorldNode_Constant : public WorldNodeBase -{ - float Value{}; - virtual Variant Evaluate(const WorldNodeParameters& params) const override - { - return Value; - } - virtual Variant::Type GetReturnType() const override { return Variant::FLOAT; } - virtual Vector GetInputTypes() const override - { - return { }; - }; - virtual bool IsValid() const override - { - return !std::_Is_nan(Value); - } - virtual void Allocate(WorldGraphAllocatorBase* Allocator) const override - { - Allocator->Allocate(*this); - } - virtual void SetInput(int index, WorldNodeBase* input) override - { - DEV_ASSERT(false); - } -}; - -struct WorldNode_Branch : public WorldNodeBase -{ - WorldNodeBase* InputBool{}; - WorldNodeBase* InputTrue{}; - WorldNodeBase* InputFalse{}; - - virtual Variant Evaluate(const WorldNodeParameters& params) const override - { - bool condition = InputBool->Evaluate(params).get_unsafe_bool(); - return condition ? InputTrue->Evaluate(params) : InputFalse->Evaluate(params); - } - virtual Variant::Type GetReturnType() const override { return Variant::Type::FLOAT; } - virtual Vector GetInputTypes() const override - { - return { Variant::Type::BOOL, Variant::Type::FLOAT, Variant::Type::FLOAT }; - }; - virtual bool IsValid() const override - { - return InputBool && InputTrue && InputFalse; - } - virtual void Allocate(WorldGraphAllocatorBase* Allocator) const override - { - Allocator->Allocate(*this); - } - virtual void SetInput(int index, WorldNodeBase* input) override - { - switch (index) - { - case 0: InputBool = input; - case 1: InputTrue = input; - case 2: InputFalse = input; - } - } -}; - -struct WorldNode_NoiseBase : public WorldNodeBase -{ - float Frequency{ 1.f }; - - virtual Variant Evaluate(const WorldNodeParameters& params) const override - { - float x = params.X * Frequency; - float y = params.Y * Frequency; - return EvaluateNoise(params.Seed, x, y); - } - virtual Variant::Type GetReturnType() const override { return Variant::FLOAT; } - virtual Vector GetInputTypes() const override - { - return { }; - }; - virtual bool IsValid() const override - { - return Frequency != 0; - } - virtual void Allocate(WorldGraphAllocatorBase* Allocator) const override - { - Allocator->Allocate(*this); - } - - virtual void SetInput(int index, WorldNodeBase* input) override - { - DEV_ASSERT(false); - } - - virtual float EvaluateNoise(int seed, float x, float y) const = 0; -}; - -struct WorldNode_Simplex : public WorldNode_NoiseBase -{ - virtual float EvaluateNoise(int seed, float x, float y) const override - { - const float SQRT3 = (float)1.7320508075688772935274463415059; - const float F2 = 0.5f * (SQRT3 - 1); - float t = (x + y) * F2; - x += t; - y += t; - - return fastnoiselitestatic::SingleSimplex(seed, x, y); - } -}; - -struct WorldNode_OpenSimplex : public WorldNode_NoiseBase -{ - virtual float EvaluateNoise(int seed, float x, float y) const override - { - const float SQRT3 = (float)1.7320508075688772935274463415059; - const float F2 = 0.5f * (SQRT3 - 1); - float t = (x + y) * F2; - x += t; - y += t; - - return fastnoiselitestatic::SingleOpenSimplex2S(seed, x, y); - } -}; - -struct WorldNode_Perlin : public WorldNode_NoiseBase -{ - virtual float EvaluateNoise(int seed, float x, float y) const override - { - return fastnoiselitestatic::SinglePerlin(seed, x, y); - } -}; - -struct WorldNode_ValueCubic : public WorldNode_NoiseBase -{ - virtual float EvaluateNoise(int seed, float x, float y) const override - { - return fastnoiselitestatic::SingleValueCubic(seed, x, y); - } -}; - -struct WorldNode_Value : public WorldNode_NoiseBase -{ - virtual float EvaluateNoise(int seed, float x, float y) const override - { - return fastnoiselitestatic::SingleValue(seed, x, y); - } -}; - -struct WorldNode_IsTile : public WorldNodeBase -{ - int8_t RelativeX{}; - int8_t RelativeY{}; - TILE_TYPE TileType{}; - - virtual Variant Evaluate(const WorldNodeParameters& params) const override - { - return params.GetTile(params.X + RelativeX, params.Y + RelativeY).GetType() == TileType; - } - virtual Variant::Type GetReturnType() const override { return Variant::BOOL; } - virtual Vector GetInputTypes() const override - { - return { }; - }; - virtual bool IsValid() const override - { - return true; - } - virtual void Allocate(WorldGraphAllocatorBase* Allocator) const override - { - Allocator->Allocate(*this); - } - - virtual void SetInput(int index, WorldNodeBase* input) override - { - DEV_ASSERT(false); - } -}; - -struct WorldNode_TileDistance : public WorldNodeBase -{ - int8_t Range{}; - TILE_TYPE TileType{}; - -private: - struct SmallVectorI2{ - int8_t X; int8_t Y; - SmallVectorI2(int8_t x, int8_t y) : X{ x }, Y{ y } {}; - SmallVectorI2() = default; - }; - - static constexpr int ArraySize{(WorldNodeParameters::MaxQueryOffset * 2 + 1) * (WorldNodeParameters::MaxQueryOffset * 2 + 1) - 1}; - - static std::array CreateSortedOffsets() - { - std::array offsets{}; - int counter{}; - for (int y{-WorldNodeParameters::MaxQueryOffset}; y <= WorldNodeParameters::MaxQueryOffset; ++y) - for (int x{-WorldNodeParameters::MaxQueryOffset}; x <= WorldNodeParameters::MaxQueryOffset; ++x) - if (y != 0 && x != 0) - { - offsets[counter] = SmallVectorI2{ static_cast(x), static_cast(y) }; - ++counter; - } - std::sort(offsets.begin(), offsets.end(), [] (SmallVectorI2 lhs, SmallVectorI2 rhs) - { - return rhs.X * rhs.X + rhs.Y * rhs.Y > lhs.X * lhs.X + lhs.Y * lhs.Y; - }); - return offsets; - } - - inline static const std::array SortedOffsets{ CreateSortedOffsets() }; - - static std::array CreateRangeOffsets() - { - std::array offsets{}; - - for (int i{}; i < WorldNodeParameters::MaxQueryOffset; ++i) - { - offsets[i] = ArraySize - (((WorldNodeParameters::MaxQueryOffset - i) * 2 + 1) * ((WorldNodeParameters::MaxQueryOffset - i) * 2 + 1) - 1); - } - - return offsets; - } - - inline static const std::array RangeOffsets{ CreateRangeOffsets() }; - - // inline static const SmallVectorI2 SortedOffset[] = - // { - // {+3, +3}, {-3, -3}, {+3, -3}, {-3, +3}, // 3 - // {+2, +3}, {+3, +2}, {-2, -3}, {-3, -2}, {-2, +3}, {-3, +2}, {+2, -3}, {+3, -2}, - // {+1, +3}, {+3, +1}, {-1, -3}, {-3, -1}, {-1, +3}, {-3, +1}, {+1, -3}, {+3, -1}, - // {+3, +0}, {-3, -0}, {+0, -3}, {-0, +3}, - // {+2, +2}, {-2, -2}, {+2, -2}, {-2, +2}, // 2 - // {+1, +2}, {+2, +1}, {-1, -2}, {-2, -1}, {-1, +2}, {-2, +1}, {+1, -2}, {+2, -1}, - // {+2, +0}, {-2, -0}, {+0, -2}, {-0, +2}, - // {+1, +1}, {-1, -1}, {+1, -1}, {-1, +1}, // 1 - // {+1, +0}, {-1, -0}, {+0, -1}, {-0, +1}, - // }; - - // inline static const int8_t RangeOffsets[] = - // { - // 0, 24, 40 - // }; - -public: - virtual Variant Evaluate(const WorldNodeParameters& params) const override - { - if (!params.ChunkInfo.GetBounds().has_point(Vector2i{params.X, params.Y})) return 16'384.f; - - int maxRangeSQ = 16'384; - for (int i{RangeOffsets[Range]}; i < 48; ++i) - { - auto offset = SortedOffsets[i]; - if (params.GetTile(params.X + offset.X, params.Y + offset.Y).GetType() == TileType) - { - maxRangeSQ = offset.X * offset.X + offset.Y * offset.Y; - } - } - return sqrtf(static_cast(maxRangeSQ)); - } - virtual Variant::Type GetReturnType() const override { return Variant::FLOAT; } - virtual Vector GetInputTypes() const override - { - return { }; - }; - virtual bool IsValid() const override - { - return Range >= 1 && Range <= 3; - } - virtual void Allocate(WorldGraphAllocatorBase* Allocator) const override - { - Allocator->Allocate(*this); - } - - virtual void SetInput(int index, WorldNodeBase* input) override - { - DEV_ASSERT(false); - } -}; - -// struct WorldNode_Cellular : public WorldNode_NoiseBase -// { -// virtual float EvaluateNoise(int seed, float x, float y) const override -// { -// const float SQRT3 = (float)1.7320508075688772935274463415059; -// const float F2 = 0.5f * (SQRT3 - 1); -// float t = (x + y) * F2; -// x += t; -// y += t; - -// return fastnoiselite::FastNoiseLite::SingleCellular(seed, x, y); -// } -// }; \ No newline at end of file diff --git a/include/Types/WorldGraph/WorldGraphVisualNode.h b/include/Types/WorldGraph/WorldGraphVisualNode.h deleted file mode 100644 index b80028c..0000000 --- a/include/Types/WorldGraph/WorldGraphVisualNode.h +++ /dev/null @@ -1,174 +0,0 @@ -#pragma once - -#include "WorldGraphNode.h" -#include "core/io/resource.h" -#include "modules/factory/include/Util/Helpers.h" - -class WorldGraphVisualNodeBase : public Resource -{ - GDCLASS(WorldGraphVisualNodeBase, Resource); -public: - static void _bind_methods(); - -public: - virtual ~WorldGraphVisualNodeBase() = default; - -public: - Vector2i GetPosition() const { return Position; } - TypedArray GetInputs() const - { - return VectorToTypedArray(InputNodes); - } - - void SetPosition(Vector2i pos) { Position = pos; } - void SetInputNodes(TypedArray inputNodes) - { - InputNodes = TypedArrayToVector(inputNodes); - RefreshInputs(); - } - - bool NodeIsValid() const { return IsValid(); } - TypedArray NodeGetInputTypes() const { return VectorToTypedArrayCast(GetInputTypes()); } - int NodeGetOutputType() const { return GetOutputType(); } - void NodeSetInput(int index, Ref input) { SetInput(index, input); } - bool HasInternalNode() const { return InternalNode.get(); }; - - bool CanExecuteNode(); - - void SetInternalNode(std::unique_ptr&& node); - WorldNodeBase* GetInternalNode() const { return InternalNode.get(); } - void RefreshInputs(); - -public: - virtual Vector GetInputTypes() const { return InternalNode ? InternalNode->GetInputTypes() : Vector{}; }; - virtual Variant::Type GetOutputType() const { return InternalNode ? InternalNode->GetReturnType() : Variant::Type{}; }; - virtual void SetInput(int index, Ref input) - { - InputNodes.set(index, input); - InternalNode->SetInput(index, input.is_valid() ? input->InternalNode.get() : nullptr); - } - virtual bool IsValid() const - { - if (!InternalNode) - print_error(String("No internal node for ") + get_class_name()); - if (!InternalNode->IsValid()) - print_error(String("node is invalid ") + get_class_name()); - return InternalNode && InternalNode->IsValid(); - } - virtual void RefreshValues() {}; - -public: - Vector2i Position{}; - Vector> InputNodes{}; -private: - std::unique_ptr InternalNode{}; -}; - -class WorldGraphVisualNode_Math : public WorldGraphVisualNodeBase -{ - GDCLASS(WorldGraphVisualNode_Math, WorldGraphVisualNodeBase); -public: - static void _bind_methods(); - -public: - WorldGraphVisualNode_Math(); - virtual ~WorldGraphVisualNode_Math() = default; - -public: - TypedArray GetNodeNames() const; - void SetNode(String nodeName); - String GetNode() const { return NodeID; } - -private: - String NodeID{}; - -}; - -class WorldGraphVisualNode_Constant : public WorldGraphVisualNodeBase -{ - GDCLASS(WorldGraphVisualNode_Constant, WorldGraphVisualNodeBase); -public: - static void _bind_methods(); - -public: - WorldGraphVisualNode_Constant(); - virtual ~WorldGraphVisualNode_Constant() = default; - -private: - void SetValue(float val); - float GetValue() const; -}; - -class WorldGraphVisualNode_If : public WorldGraphVisualNodeBase -{ - GDCLASS(WorldGraphVisualNode_If, WorldGraphVisualNodeBase); -public: - static void _bind_methods() {}; - -public: - WorldGraphVisualNode_If(); - virtual ~WorldGraphVisualNode_If() = default; -}; - -class WorldGraphVisualNode_Noise : public WorldGraphVisualNodeBase -{ - GDCLASS(WorldGraphVisualNode_Noise, WorldGraphVisualNodeBase); -public: - static void _bind_methods(); - -public: - void SetNoiseType(String noiseType); - String GetNoiseType() const { return NoiseType; } - float GetFrequency() const { return Frequency; } - - TypedArray GetNoiseTypes() const; - void SetFrequency(float val); - - virtual void RefreshValues() override; - -public: - WorldGraphVisualNode_Noise(); - virtual ~WorldGraphVisualNode_Noise() = default; - -private: - String NoiseType{}; - float Frequency{}; -}; - -class WorldGraphVisualNode_Tile : public WorldGraphVisualNodeBase -{ - GDCLASS(WorldGraphVisualNode_Tile, WorldGraphVisualNodeBase); -public: - static void _bind_methods(); - -public: - WorldGraphVisualNode_Tile(); - virtual ~WorldGraphVisualNode_Tile() = default; - -public: - void SetType(int type); - void SetRelativeX(int offset); - void SetRelativeY(int offset); - - int GetType() const; - int GetRelativeX() const; - int GetRelativeY() const; -}; - -class WorldGraphVisualNode_TileDistance : public WorldGraphVisualNodeBase -{ - GDCLASS(WorldGraphVisualNode_TileDistance, WorldGraphVisualNodeBase); -public: - static void _bind_methods(); - -public: - WorldGraphVisualNode_TileDistance(); - virtual ~WorldGraphVisualNode_TileDistance() = default; - -public: - void SetType(int type); - void SetRange(int range); - - int GetType() const; - int GetRange() const; -}; \ No newline at end of file diff --git a/include/WorldGraph/WorldGraph.h b/include/WorldGraph/WorldGraph.h new file mode 100644 index 0000000..09d6768 --- /dev/null +++ b/include/WorldGraph/WorldGraph.h @@ -0,0 +1,109 @@ +#pragma once + +#include "WorldGraph/WorldGraphNode.h" + +#include +#include +#include +#include + +namespace WorldGraph { + +class GraphSerializer; + +/// Node-based graph for a single procedural world-generation pass. +/// +/// Usage overview +/// ────────────── +/// 1. Call AddNode() for each node you need; keep the returned NodeIDs. +/// 2. Wire outputs to inputs with Connect(fromNode, toNode, inputSlot). +/// 3. Call Evaluate(outputNodeID, ctx) per cell to get its tile ID. +/// +/// Evaluation is recursive and performed on the fly. Unconnected inputs +/// receive a zero value of their declared type; the caller is responsible +/// for checking IsValid() if all inputs must be wired. +/// +/// Cycle detection runs at Connect() time: a connection that would create a +/// cycle is rejected and false is returned. +class Graph { +public: + using NodeID = uint32_t; + static constexpr NodeID INVALID_ID = 0; + + // ── Node management ──────────────────────────────────────────────────── + + /// Add a node; returns its assigned ID (always > INVALID_ID). + NodeID AddNode(std::unique_ptr node); + + /// Remove a node and sever every connection that references it. + void RemoveNode(NodeID id); + + /// Look up a node by ID (returns nullptr if not found). + Node* GetNode(NodeID id); + const Node* GetNode(NodeID id) const; + + size_t NodeCount() const { return nodes.size(); } + + // ── Connection management ───────────────────────────────────────────── + + /// Wire the output of \p fromNode into input slot \p inputSlot of \p toNode. + /// + /// Returns false (and makes no change) when: + /// - either node is not in the graph, or + /// - the connection would create a directed cycle. + /// + /// Connecting to an already-wired slot replaces the previous connection. + bool Connect(NodeID fromNode, NodeID toNode, int inputSlot); + + /// Remove the wire going into \p toNode's \p inputSlot (no-op if not wired). + void Disconnect(NodeID toNode, int inputSlot); + + /// Return the source node wired to (toNode, inputSlot), or nullopt. + std::optional GetInput(NodeID toNode, int inputSlot) const; + + // ── Evaluation ──────────────────────────────────────────────────────── + + /// Recursively evaluate the subgraph rooted at \p outputNode for the cell + /// described by \p ctx. Call AsInt() on the result to obtain a tile ID. + /// + /// Unconnected inputs default to zero. Returns a zero Float value if + /// \p outputNode is not in the graph. + Value Evaluate(NodeID outputNode, const EvalContext& ctx) const; + + /// True when every required input of \p outputNode (and all its transitive + /// dependencies) is connected. + bool IsValid(NodeID outputNode) const; + +private: + friend class GraphSerializer; + + NodeID nextID { 1 }; + std::unordered_map> nodes; + + // Connection map: (toNode, inputSlot) → fromNode + struct ConnKey { + NodeID toNode; + int slot; + bool operator==(const ConnKey& o) const noexcept { + return toNode == o.toNode && slot == o.slot; + } + }; + struct ConnKeyHash { + size_t operator()(const ConnKey& k) const noexcept { + return std::hash{}( + (static_cast(k.toNode) << 32) | static_cast(k.slot)); + } + }; + std::unordered_map connections; + + // Returns true if adding an edge (from → to) would create a cycle. + // This holds when 'to' is already a transitive dependency of 'from'. + bool WouldCreateCycle(NodeID from, NodeID to) const; + + // Recursively collect 'id' and all its upstream dependencies into 'out'. + void CollectDependencies(NodeID id, std::unordered_set& out) const; + + Value EvaluateImpl(NodeID id, const EvalContext& ctx) const; +}; + +} // namespace WorldGraph diff --git a/include/WorldGraph/WorldGraphNode.h b/include/WorldGraph/WorldGraphNode.h new file mode 100644 index 0000000..354e0ea --- /dev/null +++ b/include/WorldGraph/WorldGraphNode.h @@ -0,0 +1,256 @@ +#pragma once + +#include "WorldGraph/WorldGraphTypes.h" +#include "config.h" + +#include +#include +#include + +namespace WorldGraph { + +// ─────────────────────────────── Node base ─────────────────────────────────── + +/// Abstract base for every node in the world-generation graph. +/// +/// A node declares its output type and input types, and implements Evaluate() +/// to compute an output Value from its inputs and the per-cell EvalContext. +/// Type declarations are advisory — coercion is available on Value — but they +/// allow the future visual editor to flag mismatched connections. +class Node { +public: + virtual ~Node() = default; + + virtual Type GetOutputType() const = 0; + virtual std::vector GetInputTypes() const = 0; + + /// Compute the output value. + /// \p inputs is guaranteed to have exactly GetInputCount() entries. + virtual Value Evaluate(const EvalContext& ctx, + const std::vector& inputs) const = 0; + + virtual std::string GetName() const = 0; + + size_t GetInputCount() const { return GetInputTypes().size(); } +}; + +// ─────────────────────────────── Math nodes ────────────────────────────────── + +/// Outputs a + b (Float) +class AddNode : 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 "Add"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 2); + return Value::MakeFloat(in[0].AsFloat() + in[1].AsFloat()); + }; +}; + +/// Outputs a − b (Float) +class SubtractNode : 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 "Subtract"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 2); + return Value::MakeFloat(in[0].AsFloat() - in[1].AsFloat()); + } +}; + +/// Outputs a × b (Float) +class MultiplyNode : 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 "Multiply"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 2); + return Value::MakeFloat(in[0].AsFloat() * in[1].AsFloat()); + } +}; + +/// Outputs a / b (Float; returns 0 on division by zero) +class DivideNode : 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 "Divide"; } + 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(in[0].AsFloat() / b); + } +}; + +// ─────────────────────────────── Comparison nodes ──────────────────────────── + +/// Outputs true when a < b +class LessNode : public Node { +public: + Type GetOutputType() const override { return Type::Bool; } + std::vector GetInputTypes() const override { return { Type::Float, Type::Float }; } + std::string GetName() const override { return "Less"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 2); + return Value::MakeBool(in[0].AsFloat() < in[1].AsFloat()); + } +}; + +/// Outputs true when a > b +class GreaterNode : public Node { +public: + Type GetOutputType() const override { return Type::Bool; } + std::vector GetInputTypes() const override { return { Type::Float, Type::Float }; } + std::string GetName() const override { return "Greater"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 2); + return Value::MakeBool(in[0].AsFloat() > in[1].AsFloat()); + } +}; + +/// Outputs true when a <= b +class LessEqualNode : public Node { +public: + Type GetOutputType() const override { return Type::Bool; } + std::vector GetInputTypes() const override { return { Type::Float, Type::Float }; } + std::string GetName() const override { return "LessEqual"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 2); + return Value::MakeBool(in[0].AsFloat() <= in[1].AsFloat()); + } +}; + +/// Outputs true when a >= b +class GreaterEqualNode : public Node { +public: + Type GetOutputType() const override { return Type::Bool; } + std::vector GetInputTypes() const override { return { Type::Float, Type::Float }; } + std::string GetName() const override { return "GreaterEqual"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 2); + return Value::MakeBool(in[0].AsFloat() >= in[1].AsFloat()); + } +}; + +/// Outputs true when a == b (float comparison; use with care for non-integers) +class EqualNode : public Node { +public: + Type GetOutputType() const override { return Type::Bool; } + std::vector GetInputTypes() const override { return { Type::Float, Type::Float }; } + std::string GetName() const override { return "Equal"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 2); + return Value::MakeBool(in[0].AsFloat() == in[1].AsFloat()); + } +}; + +// ─────────────────────────────── Boolean logic ─────────────────────────────── + +/// Outputs a && b +class AndNode : public Node { +public: + Type GetOutputType() const override { return Type::Bool; } + std::vector GetInputTypes() const override { return { Type::Bool, Type::Bool }; } + std::string GetName() const override { return "And"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 2); + return Value::MakeBool(in[0].AsBool() && in[1].AsBool()); + } +}; + +/// Outputs a || b +class OrNode : public Node { +public: + Type GetOutputType() const override { return Type::Bool; } + std::vector GetInputTypes() const override { return { Type::Bool, Type::Bool }; } + std::string GetName() const override { return "Or"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 2); + return Value::MakeBool(in[0].AsBool() || in[1].AsBool()); + } +}; + +/// Outputs !a +class NotNode : public Node { +public: + Type GetOutputType() const override { return Type::Bool; } + std::vector GetInputTypes() const override { return { Type::Bool }; } + std::string GetName() const override { return "Not"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 1); + return Value::MakeBool(!in[0].AsBool()); + } +}; + +// ─────────────────────────────── Control flow ───────────────────────────────── + +/// Selects between two inputs based on a boolean condition. +/// +/// inputs[0] condition (Bool) +/// inputs[1] value when true +/// inputs[2] value when false +/// +/// The output Value preserves the concrete type of whichever branch is chosen, +/// so Int tile IDs pass through correctly even though GetOutputType() reports Float. +class BranchNode : public Node { +public: + Type GetOutputType() const override { return Type::Float; } + std::vector GetInputTypes() const override { + return { Type::Bool, Type::Float, Type::Float }; + } + std::string GetName() const override { return "Branch"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 3); + return in[0].AsBool() ? in[1] : in[2]; + } +}; + +// ─────────────────────────────── Source / constant nodes ───────────────────── + +/// Outputs a fixed floating-point constant (no inputs). +class ConstantNode : public Node { +public: + float value { 0.0f }; + explicit ConstantNode(float v = 0.0f) : value(v) {} + + Type GetOutputType() const override { return Type::Float; } + std::vector GetInputTypes() const override { return {}; } + std::string GetName() const override { return "Constant"; } + Value Evaluate(const EvalContext&, const std::vector&) const override { return Value::MakeFloat(value); }; +}; + +/// Outputs a tile identifier as an integer (no inputs). +class IDNode : public Node { +public: + int32_t tileID { 0 }; + explicit IDNode(int32_t id = 0) : tileID(id) {} + + Type GetOutputType() const override { return Type::Int; } + std::vector GetInputTypes() const override { return {}; } + std::string GetName() const override { return "TileID"; } + Value Evaluate(const EvalContext&, const std::vector&) const override { return Value::MakeInt(tileID); }; +}; + +/// Outputs the world-space X coordinate of the cell being evaluated. +class PositionXNode : public Node { +public: + Type GetOutputType() const override { return Type::Int; } + std::vector GetInputTypes() const override { return {}; } + std::string GetName() const override { return "PositionX"; } + Value Evaluate(const EvalContext& ctx, const std::vector&) const override { return Value::MakeInt(ctx.worldX); }; +}; + +/// Outputs the world-space Y coordinate of the cell being evaluated. +class PositionYNode : public Node { +public: + Type GetOutputType() const override { return Type::Int; } + std::vector GetInputTypes() const override { return {}; } + std::string GetName() const override { return "PositionY"; } + Value Evaluate(const EvalContext& ctx, const std::vector&) const override { return Value::MakeInt(ctx.worldY); }; +}; + +} // namespace WorldGraph diff --git a/include/WorldGraph/WorldGraphSerializer.h b/include/WorldGraph/WorldGraphSerializer.h new file mode 100644 index 0000000..be1c824 --- /dev/null +++ b/include/WorldGraph/WorldGraphSerializer.h @@ -0,0 +1,56 @@ +#pragma once + +#include "WorldGraph/WorldGraph.h" +#include +#include +#include + +namespace WorldGraph { + +/// Serializes and deserializes a Graph to/from JSON using nlohmann/json. +/// +/// JSON format +/// ─────────── +/// { +/// "nextId": , +/// "nodes": [ +/// { "id": , "type": "", ...node-specific fields... }, +/// ... +/// ], +/// "connections": [ +/// { "from": , "to": , "slot": }, +/// ... +/// ] +/// } +/// +/// Node-specific fields +/// ──────────────────── +/// Constant : "value" (float) +/// TileID : "tileId" (int) +/// All other nodes have no extra fields. +class GraphSerializer { +public: + /// Serialise \p graph to a JSON object. + static nlohmann::json ToJson (const Graph& graph); + + /// Reconstruct a Graph from a JSON object produced by ToJson(). + /// Returns nullopt if the JSON is structurally invalid or contains an + /// unrecognised node type. + static std::optional FromJson(const nlohmann::json& j); + + /// Write the graph as pretty-printed JSON to \p path. + /// Returns false if the file could not be opened/written. + static bool Save(const Graph& graph, const std::string& path); + + /// Read and reconstruct a Graph from \p path. + /// Returns nullopt if the file cannot be read or the JSON is invalid. + static std::optional Load(const std::string& path); + +private: + /// Construct a Node of the given type name, reading any config fields from \p j. + /// Returns nullptr for unrecognised type names. + static std::unique_ptr CreateNode(const std::string& type, + const nlohmann::json& j); +}; + +} // namespace WorldGraph diff --git a/include/WorldGraph/WorldGraphTypes.h b/include/WorldGraph/WorldGraphTypes.h new file mode 100644 index 0000000..db9d91d --- /dev/null +++ b/include/WorldGraph/WorldGraphTypes.h @@ -0,0 +1,77 @@ +#pragma once + +#include + +namespace WorldGraph { + +/// Types that can flow through graph edges. +enum class Type : uint8_t { + Int, ///< Integer — used for tile IDs, positions, counts. + Float, ///< Floating-point — used for math and noise. + Bool, ///< Boolean — used for conditions / comparisons. +}; + +/// A tagged value flowing along a graph edge. +/// Provides coercion helpers so nodes can request any type they need. +struct Value { + Type type { Type::Float }; + union { int32_t i; float f; bool b; } data {}; + + inline static Value MakeInt (int32_t v) noexcept { Value r; r.type = Type::Int; r.data.i = v; return r; } + inline static Value MakeFloat(float v) noexcept { Value r; r.type = Type::Float; r.data.f = v; return r; } + inline static Value MakeBool (bool v) noexcept { Value r; r.type = Type::Bool; r.data.b = v; return r; } + + float AsFloat() const noexcept; + int32_t AsInt () const noexcept; + bool AsBool () const noexcept; + + bool operator==(const Value& o) const noexcept; + bool operator!=(const Value& o) const noexcept { return !(*this == o); } +}; + +/// Per-cell context forwarded to every node during graph evaluation. +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. +}; + +inline float Value::AsFloat() const noexcept { + switch (type) { + case Type::Float: return data.f; + case Type::Int: return static_cast(data.i); + case Type::Bool: return data.b ? 1.0f : 0.0f; + } + return 0.0f; +} + +inline int32_t Value::AsInt() const noexcept { + switch (type) { + case Type::Int: return data.i; + case Type::Float: return static_cast(data.f); + case Type::Bool: return data.b ? 1 : 0; + } + return 0; +} + +inline bool Value::AsBool() const noexcept { + switch (type) { + case Type::Bool: return data.b; + case Type::Int: return data.i != 0; + case Type::Float: return data.f != 0.0f; + } + return false; +} + +inline bool Value::operator==(const Value& o) const noexcept { + if (type != o.type) return false; + switch (type) { + case Type::Int: return data.i == o.data.i; + case Type::Float: return data.f == o.data.f; + case Type::Bool: return data.b == o.data.b; + } + return false; +} + +} // namespace WorldGraph diff --git a/src/WorldGraph/WorldGraph.cpp b/src/WorldGraph/WorldGraph.cpp new file mode 100644 index 0000000..497688c --- /dev/null +++ b/src/WorldGraph/WorldGraph.cpp @@ -0,0 +1,109 @@ +#include "WorldGraph/WorldGraph.h" + +namespace WorldGraph { + +// ─────────────────────────────── Graph ──────────────────────────────────────── + +Graph::NodeID Graph::AddNode(std::unique_ptr node) { + NodeID id = nextID++; + nodes.emplace(id, std::move(node)); + return id; +} + +void Graph::RemoveNode(NodeID id) { + nodes.erase(id); + for (auto it = connections.begin(); it != connections.end(); ) { + if (it->first.toNode == id || it->second == id) + it = connections.erase(it); + else + ++it; + } +} + +Node* Graph::GetNode(NodeID id) { + auto it = nodes.find(id); + return it != nodes.end() ? it->second.get() : nullptr; +} + +const Node* Graph::GetNode(NodeID id) const { + auto it = nodes.find(id); + return it != nodes.end() ? it->second.get() : nullptr; +} + +bool Graph::Connect(NodeID fromNode, NodeID toNode, int inputSlot) { + if (!nodes.count(fromNode) || !nodes.count(toNode)) + return false; + if (WouldCreateCycle(fromNode, toNode)) + return false; + connections[{toNode, inputSlot}] = fromNode; + return true; +} + +void Graph::Disconnect(NodeID toNode, int inputSlot) { + connections.erase({toNode, inputSlot}); +} + +std::optional Graph::GetInput(NodeID toNode, int inputSlot) const { + auto it = connections.find({toNode, inputSlot}); + if (it != connections.end()) return it->second; + return std::nullopt; +} + +void Graph::CollectDependencies(NodeID id, std::unordered_set& out) const { + if (!out.insert(id).second) return; // already visited + const Node* n = GetNode(id); + if (!n) return; + for (int s = 0; s < static_cast(n->GetInputCount()); ++s) { + auto src = GetInput(id, s); + if (src) CollectDependencies(*src, out); + } +} + +bool Graph::WouldCreateCycle(NodeID from, NodeID to) const { + // Adding edge (from → to) creates a cycle when 'to' is already an + // upstream dependency of 'from' (i.e., 'from' already depends on 'to'). + std::unordered_set deps; + CollectDependencies(from, deps); + return deps.count(to) > 0; +} + +Value Graph::Evaluate(NodeID outputNode, const EvalContext& ctx) const { + return EvaluateImpl(outputNode, ctx); +} + +Value Graph::EvaluateImpl(NodeID id, const EvalContext& ctx) const { + const Node* node = GetNode(id); + if (!node) return Value::MakeFloat(0.0f); + + const auto& inputTypes = node->GetInputTypes(); + std::vector inputs; + inputs.reserve(inputTypes.size()); + + for (int slot = 0; slot < static_cast(inputTypes.size()); ++slot) { + auto src = GetInput(id, slot); + if (src) { + inputs.push_back(EvaluateImpl(*src, ctx)); + } else { + // Unconnected inputs default to the zero value for their declared type. + switch (inputTypes[slot]) { + case Type::Int: inputs.push_back(Value::MakeInt(0)); break; + case Type::Float: inputs.push_back(Value::MakeFloat(0.0f)); break; + case Type::Bool: inputs.push_back(Value::MakeBool(false)); break; + } + } + } + + return node->Evaluate(ctx, inputs); +} + +bool Graph::IsValid(NodeID outputNode) const { + const Node* node = GetNode(outputNode); + if (!node) return false; + for (int s = 0; s < static_cast(node->GetInputCount()); ++s) { + auto src = GetInput(outputNode, s); + if (!src || !IsValid(*src)) return false; + } + return true; +} + +} // namespace WorldGraph diff --git a/src/WorldGraph/WorldGraphSerializer.cpp b/src/WorldGraph/WorldGraphSerializer.cpp new file mode 100644 index 0000000..711b0bf --- /dev/null +++ b/src/WorldGraph/WorldGraphSerializer.cpp @@ -0,0 +1,155 @@ +#include "WorldGraph/WorldGraphSerializer.h" + +#include +#include +#include + +namespace WorldGraph { + +// ─────────────────────────────── Node factory ──────────────────────────────── + +std::unique_ptr GraphSerializer::CreateNode(const std::string& type, + const nlohmann::json& j) +{ + if (type == "Add") return std::make_unique(); + if (type == "Subtract") return std::make_unique(); + if (type == "Multiply") return std::make_unique(); + if (type == "Divide") return std::make_unique(); + if (type == "Less") return std::make_unique(); + if (type == "Greater") return std::make_unique(); + if (type == "LessEqual") return std::make_unique(); + if (type == "GreaterEqual") return std::make_unique(); + if (type == "Equal") return std::make_unique(); + if (type == "And") return std::make_unique(); + if (type == "Or") return std::make_unique(); + if (type == "Not") return std::make_unique(); + if (type == "Branch") return std::make_unique(); + if (type == "PositionX") return std::make_unique(); + if (type == "PositionY") return std::make_unique(); + + // Nodes with config data + if (type == "Constant") { + return std::make_unique(j.value("value", 0.0f)); + } + if (type == "TileID") { + return std::make_unique(j.value("tileId", 0)); + } + + return nullptr; // unrecognised type +} + +// ─────────────────────────────── ToJson ────────────────────────────────────── + +nlohmann::json GraphSerializer::ToJson(const Graph& g) +{ + nlohmann::json j; + j["nextId"] = g.nextID; + + // Nodes — sorted by ID for deterministic output. + std::vector ids; + ids.reserve(g.nodes.size()); + for (const auto& [id, _] : g.nodes) + ids.push_back(id); + std::sort(ids.begin(), ids.end()); + + auto& jNodes = j["nodes"] = nlohmann::json::array(); + for (Graph::NodeID id : ids) { + const Node* node = g.GetNode(id); + nlohmann::json jNode; + jNode["id"] = id; + jNode["type"] = node->GetName(); + + // Node-specific config fields + if (const auto* cn = dynamic_cast(node)) { + jNode["value"] = cn->value; + } else if (const auto* idn = dynamic_cast(node)) { + jNode["tileId"] = idn->tileID; + } + + jNodes.push_back(std::move(jNode)); + } + + // Connections — sorted by (toNode, slot) for deterministic output. + struct ConnEntry { Graph::NodeID from, to; int slot; }; + std::vector conns; + conns.reserve(g.connections.size()); + for (const auto& [key, from] : g.connections) + conns.push_back({ from, key.toNode, key.slot }); + std::sort(conns.begin(), conns.end(), [](const ConnEntry& a, const ConnEntry& b) { + return a.to != b.to ? a.to < b.to : a.slot < b.slot; + }); + + auto& jConns = j["connections"] = nlohmann::json::array(); + for (const auto& c : conns) { + nlohmann::json jConn; + jConn["from"] = c.from; + jConn["to"] = c.to; + jConn["slot"] = c.slot; + jConns.push_back(std::move(jConn)); + } + + return j; +} + +// ─────────────────────────────── FromJson ──────────────────────────────────── + +std::optional GraphSerializer::FromJson(const nlohmann::json& j) +{ + if (!j.contains("nextId") || !j.contains("nodes") || !j.contains("connections")) + return std::nullopt; + if (!j["nodes"].is_array() || !j["connections"].is_array()) + return std::nullopt; + + Graph g; + g.nextID = j["nextId"].get(); + + // Restore nodes directly into the map to preserve original IDs. + for (const auto& jNode : j["nodes"]) { + if (!jNode.contains("id") || !jNode.contains("type")) + return std::nullopt; + + auto id = jNode["id"].get(); + auto type = jNode["type"].get(); + auto node = CreateNode(type, jNode); + if (!node) return std::nullopt; // unrecognised node type + + g.nodes.emplace(id, std::move(node)); + } + + // Restore connections directly — skip the cycle check since we trust + // that the saved graph was already valid when it was serialised. + for (const auto& jConn : j["connections"]) { + if (!jConn.contains("from") || !jConn.contains("to") || !jConn.contains("slot")) + return std::nullopt; + + auto from = jConn["from"].get(); + auto to = jConn["to"].get(); + auto slot = jConn["slot"].get(); + g.connections[{to, slot}] = from; + } + + return g; +} + +// ─────────────────────────────── File I/O ──────────────────────────────────── + +bool GraphSerializer::Save(const Graph& graph, const std::string& path) +{ + std::ofstream f(path); + if (!f) return false; + f << ToJson(graph).dump(2); + return f.good(); +} + +std::optional GraphSerializer::Load(const std::string& path) +{ + std::ifstream f(path); + if (!f) return std::nullopt; + try { + return FromJson(nlohmann::json::parse(f)); + } catch (...) { + return std::nullopt; + } +} + +} // namespace WorldGraph diff --git a/tests/WorldGraph/test_WorldGraph.cpp b/tests/WorldGraph/test_WorldGraph.cpp new file mode 100644 index 0000000..496645e --- /dev/null +++ b/tests/WorldGraph/test_WorldGraph.cpp @@ -0,0 +1,868 @@ +#include +#include "WorldGraph/WorldGraph.h" +#include "WorldGraph/WorldGraphSerializer.h" +#include + +using namespace WorldGraph; + +// ─────────────────────────────── Value ─────────────────────────────────────── + +TEST_SUITE("WorldGraph::Value") { + + TEST_CASE("MakeFloat round-trips") { + auto v = Value::MakeFloat(3.14f); + CHECK(v.type == Type::Float); + CHECK(v.AsFloat() == doctest::Approx(3.14f)); + } + + TEST_CASE("MakeInt round-trips") { + auto v = Value::MakeInt(42); + CHECK(v.type == Type::Int); + CHECK(v.AsInt() == 42); + } + + TEST_CASE("MakeBool round-trips") { + CHECK(Value::MakeBool(true).AsBool() == true); + CHECK(Value::MakeBool(false).AsBool() == false); + } + + TEST_CASE("Int coerces to Float") { + CHECK(Value::MakeInt(5).AsFloat() == doctest::Approx(5.0f)); + CHECK(Value::MakeInt(-3).AsFloat() == doctest::Approx(-3.0f)); + } + + TEST_CASE("Float coerces to Int (truncates toward zero)") { + CHECK(Value::MakeFloat(2.9f).AsInt() == 2); + CHECK(Value::MakeFloat(-1.9f).AsInt() == -1); + } + + TEST_CASE("Bool coerces to Int") { + CHECK(Value::MakeBool(true).AsInt() == 1); + CHECK(Value::MakeBool(false).AsInt() == 0); + } + + TEST_CASE("Non-zero numeric coerces to Bool true") { + CHECK(Value::MakeInt(7).AsBool() == true); + CHECK(Value::MakeInt(0).AsBool() == false); + CHECK(Value::MakeFloat(0.1f).AsBool() == true); + CHECK(Value::MakeFloat(0.0f).AsBool() == false); + } + + TEST_CASE("Equality: same type and value") { + CHECK(Value::MakeInt(3) == Value::MakeInt(3)); + CHECK(Value::MakeFloat(1.0f) == Value::MakeFloat(1.0f)); + CHECK(Value::MakeBool(true) == Value::MakeBool(true)); + } + + TEST_CASE("Equality: different types are never equal") { + // Even if numerically equivalent, different tags → not equal. + CHECK_FALSE(Value::MakeInt(1) == Value::MakeFloat(1.0f)); + CHECK_FALSE(Value::MakeInt(1) == Value::MakeBool(true)); + CHECK_FALSE(Value::MakeFloat(0) == Value::MakeBool(false)); + } + + TEST_CASE("Inequality operator") { + CHECK(Value::MakeInt(1) != Value::MakeInt(2)); + CHECK_FALSE(Value::MakeInt(5) != Value::MakeInt(5)); + } +} + +// ─────────────────────────── 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") { + + // ── Node management ─────────────────────────────────────────────────────── + + TEST_CASE("AddNode: assigns unique non-zero IDs") { + Graph g; + auto id1 = g.AddNode(std::make_unique(1.0f)); + auto id2 = g.AddNode(std::make_unique(2.0f)); + CHECK(id1 != Graph::INVALID_ID); + CHECK(id2 != Graph::INVALID_ID); + CHECK(id1 != id2); + CHECK(g.NodeCount() == 2); + } + + TEST_CASE("GetNode: returns correct node") { + Graph g; + auto id = g.AddNode(std::make_unique(5.0f)); + auto* n = g.GetNode(id); + REQUIRE(n != nullptr); + CHECK(n->GetName() == "Constant"); + } + + TEST_CASE("GetNode: returns nullptr for unknown ID") { + Graph g; + CHECK(g.GetNode(99) == nullptr); + } + + TEST_CASE("RemoveNode: erases node and its connections") { + Graph g; + auto src = g.AddNode(std::make_unique(1.0f)); + auto add = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(src, add, 0)); + + g.RemoveNode(src); + CHECK(g.GetNode(src) == nullptr); + CHECK(!g.GetInput(add, 0).has_value()); + CHECK(g.NodeCount() == 1); + } + + // ── Connection management ───────────────────────────────────────────────── + + TEST_CASE("Connect: returns false for unknown node") { + Graph g; + auto id = g.AddNode(std::make_unique(1.0f)); + CHECK(!g.Connect(id, 999, 0)); + CHECK(!g.Connect(999, id, 0)); + } + + TEST_CASE("Connect and GetInput: round-trip") { + Graph g; + auto from = g.AddNode(std::make_unique(1.0f)); + auto to = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(from, to, 0)); + auto inp = g.GetInput(to, 0); + REQUIRE(inp.has_value()); + CHECK(*inp == from); + } + + TEST_CASE("Connect: replaces an existing connection on the same slot") { + Graph g; + auto a = g.AddNode(std::make_unique(1.0f)); + auto b = g.AddNode(std::make_unique(2.0f)); + auto c = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(a, c, 0)); + REQUIRE(g.Connect(b, c, 0)); // replaces a→c on slot 0 + CHECK(*g.GetInput(c, 0) == b); + } + + TEST_CASE("Disconnect: removes the connection") { + Graph g; + auto from = g.AddNode(std::make_unique(1.0f)); + auto to = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(from, to, 0)); + g.Disconnect(to, 0); + CHECK(!g.GetInput(to, 0).has_value()); + } + + TEST_CASE("Disconnect: is a no-op on unconnected slot") { + Graph g; + auto to = g.AddNode(std::make_unique()); + g.Disconnect(to, 0); // must not crash + } + + // ── Cycle detection ─────────────────────────────────────────────────────── + + TEST_CASE("Connect: rejects self-loop") { + Graph g; + auto id = g.AddNode(std::make_unique()); + CHECK(!g.Connect(id, id, 0)); + } + + TEST_CASE("Connect: rejects direct cycle (A→B then B→A)") { + Graph g; + auto a = g.AddNode(std::make_unique()); + auto b = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(a, b, 0)); // a → b (a feeds into b) + CHECK(!g.Connect(b, a, 0)); // b → a would create a cycle + } + + TEST_CASE("Connect: rejects transitive cycle (A→B→C then C→A)") { + Graph g; + auto a = g.AddNode(std::make_unique()); + auto b = g.AddNode(std::make_unique()); + auto c = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(a, b, 0)); // a → b + REQUIRE(g.Connect(b, c, 0)); // b → c + CHECK(!g.Connect(c, a, 0)); // c → a would close the cycle + } + + TEST_CASE("Connect: allows a shared dependency (diamond graph)") { + // shared → left → output + // ↘ right ↗ + Graph g; + auto shared = g.AddNode(std::make_unique(1.0f)); + auto left = g.AddNode(std::make_unique()); + auto right = g.AddNode(std::make_unique()); + auto output = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(shared, left, 0)); + REQUIRE(g.Connect(shared, right, 0)); + REQUIRE(g.Connect(left, output, 0)); + REQUIRE(g.Connect(right, output, 1)); + } + + // ── Evaluation ──────────────────────────────────────────────────────────── + + TEST_CASE("Evaluate: standalone constant") { + Graph g; + auto id = g.AddNode(std::make_unique(42.0f)); + CHECK(g.Evaluate(id, {}).AsFloat() == doctest::Approx(42.0f)); + } + + TEST_CASE("Evaluate: unknown node returns zero") { + Graph g; + CHECK(g.Evaluate(999, {}).AsFloat() == doctest::Approx(0.0f)); + } + + TEST_CASE("Evaluate: unconnected inputs default to zero") { + Graph g; + auto add = g.AddNode(std::make_unique()); + CHECK(g.Evaluate(add, {}).AsFloat() == doctest::Approx(0.0f)); // 0+0 + } + + TEST_CASE("Evaluate: add two constants") { + Graph g; + auto c1 = g.AddNode(std::make_unique(3.0f)); + auto c2 = g.AddNode(std::make_unique(4.0f)); + auto add = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(c1, add, 0)); + REQUIRE(g.Connect(c2, add, 1)); + CHECK(g.Evaluate(add, {}).AsFloat() == doctest::Approx(7.0f)); + } + + TEST_CASE("Evaluate: subtract two constants") { + Graph g; + auto c1 = g.AddNode(std::make_unique(10.0f)); + auto c2 = g.AddNode(std::make_unique(3.0f)); + auto sub = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(c1, sub, 0)); + REQUIRE(g.Connect(c2, sub, 1)); + CHECK(g.Evaluate(sub, {}).AsFloat() == doctest::Approx(7.0f)); + } + + TEST_CASE("Evaluate: chained additions ((1+2)+3) == 6") { + Graph g; + auto c1 = g.AddNode(std::make_unique(1.0f)); + auto c2 = g.AddNode(std::make_unique(2.0f)); + auto c3 = g.AddNode(std::make_unique(3.0f)); + auto add1 = g.AddNode(std::make_unique()); + auto add2 = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(c1, add1, 0)); + REQUIRE(g.Connect(c2, add1, 1)); + REQUIRE(g.Connect(add1, add2, 0)); + REQUIRE(g.Connect(c3, add2, 1)); + CHECK(g.Evaluate(add2, {}).AsFloat() == doctest::Approx(6.0f)); + } + + TEST_CASE("Evaluate: Less comparison") { + Graph g; + auto c1 = g.AddNode(std::make_unique(1.0f)); + auto c2 = g.AddNode(std::make_unique(2.0f)); + auto less = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(c1, less, 0)); + REQUIRE(g.Connect(c2, less, 1)); + CHECK(g.Evaluate(less, {}).AsBool() == true); // 1 < 2 + } + + TEST_CASE("Evaluate: Greater comparison") { + Graph g; + auto c1 = g.AddNode(std::make_unique(5.0f)); + auto c2 = g.AddNode(std::make_unique(3.0f)); + auto greater = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(c1, greater, 0)); + REQUIRE(g.Connect(c2, greater, 1)); + CHECK(g.Evaluate(greater, {}).AsBool() == true); // 5 > 3 + } + + TEST_CASE("Evaluate: PositionX/Y read from EvalContext") { + Graph g; + auto px = g.AddNode(std::make_unique()); + auto py = g.AddNode(std::make_unique()); + EvalContext ctx; ctx.worldX = 5; ctx.worldY = -3; + CHECK(g.Evaluate(px, ctx).AsInt() == 5); + CHECK(g.Evaluate(py, ctx).AsInt() == -3); + } + + // ── IsValid ─────────────────────────────────────────────────────────────── + + TEST_CASE("IsValid: ConstantNode (no inputs) is valid") { + Graph g; + auto id = g.AddNode(std::make_unique(1.0f)); + CHECK(g.IsValid(id)); + } + + TEST_CASE("IsValid: AddNode with no connections is invalid") { + Graph g; + auto add = g.AddNode(std::make_unique()); + CHECK(!g.IsValid(add)); + } + + TEST_CASE("IsValid: AddNode fully connected is valid") { + 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)); + CHECK(g.IsValid(add)); + } + + TEST_CASE("IsValid: returns false for unknown node ID") { + Graph g; + CHECK(!g.IsValid(42)); + } + + // ─────────────────────── Integration: flat world ───────────────────────── + // + // Pass 1 — flat stone layer: + // output tile ID = (worldY < 0) ? STONE : AIR + // + // Graph: + // [PositionY] ──┐ + // ├──> [Less] ──> [Branch] ──> output + // [Constant(0)] ┘ / \ + // [ID(STONE)] [ID(AIR)] + // + TEST_CASE("Integration: flat world — below y=0 is stone, at or above is air") { + const int32_t AIR = 0; + const int32_t STONE = 1; + + 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 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(posY, less, 0)); // a = worldY + REQUIRE(g.Connect(zero, less, 1)); // b = 0 + REQUIRE(g.Connect(less, branch, 0)); // condition + REQUIRE(g.Connect(stone, branch, 1)); // if true → stone + REQUIRE(g.Connect(air, branch, 2)); // if false → air + + CHECK(g.IsValid(branch)); + + // Underground cells → stone + for (int y : { -1, -5, -100 }) { + INFO("y = " << y); + EvalContext ctx; ctx.worldY = y; + CHECK(g.Evaluate(branch, ctx).AsInt() == STONE); + } + + // Surface and above → air + for (int y : { 0, 1, 5, 100 }) { + INFO("y = " << y); + EvalContext ctx; ctx.worldY = y; + CHECK(g.Evaluate(branch, ctx).AsInt() == AIR); + } + } + + // ─────────────────── Integration: conditional by X ─────────────────────── + // + // Split the world vertically: x > 5 → RED, else → BLUE + // + TEST_CASE("Integration: tile depends on X position") { + const int32_t BLUE = 3; + const int32_t RED = 2; + + Graph g; + auto posX = g.AddNode(std::make_unique()); + auto five = g.AddNode(std::make_unique(5.0f)); + auto greater = g.AddNode(std::make_unique()); + auto branch = g.AddNode(std::make_unique()); + auto red = g.AddNode(std::make_unique(RED)); + auto blue = g.AddNode(std::make_unique(BLUE)); + + REQUIRE(g.Connect(posX, greater, 0)); + REQUIRE(g.Connect(five, greater, 1)); + REQUIRE(g.Connect(greater, branch, 0)); + REQUIRE(g.Connect(red, branch, 1)); + REQUIRE(g.Connect(blue, branch, 2)); + + EvalContext ctx; + ctx.worldX = 6; CHECK(g.Evaluate(branch, ctx).AsInt() == RED); + ctx.worldX = 5; CHECK(g.Evaluate(branch, ctx).AsInt() == BLUE); + ctx.worldX = 3; CHECK(g.Evaluate(branch, ctx).AsInt() == BLUE); + } + + // ─────────────────── Integration: arithmetic on position ───────────────── + // + // Evaluate (worldX + worldY) at a specific cell. + // + TEST_CASE("Integration: add X and Y positions") { + Graph g; + auto px = g.AddNode(std::make_unique()); + auto py = g.AddNode(std::make_unique()); + auto add = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(px, add, 0)); + REQUIRE(g.Connect(py, add, 1)); + + EvalContext ctx; ctx.worldX = 3; ctx.worldY = 7; + CHECK(g.Evaluate(add, ctx).AsFloat() == doctest::Approx(10.0f)); + } + + // ─────────────────── Integration: nested branch ─────────────────────────── + // + // Three-layer terrain: + // y < -10 → DEEP_STONE (ID=3) + // y < 0 → STONE (ID=1) + // otherwise → AIR (ID=0) + // + TEST_CASE("Integration: three-layer terrain with nested branch") { + const int32_t AIR = 0; + const int32_t STONE = 1; + const int32_t DEEP_STONE = 3; + + Graph g; + + // Sources + auto posY = g.AddNode(std::make_unique()); + auto negTen = g.AddNode(std::make_unique(-10.0f)); + auto zeroC = g.AddNode(std::make_unique(0.0f)); + auto idAir = g.AddNode(std::make_unique(AIR)); + auto idStone = g.AddNode(std::make_unique(STONE)); + auto idDeep = g.AddNode(std::make_unique(DEEP_STONE)); + + // y < -10 + auto lessDeep = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(posY, lessDeep, 0)); + REQUIRE(g.Connect(negTen, lessDeep, 1)); + + // y < 0 + auto lessZero = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(posY, lessZero, 0)); + REQUIRE(g.Connect(zeroC, lessZero, 1)); + + // Inner branch: y < 0 → STONE else AIR + auto innerBranch = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(lessZero, innerBranch, 0)); + REQUIRE(g.Connect(idStone, innerBranch, 1)); + REQUIRE(g.Connect(idAir, innerBranch, 2)); + + // Outer branch: y < -10 → DEEP_STONE else (inner result) + auto outerBranch = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(lessDeep, outerBranch, 0)); + REQUIRE(g.Connect(idDeep, outerBranch, 1)); + REQUIRE(g.Connect(innerBranch, outerBranch, 2)); + + auto eval = [&](int y) { + EvalContext ctx; ctx.worldY = y; + return g.Evaluate(outerBranch, ctx).AsInt(); + }; + + CHECK(eval(-11) == DEEP_STONE); + CHECK(eval(-10) == STONE); // -10 is not < -10 + CHECK(eval(-5) == STONE); + CHECK(eval(-1) == STONE); + CHECK(eval(0) == AIR); + 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()); + } +}