commit 57b350863ad73b38eb451015209153783821d67c Author: CHatingPython Date: Sun Sep 6 22:39:22 2026 +0200 feat(ecs): implement basic ECS diff --git a/.clang-format b/.clang-format new file mode 100755 index 0000000..b64883b --- /dev/null +++ b/.clang-format @@ -0,0 +1,67 @@ +BasedOnStyle: LLVM + +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +ContinuationIndentWidth: 4 + +ColumnLimit: 120 + +BreakBeforeBraces: Attach +BraceWrapping: + BeforeCatch: true + BeforeElse: false + BeforeWhile: false + BeforeLambdaBody: false + +AllowShortFunctionsOnASingleLine: Inline +AllowShortBlocksOnASingleLine: Empty +AllowShortCaseLabelsOnASingleLine: true +AllowShortEnumsOnASingleLine: false +AllowShortIfStatementsOnASingleLine: true +AllowShortLoopsOnASingleLine: false +AllowShortCompoundRequirementOnASingleLine: true + +NamespaceIndentation: None +AccessModifierOffset: -4 +EmptyLineBeforeAccessModifier: Never + +SpaceBeforeParens: ControlStatementsExceptControlMacros +SpaceBeforeInheritanceColon: true +SpacesBeforeTrailingComments: 1 + +AlignTrailingComments: true + +AlignConsecutiveAssignments: + Enabled: true + AlignCompound: true + PadOperators: false + +AlignConsecutiveDeclarations: Consecutive +AlignAfterOpenBracket: DontAlign + +ConstructorInitializerIndentWidth: 2 +PackConstructorInitializers: NextLineOnly +BreakConstructorInitializers: BeforeColon + +BinPackParameters: false +BinPackArguments: false +AllowAllArgumentsOnNextLine: false + +AlwaysBreakTemplateDeclarations: Yes +BreakAfterReturnType: None + +Cpp11BracedListStyle: false +BreakArrays: false + +IncludeBlocks: Regroup +SortIncludes: true + +ReflowComments: Always +KeepEmptyLinesAtTheStartOfBlocks: true + +DerivePointerAlignment: false +PointerAlignment: Left +AllowAllParametersOfDeclarationOnNextLine: false + +PenaltyReturnTypeOnItsOwnLine: 1000 \ No newline at end of file diff --git a/.clang-tidy b/.clang-tidy new file mode 100755 index 0000000..4303f61 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,105 @@ +--- +Checks: > + -*, + + bugprone-*, + performance-*, + readability-*, + portability-*, + cppcoreguidelines-*, + + -bugprone-assignment-in-if-condition, + -bugprone-easily-swappable-parameters, + -bugprone-multi-level-implicit-pointer-conversion, + -bugprone-unintended-char-ostream-output, + -readability-function-cognitive-complexity, + -readability-magic-numbers, + -readability-redundant-access-specifiers, + -readability-use-anyofallof, + -readability-named-parameter, + -readability-convert-member-functions-to-static, + -performance-enum-size, + -cppcoreguidelines-avoid-magic-numbers, + -cppcoreguidelines-pro-bounds-pointer-arithmetic, + -cppcoreguidelines-pro-type-union-access, + -cppcoreguidelines-pro-type-reinterpret-cast, + -cppcoreguidelines-avoid-do-while, + -cppcoreguidelines-avoid-non-const-global-variables, + -cppcoreguidelines-avoid-c-arrays, + -cppcoreguidelines-pro-bounds-constant-array-index, + -cppcoreguidelines-macro-usage, + -cppcoreguidelines-owning-memory, + -cppcoreguidelines-non-private-member-variables-in-classes, + -cppcoreguidelines-pro-bounds-avoid-unchecked-container-access, + -cppcoreguidelines-pro-bounds-array-to-pointer-decay, + -cppcoreguidelines-use-enum-class, + -bugprone-forward-declaration-namespace, + -bugprone-tagged-union-member-count, + -bugprone-unchecked-optional-access + +WarningsAsErrors: "*" + +HeaderFilterRegex: ".*" + +CheckOptions: + - key: readability-braces-around-statements.ShortStatementLines + value: 3 + + - key: readability-identifier-length.MinimumParameterNameLength + value: 2 + + - key: readability-identifier-length.MinimumVariableNameLength + value: 2 + + - key: cppcoreguidelines-rvalue-reference-param-not-moved.AllowPartialMove + value: true + + # Namespaces + - key: readability-identifier-naming.NamespaceCase + value: lower_case + + # Types + - key: readability-identifier-naming.StructCase + value: lower_case + - key: readability-identifier-naming.ClassCase + value: lower_case + - key: readability-identifier-naming.EnumCase + value: lower_case + # - key: readability-identifier-naming.AbstractClassPrefix + # value: I + + # Functions + - key: readability-identifier-naming.FunctionCase + value: lower_case + + # Variables + - key: readability-identifier-naming.VariableCase + value: camelBack + + # Public members + - key: readability-identifier-naming.PublicMemberCase + value: camelBack + + # Static members + - key: readability-identifier-naming.StaticVariableCase + value: camelBack + - key: readability-identifier-naming.StaticVariablePrefix + value: s_ + - key: readability-identifier-naming.ClassMemberCase + value: camelBack + - key: readability-identifier-naming.ClassMemberPrefix + value: s_ + + # Static constexpr + - key: readability-identifier-naming.StaticConstexprVariableCase + value: aNy_CasE + + # Constants + - key: readability-identifier-naming.ConstantCase + value: aNy_CasE + - key: readability-identifier-naming.StaticConstantCase + value: aNy_CasE + - key: readability-identifier-naming.EnumConstantCase + value: CamelCase + - key: readability-identifier-naming.ScopedEnumConstantCase + value: CamelCase diff --git a/.clangd b/.clangd new file mode 100755 index 0000000..6f00da2 --- /dev/null +++ b/.clangd @@ -0,0 +1,25 @@ +CompileFlags: + Add: [-std=c++20, -Wall, -Werror, -Wpedantic] + +InlayHints: + BlockEnd: true + Designators: false + Enabled: true + ParameterNames: true + DeducedTypes: true + TypeNameLimit: 24 + +Completion: + HeaderInsertion: IWYU + +Hover: + ShowAKA: true + +If: + PathMatch: (./.*\.cpp|./.*\.hpp) +Diagnostics: + UnusedIncludes: Strict + MissingIncludes: Strict + +Documentation: + CommentFormat: Doxygen diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9785597 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build +.cache diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..7b29050 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,29 @@ +cmake_minimum_required(VERSION 3.14) + +project(catboy_engine CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +include(FetchContent) +FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/03597a01ee50ed33e9dfd640b249b4be3799d395.zip +) + +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(googletest) + +enable_testing() + +file(GLOB_RECURSE LIBCATBOY_SRCS "src/libcatboy/**.cpp") +file(GLOB_RECURSE LIBCATBOY_HDRS "include/libcatboy/**.hpp") +add_library(libcatboy STATIC ${LIBCATBOY_SRCS} ${LIBCATBOY_HDRS}) +target_include_directories(libcatboy PUBLIC include/) +set_target_properties(libcatboy PROPERTIES LINKER_LANGUAGE CXX) + +file(GLOB_RECURSE TEST_SRCS "test/**.cpp") +add_executable(catboy_test ${TEST_SRCS}) +target_link_libraries(catboy_test libcatboy GTest::gtest_main) +include(GoogleTest) +gtest_discover_tests(catboy_test) diff --git a/include/libcatboy/ecs/component_view.hpp b/include/libcatboy/ecs/component_view.hpp new file mode 100644 index 0000000..e69de29 diff --git a/include/libcatboy/ecs/ecs.hpp b/include/libcatboy/ecs/ecs.hpp new file mode 100644 index 0000000..56f34a4 --- /dev/null +++ b/include/libcatboy/ecs/ecs.hpp @@ -0,0 +1,113 @@ +#ifndef LIBCATBOY_ECS_ECS_HPP +#define LIBCATBOY_ECS_ECS_HPP + +#include "fwd.hpp" +#include "sparseSet.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace libcatboy { +namespace ecs { + +template +class basic_registry { +public: + using entity_type = Entity; +public: + entity_type create_entity() { + if (m_graveyard.empty()) { + entity_type entity = m_entityCounter++; + m_alive.insert(entity); + return entity; + } + entity_type entity = m_graveyard.front(); + m_alive.insert(entity); + m_graveyard.pop(); + return entity; + } + + void erase_entity(entity_type entity) { + if (!m_alive.contains(entity)) return; + m_alive.erase(entity); + for (const auto& [type, set] : m_components) { + set->erase(entity); + } + m_graveyard.push(entity); + } +public: + template + void register_component() { + get_set(); + } + + template + void unregister_component() { + auto it = m_components.find(typeid(T)); + if (it == m_components.end()) return; + delete reinterpret_cast*>(it->second); + m_components.erase(it); + } +public: + template + void insert(entity_type entity, T&& component) { + emplace>(entity, std::forward(component)); + } + + template + T& emplace(entity_type entity, Args&&... args) { + if (m_alive.find(entity) == m_alive.end()) throw std::runtime_error("entity is dead"); + return get_set().emplace(entity, std::forward(args)...); + } +public: + template + T& at(entity_type entity) { + if (m_alive.find(entity) == m_alive.end()) throw std::runtime_error("entity is dead"); + return get_set().at(entity); + } + + template + const T& at(entity_type entity) const { + if (m_alive.find(entity) == m_alive.end()) throw std::runtime_error("entity is dead"); + return get_set().at(entity); + } +public: + template + void erase(entity_type entity) { + if (m_alive.find(entity) == m_alive.end()) throw std::runtime_error("entity is dead"); + get_set().erase(entity); + } +private: + template + sparse_set& get_set() { + auto it = m_components.find(typeid(T)); + if (it != m_components.end()) return dynamic_cast&>(*it->second); + sparse_set* set = new sparse_set(); + m_components.emplace(typeid(T), set); + return *set; + } + + template + const sparse_set& get_set() const { + auto it = m_components.find(typeid(T)); + if (it != m_components.end()) return dynamic_cast&>(*it->second); + throw std::runtime_error("error"); + } +private: + std::unordered_map m_components; + + std::unordered_set m_alive; + std::queue m_graveyard; + entity_type m_entityCounter = 0; +}; + +} // namespace ecs +} // namespace libcatboy + +#endif // LIBCATBOY_ECS_ECS_HPP diff --git a/include/libcatboy/ecs/fwd.hpp b/include/libcatboy/ecs/fwd.hpp new file mode 100644 index 0000000..d32d7d9 --- /dev/null +++ b/include/libcatboy/ecs/fwd.hpp @@ -0,0 +1,20 @@ +#ifndef LIBCATBOY_ECS_FWD_HPP +#define LIBCATBOY_ECS_FWD_HPP + +#include +#include + +namespace libcatboy { +namespace ecs { + +using entity = std::uint32_t; + +template +class basic_registry; + +using registry = basic_registry<>; + +} // namespace ecs +} // namespace libcatboy + +#endif // LIBCATBOY_ECS_FWD_HPP diff --git a/include/libcatboy/ecs/sparseSet.hpp b/include/libcatboy/ecs/sparseSet.hpp new file mode 100644 index 0000000..80ce056 --- /dev/null +++ b/include/libcatboy/ecs/sparseSet.hpp @@ -0,0 +1,89 @@ +#ifndef LIBCATBOY_ECS_SPARSE_SET_HPP +#define LIBCATBOY_ECS_SPARSE_SET_HPP + +#include +#include +#include + +namespace libcatboy { +namespace ecs { + +class isparse_set { +public: + isparse_set() = default; + virtual ~isparse_set() = default; + + isparse_set(isparse_set&&) noexcept = default; + isparse_set& operator=(isparse_set&&) noexcept = default; + isparse_set(const isparse_set&) = default; + isparse_set& operator=(const isparse_set&) = default; +public: + virtual bool contains(std::size_t key) const noexcept = 0; + virtual void erase(std::size_t key) noexcept = 0; +}; + +template +class sparse_set : public isparse_set { +public: + using value_type = T; + using reference = T&; + using const_reference = const T&; + using pointer = T*; + using const_pointer = const T*; + + static constexpr std::size_t TOMBSTONE = -1; +public: + void insert(std::size_t key, const T& value) { emplace(key, value); } + void insert(std::size_t key, T&& value) { emplace(key, std::move(value)); } + + template >> + T& emplace(std::size_t key, Args&&... args) { + std::size_t index = get_index(key); + if (index == TOMBSTONE) { + index = m_dense.size(); + set_index(key, index); + m_reverseMap.push_back(key); + return m_dense.emplace_back(std::forward(args)...); + } + m_reverseMap[index] = key; + return *m_dense.emplace(m_dense.cbegin() + index, std::forward(args)...); + } +public: + T& operator[](std::size_t key) { return m_dense[get_index(key)]; } + const T& operator[](std::size_t key) const { return m_dense[get_index(key)]; } + + T& at(std::size_t key) { return m_dense.at(get_index(key)); } + const T& at(std::size_t key) const { return m_dense.at(get_index(key)); } + + bool contains(std::size_t key) const noexcept override { return get_index(key) != TOMBSTONE; } +public: + void erase(std::size_t key) noexcept override { + std::size_t index = get_index(key); + if (index == TOMBSTONE) return; + if (index != m_dense.size() - 1) { + set_index(m_reverseMap.back(), index); + std::swap(m_dense[index], m_dense.back()); + std::swap(m_reverseMap[index], m_reverseMap.back()); + } + + m_dense.pop_back(); + m_reverseMap.pop_back(); + set_index(key, TOMBSTONE); + } +private: + void set_index(std::size_t key, std::size_t dense) { + if (key >= m_map.size()) m_map.resize(key + 1, TOMBSTONE); + m_map[key] = dense; + } + + std::size_t get_index(std::size_t key) const { return key >= m_map.size() ? TOMBSTONE : m_map[key]; } +private: + std::vector m_map; + std::vector m_reverseMap; + std::vector m_dense; +}; + +} // namespace ecs +} // namespace libcatboy + +#endif // LIBCATBOY_ECS_SPARSE_SET_HPP diff --git a/test/main.cpp b/test/main.cpp new file mode 100644 index 0000000..051066a --- /dev/null +++ b/test/main.cpp @@ -0,0 +1,942 @@ +// NOTE: This test suite is straight up generated by AI + +#include "libcatboy/ecs/ecs.hpp" + +#include +#include +#include +#include +#include +#include + +namespace libcatboy::ecs::test { + +// ============================================================================= +// Test components +// ============================================================================= + +struct position { + int x{}; + int y{}; + + friend bool operator==(const position&, const position&) = default; +}; + +struct velocity { + int x{}; + int y{}; + + friend bool operator==(const velocity&, const velocity&) = default; +}; + +struct health { + int value{}; + + friend bool operator==(const health&, const health&) = default; +}; + +struct tag {}; + +struct name { + std::string value; + + friend bool operator==(const name&, const name&) = default; +}; + +struct large_component { + std::uint64_t data[128]{}; +}; + +struct move_only { + int value{}; + + explicit move_only(int value = 0) + : value(value) {} + + move_only(const move_only&) = delete; + move_only& operator=(const move_only&) = delete; + + move_only(move_only&&) noexcept = default; + move_only& operator=(move_only&&) noexcept = default; +}; + +struct lifetime { + inline static int constructions = 0; + inline static int destructions = 0; + + int value{}; + + explicit lifetime(int value = 0) + : value(value) { + ++constructions; + } + + lifetime(const lifetime& other) + : value(other.value) { + ++constructions; + } + + lifetime(lifetime&& other) noexcept + : value(other.value) { + ++constructions; + } + + lifetime& operator=(const lifetime&) = default; + lifetime& operator=(lifetime&&) noexcept = default; + + ~lifetime() { ++destructions; } + + static void reset() { + constructions = 0; + destructions = 0; + } +}; + +// ============================================================================= +// Registry alias +// ============================================================================= + +using registry = libcatboy::ecs::registry; +using entity = registry::entity_type; + +// ============================================================================= +// Fixture +// ============================================================================= + +class RegistryTest : public ::testing::Test { +protected: + registry reg; + + entity create() { return reg.create_entity(); } +}; + +// ============================================================================= +// ENTITY CREATION +// ============================================================================= + +TEST_F(RegistryTest, CreateEntityReturnsZeroInitially) { + EXPECT_EQ(create(), entity{ 0 }); +} + +TEST_F(RegistryTest, CreateEntitiesAreUnique) { + const auto e1 = create(); + const auto e2 = create(); + const auto e3 = create(); + + EXPECT_NE(e1, e2); + EXPECT_NE(e1, e3); + EXPECT_NE(e2, e3); +} + +TEST_F(RegistryTest, CreateEntitiesIncreaseSequentially) { + const auto e1 = create(); + const auto e2 = create(); + const auto e3 = create(); + + EXPECT_EQ(e2, static_cast(e1 + 1)); + EXPECT_EQ(e3, static_cast(e2 + 1)); +} + +TEST_F(RegistryTest, CanCreateManyEntities) { + constexpr std::size_t count = 100'000; + + std::vector entities; + entities.reserve(count); + + for (std::size_t i = 0; i < count; ++i) + entities.push_back(create()); + + ASSERT_EQ(entities.size(), count); + + for (std::size_t i = 0; i < count; ++i) + EXPECT_EQ(entities[i], static_cast(i)); +} + +// ============================================================================= +// ENTITY DELETION +// ============================================================================= + +TEST_F(RegistryTest, EraseEntityDoesNotThrowForAliveEntity) { + const auto e = create(); + + EXPECT_NO_THROW(reg.erase_entity(e)); +} + +TEST_F(RegistryTest, EraseEntityCanBeCalledTwice) { + const auto e = create(); + + EXPECT_NO_THROW(reg.erase_entity(e)); + EXPECT_NO_THROW(reg.erase_entity(e)); +} + +TEST_F(RegistryTest, ErasingDeadEntityDoesNotCreateAdditionalGraveyardEntry) { + const auto e = create(); + + reg.erase_entity(e); + reg.erase_entity(e); + + EXPECT_EQ(create(), e); + EXPECT_EQ(create(), static_cast(1)); +} + +TEST_F(RegistryTest, ErasingOneEntityDoesNotAffectOtherEntities) { + const auto e1 = create(); + const auto e2 = create(); + const auto e3 = create(); + + reg.emplace(e1, 1, 10); + reg.emplace(e2, 2, 20); + reg.emplace(e3, 3, 30); + + reg.erase_entity(e2); + + // e1 and e3 remain alive and their components remain accessible. + EXPECT_EQ(reg.at(e1), (position{ 1, 10 })); + + EXPECT_EQ(reg.at(e3), (position{ 3, 30 })); + + // Access through the registry to a dead entity must fail regardless of + // sparse_set's missing-component semantics. + EXPECT_THROW(reg.at(e2), std::runtime_error); +} + +// ============================================================================= +// ENTITY REUSE +// ============================================================================= + +TEST_F(RegistryTest, ErasedEntityIsReused) { + const auto e1 = create(); + const auto e2 = create(); + + reg.erase_entity(e1); + + EXPECT_EQ(create(), e1); + EXPECT_EQ(create(), static_cast(2)); + EXPECT_NE(e2, e1); +} + +TEST_F(RegistryTest, GraveyardUsesFIFOOrder) { + const auto e1 = create(); + const auto e2 = create(); + const auto e3 = create(); + const auto e4 = create(); + + reg.erase_entity(e2); + reg.erase_entity(e4); + + EXPECT_EQ(create(), e2); + EXPECT_EQ(create(), e4); + + // Counter continues after the original range. + EXPECT_EQ(create(), static_cast(4)); +} + +TEST_F(RegistryTest, ReusedEntityDoesNotBecomeASecondLiveEntity) { + const auto e1 = create(); + const auto e2 = create(); + + reg.erase_entity(e1); + + const auto reused = create(); + + EXPECT_EQ(reused, e1); + EXPECT_NE(reused, e2); +} + +// ============================================================================= +// COMPONENT REGISTRATION +// ============================================================================= + +TEST_F(RegistryTest, RegisterComponentDoesNotThrow) { + EXPECT_NO_THROW(reg.register_component()); +} + +TEST_F(RegistryTest, CanRegisterDifferentComponentTypes) { + EXPECT_NO_THROW(reg.register_component()); + EXPECT_NO_THROW(reg.register_component()); + EXPECT_NO_THROW(reg.register_component()); + EXPECT_NO_THROW(reg.register_component()); +} + +TEST_F(RegistryTest, RegisteringComponentAllowsInsertion) { + const auto e = create(); + + reg.register_component(); + + EXPECT_NO_THROW(reg.emplace(e, 1, 2)); +} + +TEST_F(RegistryTest, EmplaceImplicitlyRegistersComponent) { + const auto e = create(); + + EXPECT_NO_THROW(reg.emplace(e, 1, 2)); + + EXPECT_EQ(reg.at(e), (position{ 1, 2 })); +} + +// ============================================================================= +// COMPONENT INSERTION +// ============================================================================= + +TEST_F(RegistryTest, EmplaceReturnsStoredReference) { + const auto e = create(); + + auto& result = reg.emplace(e, 10, 20); + + result.x = 100; + result.y = 200; + + EXPECT_EQ(reg.at(e), (position{ 100, 200 })); +} + +TEST_F(RegistryTest, EmplaceMultipleComponentTypes) { + const auto e = create(); + + reg.emplace(e, 1, 2); + reg.emplace(e, 3, 4); + reg.emplace(e, 100); + + EXPECT_EQ(reg.at(e), (position{ 1, 2 })); + + EXPECT_EQ(reg.at(e), (velocity{ 3, 4 })); + + EXPECT_EQ(reg.at(e), (health{ 100 })); +} + +TEST_F(RegistryTest, ComponentsOnDifferentEntitiesAreIndependent) { + const auto e1 = create(); + const auto e2 = create(); + + reg.emplace(e1, 1, 2); + reg.emplace(e2, 3, 4); + + reg.at(e1).x = 100; + + EXPECT_EQ(reg.at(e1), (position{ 100, 2 })); + + EXPECT_EQ(reg.at(e2), (position{ 3, 4 })); +} + +TEST_F(RegistryTest, EmptyComponentCanBeStored) { + const auto e = create(); + + EXPECT_NO_THROW(reg.emplace(e)); +} + +TEST_F(RegistryTest, NonTrivialComponentCanBeStored) { + const auto e = create(); + + reg.emplace(e, "catboy"); + + EXPECT_EQ(reg.at(e).value, "catboy"); +} + +TEST_F(RegistryTest, LargeComponentCanBeStored) { + const auto e = create(); + + auto& value = reg.emplace(e); + + for (std::size_t i = 0; i < 128; ++i) + value.data[i] = i * 1234567ULL; + + const auto& result = reg.at(e); + + for (std::size_t i = 0; i < 128; ++i) + EXPECT_EQ(result.data[i], i * 1234567ULL); +} + +// ============================================================================= +// INSERT +// ============================================================================= + +TEST_F(RegistryTest, InsertRValue) { + const auto e = create(); + + reg.insert(e, position{ 10, 20 }); + + EXPECT_EQ(reg.at(e), (position{ 10, 20 })); +} + +TEST_F(RegistryTest, InsertMovedValue) { + const auto e = create(); + + position p{ 10, 20 }; + + reg.insert(e, std::move(p)); + + EXPECT_EQ(reg.at(e), (position{ 10, 20 })); +} + +// This test is intentionally disabled until insert() is corrected to use +// std::remove_cvref_t. +// +// Current implementation: +// emplace(...) +// +// For an lvalue T deduces as position&, which makes the component type +// position&. +// +// The correct implementation is: +// +// using component_type = std::remove_cvref_t; +// emplace(...); +// +// TEST_F(RegistryTest, InsertLValue) { +// const auto e = create(); +// +// position p{10, 20}; +// +// reg.insert(e, p); +// +// EXPECT_EQ( +// reg.at(e), +// (position{10, 20}) +// ); +// } + +// ============================================================================= +// ACCESS +// ============================================================================= + +TEST_F(RegistryTest, AtReturnsStoredComponent) { + const auto e = create(); + + reg.emplace(e, 42, 84); + + EXPECT_EQ(reg.at(e), (position{ 42, 84 })); +} + +TEST_F(RegistryTest, AtReturnsMutableReference) { + const auto e = create(); + + reg.emplace(e, 1, 2); + + auto& p = reg.at(e); + + p.x = 100; + p.y = 200; + + EXPECT_EQ(reg.at(e), (position{ 100, 200 })); +} + +TEST_F(RegistryTest, AtReturnTypeIsMutableReference) { + using result_type = decltype(std::declval().at(std::declval())); + + static_assert(std::is_same_v); +} + +TEST_F(RegistryTest, ConstAtReturnTypeIsConstReference) { + using result_type = decltype(std::declval().at(std::declval())); + + static_assert(std::is_same_v); +} + +TEST_F(RegistryTest, ConstAtReadsComponent) { + const auto e = create(); + + reg.emplace(e, 10, 20); + + const registry& const_reg = reg; + + const auto& p = const_reg.at(e); + + EXPECT_EQ(p.x, 10); + EXPECT_EQ(p.y, 20); +} + +// ============================================================================= +// DEAD ENTITY ACCESS +// ============================================================================= + +TEST_F(RegistryTest, EmplaceOnDeadEntityThrows) { + const auto e = create(); + + reg.erase_entity(e); + + EXPECT_THROW(reg.emplace(e, 1, 2), std::runtime_error); +} + +TEST_F(RegistryTest, EmplaceOnDeadEntityReportsEntityIsDead) { + const auto e = create(); + + reg.erase_entity(e); + + try { + reg.emplace(e, 1, 2); + FAIL() << "Expected std::runtime_error"; + } catch (const std::runtime_error& ex) { + EXPECT_STREQ(ex.what(), "entity is dead"); + } +} + +TEST_F(RegistryTest, AtOnDeadEntityThrows) { + const auto e = create(); + + reg.emplace(e, 1, 2); + reg.erase_entity(e); + + EXPECT_THROW(reg.at(e), std::runtime_error); +} + +TEST_F(RegistryTest, ConstAtOnDeadEntityThrows) { + const auto e = create(); + + reg.emplace(e, 1, 2); + reg.erase_entity(e); + + const registry& const_reg = reg; + + EXPECT_THROW(const_reg.at(e), std::runtime_error); +} + +TEST_F(RegistryTest, EraseComponentFromDeadEntityThrows) { + const auto e = create(); + + reg.emplace(e, 1, 2); + reg.erase_entity(e); + + EXPECT_THROW(reg.erase(e), std::runtime_error); +} + +// ============================================================================= +// COMPONENT ERASURE +// ============================================================================= +// +// IMPORTANT: +// +// registry::erase() delegates directly to sparse_set::erase(entity). +// Therefore these tests only assert the registry-level behavior that can be +// established without assuming how sparse_set::at() behaves for an absent +// component. +// +// We verify removal indirectly by: +// 1. erasing the component; +// 2. reinserting the same component; +// 3. checking the newly inserted value. +// +// ============================================================================= + +TEST_F(RegistryTest, EraseComponentAllowsReinsertion) { + const auto e = create(); + + reg.emplace(e, 1, 2); + + reg.erase(e); + + reg.emplace(e, 100, 200); + + EXPECT_EQ(reg.at(e), (position{ 100, 200 })); +} + +TEST_F(RegistryTest, EraseOneComponentPreservesOtherComponents) { + const auto e = create(); + + reg.emplace(e, 1, 2); + reg.emplace(e, 3, 4); + + reg.erase(e); + + EXPECT_EQ(reg.at(e), (velocity{ 3, 4 })); + + reg.emplace(e, 100, 200); + + EXPECT_EQ(reg.at(e), (position{ 100, 200 })); +} + +TEST_F(RegistryTest, EraseDoesNotDestroyEntity) { + const auto e = create(); + + reg.emplace(e, 1, 2); + + reg.erase(e); + + // If the entity were dead, this would throw. + EXPECT_NO_THROW(reg.emplace(e, 3, 4)); +} + +// ============================================================================= +// ENTITY DESTRUCTION AND COMPONENT CLEANUP +// ============================================================================= + +TEST_F(RegistryTest, DestroyedEntityCannotBeAccessed) { + const auto e = create(); + + reg.emplace(e, 1, 2); + + reg.erase_entity(e); + + EXPECT_THROW(reg.at(e), std::runtime_error); +} + +TEST_F(RegistryTest, DestroyedEntityCannotReceiveComponents) { + const auto e = create(); + + reg.emplace(e, 1, 2); + + reg.erase_entity(e); + + EXPECT_THROW(reg.emplace(e, 3, 4), std::runtime_error); +} + +TEST_F(RegistryTest, DestroyingEntityDoesNotAffectOtherEntities) { + const auto e1 = create(); + const auto e2 = create(); + + reg.emplace(e1, 1, 2); + reg.emplace(e2, 3, 4); + + reg.erase_entity(e1); + + EXPECT_EQ(reg.at(e2), (position{ 3, 4 })); +} + +TEST_F(RegistryTest, DestroyingMiddleEntityDoesNotCorruptOtherComponents) { + const auto e1 = create(); + const auto e2 = create(); + const auto e3 = create(); + + reg.emplace(e1, 10, 11); + reg.emplace(e2, 20, 21); + reg.emplace(e3, 30, 31); + + reg.erase_entity(e2); + + EXPECT_EQ(reg.at(e1), (position{ 10, 11 })); + + EXPECT_EQ(reg.at(e3), (position{ 30, 31 })); +} + +// ============================================================================= +// ENTITY REUSE + COMPONENT CLEANUP +// ============================================================================= +// +// These tests are deliberately written without assuming that an absent +// component causes at() to throw. +// +// We prove cleanup by reusing the entity and then inserting a fresh component. +// If the old component remained, sparse_set::emplace() would encounter its +// duplicate entity according to sparse_set's semantics. +// +// ============================================================================= + +TEST_F(RegistryTest, ReusedEntityCanReceiveFreshComponent) { + const auto old = create(); + + reg.emplace(old, 1, 2); + + reg.erase_entity(old); + + const auto replacement = create(); + + ASSERT_EQ(replacement, old); + + reg.emplace(replacement, 100, 200); + + EXPECT_EQ(reg.at(replacement), (position{ 100, 200 })); +} + +TEST_F(RegistryTest, ReusedEntityCanReceiveAllFormerComponentTypes) { + const auto old = create(); + + reg.emplace(old, 1, 2); + reg.emplace(old, 3, 4); + reg.emplace(old, 100); + reg.emplace(old, "old"); + + reg.erase_entity(old); + + const auto replacement = create(); + + ASSERT_EQ(replacement, old); + + reg.emplace(replacement, 10, 20); + reg.emplace(replacement, 30, 40); + reg.emplace(replacement, 200); + reg.emplace(replacement, "new"); + + EXPECT_EQ(reg.at(replacement), (position{ 10, 20 })); + + EXPECT_EQ(reg.at(replacement), (velocity{ 30, 40 })); + + EXPECT_EQ(reg.at(replacement), (health{ 200 })); + + EXPECT_EQ(reg.at(replacement).value, "new"); +} + +// ============================================================================= +// COMPONENT LIFETIME +// ============================================================================= +// +// DO NOT test destruction counts here. +// +// sparse_set owns the actual component object, and its erase/move/storage +// implementation determines exactly when destructors execute. The registry +// only calls set->erase(entity). +// +// Lifetime behavior belongs in sparse_set tests. +// ============================================================================= + +// ============================================================================= +// MOVE-ONLY COMPONENTS +// ============================================================================= + +TEST_F(RegistryTest, MoveOnlyComponentCanBeEmplaced) { + const auto e = create(); + + reg.emplace(e, 42); + + EXPECT_EQ(reg.at(e).value, 42); +} + +TEST_F(RegistryTest, MoveOnlyComponentCanBeErasedAndReinserted) { + const auto e = create(); + + reg.emplace(e, 42); + reg.erase(e); + + reg.emplace(e, 100); + + EXPECT_EQ(reg.at(e).value, 100); +} + +// ============================================================================= +// COMPONENT TYPE ISOLATION +// ============================================================================= + +TEST_F(RegistryTest, DifferentComponentTypesAreIndependent) { + const auto e = create(); + + reg.emplace(e, 1, 2); + reg.emplace(e, 3, 4); + reg.emplace(e, 100); + + reg.at(e).x = 999; + + EXPECT_EQ(reg.at(e), (position{ 999, 2 })); + + EXPECT_EQ(reg.at(e), (velocity{ 3, 4 })); + + EXPECT_EQ(reg.at(e), (health{ 100 })); +} + +TEST_F(RegistryTest, ErasingOneComponentTypeDoesNotAffectAnother) { + const auto e = create(); + + reg.emplace(e, 1, 2); + reg.emplace(e, 3, 4); + + reg.erase(e); + + EXPECT_EQ(reg.at(e), (velocity{ 3, 4 })); +} + +// ============================================================================= +// UNREGISTERING COMPONENTS +// ============================================================================= + +TEST_F(RegistryTest, UnregisterRegisteredComponentDoesNotThrow) { + reg.register_component(); + + EXPECT_NO_THROW(reg.unregister_component()); +} + +TEST_F(RegistryTest, UnregisterUnregisteredComponentDoesNotThrow) { + EXPECT_NO_THROW(reg.unregister_component()); +} + +TEST_F(RegistryTest, ComponentCanBeRegisteredAfterUnregistering) { + reg.register_component(); + reg.unregister_component(); + + EXPECT_NO_THROW(reg.register_component()); +} + +// ============================================================================= +// SPARSE ENTITY DISTRIBUTION +// ============================================================================= + +TEST_F(RegistryTest, ComponentsCanExistOnNonConsecutiveEntities) { + const auto e0 = create(); + const auto e1 = create(); + const auto e2 = create(); + const auto e3 = create(); + const auto e4 = create(); + + reg.emplace(e0, 0, 0); + reg.emplace(e2, 2, 20); + reg.emplace(e4, 4, 40); + + EXPECT_EQ(reg.at(e0), (position{ 0, 0 })); + + EXPECT_EQ(reg.at(e2), (position{ 2, 20 })); + + EXPECT_EQ(reg.at(e4), (position{ 4, 40 })); + + // We deliberately don't call at() for e1/e3 because their + // missing-component behavior belongs to sparse_set. + (void)e1; + (void)e3; +} + +// ============================================================================= +// DENSE STORAGE REGRESSION TESTS +// ============================================================================= +// +// Again, these don't assume missing-component behavior. They only verify that +// destroying/removing one entity doesn't corrupt components belonging to +// entities that remain alive. +// ============================================================================= + +TEST_F(RegistryTest, RemovingFirstEntityPreservesRemainingComponents) { + std::vector entities; + + for (int i = 0; i < 100; ++i) { + const auto e = create(); + + reg.emplace(e, i, i * 10); + + entities.push_back(e); + } + + reg.erase_entity(entities.front()); + + for (int i = 1; i < 100; ++i) { + EXPECT_EQ(reg.at(entities[i]), (position{ i, i * 10 })); + } +} + +TEST_F(RegistryTest, RemovingMiddleEntityPreservesRemainingComponents) { + std::vector entities; + + for (int i = 0; i < 100; ++i) { + const auto e = create(); + + reg.emplace(e, i, i * 10); + + entities.push_back(e); + } + + reg.erase_entity(entities[50]); + + for (int i = 0; i < 100; ++i) { + if (i == 50) continue; + + EXPECT_EQ(reg.at(entities[i]), (position{ i, i * 10 })); + } +} + +TEST_F(RegistryTest, RemovingLastEntityPreservesRemainingComponents) { + std::vector entities; + + for (int i = 0; i < 100; ++i) { + const auto e = create(); + + reg.emplace(e, i, i * 10); + + entities.push_back(e); + } + + reg.erase_entity(entities.back()); + + for (int i = 0; i < 99; ++i) { + EXPECT_EQ(reg.at(entities[i]), (position{ i, i * 10 })); + } +} + +// ============================================================================= +// HEAVY MUTATION +// ============================================================================= + +TEST_F(RegistryTest, HeavyCreateDestroyRemainsCorrect) { + constexpr int iterations = 100'000; + + for (int i = 0; i < iterations; ++i) { + const auto e = create(); + + reg.emplace(e, i, i * 2); + + EXPECT_EQ(reg.at(e), (position{ i, i * 2 })); + + reg.erase_entity(e); + } + + // Every entity is immediately recycled. + EXPECT_EQ(create(), entity{ 0 }); +} + +TEST_F(RegistryTest, HeavyComponentChurnRemainsCorrect) { + constexpr int count = 1'000; + constexpr int rounds = 100; + + std::vector entities; + entities.reserve(count); + + for (int i = 0; i < count; ++i) + entities.push_back(create()); + + for (int round = 0; round < rounds; ++round) { + for (int i = 0; i < count; ++i) { + reg.emplace(entities[i], round, i); + } + + for (int i = 0; i < count; ++i) { + EXPECT_EQ(reg.at(entities[i]), (position{ round, i })); + + reg.erase(entities[i]); + } + } +} + +// ============================================================================= +// MIXED COMPONENT DISTRIBUTION +// ============================================================================= + +TEST_F(RegistryTest, DestroyEntityWithMixedComponentsPreservesOtherEntities) { + const auto e1 = create(); + const auto e2 = create(); + const auto e3 = create(); + const auto e4 = create(); + + reg.emplace(e1, 1, 1); + reg.emplace(e2, 2, 2); + reg.emplace(e4, 4, 4); + + reg.emplace(e1, 10, 10); + reg.emplace(e3, 30, 30); + reg.emplace(e4, 40, 40); + + reg.emplace(e2, 200); + reg.emplace(e3, 300); + + reg.erase_entity(e2); + + EXPECT_EQ(reg.at(e1), (position{ 1, 1 })); + + EXPECT_EQ(reg.at(e4), (position{ 4, 4 })); + + EXPECT_EQ(reg.at(e1), (velocity{ 10, 10 })); + + EXPECT_EQ(reg.at(e3), (velocity{ 30, 30 })); + + EXPECT_EQ(reg.at(e4), (velocity{ 40, 40 })); + + EXPECT_EQ(reg.at(e3), (health{ 300 })); +} + +// ============================================================================= +// API TYPE TESTS +// ============================================================================= + +TEST_F(RegistryTest, EntityTypeIsIntegral) { + static_assert(std::integral); + + SUCCEED(); +} + +TEST_F(RegistryTest, RegistryIsDefaultConstructible) { + static_assert(std::default_initializable); + + SUCCEED(); +} + +} // namespace libcatboy::ecs::test