#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { class TestComponent final : public Component { public: explicit TestComponent(GameObject& owner) : Component(owner, "TestComponent") {} void Update() override {} std::string GetTypeName() const override { return "TestComponent"; } }; class RemovingListener final : public EventListener { public: explicit RemovingListener(std::unique_ptr* victim) : victim(victim) {} void OnEvent(int) { if (victim != nullptr) { victim->reset(); victim = nullptr; } ++calls; } int calls{0}; private: std::unique_ptr* victim; }; void check(bool condition, const char* message) { if (!condition) { std::cerr << "FAILED: " << message << '\n'; std::exit(EXIT_FAILURE); } } bool close(float a, float b) { return std::abs(a - b) < 0.001f; } void testTransforms() { GameObject parent{"parent"}; GameObject child{"child"}; parent.GetTransform().SetLocalPosition({10.f, 2.f, -4.f}); parent.GetTransform().SetLocalRotation({0.f, 0.f, 90.f}); parent.GetTransform().SetLocalScale({2.f, 2.f, 2.f}); child.GetTransform().SetLocalPosition({1.f, 0.f, 0.f}); child.GetTransform().SetParent(&parent.GetTransform(), true); const auto worldPosition = child.GetTransform().GetWorldPosition(); check(close(worldPosition.x, 1.f) && close(worldPosition.y, 0.f), "reparenting must preserve world position"); child.GetTransform().SetWorldPosition({20.f, 10.f, 0.f}); const auto movedPosition = child.GetTransform().GetWorldPosition(); check(close(movedPosition.x, 20.f) && close(movedPosition.y, 10.f), "world position must account for parent rotation and scale"); parent.Destroy(); check(parent.IsBeingDestroyed() && child.IsBeingDestroyed(), "destroying a parent must mark descendants"); } void testSceneRoundTrip() { Scene& scene = SceneManager::GetInstance().CreateScene("round-trip"); GameObject* parent = scene.CreateGameObject("parent"); GameObject* child = scene.CreateGameObject("child"); child->GetTransform().SetLocalPosition({1.f, 2.f, 3.f}); child->GetTransform().SetParent(&parent->GetTransform(), false); scene.CommitPendingAdditions(); const auto path = std::filesystem::temp_directory_path() / "destrum_scene_test.json"; check(SceneSerializer::Save(scene, path), "scene save must succeed"); check(SceneSerializer::Load(scene, path), "scene load must succeed"); std::filesystem::remove(path); check(scene.GetObjects().size() == 2, "scene round-trip must preserve objects"); const auto* loadedChild = scene.GetObjects().at(1).get(); check(loadedChild->GetTransform().GetParent() != nullptr, "scene round-trip must preserve hierarchy"); check(close(loadedChild->GetTransform().GetLocalPosition().x, 1.f), "scene round-trip must preserve local transforms"); SceneManager::GetInstance().Destroy(); } void testEventMutation() { Event event; int calls = 0; event.AddListener([&](int) { ++calls; }); event.AddListener([&](int) { ++calls; event.AddListener([&](int) { ++calls; }); }); event.Invoke(1); check(calls == 2, "event mutation must not invalidate the current invocation"); event.Invoke(1); check(calls == 5, "new event listeners must run on subsequent invocations"); } void testEventRemoval() { Event event; std::unique_ptr victim; RemovingListener remover{&victim}; victim = std::make_unique(nullptr); event.AddListener(&remover, &RemovingListener::OnEvent); event.AddListener(victim.get(), &RemovingListener::OnEvent); event.Invoke(1); check(remover.calls == 1, "event remover must run once"); check(victim == nullptr, "event listener must be removable during invocation"); } void testComponentRemoval() { Scene& scene = SceneManager::GetInstance().CreateScene("component-removal"); GameObject* object = scene.CreateGameObject("object"); object->AddComponent(); scene.CommitPendingAdditions(); check(object->GetComponent() != nullptr, "test component must be discoverable before removal"); check(object->DestroyComponent() != nullptr, "destroying a component must find it"); scene.CleanupDestroyedGameObjects(); check(object->GetComponent() == nullptr, "destroyed components must be removed at the scene boundary"); SceneManager::GetInstance().Destroy(); } void testAssetPathValidation() { const auto root = std::filesystem::temp_directory_path() / "destrum_asset_test"; std::filesystem::create_directories(root / "assets" / "engine"); std::filesystem::create_directories(root / "assets" / "game"); std::ofstream{root / "assets" / "engine" / "asset.txt"} << "asset"; std::ofstream{root / "assets" / "engine" / "cooked.asset"} << "cooked"; std::ofstream{root / "assets" / "engine" / "manifest.json"} << R"({"version":1,"assets":[{"src":"asset.txt","out":"cooked.asset","type":"raw","mtime_epoch_ns":-5020137004799419216,"size_bytes":355972}]})"; auto& assetFs = AssetFS::GetInstance(); assetFs.Init(root); check(std::filesystem::exists(assetFs.GetFullPath("engine://asset.txt")), "valid virtual asset paths must resolve"); check(assetFs.GetCookedPathForFile("engine://asset.txt").filename() == "cooked.asset", "manifest output paths must resolve to cooked assets"); bool rejected = false; try { (void)assetFs.GetFullPath("engine://../outside.txt"); } catch (const std::exception&) { rejected = true; } check(rejected, "asset traversal must be rejected"); const auto outside = root / "outside"; const auto link = root / "assets" / "engine" / "link"; std::filesystem::create_directories(outside); std::error_code symlinkError; std::filesystem::create_directory_symlink(outside, link, symlinkError); if (!symlinkError) { bool symlinkEscapeRejected = false; try { (void)assetFs.GetFullPath("engine://link/missing.txt"); } catch (const std::exception&) { symlinkEscapeRejected = true; } check(symlinkEscapeRejected, "symlink traversal must be rejected for missing targets"); } std::filesystem::remove_all(root); assetFs.Reset(); } void testLegacySceneLoad() { Scene& scene = SceneManager::GetInstance().CreateScene("legacy"); const auto path = std::filesystem::temp_directory_path() / "destrum_legacy_scene.json"; std::ofstream{path} << R"({"version":1,"name":"legacy","objects":[{"id":9001,"name":"object","active":true,"transform":{},"parent":0,"components":[]}]})"; check(SceneSerializer::Load(scene, path), "version 1 placeholder transforms must remain loadable"); check(scene.GetObjects().size() == 1, "legacy scene load must create its object"); std::filesystem::remove(path); SceneManager::GetInstance().Destroy(); } void testSceneLoadRollback() { Scene& scene = SceneManager::GetInstance().CreateScene("rollback"); scene.CreateGameObject("original"); scene.CommitPendingAdditions(); const auto path = std::filesystem::temp_directory_path() / "destrum_bad_scene.json"; std::ofstream{path} << R"({"version":2,"name":"bad","objects":[{"id":9101,"name":"bad","transform":{"position":[0,0,0],"rotation":[0,0,0,1],"scale":[1,1,1]},"parent":0,"components":[{"type":"Rigidbody","enabled":true,"data":{}}]}]})"; check(!SceneSerializer::Load(scene, path), "scene loading must reject a rigidbody without a collider"); check(scene.GetObjects().size() == 1 && scene.GetObjects().at(0)->GetName() == "original", "failed scene loading must preserve the live scene"); std::filesystem::remove(path); SceneManager::GetInstance().Destroy(); } void testPhysicsWorldUnits() { SimplePhysicsWorld world{glm::vec3{0.0f}}; GameObject object{"scaled-physics"}; object.GetTransform().SetLocalScale(glm::vec3{0.5f}); object.AddComponent(1.0f); Rigidbody* rigidbody = object.AddComponent(); world.RegisterRigidbody(*rigidbody); PhysicsRaycastHit hit; check(world.Raycast({-2.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, 5.0f, hit), "scaled physics bodies must remain raycastable"); check(close(hit.distance, 1.0f), "physics collider dimensions must remain in world units"); object.GetTransform().SetLocalPosition({10.0f, 0.0f, 0.0f}); world.RefreshRigidbody(*rigidbody); check(world.Raycast({8.0f, 0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}, 5.0f, hit), "refreshing a physics body must use the current transform position"); check(close(hit.distance, 1.0f), "refreshed physics bodies must remain aligned with rendered transforms"); } void testJoltSphereCollisions() { JoltPhysicsWorld world{{ .workerThreadCount = 1, .gravity = {0.0f, -9.81f, 0.0f} }}; GameObject floor{"floor"}; floor.GetTransform().SetLocalPosition({0.0f, -1.0f, 0.0f}); floor.AddComponent(glm::vec3{10.0f, 0.5f, 10.0f}); auto* floorBody = floor.AddComponent(); floorBody->SetType(RigidbodyType::Static); world.RegisterRigidbody(*floorBody); GameObject first{"first"}; first.AddComponent(1.5f); auto* firstBody = first.AddComponent(); world.RegisterRigidbody(*firstBody); first.GetTransform().SetLocalPosition({0.0f, 100.0f, 0.0f}); first.GetTransform().SetLocalScale(glm::vec3{0.015f}); world.RefreshRigidbody(*firstBody); GameObject second{"second"}; second.AddComponent(1.5f); auto* secondBody = second.AddComponent(); world.RegisterRigidbody(*secondBody); second.GetTransform().SetLocalPosition({0.0f, 100.0f, 0.0f}); second.GetTransform().SetLocalScale(glm::vec3{0.015f}); world.RefreshRigidbody(*secondBody); for (int step = 0; step < 240; ++step) { world.Step(1.0f / 60.0f); world.SyncDynamicBodiesToTransforms(); } check(first.GetTransform().GetWorldPosition().y > 0.8f && second.GetTransform().GetWorldPosition().y > 0.8f, "Jolt spheres must collide with the ground plane"); check(glm::distance(first.GetTransform().GetWorldPosition(), second.GetTransform().GetWorldPosition()) > 2.5f, "Jolt spheres must collide with one another instead of occupying the same point"); } void testSceneJoltSphereCollisions() { Scene& scene = SceneManager::GetInstance().CreateScene("scene-physics"); GameObject* floor = scene.CreateGameObject("floor"); floor->GetTransform().SetLocalPosition({0.0f, -1.0f, 0.0f}); floor->AddComponent(glm::vec3{10.0f, 0.5f, 10.0f}); auto* floorBody = floor->AddComponent(); floorBody->SetType(RigidbodyType::Static); GameObject* first = scene.CreateGameObject("first"); first->AddComponent(1.5f); first->AddComponent(); first->GetTransform().SetWorldPosition({0.0f, 100.0f, 0.0f}); first->GetTransform().SetWorldScale(glm::vec3{0.015f}); GameObject* second = scene.CreateGameObject("second"); second->AddComponent(1.5f); second->AddComponent(); second->GetTransform().SetWorldPosition({0.0f, 100.0f, 0.0f}); second->GetTransform().SetWorldScale(glm::vec3{0.015f}); scene.CommitPendingAdditions(); scene.GetPhysics().RefreshGameObject(*first); scene.GetPhysics().RefreshGameObject(*second); for (int step = 0; step < 240; ++step) { scene.FixedUpdate(1.0f / 60.0f); } check(first->GetTransform().GetWorldPosition().y > 0.8f && second->GetTransform().GetWorldPosition().y > 0.8f, "scene physics spheres must collide with the ground plane"); check(glm::distance(first->GetTransform().GetWorldPosition(), second->GetTransform().GetWorldPosition()) > 2.5f, "scene physics spheres must not overlap after settling"); SceneManager::GetInstance().Destroy(); } } int main() { testTransforms(); testSceneRoundTrip(); testEventMutation(); testEventRemoval(); testComponentRemoval(); testAssetPathValidation(); testLegacySceneLoad(); testSceneLoadRollback(); testPhysicsWorldUnits(); testJoltSphereCollisions(); testSceneJoltSphereCollisions(); std::cout << "destrum tests passed\n"; return EXIT_SUCCESS; }