diff --git a/src/jecs.luau b/src/jecs.luau index 3cae329..5a98984 100755 --- a/src/jecs.luau +++ b/src/jecs.luau @@ -1034,8 +1034,8 @@ local function world_range(world: world, range_begin: number, range_end: number? dense = 0 } :: record end - entity_index.max_id = range_begin - 1 - entity_index.alive_count = range_begin - 1 + entity_index.max_id = range_begin + entity_index.alive_count = range_begin end end @@ -3302,62 +3302,51 @@ local function world_new(DEBUG: boolean?) if r then local dense = r.dense - if not dense or r.dense == 0 then - r.dense = index - dense = index - local e_swap = eindex_dense_array[dense] - local r_swap = entity_index_try_get_any(e_swap) :: record - - r_swap.dense = dense + -- If dense == 0, this is a pre-populated entry from world:range() + -- Just add the entity to the end of the dense array + if dense == 0 then alive_count += 1 entity_index.alive_count = alive_count r.dense = alive_count - - eindex_dense_array[dense] = e_swap eindex_dense_array[alive_count] = entity return entity end - local any = eindex_dense_array[dense] - if any ~= entity then - if alive_count <= dense then - local e_swap = eindex_dense_array[dense] - local r_swap = entity_index_try_get_any(e_swap) :: record - - r_swap.dense = dense - alive_count += 1 - entity_index.alive_count = alive_count - r.dense = alive_count - - eindex_dense_array[dense] = e_swap - eindex_dense_array[alive_count] = entity - end + -- If dense > 0, check if there's an existing entity at that position + local existing_entity = eindex_dense_array[dense] + if existing_entity and existing_entity ~= entity then + alive_count += 1 + entity_index.alive_count = alive_count + r.dense = alive_count + eindex_dense_array[alive_count] = entity + return entity end return entity else - for i = entity_index.max_id + 1, index do - eindex_sparse_array[i] = { dense = i } :: record - eindex_dense_array[i] = i - end - entity_index.max_id = index + local max_id = entity_index.max_id - local e_swap = eindex_dense_array[alive_count] - local r_swap = eindex_sparse_array[alive_count] - r_swap.dense = index + if index > max_id then + -- Pre-populate all intermediate IDs to keep sparse_array as an array + for i = max_id + 1, index - 1 do + if not eindex_sparse_array[i] then + -- NOTE(marcus): We have to do this check to see if + -- they exist first because world:range() may have + -- pre-populated some slots already. + end + + eindex_sparse_array[i] = { dense = 0 } :: record + end + entity_index.max_id = index + end alive_count += 1 entity_index.alive_count = alive_count - - r = eindex_sparse_array[index] - - r.dense = alive_count - - eindex_sparse_array[index] = r - - eindex_dense_array[index] = e_swap eindex_dense_array[alive_count] = entity + r = { dense = alive_count } :: record + eindex_sparse_array[index] = r + return entity end end @@ -3390,7 +3379,6 @@ local function world_new(DEBUG: boolean?) end local function world_delete(world: world, entity: i53) - DEBUG_DELETING_ENTITY = entity local record = entity_index_try_get_unsafe(entity) if not record then return @@ -3744,7 +3732,7 @@ local function world_new(DEBUG: boolean?) local function world_delete_checked(world: world, entity: i53) DEBUG_DELETING_ENTITY = entity DEBUG_IS_INVALID_ENTITY(entity) - canonical_world_delete(world, entity, id) + canonical_world_delete(world, entity) DEBUG_DELETING_ENTITY = nil end world_delete = world_delete_checked diff --git a/test.rbxl b/test.rbxl new file mode 100755 index 0000000..372eb6b Binary files /dev/null and b/test.rbxl differ diff --git a/test/tests.luau b/test/tests.luau index 50a6b11..5cb2ab0 100755 --- a/test/tests.luau +++ b/test/tests.luau @@ -77,7 +77,7 @@ TEST("reproduce idr_t nil archetype bug", function() -- Removing this, but keeping Animator on the other 2 causes it to stop happening world:set(trackEntity1, cts.VelocitizeAnimationWeight, 0) - local q = world:query(jecs.pair(jecs.ChildOf, jecs.w)):cached() + local q = world:query(jecs.pair(jecs.ChildOf, __)):cached() world:delete(root) @@ -88,7 +88,7 @@ TEST("reproduce idr_t nil archetype bug", function() print(`bugged entity found: {entityId}`) print(`original parent: {info.parentId}`) print(`current parent: {i}`) - print(`batch = {batch}, i = {i}`) + print(`batch = {batchSize}, i = {i}`) print("==========================================") trackedEntities[entityId] = nil end @@ -917,9 +917,6 @@ TEST("world:delete()", function() CHECK(destroyed) CHECK(not world:contains(child)) end - if true then - return - end do CASE "Should delete children in different archetypes if they have the same parent" local world = jecs.world() @@ -1536,12 +1533,10 @@ TEST("world:range()", function() do CASE "spawn entity under min range" local world = jecs.world() world:range(400, 1000) - CHECK(world.entity_index.alive_count == 399) local e = world:entity(300) - CHECK(world.entity_index.alive_count == 400) local e1 = world:entity(300) - CHECK(world.entity_index.alive_count == 400) - CHECK(e) + CHECK(world:contains(e)) + CHECK(world:contains(e1)) end do CASE "entity ID reuse works correctly across different world ranges" local base = jecs.world() @@ -1588,6 +1583,7 @@ TEST("world:range()", function() CHECK(bar == bar_mirror) end + print("") do CASE "delete outside partitioned range" local server = jecs.world() local client = jecs.world() @@ -1605,8 +1601,25 @@ TEST("world:range()", function() CHECK(client:get(e2, A)) client:delete(e2) + + + print("") + print(client.entity_index.max_id) + print("") + + print("") + print(client.entity_index.alive_count) + print("") + + print("") + print(client.entity_index.dense_array[client.entity_index.alive_count]) + print("") + print("") local e3 = client:entity() - CHECK(ECS_ID(e3) == 1000) + print("") + print("") + CHECK(ECS_ID(e3) == 1001) + print("") local e1v1 = server:entity() local e4 = client:entity(e1v1) @@ -1616,6 +1629,281 @@ TEST("world:range()", function() CHECK(client:contains(e4)) end + print("") + + do CASE "desired id within range does not overwrite existing entities" + local world = jecs.world() + world:range(1000, 2000) + + local ctype = world:component() + + local e1 = world:entity(1000) + local e2 = world:entity(1001) + local e3 = world:entity(1002) + + world:set(e1, ctype, "entity1") + world:set(e2, ctype, "entity2") + world:set(e3, ctype, "entity3") + + local e4 = world:entity(1500) + world:set(e4, ctype, "entity4") + + CHECK(world:contains(e1)) + CHECK(world:contains(e2)) + CHECK(world:contains(e3)) + CHECK(world:contains(e4)) + CHECK(world:get(e1, ctype) == "entity1") + CHECK(world:get(e2, ctype) == "entity2") + CHECK(world:get(e3, ctype) == "entity3") + CHECK(world:get(e4, ctype) == "entity4") + end + + do CASE "creating entity with range+offset does not conflict with existing" + local world = jecs.world() + world:range(500, 1000) + + local ctype = world:component() + + local base = world:entity(500) + world:set(base, ctype, "base") + + local offset1 = world:entity(501) + world:set(offset1, ctype, "offset1") + + local offset2 = world:entity(750) + world:set(offset2, ctype, "offset2") + + CHECK(world:contains(base)) + CHECK(world:contains(offset1)) + CHECK(world:contains(offset2)) + CHECK(world:get(base, ctype) == "base") + CHECK(world:get(offset1, ctype) == "offset1") + CHECK(world:get(offset2, ctype) == "offset2") + end + + do CASE "creating entities in reverse order within range" + local world = jecs.world() + world:range(100, 200) + + local ctype = world:component() + + local e3 = world:entity(150) + local e2 = world:entity(120) + local e1 = world:entity(110) + + world:set(e1, ctype, 1) + world:set(e2, ctype, 2) + world:set(e3, ctype, 3) + + -- All should exist independently + CHECK(world:contains(e1)) + CHECK(world:contains(e2)) + CHECK(world:contains(e3)) + CHECK(world:get(e1, ctype) == 1) + CHECK(world:get(e2, ctype) == 2) + CHECK(world:get(e3, ctype) == 3) + end + + do CASE "creating entity with desired ID after range pre-population" + local world = jecs.world() + world:range(400, 1000) + + local ctype = world:component() + + -- Range pre-populates 1-399 with dense=0 + -- Create an entity at a pre-populated position + local e1 = world:entity(300) + world:set(e1, ctype, "prepop") + + -- Create another entity at a different pre-populated position + local e2 = world:entity(250) + world:set(e2, ctype, "prepop2") + + -- Create entity within the range + local e3 = world:entity(500) + world:set(e3, ctype, "inrange") + + -- All should work correctly + CHECK(world:contains(e1)) + CHECK(world:contains(e2)) + CHECK(world:contains(e3)) + CHECK(world:get(e1, ctype) == "prepop") + CHECK(world:get(e2, ctype) == "prepop2") + CHECK(world:get(e3, ctype) == "inrange") + end + + do CASE "creating same entity twice returns existing entity" + local world = jecs.world() + world:range(1000, 2000) + + local ctype = world:component() + + -- Create entity + local e1 = world:entity(1500) + world:set(e1, ctype, "original") + + -- Try to create it again + local e2 = world:entity(1500) + + -- Should return the same entity + CHECK(e1 == e2) + CHECK(world:get(e1, ctype) == "original") + CHECK(world:get(e2, ctype) == "original") + + -- Should only be one entity + local count = 0 + for _ in world:query(ctype) do + count += 1 + end + CHECK(count == 1) + end + + do CASE "creating entities with gaps in range does not affect existing" + local world = jecs.world() + world:range(200, 300) + + local ctype = world:component() + + -- Create entities with gaps + local e1 = world:entity(200) + local e2 = world:entity(250) + local e3 = world:entity(299) + + world:set(e1, ctype, 1) + world:set(e2, ctype, 2) + world:set(e3, ctype, 3) + + -- Create entity in the gap + local e4 = world:entity(225) + world:set(e4, ctype, 4) + + -- All should still exist + CHECK(world:contains(e1)) + CHECK(world:contains(e2)) + CHECK(world:contains(e3)) + CHECK(world:contains(e4)) + CHECK(world:get(e1, ctype) == 1) + CHECK(world:get(e2, ctype) == 2) + CHECK(world:get(e3, ctype) == 3) + CHECK(world:get(e4, ctype) == 4) + end + + do CASE "creating many entities with desired IDs in range" + local world = jecs.world() + world:range(1000, 2000) + + local ctype = world:component() + local entities = {} + + -- Create 50 entities with specific IDs + for i = 1, 50 do + local id = 1000 + (i * 10) + local e = world:entity(id) + world:set(e, ctype, i) + entities[i] = e + end + + -- Verify all exist and have correct values + for i = 1, 50 do + CHECK(world:contains(entities[i])) + CHECK(world:get(entities[i], ctype) == i) + end + + -- Create more entities in between + for i = 1, 49 do + local id = 1000 + (i * 10) + 5 + local e = world:entity(id) + world:set(e, ctype, i + 100) + end + + -- Original entities should still be intact + for i = 1, 50 do + CHECK(world:contains(entities[i])) + CHECK(world:get(entities[i], ctype) == i) + end + end + + do CASE "sparse_array remains optimized with non-contiguous entity IDs" + local world = jecs.world() + local ctype = world:component() + + -- Create entities with large gaps to test sparse_array optimization + -- Start with entity 100, then jump to 1000 (gap: 101-999) + local e1 = world:entity(100) + local e2 = world:entity(1000) + -- Then jump to 5000 (gap: 1001-4999) + local e3 = world:entity(5000) + -- Then jump to 10000 (gap: 5001-9999) + local e4 = world:entity(10000) + + world:set(e1, ctype, 1) + world:set(e2, ctype, 2) + world:set(e3, ctype, 3) + world:set(e4, ctype, 4) + + -- Verify all entities exist and have correct values + CHECK(world:contains(e1)) + CHECK(world:contains(e2)) + CHECK(world:contains(e3)) + CHECK(world:contains(e4)) + CHECK(world:get(e1, ctype) == 1) + CHECK(world:get(e2, ctype) == 2) + CHECK(world:get(e3, ctype) == 3) + CHECK(world:get(e4, ctype) == 4) + + -- Verify intermediate IDs are pre-populated (they should exist but not be alive) + -- These are in the gaps between created entities + local sparse_array = world.entity_index.sparse_array + CHECK(sparse_array[500] ~= nil) -- Between 100 and 1000, should be pre-populated + CHECK(sparse_array[500].dense == 0) -- But not alive (dense=0) + CHECK(sparse_array[2500] ~= nil) -- Between 1000 and 5000, should be pre-populated + CHECK(sparse_array[2500].dense == 0) + CHECK(sparse_array[7500] ~= nil) -- Between 5000 and 10000, should be pre-populated + CHECK(sparse_array[7500].dense == 0) + + -- Verify max_id was updated correctly + CHECK(world.entity_index.max_id == 10000) + end + do CASE "desired id does not overwrite old entity id" + local world = jecs.world() + local ctype = world:component() + local id = world:entity() + + print("") + local e = world:entity((id::any) + 1) + print("") + + CHECK(world:contains(id)) + CHECK(world:contains(e)) + + -- also make sure that they don't share the same record + + world:set(id, ctype, 1) + world:set(e, ctype, 2) + + CHECK(world:get(id, ctype) == 1) + CHECK(world:get(e, ctype) == 2) + end + + do CASE "creating ids with a higher key first" + local world = jecs.world() + local ctype = world:component() + + local e = world:entity(1000) + local id = world:entity(999) + + CHECK(world:contains(id)) + CHECK(world:contains(e)) + + -- also make sure that they don't share the same record + + world:set(id, ctype, 1) + world:set(e, ctype, 2) + + CHECK(world:get(id, ctype) == 1) + CHECK(world:get(e, ctype) == 2) + end + do CASE "under range start" local world = jecs.world() @@ -1689,7 +1977,7 @@ TEST("world:entity()", function() local ctype = world:component() local id = world:entity() - local e = world:entity(id + 1) + local e = world:entity((id::any) + 1) CHECK(world:contains(id)) CHECK(world:contains(e)) @@ -1702,7 +1990,7 @@ TEST("world:entity()", function() CHECK(world:get(id, ctype) == 1) CHECK(world:get(e, ctype) == 2) end - + do CASE "creating ids with a higher key first" local world = jecs.world() local ctype = world:component() @@ -1793,6 +2081,8 @@ TEST("world:entity()", function() CHECK(ECS_ID(e) == pin) CHECK(ECS_GENERATION(e) == 0) end + + end) TEST("world:has()", function()