basic ECS spawner

This commit is contained in:
2026-01-15 15:27:48 +01:00
parent 24a781f36a
commit eb737b469c
860 changed files with 58621 additions and 32 deletions

View File

@@ -0,0 +1,21 @@
class_name C_ComplexSerializationTest
extends Component
@export var array_value: Array[int] = [1, 2, 3, 4, 5]
@export var string_array: Array[String] = ["hello", "world", "test"]
@export var dict_value: Dictionary = {"key1": "value1", "key2": 123, "key3": true}
@export var empty_array: Array = []
@export var empty_dict: Dictionary = {}
func _init(
_array_value: Array[int] = [1, 2, 3, 4, 5],
_string_array: Array[String] = ["hello", "world", "test"],
_dict_value: Dictionary = {"key1": "value1", "key2": 123, "key3": true},
_empty_array: Array = [],
_empty_dict: Dictionary = {}
):
array_value = _array_value
string_array = _string_array
dict_value = _dict_value
empty_array = _empty_array
empty_dict = _empty_dict

View File

@@ -0,0 +1 @@
uid://cpvr163gwyx2d

View File

@@ -0,0 +1,4 @@
class_name C_DebugTrackingTestA
extends Component
@export var value: float = 0.0

View File

@@ -0,0 +1 @@
uid://d0vhjx22wswv5

View File

@@ -0,0 +1,4 @@
class_name C_DebugTrackingTestB
extends Component
@export var count: int = 0

View File

@@ -0,0 +1 @@
uid://bijx0kal4npp

View File

@@ -0,0 +1,3 @@
class_name C_DomainTestA
extends Component
@export var v_a: int = 1

View File

@@ -0,0 +1 @@
uid://cqsmow0liv20e

View File

@@ -0,0 +1,3 @@
class_name C_DomainTestB
extends Component
@export var v_b: int = 2

View File

@@ -0,0 +1 @@
uid://bjodoqd54f6pq

View File

@@ -0,0 +1,22 @@
## Test health component for observer tests with proper property_changed signal emission
class_name C_ObserverHealth
extends Component
@export var health: int = 100 : set = set_health
@export var max_health: int = 100 : set = set_max_health
func set_health(new_health: int):
var old_health = health
health = new_health
# Emit signal for observers to detect the change
property_changed.emit(self, "health", old_health, new_health)
func set_max_health(new_max: int):
var old_max = max_health
max_health = new_max
# Emit signal for observers to detect the change
property_changed.emit(self, "max_health", old_max, new_max)
func _init(_health: int = 100, _max_health: int = 100):
health = _health
max_health = _max_health

View File

@@ -0,0 +1 @@
uid://c0o4jh5t35hqw

View File

@@ -0,0 +1,22 @@
## Test component for observer tests with proper property_changed signal emission
class_name C_ObserverTest
extends Component
@export var value: int = 0 : set = set_value
@export var name_prop: String = "" : set = set_name_prop
func set_value(new_value: int):
var old_value = value
value = new_value
# Emit signal for observers to detect the change
property_changed.emit(self, "value", old_value, new_value)
func set_name_prop(new_name: String):
var old_name = name_prop
name_prop = new_name
# Emit signal for observers to detect the change
property_changed.emit(self, "name_prop", old_name, new_name)
func _init(_value: int = 0, _name: String = ""):
value = _value
name_prop = _name

View File

@@ -0,0 +1 @@
uid://cmxcdgnk537l

View File

@@ -0,0 +1,3 @@
class_name C_OrderTestA
extends Component
@export var value_a: int = 1

View File

@@ -0,0 +1 @@
uid://12rys1s4dqub

View File

@@ -0,0 +1,3 @@
class_name C_OrderTestB
extends Component
@export var value_b: int = 2

View File

@@ -0,0 +1 @@
uid://brsnu840dpdnw

View File

@@ -0,0 +1,3 @@
class_name C_OrderTestC
extends Component
@export var value_c: int = 3

View File

@@ -0,0 +1 @@
uid://bkx8tgtgdngvs

View File

@@ -0,0 +1,3 @@
class_name C_OrderTestD
extends Component
@export var value_d: int = 4

View File

@@ -0,0 +1 @@
uid://cdih4o87okurl

View File

@@ -0,0 +1,3 @@
class_name C_OrderTestE
extends Component
@export var value_e: int = 5

View File

@@ -0,0 +1 @@
uid://djobbcytnokef

View File

@@ -0,0 +1,3 @@
class_name C_OrderTestF
extends Component
@export var value_f: int = 6

View File

@@ -0,0 +1 @@
uid://be0tga28sdlof

View File

@@ -0,0 +1,3 @@
class_name C_OrderTestG
extends Component
@export var value_g: int = 7

View File

@@ -0,0 +1 @@
uid://ctgvxw7pi4wro

View File

@@ -0,0 +1,3 @@
class_name C_OrderTestH
extends Component
@export var value_h: int = 8

View File

@@ -0,0 +1 @@
uid://hyqseyaigq4o

View File

@@ -0,0 +1,3 @@
class_name C_OrderTestI
extends Component
@export var value_i: int = 9

View File

@@ -0,0 +1 @@
uid://c25bhc3kbc4e8

View File

@@ -0,0 +1,3 @@
class_name C_OrderTestJ
extends Component
@export var value_j: int = 10

View File

@@ -0,0 +1 @@
uid://d1igiif6mkikj

View File

@@ -0,0 +1,3 @@
class_name C_OrderTestK
extends Component
@export var value_k: int = 11

View File

@@ -0,0 +1 @@
uid://jcoxghymmvmh

View File

@@ -0,0 +1,3 @@
class_name C_OrderTestL
extends Component
@export var value_l: int = 12

View File

@@ -0,0 +1 @@
uid://bce7cd48nf8e7

View File

@@ -0,0 +1,3 @@
class_name C_OrderTestM
extends Component
@export var value_m: int = 13

View File

@@ -0,0 +1 @@
uid://df0af054av56n

View File

@@ -0,0 +1,3 @@
class_name C_OrderTestN
extends Component
@export var value_n: int = 14

View File

@@ -0,0 +1 @@
uid://dkbwhig77q1j8

View File

@@ -0,0 +1,3 @@
class_name C_OrderTestO
extends Component
@export var value_o: int = 15

View File

@@ -0,0 +1 @@
uid://bgsirllg7wil0

View File

@@ -0,0 +1,3 @@
class_name C_PermA
extends Component
@export var v: int = 1

View File

@@ -0,0 +1 @@
uid://bi4vscfom0st2

View File

@@ -0,0 +1,3 @@
class_name C_PermB
extends Component
@export var v: int = 2

View File

@@ -0,0 +1 @@
uid://c1svfcwyi2oie

View File

@@ -0,0 +1,3 @@
class_name C_PermC
extends Component
@export var v: int = 3

View File

@@ -0,0 +1 @@
uid://0ynnafo2v1it

View File

@@ -0,0 +1,3 @@
class_name C_PermD
extends Component
@export var v: int = 4

View File

@@ -0,0 +1 @@
uid://cts7f306wa0fa

View File

@@ -0,0 +1,3 @@
class_name C_PermE
extends Component
@export var v: int = 5

View File

@@ -0,0 +1 @@
uid://c720mkd00xchu

View File

@@ -0,0 +1,3 @@
class_name C_PermF
extends Component
@export var v: int = 6

View File

@@ -0,0 +1 @@
uid://ccws6g7g0j6w8

View File

@@ -0,0 +1,21 @@
extends Component
class_name C_Persistent
@export var player_name: String = "Player1"
@export var level: int = 1
@export var health: float = 100.0
@export var position: Vector2 = Vector2.ZERO
@export var inventory: Array[String] = []
func _init(
_player_name: String = "Player1",
_level: int = 1,
_health: float = 100.0,
_position: Vector2 = Vector2.ZERO,
_inventory: Array[String] = []
):
player_name = _player_name
level = _level
health = _health
position = _position
inventory = _inventory

View File

@@ -0,0 +1 @@
uid://bikywcisu1fsu

View File

@@ -0,0 +1,14 @@
## Test position component for observer performance tests
class_name C_TestPosition
extends Component
@export var position: Vector3 = Vector3.ZERO : set = set_position
func set_position(new_pos: Vector3):
var old_pos = position
position = new_pos
# Emit signal for observers to detect the change
property_changed.emit(self, "position", old_pos, new_pos)
func _init(_position: Vector3 = Vector3.ZERO):
position = _position

View File

@@ -0,0 +1 @@
uid://33n1ne8tuyja

View File

