diff --git a/CMakeLists.txt b/CMakeLists.txt index 6c5943f..0816331 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,6 +39,7 @@ if(ECOSIM_BUILD_APP) src/scene/Scene.cpp src/scene/Scene_Menu.cpp src/scene/Scene_Simulation.cpp + src/simulation/SimulationClock.cpp src/simulation/SimulationWorld.cpp ) @@ -96,6 +97,8 @@ if(BUILD_TESTING) add_executable( EcoSimEngineFoundationTests tests/foundation_tests.cpp + src/simulation/SimulationClock.cpp + src/simulation/SimulationWorld.cpp ) target_compile_features(EcoSimEngineFoundationTests PRIVATE cxx_std_20) target_include_directories(EcoSimEngineFoundationTests PRIVATE include) diff --git a/include/EcoSimEngine/scene/Scene_Simulation.hpp b/include/EcoSimEngine/scene/Scene_Simulation.hpp index 7a05717..3f1e6b6 100644 --- a/include/EcoSimEngine/scene/Scene_Simulation.hpp +++ b/include/EcoSimEngine/scene/Scene_Simulation.hpp @@ -9,15 +9,21 @@ #include "EcoSimEngine/component/Components.hpp" #include "EcoSimEngine/scene/Scene.hpp" +#include "EcoSimEngine/simulation/SimulationClock.hpp" #include "EcoSimEngine/simulation/SimulationWorld.hpp" class Scene_Simulation : public Scene { private: std::string m_simKey; + SimulationWorld m_world; + SimulationClock m_simulationClock; + const std::string m_defaultSimulationPath{ "resources/defaults/default_simulation.json" }; std::unordered_map m_speciesColors; + + // measure real time elapsed between frames for simulation update sf::Clock m_clock; protected: diff --git a/include/EcoSimEngine/simulation/SimulationClock.hpp b/include/EcoSimEngine/simulation/SimulationClock.hpp new file mode 100644 index 0000000..4ec941d --- /dev/null +++ b/include/EcoSimEngine/simulation/SimulationClock.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include + +class SimulationClock +{ + static constexpr double EPSILON = 1e-12; + + double m_fixedStep{1.0 / 60.0}; + double m_accumulator{0.0}; + double m_speed{1.0}; + + std::uint64_t m_tick{0}; + std::uint64_t m_maxTicks{100000}; + +public: + SimulationClock() = default; + + SimulationClock( + double fixedStep, + std::uint64_t maxTicks); + + void configure( + double fixedStep, + std::uint64_t maxTicks); + + void reset() noexcept; + + void addElapsed(double realDeltaSeconds); + + [[nodiscard]] bool canStep() const noexcept; + void consumeStep(); + + void setSpeed(double speed); + + [[nodiscard]] double fixedStep() const noexcept; + [[nodiscard]] double speed() const noexcept; + + [[nodiscard]] std::uint64_t tick() const noexcept; + [[nodiscard]] std::uint64_t maxTicks() const noexcept; + + [[nodiscard]] bool finished() const noexcept; +}; \ No newline at end of file diff --git a/include/EcoSimEngine/simulation/SimulationWorld.hpp b/include/EcoSimEngine/simulation/SimulationWorld.hpp index 4421bf7..5b78205 100644 --- a/include/EcoSimEngine/simulation/SimulationWorld.hpp +++ b/include/EcoSimEngine/simulation/SimulationWorld.hpp @@ -1,5 +1,8 @@ #pragma once +#include +#include + #include "EcoSimEngine/component/ComponentManager.hpp" #include "EcoSimEngine/ecs/EntityManager.hpp" #include "EcoSimEngine/system/SystemManager.hpp" @@ -11,12 +14,18 @@ class SimulationWorld ComponentManager m_componentManager; EntityManager m_entityManager; + std::mt19937 m_rng{std::mt19937::default_seed}; + public: SimulationWorld(); void update(float dt); - EntityManager& entityManager() noexcept; - ComponentManager& componentManager() noexcept; - SystemManager& systemManager() noexcept; + void reseed(std::uint32_t seed); + + [[nodiscard]] std::mt19937& rng() noexcept; + + [[nodiscard]] EntityManager& entityManager() noexcept; + [[nodiscard]] ComponentManager& componentManager() noexcept; + [[nodiscard]] SystemManager& systemManager() noexcept; }; diff --git a/include/EcoSimEngine/system/AISystem.hpp b/include/EcoSimEngine/system/AISystem.hpp index ad41845..cb699b7 100644 --- a/include/EcoSimEngine/system/AISystem.hpp +++ b/include/EcoSimEngine/system/AISystem.hpp @@ -1,35 +1,45 @@ #pragma once -#include "EcoSimEngine/system/System.hpp" +#include + #include "EcoSimEngine/ecs/Entity.hpp" #include "EcoSimEngine/ecs/EntityManager.hpp" #include "EcoSimEngine/math/Vec2.hpp" -#include "EcoSimEngine/utils/Utils.hpp" - +#include "EcoSimEngine/system/System.hpp" +#include "EcoSimEngine/utils/Random.hpp" -class AISystem : public System { +class AISystem : public System +{ public: - void update(EntityManager& em, ComponentManager& cm, float dt) + void update( + EntityManager &em, + ComponentManager &cm, + float dt, + std::mt19937 &rng) { for (EntityId id : mEntities) { auto e = em.getEntityById(id); - if (!e || !e->isActive()) continue; - if (!cm.has(id) || !cm.has(id)) continue; - - auto& behavior = cm.get(id); - auto& t = cm.get(id); + if (!e || !e->isActive()) + continue; + if (!cm.has(id) || !cm.has(id)) + continue; + + auto &behavior = cm.get(id); + auto &transform = cm.get(id); behavior.stateTimer -= dt; - if (behavior.stateTimer <= 0.0f) { - behavior.stateTimer = randomFloat(1.0f, 5.0f); + if (behavior.stateTimer <= 0.0f) + { + behavior.stateTimer = randomFloat(rng, 1.0f, 5.0f); behavior.current = BehaviorState::Wander; } - if (behavior.current == BehaviorState::Wander && t.velocity.length() < 0.1f) { - Vec2f dir = randomUnitVector(); - t.velocity = dir * behavior.movementSpeed; + if (behavior.current == BehaviorState::Wander && transform.velocity.length() < 0.1f) + { + const Vec2f direction = randomUnitVector(rng); + transform.velocity = direction * behavior.movementSpeed; } } } -}; \ No newline at end of file +}; diff --git a/include/EcoSimEngine/utils/ColorUtils.hpp b/include/EcoSimEngine/utils/ColorUtils.hpp new file mode 100644 index 0000000..ec256f0 --- /dev/null +++ b/include/EcoSimEngine/utils/ColorUtils.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include + +inline sf::Color hslToRgb(float h, float s, float l) { + float c = (1.0f - std::fabs(2.0f * l - 1.0f)) * s; + float x = c * (1.0f - std::fabs(std::fmod(h / 60.0f, 2.0f) - 1.0f)); + const float m = l - c / 2.0f; + float r = 0.0f, g = 0.0f, b = 0.0f; + + if (h < 60.0f) { r = c; g = x; } + else if (h < 120.0f) { r = x; g = c; } + else if (h < 180.0f) { g = c; b = x; } + else if (h < 240.0f) { g = x; b = c; } + else if (h < 300.0f) { r = x; b = c; } + else { r = c; b = x; } + + return sf::Color( + static_cast((r + m) * 255.0f), + static_cast((g + m) * 255.0f), + static_cast((b + m) * 255.0f) + ); +} + diff --git a/include/EcoSimEngine/utils/Random.hpp b/include/EcoSimEngine/utils/Random.hpp new file mode 100644 index 0000000..5ae514e --- /dev/null +++ b/include/EcoSimEngine/utils/Random.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +#include "EcoSimEngine/math/Vec2.hpp" + +inline float randomFloat( + std::mt19937 &rng, + float min, + float max) +{ + std::uniform_real_distribution distribution(min, max); + + return distribution(rng); +} + +inline Vec2f randomUnitVector(std::mt19937& rng) +{ + const float angle = randomFloat( + rng, + 0.0f, + 2.0f * 3.14159265358979323846f); + + return Vec2f{ std::cos(angle), std::sin(angle) }; +} diff --git a/include/EcoSimEngine/utils/Utils.hpp b/include/EcoSimEngine/utils/Utils.hpp deleted file mode 100644 index 02f5494..0000000 --- a/include/EcoSimEngine/utils/Utils.hpp +++ /dev/null @@ -1,37 +0,0 @@ -#pragma once - -#include "EcoSimEngine/math/Vec2.hpp" -#include -#include -#include - -inline sf::Color hslToRgb(float h, float s, float l) { - float c = (1 - std::fabs(2 * l - 1)) * s; - float x = c * (1 - std::fabs(fmod(h / 60.0f, 2) - 1)); - float m = l - c / 2; - float r = 0, g = 0, b = 0; - - if (h < 60) { r = c; g = x; } - else if (h < 120) { r = x; g = c; } - else if (h < 180) { g = c; b = x; } - else if (h < 240) { g = x; b = c; } - else if (h < 300) { r = x; b = c; } - else { r = c; b = x; } - - return sf::Color( - static_cast((r + m) * 255), - static_cast((g + m) * 255), - static_cast((b + m) * 255) - ); -} - -inline float randomFloat(float a, float b) { - static thread_local std::mt19937 gen((std::random_device())()); - std::uniform_real_distribution d(a, b); - return d(gen); -} - -inline Vec2f randomUnitVector() { - float a = randomFloat(0.0f, 2.0f * 3.14159265358979323846f); - return Vec2f{ std::cos(a), std::sin(a) }; -} diff --git a/resources/defaults/default_simulation.json b/resources/defaults/default_simulation.json index 71dc9e9..478a7ba 100644 --- a/resources/defaults/default_simulation.json +++ b/resources/defaults/default_simulation.json @@ -1,5 +1,6 @@ { "simulation": { + "seed": 48596, "initialPopulation": { "fox": { "total": 10, @@ -17,8 +18,8 @@ "width": 1000, "height": 1000 }, - "timeStep": 1.0, + "timeStep": 0.0166666667, "maxTicks": 100000 } } -} +} \ No newline at end of file diff --git a/src/scene/Scene_Simulation.cpp b/src/scene/Scene_Simulation.cpp index 234e32a..d4c03b3 100644 --- a/src/scene/Scene_Simulation.cpp +++ b/src/scene/Scene_Simulation.cpp @@ -1,8 +1,8 @@ +#include #include #include #include #include -#include #include "SFML/Graphics/CircleShape.hpp" #include "SFML/Graphics/RectangleShape.hpp" @@ -18,7 +18,9 @@ #include "EcoSimEngine/scene/Scene_Menu.hpp" #include "EcoSimEngine/scene/Scene_Simulation.hpp" #include "EcoSimEngine/math/Vec2.hpp" -#include "EcoSimEngine/utils/Utils.hpp" + +#include "EcoSimEngine/utils/ColorUtils.hpp" +#include "EcoSimEngine/utils/Random.hpp" Scene_Simulation::Scene_Simulation(SimulationEngine *engine, const std::string &simKey) : Scene(engine), m_simKey(simKey) @@ -115,44 +117,72 @@ void Scene_Simulation::loadDefaultSimulation(const std::string &defaultSimulatio void Scene_Simulation::spawnFromJson(const nlohmann::json &simJson) { auto &em = m_world.entityManager(); - auto &cm = m_world.componentManager(); - // read population counts - auto populations = simJson["simulation"]["initialPopulation"]; + const auto &simulationJson = simJson.at("simulation"); + const auto &worldJson = simulationJson.at("world"); + + // --- configure deterministic RNG --- + const std::uint32_t seed = + simulationJson.at("seed").get(); + + m_world.reseed(seed); + + // --- configure simulation clock --- + const double fixedStep = + worldJson.at("timeStep").get(); + + const std::uint64_t maxTicks = + worldJson.at("maxTicks").get(); - // --- set up RNG once per load --- - std::random_device rd; - std::mt19937 gen(rd()); - std::uniform_real_distribution distX( - 0, simJson["simulation"]["world"]["size"]["width"].get()); - std::uniform_real_distribution distY( - 0, simJson["simulation"]["world"]["size"]["height"].get()); + m_simulationClock.configure(fixedStep, maxTicks); - int speciesCount = static_cast(populations.size()); + // --- load world dimensions --- + const float worldWidth = + worldJson.at("size").at("width").get(); + + const float worldHeight = + worldJson.at("size").at("height").get(); + + const auto &populations = + simulationJson.at("initialPopulation"); + + const int speciesCount = + static_cast(populations.size()); // Precompute species colours (single pass) int idx = 0; - for (auto &[speciesName, popData] : populations.items()) + + for (const auto &[speciesName, popData] : populations.items()) { - float hue = (idx * 360.0f) / std::max(1, speciesCount); // evenly spaced hues - m_speciesColors[speciesName] = hslToRgb(hue, 0.7f, 0.5f); // 70% sat, 50% lightness + const float hue = + (idx * 360.0f) / std::max(1, speciesCount); // evenly spaced hues + + m_speciesColors[speciesName] = + hslToRgb(hue, 0.7f, 0.5f); // 70% sat, 50% lightness + ++idx; } - for (auto &[speciesName, popData] : populations.items()) + for (const auto &[speciesName, popData] : populations.items()) { - int total = popData["total"]; - int males = popData["male"]; - int females = popData["female"]; + const int total = popData.at("total").get(); + const int males = popData.at("male").get(); + const int females = popData.at("female").get(); // load species data JSON (per-species data) - std::string speciesFile = "resources/definitions/species/" + speciesName + ".json"; + std::string speciesFile = + "resources/definitions/species/" + speciesName + ".json"; + std::ifstream sf(speciesFile); + if (!sf.is_open()) { - std::cerr << "Could not open species file: " << speciesFile << "\n"; + std::cerr << "Could not open species file: " + << speciesFile << '\n'; + continue; } + nlohmann::json speciesJson; sf >> speciesJson; @@ -167,19 +197,36 @@ void Scene_Simulation::spawnFromJson(const nlohmann::json &simJson) em.addComponent(entity, 100.0f); // proper random position - float x = distX(gen); - float y = distY(gen); - em.addComponent(entity, Vec2f(x, y)); + const float x = + randomFloat( + m_world.rng(), + 0.0f, + worldWidth); + + const float y = + randomFloat( + m_world.rng(), + 0.0f, + worldHeight); + + em.addComponent(entity, Vec2f{x, y}); // reproductive component - auto &repro = em.addComponent(entity); - repro.sex = (i < males ? Sex::Male : Sex::Female); - repro.canReproduce = true; + auto &reproductive = + em.addComponent(entity); + + reproductive.sex = + (i < males ? Sex::Male : Sex::Female); + + reproductive.canReproduce = true; - // TODO em.addComponent(entity); } - std::cout << "Loaded " << total << " " << speciesName << " entities.\n"; + + std::cout << "Loaded " + << total << " " + << speciesName + << " entities.\n"; } // Now flush pending entities -> this also calls EntitySignatureChanged for newly added entities @@ -194,24 +241,50 @@ void Scene_Simulation::sDoAction(const Action &action) if (action.name() == ActionName::QUIT_AND_SAVE) { onEnd(); + return; + } + + if (action.name() == ActionName::PAUSE) + { + setPaused(!m_paused); + return; } } void Scene_Simulation::update() { - float dt = m_clock.restart().asSeconds(); + const float realDeltaSeconds = m_clock.restart().asSeconds(); - if (dt <= 0.0f) + // Always restart the real-time clock, including while paused. + // Otherwise time spent paused would be accumulated and processed + // as a large catch-up burst after unpausing. + if (m_paused) { - dt = 1.0f / 60.0f; + return; } - // update when not paused - if (!m_paused) + if (m_simulationClock.finished()) { - m_world.update(dt); - m_currentFrame++; + return; + } + + m_simulationClock.addElapsed( + static_cast(realDeltaSeconds)); + + while (m_simulationClock.canStep()) + { + m_world.update( + static_cast(m_simulationClock.fixedStep())); + + m_simulationClock.consumeStep(); } + + // Keep the legacy Scene frame counter aligned with simulation + // progression for now. SimulationClock::tick() is the authoritative + // simulation counter. + m_currentFrame = + static_cast( + m_simulationClock.tick()); } void Scene_Simulation::onEnd() @@ -259,11 +332,42 @@ void Scene_Simulation::sRender() void Scene_Simulation::onGui() { // Example overlay window showing simulation info - ImGui::Begin("Simulation Info", nullptr, ImGuiWindowFlags_AlwaysAutoResize); + ImGui::Begin( + "Simulation Info ", + nullptr, + ImGuiWindowFlags_AlwaysAutoResize); + + ImGui::Text( + "Tick: %llu / %llu", + static_cast(m_simulationClock.tick()), + static_cast(m_simulationClock.maxTicks())); + + ImGui::Text( + "Fixed timestep: %.6f s", + m_simulationClock.fixedStep()); + + ImGui::Text( + "Paused: %s", + m_paused ? "yes" : "no"); + + if (m_simulationClock.finished()) + { + ImGui::Text("Simulation finished."); + } + + float simulationSped = static_cast(m_simulationClock.speed()); + + if (ImGui::SliderFloat( + "Simulation Speed", + &simulationSped, + 0.25f, + 4.0f, + "%.2fx")) + { + m_simulationClock.setSpeed(static_cast(simulationSped)); + } - // Frame/time info - ImGui::Text("Frame: %zu", m_currentFrame); - ImGui::Text("Delta time: %.3f s", m_clock.getElapsedTime().asSeconds()); + ImGui::Separator(); // Mouse info ImGui::Text("Mouse Position: (%.1f, %.1f)", m_mousePos.x, m_mousePos.y); diff --git a/src/simulation/SimulationClock.cpp b/src/simulation/SimulationClock.cpp new file mode 100644 index 0000000..919767a --- /dev/null +++ b/src/simulation/SimulationClock.cpp @@ -0,0 +1,108 @@ +#include "EcoSimEngine/simulation/SimulationClock.hpp" + +#include +#include + +SimulationClock::SimulationClock( + double fixedStep, + std::uint64_t maxTicks) +{ + configure(fixedStep, maxTicks); +} + +void SimulationClock::configure( + double fixedStep, + std::uint64_t maxTicks) +{ + if (!std::isfinite(fixedStep) || fixedStep <= 0.0) + { + throw std::invalid_argument( + "SimulationClock fixed step must be a positive finite value"); + } + + m_fixedStep = fixedStep; + m_maxTicks = maxTicks; + + reset(); +} + +void SimulationClock::reset() noexcept +{ + m_accumulator = 0.0; + m_tick = 0; +} + +void SimulationClock::addElapsed(double realDeltaSeconds) +{ + if (!std::isfinite(realDeltaSeconds) || realDeltaSeconds < 0.0) + { + throw std::invalid_argument( + "SimulationClock elapsed time must be a non-negative finite value"); + } + + m_accumulator += realDeltaSeconds * m_speed; +} + +bool SimulationClock::canStep() const noexcept +{ + if (finished()) + { + return false; + } + + return m_accumulator + EPSILON >= m_fixedStep; +} + +void SimulationClock::consumeStep() +{ + if (!canStep()) + { + throw std::logic_error( + "SimulationClock cannot consume a step when no step is available"); + } + + m_accumulator -= m_fixedStep; + + if (m_accumulator < 0.0) + { + m_accumulator = 0.0; + } + + ++m_tick; +} + +void SimulationClock::setSpeed(double speed) +{ + if (!std::isfinite(speed) || speed <= 0.0) + { + throw std::invalid_argument( + "SimulationClock speed must be a positive finite value"); + } + + m_speed = speed; +} + +double SimulationClock::fixedStep() const noexcept +{ + return m_fixedStep; +} + +double SimulationClock::speed() const noexcept +{ + return m_speed; +} + +std::uint64_t SimulationClock::tick() const noexcept +{ + return m_tick; +} + +std::uint64_t SimulationClock::maxTicks() const noexcept +{ + return m_maxTicks; +} + +bool SimulationClock::finished() const noexcept +{ + return m_tick >= m_maxTicks; +} \ No newline at end of file diff --git a/src/simulation/SimulationWorld.cpp b/src/simulation/SimulationWorld.cpp index 72f8cad..b9dc9db 100644 --- a/src/simulation/SimulationWorld.cpp +++ b/src/simulation/SimulationWorld.cpp @@ -31,7 +31,7 @@ void SimulationWorld::update(float dt) if (auto ai = m_systemManager.GetSystem()) { - ai->update(m_entityManager, m_componentManager, dt); + ai->update(m_entityManager, m_componentManager, dt, m_rng); } if (auto movement = m_systemManager.GetSystem()) @@ -43,6 +43,16 @@ void SimulationWorld::update(float dt) } } +void SimulationWorld::reseed(std::uint32_t seed) +{ + m_rng.seed(seed); +} + +std::mt19937& SimulationWorld::rng() noexcept +{ + return m_rng; +} + // getters for the managers EntityManager& SimulationWorld::entityManager() noexcept { diff --git a/tests/foundation_tests.cpp b/tests/foundation_tests.cpp index 0eccf9d..f1ea870 100644 --- a/tests/foundation_tests.cpp +++ b/tests/foundation_tests.cpp @@ -1,193 +1,417 @@ +#include "EcoSimEngine/component/Components.hpp" #include "EcoSimEngine/component/ComponentManager.hpp" #include "EcoSimEngine/component/ComponentIndices.hpp" #include "EcoSimEngine/event/EventBus.hpp" #include "EcoSimEngine/system/System.hpp" #include "EcoSimEngine/system/SystemManager.hpp" #include "EcoSimEngine/ecs/EntityManager.hpp" +#include "EcoSimEngine/simulation/SimulationWorld.hpp" +#include "EcoSimEngine/simulation/SimulationClock.hpp" #include #include -namespace { +namespace +{ + + struct Health + { + int value; + explicit Health(int initialValue) : value(initialValue) {} + }; + + struct SimulationSnapshot + { + float positionX, positionY; + float velocityX, velocityY; + + BehaviorState behaviorState; + float stateTimer; + }; + + class TransformSystem final : public System + { + }; + class UnregisteredSystem final : public System + { + }; + + void require(bool condition, const char *message) + { + if (!condition) + { + throw std::runtime_error(message); + } + } -struct Health { - int value; - explicit Health(int initialValue) : value(initialValue) {} -}; + // helper + SimulationSnapshot runDeterministicSimulation( + std::uint32_t seed) +{ + SimulationWorld world; -class TransformSystem final : public System {}; -class UnregisteredSystem final : public System {}; + world.reseed(seed); -void require(bool condition, const char* message) { - if (!condition) { - throw std::runtime_error(message); - } -} + auto& entities = + world.entityManager(); -void testComponentManager() { - ComponentManager components; + auto& components = + world.componentManager(); - require(!components.has(7), "new manager must not contain components"); + auto entity = + entities.addEntity("test"); - auto& health = components.add(7, 125); - require(health.value == 125, "added component must keep constructor state"); - require(components.has(7), "added component must be discoverable"); - require(&components.get(7) == &health, "get must return stored component"); + entities.addComponent( + entity, + Vec2f{0.0f, 0.0f}); - bool duplicateRejected = false; - try { - static_cast(components.add(7, 90)); - } catch (const std::logic_error&) { - duplicateRejected = true; - } - require(duplicateRejected, "duplicate component insertion must be rejected"); + entities.addComponent( + entity); + + entities.update(); + + constexpr float fixedStep = + 1.0f / 60.0f; - components.remove(7); - require(!components.has(7), "removed component must no longer exist"); + constexpr int ticks = 300; - bool missingGetRejected = false; - try { - static_cast(components.get(7)); - } catch (const std::out_of_range&) { - missingGetRejected = true; + for (int i = 0; i < ticks; ++i) + { + world.update(fixedStep); } - require(missingGetRejected, "getting a missing component must fail explicitly"); + + const auto id = + entity->id(); + + const auto& transform = + components.get(id); + + const auto& behavior = + components.get(id); + + return SimulationSnapshot{ + transform.pos.x, + transform.pos.y, + transform.velocity.x, + transform.velocity.y, + behavior.current, + behavior.stateTimer}; } -void testSystemManager() { - SystemManager systems; - auto transformSystem = systems.RegisterSystem(); - - Signature required; - required.set(0); - systems.SetSignature(required); - - Signature entitySignature; - systems.EntitySignatureChanged(11, entitySignature); - require(!transformSystem->mEntities.contains(11), - "entity without required components must not join system"); - - entitySignature.set(0); - systems.EntitySignatureChanged(11, entitySignature); - require(transformSystem->mEntities.contains(11), - "matching entity must join system"); - - entitySignature.reset(0); - systems.EntitySignatureChanged(11, entitySignature); - require(!transformSystem->mEntities.contains(11), - "entity must leave system after signature stops matching"); - - bool duplicateRejected = false; - try { - static_cast(systems.RegisterSystem()); - } catch (const std::logic_error&) { - duplicateRejected = true; + void testComponentManager() + { + ComponentManager components; + + require(!components.has(7), "new manager must not contain components"); + + auto &health = components.add(7, 125); + require(health.value == 125, "added component must keep constructor state"); + require(components.has(7), "added component must be discoverable"); + require(&components.get(7) == &health, "get must return stored component"); + + bool duplicateRejected = false; + try + { + static_cast(components.add(7, 90)); + } + catch (const std::logic_error &) + { + duplicateRejected = true; + } + require(duplicateRejected, "duplicate component insertion must be rejected"); + + components.remove(7); + require(!components.has(7), "removed component must no longer exist"); + + bool missingGetRejected = false; + try + { + static_cast(components.get(7)); + } + catch (const std::out_of_range &) + { + missingGetRejected = true; + } + require(missingGetRejected, "getting a missing component must fail explicitly"); } - require(duplicateRejected, "duplicate system registration must fail in release builds too"); - bool unregisteredSignatureRejected = false; - try { - systems.SetSignature(required); - } catch (const std::logic_error&) { - unregisteredSignatureRejected = true; + void testSystemManager() + { + SystemManager systems; + auto transformSystem = systems.RegisterSystem(); + + Signature required; + required.set(0); + systems.SetSignature(required); + + Signature entitySignature; + systems.EntitySignatureChanged(11, entitySignature); + require(!transformSystem->mEntities.contains(11), + "entity without required components must not join system"); + + entitySignature.set(0); + systems.EntitySignatureChanged(11, entitySignature); + require(transformSystem->mEntities.contains(11), + "matching entity must join system"); + + entitySignature.reset(0); + systems.EntitySignatureChanged(11, entitySignature); + require(!transformSystem->mEntities.contains(11), + "entity must leave system after signature stops matching"); + + bool duplicateRejected = false; + try + { + static_cast(systems.RegisterSystem()); + } + catch (const std::logic_error &) + { + duplicateRejected = true; + } + require(duplicateRejected, "duplicate system registration must fail in release builds too"); + + bool unregisteredSignatureRejected = false; + try + { + systems.SetSignature(required); + } + catch (const std::logic_error &) + { + unregisteredSignatureRejected = true; + } + require(unregisteredSignatureRejected, + "setting a signature for an unregistered system must fail explicitly"); } - require(unregisteredSignatureRejected, - "setting a signature for an unregistered system must fail explicitly"); -} -void testEntityDestruction() { - SystemManager systems; - ComponentManager components; - EntityManager entities(systems, components); + void testEntityDestruction() + { + SystemManager systems; + ComponentManager components; + EntityManager entities(systems, components); - auto transformSystem = systems.RegisterSystem(); + auto transformSystem = systems.RegisterSystem(); - Signature required; - required.set(COMP_INDEX_CTransform); - systems.SetSignature(required); + Signature required; + required.set(COMP_INDEX_CTransform); + systems.SetSignature(required); - auto destroyedEntity = entities.addEntity("destroyed"); - auto survivingEntity = entities.addEntity("survivor"); + auto destroyedEntity = entities.addEntity("destroyed"); + auto survivingEntity = entities.addEntity("survivor"); - entities.addComponent(destroyedEntity); - entities.addComponent(destroyedEntity, 50.0f); + entities.addComponent(destroyedEntity); + entities.addComponent(destroyedEntity, 50.0f); - entities.addComponent(survivingEntity); + entities.addComponent(survivingEntity); - entities.update(); + entities.update(); - const auto destroyedId = destroyedEntity->id(); - const auto survivingId = survivingEntity->id(); + const auto destroyedId = destroyedEntity->id(); + const auto survivingId = survivingEntity->id(); - require(components.has(destroyedId), - "entity must have Transform before destruction"); + require(components.has(destroyedId), + "entity must have Transform before destruction"); - require(components.has(destroyedId), - "entity must have Energy before destruction"); + require(components.has(destroyedId), + "entity must have Energy before destruction"); - require(transformSystem->mEntities.contains(destroyedId), - "entity must belong to matching system before destruction"); + require(transformSystem->mEntities.contains(destroyedId), + "entity must belong to matching system before destruction"); - entities.destroyEntity(destroyedEntity); + entities.destroyEntity(destroyedEntity); - require(!destroyedEntity->isActive(), - "destroyed entity must become inactive"); + require(!destroyedEntity->isActive(), + "destroyed entity must become inactive"); - require(!components.has(destroyedId), - "destroying entity must remove Transform"); + require(!components.has(destroyedId), + "destroying entity must remove Transform"); - require(!components.has(destroyedId), - "destroying entity must remove Energy"); + require(!components.has(destroyedId), + "destroying entity must remove Energy"); - require(destroyedEntity->signature().none(), - "destroying entity must clear its component signature"); + require(destroyedEntity->signature().none(), + "destroying entity must clear its component signature"); - require(!transformSystem->mEntities.contains(destroyedId), - "destroying entity must remove it from systems"); + require(!transformSystem->mEntities.contains(destroyedId), + "destroying entity must remove it from systems"); - require(components.has(survivingId), - "destroying one entity must not remove another entity's components"); + require(components.has(survivingId), + "destroying one entity must not remove another entity's components"); - require(transformSystem->mEntities.contains(survivingId), - "destroying one entity must not remove another entity from systems"); + require(transformSystem->mEntities.contains(survivingId), + "destroying one entity must not remove another entity from systems"); - entities.update(); + entities.update(); - require(entities.getEntityById(destroyedId) == nullptr, - "destroyed entity must be removed from EntityManager after update"); + require(entities.getEntityById(destroyedId) == nullptr, + "destroyed entity must be removed from EntityManager after update"); - require(entities.getEntityById(survivingId) != nullptr, - "surviving entity must remain managed"); -} + require(entities.getEntityById(survivingId) != nullptr, + "surviving entity must remain managed"); + } + + void testEventBus() + { + EventBus bus; + int total = 0; + + const auto first = bus.subscribe([&](const int &value) + { total += value; }); + bus.subscribe([&](const int &value) + { total += value * 10; }); + + bus.publish(2); + require(total == 22, "publish must notify all subscribers"); + + bus.unsubscribe(first); + bus.publish(1); + require(total == 32, "unsubscribe must remove only the selected subscription"); + } + + void testSimulationClock() + { + // A. Not enough elapsed time for one fixed step. + SimulationClock clock(0.1, 10); + + clock.addElapsed(0.05); + + require( + !clock.canStep(), + "clock must not step before enough time has accumulated"); + + require( + clock.tick() == 0, + "tick must remain zero before a step is consumed"); + + // B. Accumulated frame time should eventually produce one step. + clock.addElapsed(0.05); + + require( + clock.canStep(), + "accumulated elapsed time must make one step available"); -void testEventBus() { - EventBus bus; - int total = 0; + clock.consumeStep(); - const auto first = bus.subscribe([&](const int& value) { - total += value; - }); - bus.subscribe([&](const int& value) { - total += value * 10; - }); + require( + clock.tick() == 1, + "consuming one step must increment the simulation tick"); - bus.publish(2); - require(total == 22, "publish must notify all subscribers"); + require( + !clock.canStep(), + "consuming the available step must remove it from the accumulator"); - bus.unsubscribe(first); - bus.publish(1); - require(total == 32, "unsubscribe must remove only the selected subscription"); + // C. One frame may produce multiple fixed simulation steps. + clock.addElapsed(0.25); + + int stepsConsumed = 0; + + while (clock.canStep()) + { + clock.consumeStep(); + ++stepsConsumed; + } + + require( + stepsConsumed == 2, + "0.25 seconds with a 0.1 second fixed step must produce two complete steps"); + + require( + clock.tick() == 3, + "simulation tick must count every consumed fixed step"); + + // D. Speed changes accumulation rate, not fixed-step size. + SimulationClock fastClock(0.1, 10); + + fastClock.setSpeed(2.0); + fastClock.addElapsed(0.05); + + require( + fastClock.canStep(), + "2x speed must turn 0.05 real seconds into 0.1 simulation seconds"); + + require( + fastClock.fixedStep() == 0.1, + "changing simulation speed must not change the fixed timestep"); + + fastClock.consumeStep(); + + require( + fastClock.tick() == 1, + "2x speed clock must consume the resulting simulation step normally"); + + // E. maxTicks is a hard upper bound. + SimulationClock limitedClock(0.1, 3); + + limitedClock.addElapsed(1.0); + + int limitedStepsConsumed = 0; + + while (limitedClock.canStep()) + { + limitedClock.consumeStep(); + ++limitedStepsConsumed; + } + + require( + limitedStepsConsumed == 3, + "clock must stop after maxTicks even when more accumulated time remains"); + + require( + limitedClock.tick() == 3, + "tick must stop exactly at maxTicks"); + + require( + limitedClock.finished(), + "clock must report finished after reaching maxTicks"); + + require( + !limitedClock.canStep(), + "finished clock must not expose another simulation step"); + } + + void testDeterministicSimulation() +{ + constexpr std::uint32_t seed = 48596; + + const SimulationSnapshot first = + runDeterministicSimulation(seed); + + const SimulationSnapshot second = + runDeterministicSimulation(seed); + + require( + first.positionX == second.positionX && + first.positionY == second.positionY, + "same seed and tick sequence must reproduce entity position"); + + require( + first.velocityX == second.velocityX && + first.velocityY == second.velocityY, + "same seed and tick sequence must reproduce entity velocity"); + + require( + first.behaviorState == second.behaviorState, + "same seed and tick sequence must reproduce behavior state"); + + require( + first.stateTimer == second.stateTimer, + "same seed and tick sequence must reproduce behavior timing"); } -} // namespace +} // namespace -int main() { - try { +int main() +{ + try + { testComponentManager(); testSystemManager(); testEntityDestruction(); testEventBus(); - } catch (const std::exception& error) { + testSimulationClock(); + testDeterministicSimulation(); + } + catch (const std::exception &error) + { std::cerr << "foundation test failure: " << error.what() << '\n'; return 1; }