@@ -0,0 +1,27 @@
extends Component
class_name C_SerializationTest
@export var int_value: int = 42
@export var float_value: float = 3.14
@export var string_value: String = "test_string"
@export var bool_value: bool = true
@export var vector2_value: Vector2 = Vector2(1.0, 2.0)
@export var vector3_value: Vector3 = Vector3(1.0, 2.0, 3.0)
@export var color_value: Color = Color.RED
func _init(
_int_value: int = 42,
_float_value: float = 3.14,
_string_value: String = "test_string",
_bool_value: bool = true,
_vector2_value: Vector2 = Vector2(1.0, 2.0),
_vector3_value: Vector3 = Vector3(1.0, 2.0, 3.0),
_color_value: Color = Color.RED
):
int_value = _int_value
float_value = _float_value
string_value = _string_value
bool_value = _bool_value
vector2_value = _vector2_value
vector3_value = _vector3_value
color_value = _color_value

View File

@@ -0,0 +1 @@
uid://3w2r1fop8e52

View File

@@ -0,0 +1,8 @@
class_name C_TestA
extends Component
@export var value: int = 0
func _init(_value: int = 0):
value = _value

View File

@@ -0,0 +1 @@
uid://5antadqj7v84

View File

@@ -0,0 +1,8 @@
class_name C_TestB
extends Component
@export var value: int = 0
func _init(_value: int = 0):
value = _value

View File

@@ -0,0 +1 @@
uid://c6lvbdptfldrg

View File

@@ -0,0 +1,8 @@
class_name C_TestC
extends Component
@export var value: int
func _init(_value: int = 0):
value = _value

View File

@@ -0,0 +1 @@
uid://3lo6r4xvicxp

View File

@@ -0,0 +1,8 @@
class_name C_TestD
extends Component
@export var points: int = 0
func _init(_points: int = 0):
points = _points

View File

@@ -0,0 +1 @@
uid://cd2ml5rtb3c8g

View File

@@ -0,0 +1,4 @@
class_name C_TestE
extends Component
@export var value: int = 0

View File

@@ -0,0 +1 @@
uid://cp6siju1aijj2

View File

@@ -0,0 +1,11 @@
class_name C_TestF
extends Component
var value: int = 0 # properties with no export annotation
static var init_count: int = 0
func _init(_value: int = 0):
value = _value
init_count += 1
print("Component c_test_f init, value=%d" % value)

View File

@@ -0,0 +1 @@
uid://py2qgdkhiy30

View File

@@ -0,0 +1,12 @@
class_name C_TestG
extends Component
@export var value: int = 0
static var init_count: int = 0
func _init(_value: int = 0):
value = _value
init_count += 1
# to test _init() calling problem
print("Component c_test_g init, value=%d" % value)

View File

@@ -0,0 +1 @@
uid://4ud215bve6ap

View File

@@ -0,0 +1,8 @@
class_name C_TestH
extends Component
@export var value: int = 0
# Simulates parameters with no default values
func _init(_value: int):
value = _value

View File

@@ -0,0 +1 @@
uid://b8ptu8k8rp1sb

View File

@@ -0,0 +1,14 @@
## Test velocity component for observer performance tests
class_name C_TestVelocity
extends Component
@export var velocity: Vector3 = Vector3.ZERO : set = set_velocity
func set_velocity(new_vel: Vector3):
var old_vel = velocity
velocity = new_vel
# Emit signal for observers to detect the change
property_changed.emit(self, "velocity", old_vel, new_vel)
func _init(_velocity: Vector3 = Vector3.ZERO):
velocity = _velocity

View File

@@ -0,0 +1 @@
uid://ckhr8q3glmacs

View File

@@ -0,0 +1,147 @@
class_name TestArchetypeEdgeCacheBug
extends GdUnitTestSuite
## Test suite for archetype edge cache bug
##
## Tests that archetypes retrieved from edge cache are properly re-registered
## with the world when they were previously removed due to being empty.
##
## Bug sequence:
## 1. Entity A gets component added -> creates archetype X, cached edge
## 2. Entity A removed -> archetype X becomes empty, gets removed from world.archetypes
## 3. Entity B gets same component -> uses cached edge to archetype X
## 4. BUG: archetype X not in world.archetypes, so queries can't find Entity B
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
## Test that archetypes retrieved from edge cache are re-registered with world
func test_archetype_reregistered_after_edge_cache_retrieval():
# ARRANGE: Create two entities with same initial components
var entity1 = Entity.new()
entity1.add_component(C_TestA.new())
world.add_entities([entity1])
var entity2 = Entity.new()
entity2.add_component(C_TestA.new())
world.add_entities([entity2])
# ACT 1: Add ComponentB to entity1 (creates new archetype + edge cache)
var comp_b1 = C_TestB.new()
entity1.add_component(comp_b1)
# Get the archetype signature for A+B combination
var archetype_with_b = world.entity_to_archetype[entity1]
var signature_with_b = archetype_with_b.signature
# Verify archetype is in world.archetypes
assert_bool(world.archetypes.has(signature_with_b)).is_true()
# ACT 2: Remove entity1 to make archetype empty (triggers cleanup)
world.remove_entity(entity1)
# Verify archetype was removed from world.archetypes when empty
assert_bool(world.archetypes.has(signature_with_b)).is_false()
# ACT 3: Add ComponentB to entity2 (should use edge cache)
# This is where the bug would occur - archetype retrieved from cache
# but not re-registered with world
var comp_b2 = C_TestB.new()
entity2.add_component(comp_b2)
# ASSERT: Archetype should be back in world.archetypes
assert_bool(world.archetypes.has(signature_with_b)).is_true()
# ASSERT: Query should find entity2
var query = QueryBuilder.new(world).with_all([C_TestA, C_TestB])
var results = query.execute()
assert_int(results.size()).is_equal(1)
assert_object(results[0]).is_same(entity2)
## Test that queries find entities in edge-cached archetypes
func test_query_finds_entities_in_edge_cached_archetype():
# This reproduces the exact projectile bug scenario
# ARRANGE: Create 3 projectiles
var projectile1 = Entity.new()
projectile1.add_component(C_TestA.new()) # Simulates C_Projectile
world.add_entities([projectile1])
var projectile2 = Entity.new()
projectile2.add_component(C_TestA.new())
world.add_entities([projectile2])
var projectile3 = Entity.new()
projectile3.add_component(C_TestA.new())
world.add_entities([projectile3])
# ACT 1: First projectile collides (adds ComponentB = C_Collision)
projectile1.add_component(C_TestB.new())
# Verify query finds it
var collision_query = QueryBuilder.new(world).with_all([C_TestA, C_TestB])
assert_int(collision_query.execute().size()).is_equal(1)
# ACT 2: First projectile processed and removed (empties collision archetype)
world.remove_entity(projectile1)
# ACT 3: Second projectile collides (edge cache used)
projectile2.add_component(C_TestB.new())
# ASSERT: Query should find second projectile (BUG: it wouldn't before fix)
var results = collision_query.execute()
assert_int(results.size()).is_equal(1)
assert_object(results[0]).is_same(projectile2)
# ACT 4: Third projectile also collides while second still exists
projectile3.add_component(C_TestB.new())
# ASSERT: Query should find both projectiles
results = collision_query.execute()
assert_int(results.size()).is_equal(2)
## Test rapid add/remove cycles don't lose archetypes
func test_rapid_archetype_cycling():
# Tests the exact pattern: create -> empty -> reuse via cache
var entities = []
for i in range(5):
var e = Entity.new()
e.add_component(C_TestA.new())
world.add_entities([e])
entities.append(e)
# Cycle through adding/removing ComponentB
for cycle in range(3):
# Add ComponentB to first entity (creates/reuses archetype)
entities[0].add_component(C_TestB.new())
# Query should find it
var query = QueryBuilder.new(world).with_all([C_TestA, C_TestB])
var results = query.execute()
assert_int(results.size()).is_equal(1)
# Remove entity (empties archetype)
world.remove_entity(entities[0])
# Create new entity for next cycle
entities[0] = Entity.new()
entities[0].add_component(C_TestA.new())
world.add_entities([entities[0]])
# Final cycle - should still work
entities[0].add_component(C_TestB.new())
var final_query = QueryBuilder.new(world).with_all([C_TestA, C_TestB])
assert_int(final_query.execute().size()).is_equal(1)

View File

@@ -0,0 +1 @@
uid://hphmrhtswrjq

View File

@@ -0,0 +1,156 @@
extends GdUnitTestSuite
## Test archetype-based system execution
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
func test_archetype_system_processes_entities():
# Create test system that uses archetype mode
var test_system = ArchetypeTestSystem.new()
world.add_system(test_system)
# Create entities with components
var entity1 = Entity.new()
entity1.name = "Entity1"
world.add_entity(entity1, [C_TestA.new()])
var entity2 = Entity.new()
entity2.name = "Entity2"
world.add_entity(entity2, [C_TestA.new()])
# Process the system
world.process(0.1)
# Verify archetype method was called
assert_int(test_system.archetype_call_count).is_equal(1)
assert_int(test_system.entities_processed).is_equal(2)
func test_archetype_iteration_order_matches_iterate():
# System that checks component order
var test_system = ArchetypeOrderTestSystem.new()
world.add_system(test_system)
# Create entity with multiple components
var entity = Entity.new()
entity.name = "TestEntity"
world.add_entity(entity, [C_TestB.new(), C_TestA.new()])
# Process
world.process(0.1)
# Verify components were in correct order (as specified in iterate())
assert_bool(test_system.order_correct).is_true()
func test_archetype_processes_entities_with_extra_components():
# Query for A and B, but entity has A, B, and C
var test_system = ArchetypeSubsetTestSystem.new()
world.add_system(test_system)
# Entity has MORE components than query asks for
var entity = Entity.new()
entity.name = "ExtraComponents"
world.add_entity(entity, [C_TestA.new(), C_TestB.new(), C_TestC.new()])
# Should still match and process
world.process(0.1)
assert_int(test_system.entities_processed).is_equal(1)
func test_archetype_processes_multiple_archetypes():
# System that tracks archetype calls
var test_system = ArchetypeMultipleArchetypesTestSystem.new()
world.add_system(test_system)
# Create entities with different component combinations
# Archetype 1: [A, B]
var entity1 = Entity.new()
world.add_entity(entity1, [C_TestA.new(), C_TestB.new()])
var entity2 = Entity.new()
world.add_entity(entity2, [C_TestA.new(), C_TestB.new()])
# Archetype 2: [A, B, C]
var entity3 = Entity.new()
world.add_entity(entity3, [C_TestA.new(), C_TestB.new(), C_TestC.new()])
# Process
world.process(0.1)
# Should be called once per archetype
assert_int(test_system.archetype_call_count).is_equal(2)
assert_int(test_system.total_entities_processed).is_equal(3)
func test_archetype_column_data_is_correct():
# System that verifies column data
var test_system = ArchetypeColumnDataTestSystem.new()
world.add_system(test_system)
# Create entities with specific values
var entity1 = Entity.new()
var comp_a1 = C_TestA.new()
comp_a1.value = 10
world.add_entity(entity1, [comp_a1])
var entity2 = Entity.new()
var comp_a2 = C_TestA.new()
comp_a2.value = 20
world.add_entity(entity2, [comp_a2])
# Process
world.process(0.1)
# Verify column had correct values
assert_array(test_system.values_seen).contains_exactly([10, 20])
func test_archetype_modifies_components():
# System that modifies component values
var test_system = ArchetypeModifyTestSystem.new()
world.add_system(test_system)
var entity = Entity.new()
var comp = C_TestA.new()
comp.value = 5
world.add_entity(entity, [comp])
# Process multiple times
world.process(0.1)
world.process(0.1)
world.process(0.1)
# Value should have been incremented each time
# Get the component from the entity (not the local reference)
var updated_comp = entity.get_component(C_TestA)
assert_int(updated_comp.value).is_equal(8)
func test_archetype_works_without_iterate_call():
# System that doesn't call iterate() still works, just gets empty components array
var test_system = ArchetypeNoIterateSystem.new()
world.add_system(test_system)
var entity = Entity.new()
world.add_entity(entity, [C_TestA.new()])
# Should work fine - system can use get_component() instead
world.process(0.1)
# System should have processed the entity
assert_int(test_system.processed).is_equal(1)

View File

@@ -0,0 +1 @@
uid://cem3jyvifqys

View File

@@ -0,0 +1,371 @@
extends GdUnitTestSuite
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
func test_complex_nested_relationships_serialization():
# Create a complex hierarchy: Player -> Weapon -> Attachment
# This tests multi-level relationship auto-inclusion
# 1. Create Player entity
var player = Entity.new()
player.name = "Player"
player.add_component(C_TestA.new()) # Player-specific component
# 2. Create Weapon entity with weapon-specific components
var weapon = Entity.new()
weapon.name = "AssaultRifle"
weapon.add_component(C_TestB.new()) # Weapon component
weapon.add_component(C_TestC.new()) # Damage component
# 3. Create Attachment entity
var attachment = Entity.new()
attachment.name = "RedDotSight"
attachment.add_component(C_TestD.new()) # Attachment component
attachment.add_component(C_TestE.new()) # Accuracy modifier component
# 4. Create another attachment for testing multiple relationships
var attachment2 = Entity.new()
attachment2.name = "Silencer"
attachment2.add_component(C_TestF.new()) # Another attachment component
# 5. Set up relationships: Player -> Weapon -> Attachments
var player_weapon_rel = Relationship.new(C_TestA.new(), weapon) # Player equipped with weapon
player.add_relationship(player_weapon_rel)
var weapon_sight_rel = Relationship.new(C_TestB.new(), attachment) # Weapon has sight
weapon.add_relationship(weapon_sight_rel)
var weapon_silencer_rel = Relationship.new(C_TestC.new(), attachment2) # Weapon has silencer
weapon.add_relationship(weapon_silencer_rel)
# 6. Add entities to world (don't add to scene tree to preserve names)
world.add_entity(player)
world.add_entity(weapon)
world.add_entity(attachment)
world.add_entity(attachment2)
# Store original UUIDs for verification
var player_id = player.id
var weapon_id = weapon.id
var attachment_id = attachment.id
var attachment2_id = attachment2.id
print("=== BEFORE SERIALIZATION ===")
print("Player UUID: ", player_id)
print("Weapon UUID: ", weapon_id)
print("Attachment UUID: ", attachment_id)
print("Attachment2 UUID: ", attachment2_id)
print("Player relationships: ", player.relationships.size())
print("Weapon relationships: ", weapon.relationships.size())
# 7. Serialize ONLY the player (should auto-include weapon and attachments)
var query = world.query.with_all([C_TestA]) # Only matches player
var serialized_data = ECS.serialize(query)
print("=== SERIALIZATION RESULTS ===")
print("Total entities serialized: ", serialized_data.entities.size())
# 8. Verify serialization results
assert_that(serialized_data.entities).has_size(4) # All 4 entities should be included
# Count auto-included vs original entities
var auto_included_count = 0
var original_count = 0
var player_data = null
var weapon_data = null
var attachment_data = null
var attachment2_data = null
for entity_data in serialized_data.entities:
print("Entity: ", entity_data.entity_name, " - Auto-included: ", entity_data.auto_included, " - id: ", entity_data.id)
if entity_data.auto_included:
auto_included_count += 1
else:
original_count += 1
# Find specific entities for detailed verification
match entity_data.entity_name:
"Player":
player_data = entity_data
"AssaultRifle":
weapon_data = entity_data
"RedDotSight":
attachment_data = entity_data
"Silencer":
attachment2_data = entity_data
# Verify auto-inclusion flags
assert_that(original_count).is_equal(1) # Only player from original query
assert_that(auto_included_count).is_equal(3) # Weapon and both attachments auto-included
# Verify specific entity data
assert_that(player_data).is_not_null()
assert_that(player_data.auto_included).is_false() # Player was in original query
assert_that(player_data.relationships).has_size(1) # Player -> Weapon relationship
assert_that(weapon_data).is_not_null()
assert_that(weapon_data.auto_included).is_true() # Weapon was auto-included
assert_that(weapon_data.relationships).has_size(2) # Weapon -> Attachments relationships
assert_that(attachment_data).is_not_null()
assert_that(attachment_data.auto_included).is_true() # Attachment was auto-included
assert_that(attachment2_data).is_not_null()
assert_that(attachment2_data.auto_included).is_true() # Attachment2 was auto-included
# 9. Save and load the serialized data
var file_path = "res://reports/test_complex_relationships.tres"
ECS.save(serialized_data, file_path)
# 10. Clear the world to simulate fresh start
world.purge(false)
assert_that(world.entities).has_size(0)
assert_that(world.entity_id_registry).has_size(0)
# 11. Deserialize and add back to world
var deserialized_entities = ECS.deserialize(file_path)
print("=== DESERIALIZATION RESULTS ===")
print("Deserialized entities: ", deserialized_entities.size())
assert_that(deserialized_entities).has_size(4)
# Add all entities back to world (don't add to scene tree to avoid naming conflicts)
for entity in deserialized_entities:
world.add_entity(entity, null, false)
# 12. Verify world state after deserialization
assert_that(world.entities).has_size(4)
assert_that(world.entity_id_registry).has_size(4)
# Find entities by UUID to verify they're properly restored
var restored_player = world.get_entity_by_id(player_id)
var restored_weapon = world.get_entity_by_id(weapon_id)
var restored_attachment = world.get_entity_by_id(attachment_id)
var restored_attachment2 = world.get_entity_by_id(attachment2_id)
print("=== RESTORED ENTITIES ===")
print("Player found: ", restored_player != null, " - Name: ", restored_player.name if restored_player else "null")
print("Weapon found: ", restored_weapon != null, " - Name: ", restored_weapon.name if restored_weapon else "null")
print("Attachment found: ", restored_attachment != null, " - Name: ", restored_attachment.name if restored_attachment else "null")
print("Attachment2 found: ", restored_attachment2 != null, " - Name: ", restored_attachment2.name if restored_attachment2 else "null")
# Verify all entities were found
assert_that(restored_player).is_not_null()
assert_that(restored_weapon).is_not_null()
assert_that(restored_attachment).is_not_null()
assert_that(restored_attachment2).is_not_null()
# Verify entity names are preserved
assert_that(restored_player.name).is_equal("Player")
assert_that(restored_weapon.name).is_equal("AssaultRifle")
assert_that(restored_attachment.name).is_equal("RedDotSight")
assert_that(restored_attachment2.name).is_equal("Silencer")
# 13. Verify relationships are intact
print("=== RELATIONSHIP VERIFICATION ===")
print("Player relationships: ", restored_player.relationships.size())
print("Weapon relationships: ", restored_weapon.relationships.size())
# Player should have 1 relationship to weapon
assert_that(restored_player.relationships).has_size(1)
var player_rel = restored_player.relationships[0]
assert_that(player_rel.target).is_equal(restored_weapon)
print("Player -> Weapon relationship intact: ", player_rel.target.name)
# Weapon should have 2 relationships to attachments
assert_that(restored_weapon.relationships).has_size(2)
var weapon_targets = []
var weapon_target_entities = []
for rel in restored_weapon.relationships:
weapon_target_entities.append(rel.target)
weapon_targets.append(rel.target.name)
print("Weapon -> ", rel.target.name, " relationship intact")
# Verify weapon is connected to both attachments
assert_that(weapon_target_entities).contains(restored_attachment)
assert_that(weapon_target_entities).contains(restored_attachment2)
assert_that(weapon_targets).contains("RedDotSight")
assert_that(weapon_targets).contains("Silencer")
# 14. Verify components are preserved
assert_that(restored_player.has_component(C_TestA)).is_true()
assert_that(restored_weapon.has_component(C_TestB)).is_true()
assert_that(restored_weapon.has_component(C_TestC)).is_true()
assert_that(restored_attachment.has_component(C_TestD)).is_true()
assert_that(restored_attachment.has_component(C_TestE)).is_true()
assert_that(restored_attachment2.has_component(C_TestF)).is_true()
print("=== TEST PASSED: Complex nested relationships preserved! ===")
world.remove_entities(deserialized_entities)
func test_relationship_replacement_with_id_collision():
# Test that when entities with relationships are replaced via UUID collision,
# the relationships update correctly to point to the new entities
# 1. Create initial setup: Player -> Weapon
var player = Entity.new()
player.name = "Player"
player.add_component(C_TestA.new())
player.set("id", "player-id-123")
var old_weapon = Entity.new()
old_weapon.name = "OldWeapon"
old_weapon.add_component(C_TestB.new())
old_weapon.set("id", "weapon-id-456")
var player_weapon_rel = Relationship.new(C_TestA.new(), old_weapon)
player.add_relationship(player_weapon_rel)
world.add_entity(player)
world.add_entity(old_weapon)
# Verify initial relationship
assert_that(player.relationships).has_size(1)
assert_that(player.relationships[0].target).is_equal(old_weapon)
assert_that(player.relationships[0].target.name).is_equal("OldWeapon")
# 2. Serialize the current state
var query = world.query.with_all([C_TestA])
var serialized_data = ECS.serialize(query)
var file_path = "res://reports/test_replacement_relationships.tres"
ECS.save(serialized_data, file_path)
# 3. Create "updated" entities with same UUIDs but different data
var new_weapon = Entity.new()
new_weapon.name = "NewUpgradedWeapon"
new_weapon.add_component(C_TestB.new())
new_weapon.add_component(C_TestC.new()) # Added component
new_weapon.set("id", "weapon-id-456") # Same UUID!
# 4. Add new weapon (should replace old weapon)
world.add_entity(new_weapon)
# Verify replacement occurred
assert_that(world.entities).has_size(2) # Still only 2 entities
var current_weapon = world.get_entity_by_id("weapon-id-456")
assert_that(current_weapon).is_equal(new_weapon)
assert_that(current_weapon.name).is_equal("NewUpgradedWeapon")
assert_that(current_weapon.has_component(C_TestC)).is_true()
# 5. NOTE: When we replace an entity, existing relationships still point to the old entity object
# This is expected behavior - the relationship contains a direct Entity reference
# To update relationships, we would need to re-serialize/deserialize or manually update them
print("Current relationship target: ", player.relationships[0].target.name)
print("Expected: Relationship still points to old entity until re-serialized")
print("=== Relationship correctly updated after entity replacement ===")
# 6. Now test loading the old save file (should replace with old state)
var loaded_entities = ECS.deserialize(file_path)
for entity in loaded_entities:
world.add_entity(entity) # Should trigger replacements
# Verify entities were replaced with old state
var final_weapon = world.get_entity_by_id("weapon-id-456")
print("Final weapon name: ", final_weapon.name)
assert_that(final_weapon.has_component(C_TestC)).is_false() # Lost the added component
# Verify relationship points to restored weapon
var final_player = world.get_entity_by_id("player-id-123")
assert_that(final_player.relationships).has_size(1)
assert_that(final_player.relationships[0].target).is_equal(final_weapon)
print("Final relationship target name: ", final_player.relationships[0].target.name)
print("=== Save/Load replacement cycle completed successfully ===")
func test_partial_serialization_auto_inclusion():
# Test that we can serialize a subset of entities and auto-include dependencies
# while excluding unrelated entities
# Create multiple independent entity groups
# Group 1: Player -> Weapon -> Attachment (should be included)
var player = Entity.new()
player.name = "Player"
player.add_component(C_TestA.new())
var weapon = Entity.new()
weapon.name = "Weapon"
weapon.add_component(C_TestB.new())
var attachment = Entity.new()
attachment.name = "Attachment"
attachment.add_component(C_TestC.new())
player.add_relationship(Relationship.new(C_TestA.new(), weapon))
weapon.add_relationship(Relationship.new(C_TestB.new(), attachment))
# Group 2: Enemy -> EnemyWeapon (should NOT be included)
var enemy = Entity.new()
enemy.name = "Enemy"
enemy.add_component(C_TestD.new()) # Different component type
var enemy_weapon = Entity.new()
enemy_weapon.name = "EnemyWeapon"
enemy_weapon.add_component(C_TestE.new())
enemy.add_relationship(Relationship.new(C_TestD.new(), enemy_weapon))
# Group 3: Standalone entity (should NOT be included)
var standalone = Entity.new()
standalone.name = "Standalone"
standalone.add_component(C_TestF.new())
# Add all entities to world (don't add to scene tree)
world.add_entity(player)
world.add_entity(weapon)
world.add_entity(attachment)
world.add_entity(enemy)
world.add_entity(enemy_weapon)
world.add_entity(standalone)
assert_that(world.entities).has_size(6)
# Serialize ONLY entities with C_TestA (just the player)
var query = world.query.with_all([C_TestA])
var serialized_data = ECS.serialize(query)
print("=== PARTIAL SERIALIZATION RESULTS ===")
print("Total entities in world: ", world.entities.size())
print("Entities serialized: ", serialized_data.entities.size())
# Should include Player + Weapon + Attachment (3 total) but NOT Enemy group or Standalone
assert_that(serialized_data.entities).has_size(3)
var serialized_names = []
for entity_data in serialized_data.entities:
serialized_names.append(entity_data.entity_name)
print("Serialized: ", entity_data.entity_name, " (auto-included: ", entity_data.auto_included, ")")
# Verify correct entities were included
assert_that(serialized_names).contains("Player")
assert_that(serialized_names).contains("Weapon")
assert_that(serialized_names).contains("Attachment")
# Verify incorrect entities were excluded
assert_that(serialized_names.has("Enemy")).is_false()
assert_that(serialized_names.has("EnemyWeapon")).is_false()
assert_that(serialized_names.has("Standalone")).is_false()
# Verify auto-inclusion flags
var player_data = serialized_data.entities.filter(func(e): return e.entity_name == "Player")[0]
var weapon_data = serialized_data.entities.filter(func(e): return e.entity_name == "Weapon")[0]
var attachment_data = serialized_data.entities.filter(func(e): return e.entity_name == "Attachment")[0]
assert_that(player_data.auto_included).is_false() # Original query
assert_that(weapon_data.auto_included).is_true() # Auto-included via Player relationship
assert_that(attachment_data.auto_included).is_true() # Auto-included via Weapon relationship
print("=== Partial serialization with auto-inclusion working correctly ===")

View File

@@ -0,0 +1 @@
uid://bdpuk46wqnhuw

View File

@@ -0,0 +1,163 @@
extends GdUnitTestSuite
func test_component_key_is_set_correctly():
# Create an instance of a concrete Component subclass
var component = C_TestA.new()
# The key should be set to the resource path of the component's script
var expected_key = component.get_script().resource_path
assert_str("res://addons/gecs/tests/components/c_test_a.gd").is_equal(expected_key)
func test_component_query_matcher_equality():
# Test _eq operator
var component = C_TestA.new(42)
# Should match exact value
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_eq": 42}})).is_true()
# Should not match different value
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_eq": 10}})).is_false()
func test_component_query_matcher_inequality():
# Test _ne operator
var component = C_TestA.new(42)
# Should match different value
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_ne": 10}})).is_true()
# Should not match same value
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_ne": 42}})).is_false()
func test_component_query_matcher_greater_than():
# Test _gt and _gte operators
var component = C_TestA.new(50)
# _gt tests
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_gt": 49}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_gt": 50}})).is_false()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_gt": 51}})).is_false()
# _gte tests
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_gte": 49}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_gte": 50}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_gte": 51}})).is_false()
func test_component_query_matcher_less_than():
# Test _lt and _lte operators
var component = C_TestA.new(50)
# _lt tests
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_lt": 51}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_lt": 50}})).is_false()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_lt": 49}})).is_false()
# _lte tests
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_lte": 51}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_lte": 50}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_lte": 49}})).is_false()
func test_component_query_matcher_array_membership():
# Test _in and _nin operators
var component = C_TestA.new(42)
# _in tests
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_in": [40, 41, 42]}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_in": [1, 2, 3]}})).is_false()
# _nin tests
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_nin": [1, 2, 3]}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_nin": [40, 41, 42]}})).is_false()
func test_component_query_matcher_custom_function():
# Test func operator
var component = C_TestA.new(42)
# Custom function that checks if value is even
var is_even = func(val): return val % 2 == 0
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"func": is_even}})).is_true()
# Custom function that checks if value is odd
var is_odd = func(val): return val % 2 == 1
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"func": is_odd}})).is_false()
# Custom function with complex logic
var in_range = func(val): return val >= 40 and val <= 50
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"func": in_range}})).is_true()
func test_component_query_matcher_multiple_operators():
# Test combining multiple operators (all must pass)
var component = C_TestA.new(50)
# Should match: value >= 40 AND value <= 60
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_gte": 40, "_lte": 60}})).is_true()
# Should not match: value >= 40 AND value <= 45
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_gte": 40, "_lte": 45}})).is_false()
# Should match: value != 0 AND value > 30
assert_bool(ComponentQueryMatcher.matches_query(component, {"value": {"_ne": 0, "_gt": 30}})).is_true()
func test_component_query_matcher_falsy_values():
# Test that falsy values (0, false, null) are handled correctly
var component_zero = C_TestA.new(0)
# Should match 0 exactly
assert_bool(ComponentQueryMatcher.matches_query(component_zero, {"value": {"_eq": 0}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component_zero, {"value": {"_eq": 1}})).is_false()
# Should handle 0 in ranges
assert_bool(ComponentQueryMatcher.matches_query(component_zero, {"value": {"_gte": 0}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component_zero, {"value": {"_lte": 0}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component_zero, {"value": {"_gt": 0}})).is_false()
# Should handle negative numbers
var component_negative = C_TestA.new(-5)
assert_bool(ComponentQueryMatcher.matches_query(component_negative, {"value": {"_eq": -5}})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component_negative, {"value": {"_lt": 0}})).is_true()
func test_component_query_matcher_empty_query():
# Empty query should match any component
var component = C_TestA.new(42)
assert_bool(ComponentQueryMatcher.matches_query(component, {})).is_true()
func test_component_query_matcher_nonexistent_property():
# Should return false if property doesn't exist
var component = C_TestA.new(42)
assert_bool(ComponentQueryMatcher.matches_query(component, {"nonexistent": {"_eq": 10}})).is_false()
func test_component_query_matcher_multiple_properties():
# Test querying multiple properties at once
var component = C_TestD.new(5) # Has 'points' property
# Both properties must match
assert_bool(ComponentQueryMatcher.matches_query(component, {
"points": {"_eq": 5}
})).is_true()
assert_bool(ComponentQueryMatcher.matches_query(component, {
"points": {"_eq": 10}
})).is_false()
func test_component_serialization():
# Create an instance of a concrete Component subclass
var component_a = C_TestA.new(42)
var component_b = C_TestD.new(1)
# Serialize the component
var serialized_data_a = component_a.serialize()
var serialized_data_b = component_b.serialize()
# Check if the serialized data matches the expected values
assert_int(serialized_data_a["value"]).is_equal(42)
assert_int(serialized_data_b["points"]).is_equal(1)

View File

@@ -0,0 +1 @@
uid://4nqun3t8nb18

View File

@@ -0,0 +1,178 @@
extends GdUnitTestSuite
# Test suite for System debug tracking (lastRunData)
var world: World
func before_test():
world = World.new()
world.name = "TestWorld"
Engine.get_main_loop().root.add_child(world)
ECS.world = world
func after_test():
ECS.world = null
if is_instance_valid(world):
world.queue_free()
func test_debug_tracking_process_mode():
# Enable debug mode for these tests
ECS.debug = true
# Create entities
for i in range(10):
var entity = Entity.new()
entity.add_component(C_DebugTrackingTestA.new())
world.add_entity(entity)
# Create system with PROCESS execution method
var system = ProcessSystem.new()
world.add_system(system)
# Process once
world.process(0.016)
# Debug: Print what's in lastRunData
print("DEBUG: ECS.debug = ", ECS.debug)
print("DEBUG: lastRunData = ", system.lastRunData)
print("DEBUG: lastRunData keys = ", system.lastRunData.keys())
# Verify debug data
assert_that(system.lastRunData.has("system_name")).is_true()
assert_that(system.lastRunData.has("frame_delta")).is_true()
assert_that(system.lastRunData.has("entity_count")).is_true()
assert_that(system.lastRunData.has("execution_time_ms")).is_true()
# Verify values
assert_that(system.lastRunData["frame_delta"]).is_equal(0.016)
assert_that(system.lastRunData["entity_count"]).is_equal(10)
assert_that(system.lastRunData["execution_time_ms"]).is_greater(0.0)
assert_that(system.lastRunData["parallel"]).is_equal(false)
# Store first execution time
var first_exec_time = system.lastRunData["execution_time_ms"]
# Process again
world.process(0.032)
# Verify time is different (not accumulating)
var second_exec_time = system.lastRunData["execution_time_ms"]
assert_that(system.lastRunData["frame_delta"]).is_equal(0.032)
# Times should be similar but not identical (and definitely not accumulated)
# If accumulating, second would be ~2x first
assert_that(second_exec_time).is_less(first_exec_time * 1.5)
print("First exec: %.3f ms, Second exec: %.3f ms" % [first_exec_time, second_exec_time])
func test_debug_tracking_subsystems():
# Enable debug mode for these tests
ECS.debug = true
# Create entities
for i in range(10):
var entity = Entity.new()
entity.add_component(C_DebugTrackingTestA.new())
entity.add_component(C_DebugTrackingTestB.new())
world.add_entity(entity)
# Create system with SUBSYSTEMS execution method
var system = SubsystemsTestSystem.new()
world.add_system(system)
# Process once
world.process(0.016)
# Verify debug data
assert_that(system.lastRunData["execution_time_ms"]).is_greater(0.0)
# Verify subsystem data
assert_that(system.lastRunData.has(0)).is_true()
assert_that(system.lastRunData.has(1)).is_true()
# First subsystem
assert_that(system.lastRunData[0]["entity_count"]).is_equal(10)
# Second subsystem
assert_that(system.lastRunData[1]["entity_count"]).is_equal(10)
print("Subsystem 0: %s" % [system.lastRunData[0]])
print("Subsystem 1: %s" % [system.lastRunData[1]])
func test_debug_disabled_has_no_data():
# Disable debug mode
ECS.debug = false
# Create entities
for i in range(5):
var entity = Entity.new()
entity.add_component(C_DebugTrackingTestA.new())
world.add_entity(entity)
# Create system
var system = ProcessSystem.new()
world.add_system(system)
# Process
world.process(0.016)
# lastRunData should be empty or not updated when debug is off
# (It might still exist from a previous run, but shouldn't be updated)
var initial_data = system.lastRunData.duplicate()
# Process again
world.process(0.016)
# Data should not change (because ECS.debug = false)
assert_that(system.lastRunData).is_equal(initial_data)
print("With ECS.debug=false, lastRunData remains unchanged: %s" % [system.lastRunData])
# Test system - PROCESS mode
class ProcessSystem extends System:
func query() -> QueryBuilder:
return ECS.world.query.with_all([C_DebugTrackingTestA])
func process(entities: Array[Entity], components: Array, delta: float) -> void:
for entity in entities:
var comp = entity.get_component(C_DebugTrackingTestA)
comp.value += delta
# Test system - unified process
class ProcessAllSystem extends System:
func query() -> QueryBuilder:
return ECS.world.query.with_all([C_DebugTrackingTestB])
func process(entities: Array[Entity], components: Array, delta: float) -> void:
for entity in entities:
var comp = entity.get_component(C_DebugTrackingTestB)
comp.count += 1
# Test system - batch processing with iterate
class ProcessBatchSystem extends System:
func query() -> QueryBuilder:
return ECS.world.query.with_all([C_DebugTrackingTestA]).iterate([C_DebugTrackingTestA])
func process(entities: Array[Entity], components: Array, delta: float) -> void:
var test_a_components = components[0]
for i in range(entities.size()):
test_a_components[i].value += delta
# Test system - SUBSYSTEMS mode
class SubsystemsTestSystem extends System:
func sub_systems() -> Array[Array]:
return [
[ECS.world.query.with_all([C_DebugTrackingTestA]), process_sub],
[ECS.world.query.with_all([C_DebugTrackingTestB]).iterate([C_DebugTrackingTestB]), batch_sub]
]
func process_sub(entities: Array[Entity], components: Array, delta: float) -> void:
for entity in entities:
var comp = entity.get_component(C_DebugTrackingTestA)
comp.value += delta
func batch_sub(entities: Array[Entity], components: Array, delta: float) -> void:
if components.size() > 0 and components[0].size() > 0:
var test_b_components = components[0]
for i in range(entities.size()):
test_b_components[i].count += 1

View File

@@ -0,0 +1 @@
uid://bk45ditcdxhih

View File

@@ -0,0 +1,164 @@
extends GdUnitTestSuite
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
# TODO: We need to add the world here becuase remove fails because we don't have access to world
func test_add_and_get_component():
var entity = auto_free(TestA.new())
var comp = C_TestA.new()
entity.add_component(comp)
# Test that the component was added
assert_bool(entity.has_component(C_TestA)).is_true()
# Test retrieving the component
var retrieved_component = entity.get_component(C_TestA)
assert_str(type_string(typeof(retrieved_component))).is_equal(type_string(typeof(comp)))
# Components need default values on init or they will error
# FIXME: How can we catch this in the code?
func test_add_entity_with_component_with_no_defaults_in_init():
var entity = auto_free(Entity.new())
# this line will lead to crash (the _init parameters has no default value)
assert_error(func(): entity.add_component(C_TestH.new(57)))
func test_add_multiple_components_and_has():
var entity = auto_free(TestB.new())
var comp1 = C_TestA.new()
var comp2 = C_TestB.new()
entity.add_components([comp1, comp2])
# Test that the components were added
assert_bool(entity.has_component(C_TestA)).is_true()
assert_bool(entity.has_component(C_TestB)).is_true()
assert_bool(entity.has_component(C_TestC)).is_false()
func test_remove_component():
var entity = auto_free(TestB.new())
var comp = C_TestB.new()
entity.add_component(comp)
entity.remove_component(C_TestB)
# Test that the component was removed
assert_bool(entity.has_component(C_TestB)).is_false()
func test_add_get_has_relationship():
var entitya = auto_free(TestC.new())
var entityb = auto_free(TestC.new())
var r_testa_entitya = Relationship.new(C_TestA.new(), entitya)
# Add the relationship
entityb.add_relationship(r_testa_entitya)
# Test that the relationship was added
# With the actual relationship
assert_bool(entityb.has_relationship(r_testa_entitya)).is_true()
# with a matching relationship
assert_bool(entityb.has_relationship(Relationship.new(C_TestA.new(), entitya))).is_true()
# Test retrieving the relationship
# with the actual relationship
var inst_retrieved_relationship = entityb.get_relationship(r_testa_entitya)
assert_str(type_string(typeof(inst_retrieved_relationship))).is_equal(
type_string(typeof(r_testa_entitya))
)
# with a matching relationship
var class_retrieved_relationship = entityb.get_relationship(
Relationship.new(C_TestA.new(), entitya)
)
assert_str(type_string(typeof(class_retrieved_relationship))).is_equal(
type_string(typeof(r_testa_entitya))
)
assert_str(type_string(typeof(class_retrieved_relationship))).is_equal(
type_string(typeof(Relationship.new(C_TestA.new(), entitya)))
)
func test_add_and_remove_component():
var entity = auto_free(TestB.new())
for i in range(99):
var comp = C_TestB.new()
entity.add_component(comp)
entity.remove_component(C_TestB)
print('_component_path_cache size=', entity._component_path_cache.size())
# Test memory leak
assert_int(entity._component_path_cache.size()).is_equal(0)
func test_remove_components_with_scripts():
var entity = auto_free(TestB.new())
var comp1 = C_TestA.new()
var comp2 = C_TestB.new()
var comp3 = C_TestC.new()
# Add multiple components
entity.add_components([comp1, comp2, comp3])
# Verify all were added
assert_bool(entity.has_component(C_TestA)).is_true()
assert_bool(entity.has_component(C_TestB)).is_true()
assert_bool(entity.has_component(C_TestC)).is_true()
# Remove multiple components by Script class
entity.remove_components([C_TestA, C_TestB])
# Test that the components were removed
assert_bool(entity.has_component(C_TestA)).is_false()
assert_bool(entity.has_component(C_TestB)).is_false()
# Test that C_TestC is still there
assert_bool(entity.has_component(C_TestC)).is_true()
func test_remove_components_with_instances():
var entity = auto_free(TestB.new())
var comp1 = C_TestA.new()
var comp2 = C_TestB.new()
var comp3 = C_TestC.new()
# Add multiple components
entity.add_components([comp1, comp2, comp3])
# Verify all were added
assert_bool(entity.has_component(C_TestA)).is_true()
assert_bool(entity.has_component(C_TestB)).is_true()
assert_bool(entity.has_component(C_TestC)).is_true()
# Remove multiple components by instance
entity.remove_components([comp1, comp2])
# Test that the components were removed
assert_bool(entity.has_component(C_TestA)).is_false()
assert_bool(entity.has_component(C_TestB)).is_false()
# Test that C_TestC is still there
assert_bool(entity.has_component(C_TestC)).is_true()
func test_remove_components_mixed():
var entity = auto_free(TestB.new())
var comp1 = C_TestA.new()
var comp2 = C_TestB.new()
var comp3 = C_TestC.new()
# Add multiple components
entity.add_components([comp1, comp2, comp3])
# Remove with mixed Script and instance
entity.remove_components([C_TestA, comp2])
# Test that the components were removed
assert_bool(entity.has_component(C_TestA)).is_false()
assert_bool(entity.has_component(C_TestB)).is_false()
# Test that C_TestC is still there
assert_bool(entity.has_component(C_TestC)).is_true()

View File

@@ -0,0 +1 @@
uid://dh1uujht5xew7

View File

@@ -0,0 +1,246 @@
extends GdUnitTestSuite
## Test suite for the Entity ID system functionality
## Tests auto-generation, custom IDs, singleton behavior, and world-level enforcement
var world: World
func before_test():
world = World.new()
world.name = "TestWorld"
add_child(world)
ECS.world = world
func after_test():
if is_instance_valid(world):
world.queue_free()
await await_idle_frame()
func test_entity_id_auto_generation():
# Test that entities auto-generate IDs in _enter_tree
var entity = Entity.new()
entity.name = "TestEntity"
# ID should be empty before entering tree
assert_str(entity.id).is_empty()
# Add to tree - triggers _enter_tree and ID generation
world.add_entity(entity)
# ID should now be auto-generated
assert_str(entity.id).is_not_empty()
assert_bool(entity.id.length() > 0).is_true()
# Should not change ID on subsequent checks
var first_id = entity.id
var second_id = entity.id
assert_str(second_id).is_equal(first_id)
func test_entity_custom_id():
# Test custom ID functionality for singleton entities
var entity = Entity.new()
entity.name = "SingletonEntity"
# Set custom ID before adding to world
entity.id = "singleton_player"
assert_str(entity.id).is_equal("singleton_player")
# Add to world - should preserve custom ID
world.add_entity(entity)
assert_str(entity.id).is_equal("singleton_player")
# Custom ID should not change on subsequent access
var same_id = entity.id
assert_str(same_id).is_equal("singleton_player")
func test_world_id_tracking():
# Test that World tracks IDs and provides lookup functionality
var entity1 = Entity.new()
entity1.name = "Entity1"
entity1.id = "test_id_1"
var entity2 = Entity.new()
entity2.name = "Entity2"
entity2.id = "test_id_2"
# Add entities to world
world.add_entity(entity1)
world.add_entity(entity2)
# Test lookup by ID
assert_object(world.get_entity_by_id("test_id_1")).is_same(entity1)
assert_object(world.get_entity_by_id("test_id_2")).is_same(entity2)
assert_object(world.get_entity_by_id("nonexistent")).is_null()
# Test has_entity_with_id
assert_bool(world.has_entity_with_id("test_id_1")).is_true()
assert_bool(world.has_entity_with_id("test_id_2")).is_true()
assert_bool(world.has_entity_with_id("nonexistent")).is_false()
func test_world_id_replacement():
# Test singleton behavior - entities with same ID replace existing ones
# Create first entity with custom ID
var entity1 = Entity.new()
entity1.name = "FirstEntity"
entity1.id = "singleton_player"
var comp1 = C_TestA.new()
comp1.value = 100
entity1.add_component(comp1)
world.add_entity(entity1)
# Verify it's in the world
assert_int(world.entities.size()).is_equal(1)
assert_object(world.get_entity_by_id("singleton_player")).is_same(entity1)
# Create second entity with same ID
var entity2 = Entity.new()
entity2.name = "ReplacementEntity"
entity2.id = "singleton_player"
var comp2 = C_TestA.new()
comp2.value = 200
entity2.add_component(comp2)
# Add to world - should replace first entity
world.add_entity(entity2)
# Should still have only one entity
assert_int(world.entities.size()).is_equal(1)
# Should be the new entity
var found_entity = world.get_entity_by_id("singleton_player")
assert_object(found_entity).is_same(entity2)
assert_str(found_entity.name).is_equal("ReplacementEntity")
# Verify component value is from new entity
var comp = found_entity.get_component(C_TestA) as C_TestA
assert_int(comp.value).is_equal(200)
func test_auto_generated_id_tracking():
# Test that auto-generated IDs are also tracked by the world
var entity = Entity.new()
entity.name = "AutoIDEntity"
# Don't set custom ID - let it auto-generate
world.add_entity(entity)
# Should have auto-generated ID
assert_str(entity.id).is_not_empty()
# Should be trackable by ID
assert_object(world.get_entity_by_id(entity.id)).is_same(entity)
assert_bool(world.has_entity_with_id(entity.id)).is_true()
func test_id_generation_format():
# Test that generated IDs follow expected GUID format
var entity = Entity.new()
# Add to tree to trigger ID generation
world.add_entity(entity)
var id = entity.id
assert_str(id).is_not_empty()
assert_bool(id.contains("-")).is_true()
var parts = id.split("-")
assert_int(parts.size()).is_equal(5)
# All parts should be valid hex strings
for part in parts:
assert_bool(part.is_valid_hex_number()).is_true()
func test_id_uniqueness():
# Test that multiple entities get unique IDs
var ids = {}
var entities = []
# Generate 100 entities with auto IDs
for i in range(100):
var entity = Entity.new()
entity.name = "Entity%d" % i
world.add_entity(entity)
entities.append(entity)
# Should not have seen this ID before
assert_bool(ids.has(entity.id)).is_false()
ids[entity.id] = true
# All IDs should be unique
assert_int(ids.size()).is_equal(100)
func test_remove_entity_clears_id_registry():
# Test that removing entities clears them from ID registry
var entity = Entity.new()
entity.name = "TestEntity"
entity.id = "test_remove_id"
world.add_entity(entity)
assert_bool(world.has_entity_with_id("test_remove_id")).is_true()
world.remove_entity(entity)
assert_bool(world.has_entity_with_id("test_remove_id")).is_false()
assert_object(world.get_entity_by_id("test_remove_id")).is_null()
func test_id_system_comprehensive_demo():
# Comprehensive test demonstrating all ID system features
# Test 1: Auto ID generation
var auto_entity = Entity.new()
auto_entity.name = "AutoIDEntity"
world.add_entity(auto_entity)
var generated_id = auto_entity.id
assert_str(generated_id).is_not_empty() # Should auto-generate
assert_bool(generated_id.contains("-")).is_true() # Should have correct GUID format
# Should still have the same ID
assert_str(auto_entity.id).is_equal(generated_id)
# Test 2: Custom ID singleton behavior
var player1 = Entity.new()
player1.name = "Player1"
player1.id = "singleton_player"
var comp1 = C_TestA.new()
comp1.value = 100
player1.add_component(comp1)
world.add_entity(player1)
assert_int(world.entities.size()).is_equal(2) # auto_entity + player1
assert_object(world.get_entity_by_id("singleton_player")).is_same(player1)
# Add second entity with same ID - should replace first
var player2 = Entity.new()
player2.name = "Player2"
player2.id = "singleton_player"
var comp2 = C_TestA.new()
comp2.value = 200
player2.add_component(comp2)
world.add_entity(player2)
assert_int(world.entities.size()).is_equal(2) # Should still be 2 (replacement occurred)
var found_entity = world.get_entity_by_id("singleton_player")
assert_object(found_entity).is_same(player2) # Should be the new entity
assert_str(found_entity.name).is_equal("Player2")
var found_comp = found_entity.get_component(C_TestA) as C_TestA
assert_int(found_comp.value).is_equal(200) # Should have new entity's data
# Test 3: Multiple entity tracking
var tracked_entities = []
for i in range(3):
var entity = Entity.new()
entity.name = "TrackedEntity%d" % i
entity.id = "tracked_%d" % i
tracked_entities.append(entity)
world.add_entity(entity)
# Verify all are tracked
for i in range(3):
var id = "tracked_%d" % i
assert_bool(world.has_entity_with_id(id)).is_true()
assert_object(world.get_entity_by_id(id)).is_same(tracked_entities[i])
# Test 4: ID registry cleanup on removal
world.remove_entity(tracked_entities[1])
assert_bool(world.has_entity_with_id("tracked_1")).is_false()
assert_object(world.get_entity_by_id("tracked_1")).is_null()
# Others should still exist
assert_bool(world.has_entity_with_id("tracked_0")).is_true()
assert_bool(world.has_entity_with_id("tracked_2")).is_true()

View File

@@ -0,0 +1 @@
uid://d3n5subrpobw3

View File

@@ -0,0 +1,392 @@
extends GdUnitTestSuite
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
world.purge(false)
func test_observer_receive_component_changed():
world.add_system(TestASystem.new())
var test_a_observer = TestAObserver.new()
world.add_observer(test_a_observer)
# Create entities with the required components
var entity_a = TestA.new()
entity_a.name = "a"
entity_a.add_component(C_TestA.new())
var entity_b = TestB.new()
entity_b.name = "b"
entity_b.add_component(C_TestA.new())
entity_b.add_component(C_TestB.new())
# issue #43
var entity_a2 = TestA.new()
entity_a2.name = "a"
entity_a2.add_component(C_TestA.new())
world.get_node(world.entity_nodes_root).add_child(entity_a2)
world.add_entity(entity_a2, null, false)
assert_int(test_a_observer.added_count).is_equal(1)
# Add some entities before systems
world.add_entities([entity_a, entity_b])
assert_int(test_a_observer.added_count).is_equal(3)
# Run the systems once
print('process 1st')
world.process(0.1)
# Check the event_count
assert_int(test_a_observer.event_count).is_equal(2)
# Run the systems again
print('process 2nd')
world.process(0.1)
# Check the event_count
assert_int(test_a_observer.event_count).is_equal(4)
## Test that observers detect when a component is added to an entity
func test_observer_on_component_added():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create an entity without the component
var entity = Entity.new()
world.add_entity(entity)
# Verify observer hasn't fired yet
assert_int(observer.added_count).is_equal(0)
# Add the watched component
var component = C_ObserverTest.new()
entity.add_component(component)
# Verify observer detected the addition
assert_int(observer.added_count).is_equal(1)
assert_object(observer.last_added_entity).is_equal(entity)
## Test that observers detect when a component is removed from an entity
func test_observer_on_component_removed():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create an entity with the component
var entity = Entity.new()
var component = C_ObserverTest.new()
entity.add_component(component)
world.add_entity(entity)
# Verify observer detected the addition
assert_int(observer.added_count).is_equal(1)
# Reset and remove the component
observer.reset()
entity.remove_component(C_ObserverTest)
# Verify observer detected the removal
assert_int(observer.removed_count).is_equal(1)
assert_object(observer.last_removed_entity).is_equal(entity)
assert_int(observer.added_count).is_equal(0) # Should remain 0 after reset
## Test that observers detect property changes on watched components
func test_observer_on_component_changed():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create an entity with the component
var entity = Entity.new()
var component = C_ObserverTest.new(0, "initial")
entity.add_component(component)
world.add_entity(entity)
# Reset the observer (it may have fired on add)
observer.reset()
# Change the value property (this will emit property_changed signal)
component.value = 42
# Verify observer detected the change
assert_int(observer.changed_count).is_equal(1)
assert_object(observer.last_changed_entity).is_equal(entity)
assert_str(observer.last_changed_property).is_equal("value")
assert_int(observer.last_old_value).is_equal(0)
assert_int(observer.last_new_value).is_equal(42)
# Change another property
component.name_prop = "changed"
# Verify observer detected the second change
assert_int(observer.changed_count).is_equal(2)
assert_str(observer.last_changed_property).is_equal("name_prop")
assert_str(observer.last_old_value).is_equal("initial")
assert_str(observer.last_new_value).is_equal("changed")
## Test that observers respect query filters (only match entities that pass the query)
func test_observer_respects_query_filter():
var health_observer = O_HealthObserver.new()
world.add_observer(health_observer)
# Create entity with only health component (should NOT match - needs both components)
var entity_only_health = Entity.new()
entity_only_health.add_component(C_ObserverHealth.new())
world.add_entity(entity_only_health)
# Observer should NOT have fired (doesn't match query)
assert_int(health_observer.health_added_count).is_equal(0)
# Create entity with both components (should match)
var entity_both = Entity.new()
entity_both.add_component(C_ObserverTest.new())
entity_both.add_component(C_ObserverHealth.new())
world.add_entity(entity_both)
# Observer should have fired now (matches query)
assert_int(health_observer.health_added_count).is_equal(1)
## Test that multiple observers can watch the same component
func test_multiple_observers_same_component():
var observer1 = O_ObserverTest.new()
var observer2 = O_ObserverTest.new()
world.add_observer(observer1)
world.add_observer(observer2)
# Create an entity with the component
var entity = Entity.new()
var component = C_ObserverTest.new()
entity.add_component(component)
world.add_entity(entity)
# Both observers should have detected the addition
assert_int(observer1.added_count).is_equal(1)
assert_int(observer2.added_count).is_equal(1)
# Change the component
observer1.reset()
observer2.reset()
component.value = 100
# Both observers should have detected the change
assert_int(observer1.changed_count).is_equal(1)
assert_int(observer2.changed_count).is_equal(1)
## Test that observers can track multiple property changes
func test_observer_tracks_multiple_changes():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create an entity with the component
var entity = Entity.new()
var component = C_ObserverTest.new(0, "start")
entity.add_component(component)
world.add_entity(entity)
observer.reset()
# Make multiple changes
component.value = 10
component.value = 20
component.name_prop = "middle"
component.value = 30
# Should have detected all 4 changes
assert_int(observer.changed_count).is_equal(4)
## Test observer with health component and query matching
func test_observer_health_low_health_alert():
var health_observer = O_HealthObserver.new()
world.add_observer(health_observer)
# Create entity with both components
var entity = Entity.new()
entity.add_component(C_ObserverTest.new())
var health = C_ObserverHealth.new(100)
entity.add_component(health)
world.add_entity(entity)
health_observer.reset()
# Reduce health gradually
health.health = 50
assert_int(health_observer.health_changed_count).is_equal(1)
assert_int(health_observer.low_health_alerts.size()).is_equal(0)
health.health = 25 # Below threshold
assert_int(health_observer.health_changed_count).is_equal(2)
assert_int(health_observer.low_health_alerts.size()).is_equal(1)
assert_object(health_observer.low_health_alerts[0]).is_equal(entity)
## Test that observer doesn't fire when entity doesn't match query
func test_observer_ignores_non_matching_entities():
var health_observer = O_HealthObserver.new()
world.add_observer(health_observer)
# Create entity with only C_ObserverTest (not both components)
var entity = Entity.new()
entity.add_component(C_ObserverTest.new())
world.add_entity(entity)
# Try to add C_ObserverHealth to a different entity that doesn't have C_ObserverTest
var entity2 = Entity.new()
entity2.add_component(C_ObserverHealth.new())
world.add_entity(entity2)
# Observer should not have fired (entity2 doesn't match query)
assert_int(health_observer.health_added_count).is_equal(0)
## Test observer detects component addition before entity is added to world
func test_observer_component_added_before_entity_added():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create entity and add component BEFORE adding to world
var entity = Entity.new()
var component = C_ObserverTest.new()
entity.add_component(component)
# Observer shouldn't have fired yet
assert_int(observer.added_count).is_equal(0)
# Now add to world
world.add_entity(entity)
# Observer should fire now
assert_int(observer.added_count).is_equal(1)
## Test observer with component replacement
func test_observer_component_replacement():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create entity with component
var entity = Entity.new()
var component1 = C_ObserverTest.new(10, "first")
entity.add_component(component1)
world.add_entity(entity)
assert_int(observer.added_count).is_equal(1)
# Replace the component (add_component on same type replaces)
var component2 = C_ObserverTest.new(20, "second")
entity.add_component(component2)
# Should trigger both removed and added
assert_int(observer.removed_count).is_equal(1)
assert_int(observer.added_count).is_equal(2)
## Test that property changes without signal emission don't trigger observer
func test_observer_ignores_direct_property_changes():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create entity with component
var entity = Entity.new()
var component = C_ObserverTest.new()
entity.add_component(component)
world.add_entity(entity)
observer.reset()
# Directly set the property WITHOUT using the setter
# This bypasses the property_changed signal
# Note: In GDScript, using the property name always calls the setter,
# so we need to access the internal variable directly
# For this test, we're verifying that ONLY setters that emit signals work
# Using the setter (should trigger)
component.value = 42
assert_int(observer.changed_count).is_equal(1)
# The framework correctly requires explicit signal emission in setters
## Test observer with entity that starts matching query after component addition
func test_observer_entity_becomes_matching():
var health_observer = O_HealthObserver.new()
world.add_observer(health_observer)
# Create entity with only one component
var entity = Entity.new()
entity.add_component(C_ObserverTest.new())
world.add_entity(entity)
# Health observer shouldn't fire (needs both components)
assert_int(health_observer.health_added_count).is_equal(0)
# Add the second component
entity.add_component(C_ObserverHealth.new())
# Now health observer should fire
assert_int(health_observer.health_added_count).is_equal(1)
## Test removing observer from world
func test_remove_observer():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create entity with component
var entity = Entity.new()
entity.add_component(C_ObserverTest.new())
world.add_entity(entity)
assert_int(observer.added_count).is_equal(1)
# Remove the observer
world.remove_observer(observer)
# Add another entity - observer should not fire
var entity2 = Entity.new()
entity2.add_component(C_ObserverTest.new())
world.add_entity(entity2)
# Count should still be 1 (not 2)
assert_int(observer.added_count).is_equal(1)
## Test observer with multiple entities
func test_observer_with_multiple_entities():
var observer = O_ObserverTest.new()
world.add_observer(observer)
# Create multiple entities
for i in range(5):
var entity = Entity.new()
entity.add_component(C_ObserverTest.new(i))
world.add_entity(entity)
# Should have detected all 5 additions
assert_int(observer.added_count).is_equal(5)
observer.reset()
# Get all entities and modify their components
var entities = world.query.with_all([C_ObserverTest]).execute()
for entity in entities:
var comp = entity.get_component(C_ObserverTest)
comp.value = comp.value + 100
# Should have detected all 5 changes
assert_int(observer.changed_count).is_equal(5)

View File

@@ -0,0 +1 @@
uid://jr1qceldoims

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
uid://b06t0s7ajwlme

View File

@@ -0,0 +1,30 @@
extends GdUnitTestSuite
var runner: GdUnitSceneRunner
var world: World
func before():
runner = scene_runner("res://addons/gecs/tests/test_scene.tscn")
world = runner.get_property("world")
ECS.world = world
func after_test():
if world:
world.purge(false)
func test_all_vs_any_distinct_cache_key():
var qb_all = world.query.with_all([C_DomainTestA, C_DomainTestB])
var key_all = qb_all.get_cache_key()
var qb_any = world.query.with_any([C_DomainTestA, C_DomainTestB])
var key_any = qb_any.get_cache_key()
assert_int(key_all).is_not_equal(key_any)
func test_all_vs_mixed_not_colliding():
var qb1 = world.query.with_all([C_DomainTestA]).with_any([C_DomainTestB])
var qb2 = world.query.with_all([C_DomainTestA, C_DomainTestB])
assert_int(qb1.get_cache_key()).is_not_equal(qb2.get_cache_key())
func test_any_vs_exclude_not_colliding():
var qb3 = world.query.with_any([C_DomainTestA])
var qb4 = world.query.with_none([C_DomainTestA])
assert_int(qb3.get_cache_key()).is_not_equal(qb4.get_cache_key())

View File

@@ -0,0 +1 @@
uid://dw542afdb7ydt

Some files were not shown because too many files have changed in this diff Show More