Compare commits

..

No commits in common. "main" and "v0.10.3" have entirely different histories.

7 changed files with 314 additions and 330 deletions

View file

@ -19,7 +19,7 @@ about the code base is lost, ability to work on it at the same level of quality
is lost. Over time code quality will decline as code size grows. is lost. Over time code quality will decline as code size grows.
- Tacit knowledge is very hard to recover by looking at a maze of code, - Tacit knowledge is very hard to recover by looking at a maze of code,
and it takes a long time to do so. and it takes
- You will often hear that "every semantic distinction deserves its own - You will often hear that "every semantic distinction deserves its own
component or tag". Sometimes this is correct. A well chosen component boundary component or tag". Sometimes this is correct. A well chosen component boundary

View file

@ -193,6 +193,7 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
if not r then return end if not r then return end
local src = r.archetype local src = r.archetype
if not src then return end
if not archetypes[src.id] then return end if not archetypes[src.id] then return end
@ -286,6 +287,9 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
end end
local r = jecs.record(world, entity) local r = jecs.record(world, entity)
local archetype = r.archetype local archetype = r.archetype
if not archetype then
return
end
if archetypes[oldarchetype.id] and not archetypes[archetype.id] then if archetypes[oldarchetype.id] and not archetypes[archetype.id] then
last_old_archetype = nil last_old_archetype = nil
@ -305,6 +309,9 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
local r = jecs.record(world, entity) local r = jecs.record(world, entity)
local archetype = r.archetype local archetype = r.archetype
if not archetype then
return
end
if last_old_archetype == archetype then if last_old_archetype == archetype then
return return
end end
@ -325,6 +332,9 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
end end
local r = jecs.record(world, entity) local r = jecs.record(world, entity)
local archetype = r.archetype local archetype = r.archetype
if not archetype then
return
end
if archetypes[oldarchetype.id] and not archetypes[archetype.id] then if archetypes[oldarchetype.id] and not archetypes[archetype.id] then
callback_removed(entity) callback_removed(entity)
@ -339,6 +349,9 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
end end
local r = jecs.record(world, entity) local r = jecs.record(world, entity)
local archetype = r.archetype local archetype = r.archetype
if not archetype then
return
end
local dst = jecs.archetype_traverse_remove(world, id, archetype) local dst = jecs.archetype_traverse_remove(world, id, archetype)
if archetypes[dst.id] then if archetypes[dst.id] then

View file

@ -1,6 +1,6 @@
{ {
"name": "@rbxts/jecs", "name": "@rbxts/jecs",
"version": "0.11.0", "version": "0.10.3",
"description": "Stupidly fast Entity Component System", "description": "Stupidly fast Entity Component System",
"main": "src/jecs.luau", "main": "src/jecs.luau",
"repository": { "repository": {

View file

@ -156,7 +156,6 @@ type componentrecord = {
counts: { [i53]: number }, counts: { [i53]: number },
flags: number, flags: number,
size: number, size: number,
cache: { number },
on_add: ((entity: i53, id: i53, value: any, oldarchetype: archetype) -> ())?, on_add: ((entity: i53, id: i53, value: any, oldarchetype: archetype) -> ())?,
on_change: ((entity: i53, id: i53, value: any, oldarchetype: archetype) -> ())?, on_change: ((entity: i53, id: i53, value: any, oldarchetype: archetype) -> ())?,
@ -568,21 +567,17 @@ end
local ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY = "Entity is outside range" local ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY = "Entity is outside range"
local function ENTITY_INDEX_NEW_ID(world: world): i53 local function ENTITY_INDEX_NEW_ID(entity_index: entityindex): i53
local entity_index = world.entity_index
local ROOT_ARCHETYPE = world.ROOT_ARCHETYPE
local dense_array = entity_index.dense_array local dense_array = entity_index.dense_array
local alive_count = entity_index.alive_count local alive_count = entity_index.alive_count
local sparse_array = entity_index.sparse_array local sparse_array = entity_index.sparse_array
local max_id = entity_index.max_id local max_id = entity_index.max_id
local next_count = alive_count + 1
if alive_count < max_id then if alive_count < max_id then
local id = dense_array[next_count] alive_count += 1
if id then entity_index.alive_count = alive_count
entity_index.alive_count = next_count local id = dense_array[alive_count]
return id return id
end
end end
local id = max_id + 1 local id = max_id + 1
@ -590,9 +585,10 @@ local function ENTITY_INDEX_NEW_ID(world: world): i53
ecs_assert(range_end == nil or id < range_end, ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY) ecs_assert(range_end == nil or id < range_end, ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY)
entity_index.max_id = id entity_index.max_id = id
entity_index.alive_count = next_count alive_count += 1
dense_array[next_count] = id entity_index.alive_count = alive_count
sparse_array[id] = { dense = next_count, row = 0, archetype = ROOT_ARCHETYPE } dense_array[alive_count] = id
sparse_array[id] = { dense = alive_count } :: record
return id return id
end end
@ -919,7 +915,6 @@ local function id_record_create(
records = {}, records = {},
counts = {}, counts = {},
flags = flags, flags = flags,
cache = {},
on_add = on_add, on_add = on_add,
on_change = on_change, on_change = on_change,
@ -957,7 +952,6 @@ local function archetype_append_to_records(
idr_records[archetype_id] = index idr_records[archetype_id] = index
idr_counts[archetype_id] = 1 idr_counts[archetype_id] = 1
columns_map[id] = column columns_map[id] = column
table.insert(idr.cache, archetype_id)
else else
local max_count = idr_counts[archetype_id] + 1 local max_count = idr_counts[archetype_id] + 1
idr_counts[archetype_id] = max_count idr_counts[archetype_id] = max_count
@ -1049,10 +1043,8 @@ local function world_range(world: world, range_begin: number, range_end: number?
for i = max_id + 1, range_begin do for i = max_id + 1, range_begin do
dense_array[i] = i dense_array[i] = i
sparse_array[i] = { sparse_array[i] = {
dense = 0, dense = 0
row = 0, } :: record
archetype = world.ROOT_ARCHETYPE
}
end end
entity_index.max_id = range_begin entity_index.max_id = range_begin
entity_index.alive_count = range_begin entity_index.alive_count = range_begin
@ -1092,11 +1084,10 @@ local function find_archetype_without(
): archetype ): archetype
local id_types = node.types local id_types = node.types
local at = table.find(id_types, id) local at = table.find(id_types, id)
if at == nil then
return node
end
local dst = table.clone(id_types) local dst = table.clone(id_types)
table.remove(dst, at) table.remove(dst, at)
return archetype_ensure(world, dst) return archetype_ensure(world, dst)
end end
@ -1219,13 +1210,6 @@ local function archetype_destroy(world: world, archetype: archetype)
if archetype == world.ROOT_ARCHETYPE then if archetype == world.ROOT_ARCHETYPE then
return return
end end
-- RAII / idempotent: already destroyed or still has entities → no-op
if world.archetypes[archetype.id] ~= archetype then
return
end
if #archetype.entities > 0 then
return
end
local component_index = world.component_index local component_index = world.component_index
local archetype_edges = world.archetype_edges local archetype_edges = world.archetype_edges
@ -2577,7 +2561,7 @@ local function ecs_bulk_insert(world: world, entity: i53, ids: { i53 }, values:
end end
local from = r.archetype local from = r.archetype
local component_index = world.component_index local component_index = world.component_index
if from == world.ROOT_ARCHETYPE then if not from then
local dst_types = table.clone(ids) local dst_types = table.clone(ids)
table.sort(dst_types) table.sort(dst_types)
@ -2665,8 +2649,6 @@ local function ecs_bulk_insert(world: world, entity: i53, ids: { i53 }, values:
end end
end end
local ON_REMOVE_STRUCTURAL_WARN = "jecs: on_remove must not perform structural changes (world:add/world:remove); this will be removed as a lint in future versions and can cause silent failures"
local function ecs_bulk_remove(world: world, entity: i53, ids: { i53 }) local function ecs_bulk_remove(world: world, entity: i53, ids: { i53 })
local entity_index = world.entity_index local entity_index = world.entity_index
local r = entity_index_try_get(entity_index, entity) local r = entity_index_try_get(entity_index, entity)
@ -2675,12 +2657,13 @@ local function ecs_bulk_remove(world: world, entity: i53, ids: { i53 })
end end
local from = r.archetype local from = r.archetype
local component_index = world.component_index local component_index = world.component_index
if not from then
return
end
local remove: { [i53]: boolean } = {} local remove: { [i53]: boolean } = {}
local columns_map = from.columns_map local columns_map = from.columns_map
local dst_types = table.clone(from.types) :: { i53 }
for i, id in ids do for i, id in ids do
if not columns_map[id] then if not columns_map[id] then
@ -2693,15 +2676,22 @@ local function ecs_bulk_remove(world: world, entity: i53, ids: { i53 })
local on_remove = idr.on_remove local on_remove = idr.on_remove
if on_remove then if on_remove then
on_remove(entity, id) on_remove(entity, id)
if from ~= r.archetype then
error(ON_REMOVE_STRUCTURAL_WARN)
end
end end
end
local to = r.archetype
if from ~= to then
from = to
end
local dst_types = table.clone(from.types) :: { i53 }
for id in remove do
local at = table.find(dst_types, id) local at = table.find(dst_types, id)
table.remove(dst_types, at) table.remove(dst_types, at)
end end
local to = archetype_ensure(world, dst_types) to = archetype_ensure(world, dst_types)
if from ~= to then if from ~= to then
entity_move(entity_index, entity, r, to) entity_move(entity_index, entity, r, to)
@ -2712,12 +2702,12 @@ local function world_new(DEBUG: boolean?)
local eindex_dense_array = {} :: { i53 } local eindex_dense_array = {} :: { i53 }
local eindex_sparse_array = {} :: { record } local eindex_sparse_array = {} :: { record }
local entity_index: entityindex = { local entity_index = {
dense_array = eindex_dense_array, dense_array = eindex_dense_array,
sparse_array = eindex_sparse_array, sparse_array = eindex_sparse_array,
alive_count = 0, alive_count = 0,
max_id = 0, max_id = 0,
} } :: entityindex
-- NOTE(marcus): with the way the component index is accessed, we want to -- NOTE(marcus): with the way the component index is accessed, we want to
-- ensure that components range has fast access. -- ensure that components range has fast access.
@ -2757,6 +2747,31 @@ local function world_new(DEBUG: boolean?)
signals = signals, signals = signals,
} :: world } :: world
local function entity_index_new_id(entity_index: entityindex): i53
local alive_count = entity_index.alive_count
local max_id = entity_index.max_id
if alive_count < max_id then
alive_count += 1
entity_index.alive_count = alive_count
local id = eindex_dense_array[alive_count]
return id
end
local id = max_id + 1
local range_end = entity_index.range_end
ecs_assert(range_end == nil or id < range_end, ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY)
entity_index.max_id = id
alive_count += 1
entity_index.alive_count = alive_count
eindex_dense_array[alive_count] = id
eindex_sparse_array[id] = { dense = alive_count } :: record
return id
end
local ROOT_ARCHETYPE = archetype_create(world, {}, "") local ROOT_ARCHETYPE = archetype_create(world, {}, "")
world.ROOT_ARCHETYPE = ROOT_ARCHETYPE world.ROOT_ARCHETYPE = ROOT_ARCHETYPE
@ -2886,7 +2901,9 @@ local function world_new(DEBUG: boolean?)
return return
end end
local src = record.archetype local from: archetype = record.archetype
local ROOT_ARCHETYPE = ROOT_ARCHETYPE
local src = from or ROOT_ARCHETYPE
local column = src.columns_map[id] local column = src.columns_map[id]
if column then if column then
local idr = component_index[id] local idr = component_index[id]
@ -2916,9 +2933,9 @@ local function world_new(DEBUG: boolean?)
local id_types = src.types local id_types = src.types
if on_remove then if on_remove then
on_remove(entity, id_types[cr]) on_remove(entity, id_types[cr])
if src ~= record.archetype then src = record.archetype
error(ON_REMOVE_STRUCTURAL_WARN) id_types = src.types
end cr = idr.records[src.id]
end end
to = exclusive_traverse_add(src, cr, id) to = exclusive_traverse_add(src, cr, id)
@ -2941,8 +2958,11 @@ local function world_new(DEBUG: boolean?)
if cr then if cr then
local id_types = src.types local id_types = src.types
on_remove(entity, id_types[cr]) on_remove(entity, id_types[cr])
if src ~= record.archetype then local arche = record.archetype
error(ON_REMOVE_STRUCTURAL_WARN) if src ~= arche then
id_types = arche.types
cr = idr.records[arche.id]
to = exclusive_traverse_add(arche, cr, id)
end end
end end
end end
@ -2961,9 +2981,7 @@ local function world_new(DEBUG: boolean?)
idr = component_index[id] idr = component_index[id]
end end
local ROOT_ARCHETYPE = ROOT_ARCHETYPE if from then
local src_is_root_archetype = src == ROOT_ARCHETYPE
if not src_is_root_archetype then
-- If there was a previous archetype, then the entity needs to move the archetype -- If there was a previous archetype, then the entity needs to move the archetype
inner_entity_move(entity, record, to) inner_entity_move(entity, record, to)
else else
@ -2990,7 +3008,9 @@ local function world_new(DEBUG: boolean?)
return return
end end
local src = record.archetype local from = record.archetype
local ROOT_ARCHETYPE = ROOT_ARCHETYPE
local src = from or ROOT_ARCHETYPE
if src.columns_map[id] then if src.columns_map[id] then
return return
end end
@ -3012,9 +3032,10 @@ local function world_new(DEBUG: boolean?)
local id_types = src.types local id_types = src.types
if on_remove then if on_remove then
on_remove(entity, id_types[cr]) on_remove(entity, id_types[cr])
if src ~= record.archetype then
error(ON_REMOVE_STRUCTURAL_WARN) src = record.archetype
end id_types = src.types
cr = idr.records[src.id]
end end
to = exclusive_traverse_add(src, cr, id) to = exclusive_traverse_add(src, cr, id)
@ -3060,9 +3081,7 @@ local function world_new(DEBUG: boolean?)
idr = component_index[id] idr = component_index[id]
end end
local ROOT_ARCHETYPE = ROOT_ARCHETYPE if from then
local src_is_root_archetype = src == ROOT_ARCHETYPE
if not src_is_root_archetype then
inner_entity_move(entity, record, to) inner_entity_move(entity, record, to)
else else
if #to.types > 0 then if #to.types > 0 then
@ -3085,6 +3104,9 @@ local function world_new(DEBUG: boolean?)
end end
local archetype = record.archetype local archetype = record.archetype
if not archetype then
return nil
end
local columns_map = archetype.columns_map local columns_map = archetype.columns_map
local row = record.row local row = record.row
@ -3142,8 +3164,7 @@ local function world_new(DEBUG: boolean?)
table.insert(listeners, fn) table.insert(listeners, fn)
return function() return function()
local n = #listeners local n = #listeners
local i = table.find(listeners, fn::Listener<any>) local i = table.find(listeners, fn)
assert(i, "Listener not found, maybe you tried to disconnect it twice")
listeners[i] = listeners[n] listeners[i] = listeners[n]
listeners[n] = nil listeners[n] = nil
end end
@ -3188,8 +3209,7 @@ local function world_new(DEBUG: boolean?)
table.insert(listeners, fn) table.insert(listeners, fn)
return function() return function()
local n = #listeners local n = #listeners
local i = table.find(listeners, fn::Listener<any>) local i = table.find(listeners, fn)
assert(i, "Listener not found, maybe you tried to disconnect it twice")
listeners[i] = listeners[n] listeners[i] = listeners[n]
listeners[n] = nil listeners[n] = nil
end end
@ -3234,7 +3254,6 @@ local function world_new(DEBUG: boolean?)
return function() return function()
local n = #listeners local n = #listeners
local i = table.find(listeners, fn::Listener<any>) local i = table.find(listeners, fn::Listener<any>)
assert(i, "Listener not found, maybe you tried to disconnect it twice")
listeners[i] = listeners[n] listeners[i] = listeners[n]
listeners[n] = nil listeners[n] = nil
end end
@ -3307,13 +3326,10 @@ local function world_new(DEBUG: boolean?)
end end
local function world_entity(world: world, entity: i53?): i53 local function world_entity(world: world, entity: i53?): i53
local sparse_array = eindex_sparse_array
local dense_array = eindex_dense_array
if entity then if entity then
local index = ECS_ID(entity) local index = ECS_ID(entity)
local alive_count = entity_index.alive_count local alive_count = entity_index.alive_count
local r = sparse_array[index] local r = eindex_sparse_array[index]
if r then if r then
local dense = r.dense local dense = r.dense
@ -3323,17 +3339,17 @@ local function world_new(DEBUG: boolean?)
alive_count += 1 alive_count += 1
entity_index.alive_count = alive_count entity_index.alive_count = alive_count
r.dense = alive_count r.dense = alive_count
dense_array[alive_count] = entity eindex_dense_array[alive_count] = entity
return entity return entity
end end
-- If dense > 0, check if there's an existing entity at that position -- If dense > 0, check if there's an existing entity at that position
local existing_entity = dense_array[dense] local existing_entity = eindex_dense_array[dense]
if existing_entity and existing_entity ~= entity then if existing_entity and existing_entity ~= entity then
alive_count += 1 alive_count += 1
entity_index.alive_count = alive_count entity_index.alive_count = alive_count
r.dense = alive_count r.dense = alive_count
dense_array[alive_count] = entity eindex_dense_array[alive_count] = entity
return entity return entity
end end
@ -3342,41 +3358,30 @@ local function world_new(DEBUG: boolean?)
local max_id = entity_index.max_id local max_id = entity_index.max_id
if index > max_id then 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 for i = max_id + 1, index - 1 do
sparse_array[i] = { dense = 0, row = 0, archetype = ROOT_ARCHETYPE } 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 end
entity_index.max_id = index entity_index.max_id = index
end end
alive_count += 1 alive_count += 1
entity_index.alive_count = alive_count entity_index.alive_count = alive_count
dense_array[alive_count] = entity eindex_dense_array[alive_count] = entity
r = { dense = alive_count, row = 0, archetype = ROOT_ARCHETYPE } r = { dense = alive_count } :: record
sparse_array[index] = r eindex_sparse_array[index] = r
return entity return entity
end end
end end
return entity_index_new_id(entity_index)
local alive_count = entity_index.alive_count
local max_id = entity_index.max_id
local next_count = alive_count + 1
if alive_count < max_id then
entity = dense_array[next_count]
if entity then
entity_index.alive_count = next_count
return entity
end
end
local id = max_id + 1
local range_end = entity_index.range_end
ecs_assert(range_end == nil or id < range_end, ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY)
entity_index.max_id = id
entity_index.alive_count = next_count
dense_array[next_count] = id
sparse_array[id] = { dense = next_count, row = 0, archetype = ROOT_ARCHETYPE }
return id
end end
local function world_remove(world: world, entity: i53, id: i53) local function world_remove(world: world, entity: i53, id: i53)
@ -3386,6 +3391,10 @@ local function world_new(DEBUG: boolean?)
end end
local from = record.archetype local from = record.archetype
if not from then
return
end
if from.columns_map[id] then if from.columns_map[id] then
local idr = world.component_index[id] local idr = world.component_index[id]
local on_remove = idr.on_remove local on_remove = idr.on_remove
@ -3463,6 +3472,7 @@ local function world_new(DEBUG: boolean?)
for i = n, 1, -1 do for i = n, 1, -1 do
world_delete(world, entities[i]) world_delete(world, entities[i])
end end
archetype_destroy(world, idr_archetype) archetype_destroy(world, idr_archetype)
end end
else else
@ -3479,10 +3489,13 @@ local function world_new(DEBUG: boolean?)
local r = eindex_sparse_array[ECS_ID(e :: number)] local r = eindex_sparse_array[ECS_ID(e :: number)]
local from = r.archetype local from = r.archetype
if from ~= idr_archetype then if from ~= idr_archetype then
-- unfortunately the on_remove hook allows a window where `e` can have changed archetype
-- this is hypothetically not that expensive of an operation anyways
to = archetype_traverse_remove(world, entity, from) to = archetype_traverse_remove(world, entity, from)
end end
inner_entity_move(e, r, to) inner_entity_move(e, r, to)
end end
archetype_destroy(world, idr_archetype) archetype_destroy(world, idr_archetype)
end end
else else
@ -3495,6 +3508,7 @@ local function world_new(DEBUG: boolean?)
local e = entities[i] local e = entities[i]
entity_move(entity_index, e, eindex_sparse_array[ECS_ID(e :: number)], to) entity_move(entity_index, e, eindex_sparse_array[ECS_ID(e :: number)], to)
end end
archetype_destroy(world, idr_archetype) archetype_destroy(world, idr_archetype)
end end
end end
@ -3502,10 +3516,11 @@ local function world_new(DEBUG: boolean?)
end end
if idr_t then if idr_t then
local to_remove = {} :: { [i53]: componentrecord } local archetype_ids = idr_t.records
local cache = idr_t.cache local to_remove = {}:: { [i53]: componentrecord}
for i = #cache, 1, -1 do local did_cascade_delete = false
local archetype_id = cache[i]
for archetype_id in archetype_ids do
local idr_t_archetype = archetypes[archetype_id] local idr_t_archetype = archetypes[archetype_id]
if not idr_t_archetype then if not idr_t_archetype then
continue continue
@ -3519,14 +3534,20 @@ local function world_new(DEBUG: boolean?)
if not ECS_IS_PAIR(id) then if not ECS_IS_PAIR(id) then
continue continue
end end
local object = entity_index_get_alive(entity_index, ECS_PAIR_SECOND(id)) local object = entity_index_get_alive(
entity_index, ECS_PAIR_SECOND(id))
if object ~= entity then if object ~= entity then
continue continue
end end
local id_record = component_index[id] local id_record = component_index[id]
local has_delete = bit32.btest(id_record.flags, ECS_ID_DELETE) local flags = id_record.flags
if has_delete then local flags_delete_mask = bit32.btest(flags, ECS_ID_DELETE)
deleted_any = true if flags_delete_mask then
for i = #entities, 1, -1 do
local child = entities[i]
world_delete(world, child)
end
deleted_any = true
break break
else else
to_remove[id] = id_record to_remove[id] = id_record
@ -3534,50 +3555,72 @@ local function world_new(DEBUG: boolean?)
end end
end end
if deleted_any then if deleted_any then
for row = #entities, 1, -1 do did_cascade_delete = true
world_delete(world, entities[row]) continue
end end
archetype_destroy(world, idr_t_archetype)
continue
end
if remove_count == 1 then
local id, id_record = next(to_remove)
local to = archetype_traverse_remove(world, id::i53, idr_t_archetype)
local on_remove = id_record.on_remove
for row = #entities, 1, -1 do
local child = entities[row]
local r = entity_index_try_get_unsafe(child)::record
local dst = to
if on_remove then
on_remove(child, id :: i53)
end
inner_entity_move(child, r, dst)
end
archetype_destroy(world, idr_t_archetype)
else
local dst_types = table.clone(idr_t_archetype.types)
for id in to_remove do
table.remove(dst_types, table.find(dst_types, id))
end
local dst = archetype_ensure(world, dst_types)
for row = #entities, 1, -1 do
local child = entities[row]
local r = entity_index_try_get_unsafe(child) :: record
for id, component_record in to_remove do
local on_remove = component_record.on_remove
if on_remove then
on_remove(child, id)
end
end
inner_entity_move(child, r, dst)
end
archetype_destroy(world, idr_t_archetype)
end
table.clear(to_remove) if remove_count == 1 then
local id, id_record = next(to_remove)
local to_u = archetype_traverse_remove(world, id :: i53, idr_t_archetype)
local on_remove = id_record.on_remove
for i = #entities, 1, -1 do
local child = entities[i]
local r = entity_index_try_get_unsafe(child) :: record
local to = to_u
if on_remove then
on_remove(child, id :: i53)
local src = r.archetype
if src ~= idr_t_archetype then
to = archetype_traverse_remove(world, id::i53, src)
end
end
inner_entity_move(child, r, to)
end
elseif remove_count > 1 then
local dst_types = table.clone(idr_t_types)
for id, component_record in to_remove do
table.remove(dst_types, table.find(dst_types, id))
end
local to_u = archetype_ensure(world, dst_types)
for i = #entities, 1, -1 do
local child = entities[i]
local r = entity_index_try_get_unsafe(child) :: record
local to = to_u
for id, component_record in to_remove do
local on_remove = component_record.on_remove
if on_remove then
on_remove(child, id)
local src = r.archetype
if src ~= idr_t_archetype then
to = archetype_traverse_remove(world, id, src)
end
end
end
inner_entity_move(child, r, to)
end
end
table.clear(to_remove)
archetype_destroy(world, idr_t_archetype)
end
if did_cascade_delete then
for archetype_id in archetype_ids do
local idr_t_archetype = archetypes[archetype_id]
if not idr_t_archetype then
continue
end
local entities = idr_t_archetype.entities
for i = #entities, 1, -1 do
world_delete(world, entities[i])
end
archetype_destroy(world, idr_t_archetype)
end
end end
end end
@ -3632,11 +3675,14 @@ local function world_new(DEBUG: boolean?)
local r = entity_index_try_get_unsafe(e) :: record local r = entity_index_try_get_unsafe(e) :: record
inner_entity_move(e, r, node) inner_entity_move(e, r, node)
end end
archetype_destroy(world, idr_r_archetype) archetype_destroy(world, idr_r_archetype)
end end
end end
end end
local dense = record.dense local dense = record.dense
local i_swap = entity_index.alive_count local i_swap = entity_index.alive_count
entity_index.alive_count = i_swap - 1 entity_index.alive_count = i_swap - 1
@ -3644,8 +3690,8 @@ local function world_new(DEBUG: boolean?)
local e_swap = eindex_dense_array[i_swap] local e_swap = eindex_dense_array[i_swap]
local r_swap = entity_index_try_get_any(e_swap) :: record local r_swap = entity_index_try_get_any(e_swap) :: record
r_swap.dense = dense r_swap.dense = dense
record.archetype = ROOT_ARCHETYPE record.archetype = nil :: any
record.row = 0 record.row = nil :: any
record.dense = i_swap record.dense = i_swap
eindex_dense_array[dense] = e_swap eindex_dense_array[dense] = e_swap
@ -3667,8 +3713,8 @@ local function world_new(DEBUG: boolean?)
end end
end end
archetype_delete(world, record.archetype, record.row) archetype_delete(world, record.archetype, record.row)
record.archetype = world.ROOT_ARCHETYPE record.archetype = nil :: any
record.row = 0 record.row = nil :: any
end end
local function world_exists(world: world, entity: i53): boolean local function world_exists(world: world, entity: i53): boolean
@ -3827,7 +3873,7 @@ local function world_new(DEBUG: boolean?)
end end
for i = 1, EcsRest do for i = 1, EcsRest do
ENTITY_INDEX_NEW_ID(world) entity_index_new_id(entity_index)
end end
for i = 1, max_component_id do for i = 1, max_component_id do
@ -3863,7 +3909,7 @@ local function world_new(DEBUG: boolean?)
world_add(world, EcsOnDeleteTarget, EcsExclusive) world_add(world, EcsOnDeleteTarget, EcsExclusive)
for i = EcsRest + 1, ecs_max_tag_id do for i = EcsRest + 1, ecs_max_tag_id do
ENTITY_INDEX_NEW_ID(world) entity_index_new_id(entity_index)
end end
for i, bundle in ecs_metadata do for i, bundle in ecs_metadata do
@ -3950,7 +3996,7 @@ local function entity_index_ensure(entity_index: entityindex, e: i53)
end end
local function new(world: world) local function new(world: world)
local e = ENTITY_INDEX_NEW_ID(world) local e = ENTITY_INDEX_NEW_ID(world.entity_index)
return e return e
end end
@ -3968,7 +4014,7 @@ local function new_low_id(world: world)
end end
end end
if e == 0 or e >= HI_COMPONENT_ID then if e == 0 or e >= HI_COMPONENT_ID then
e = ENTITY_INDEX_NEW_ID(world) e = ENTITY_INDEX_NEW_ID(entity_index)
else else
entity_index_ensure(entity_index, e) entity_index_ensure(entity_index, e)
end end
@ -3976,7 +4022,7 @@ local function new_low_id(world: world)
end end
local function new_w_id(world: world, id: i53) local function new_w_id(world: world, id: i53)
local e = ENTITY_INDEX_NEW_ID(world) local e = ENTITY_INDEX_NEW_ID(world.entity_index)
world.add(world, e, id) world.add(world, e, id)
return e return e
end end

View file

@ -10,6 +10,7 @@ local mirror = require(ReplicatedStorage.mirror:Clone())
return { return {
ParameterGenerator = function() ParameterGenerator = function()
local ecs = jecs.world() local ecs = jecs.world()
ecs:range(1000, 20000)
local mcs = mirror.World.new() local mcs = mirror.World.new()
return ecs, mcs return ecs, mcs
end, end,
@ -18,14 +19,14 @@ return {
Mirror = function(_, ecs, mcs) Mirror = function(_, ecs, mcs)
for i = 1, 100 do for i = 1, 100 do
local _e = mcs:entity() mcs:entity()
end end
end, end,
Jecs = function(_, ecs, mcs) Jecs = function(_, ecs, mcs)
for i = 1, 100 do for i = 1, 100 do
local _e = ecs:entity() ecs:entity()
end end
end, end,
}, },

View file

@ -24,174 +24,6 @@ type Id<T=unknown> = jecs.Id<T>
local entity_visualiser = require("@modules/entity_visualiser") local entity_visualiser = require("@modules/entity_visualiser")
local dwi = entity_visualiser.stringify local dwi = entity_visualiser.stringify
-- FOCUS()
TEST("Stale to_remove", function()
local world = jecs.world()
local a = world:component()
local b = world:component()
local c = world:component()
local d = world:component()
local marker = world:component()
local target = world:entity()
local first = world:entity()
world:add(first, jecs.pair(a, target))
world:add(first, jecs.pair(b, target))
world:add(first, jecs.pair(jecs.ChildOf, target))
local second = world:entity()
world:add(second, jecs.pair(c, target))
world:add(second, jecs.pair(d, target))
world:add(second, marker)
print("-------")
world:delete(target)
print("-------")
CHECK(world:contains(second))
CHECK(world:has(second, marker))
end)
-- FOCUS()
-- Exercises idr_t multi-remove path: entity has multiple pairs with same target (no cascade); delete target → both pairs removed, on_remove each called.
TEST("Target delete: multi-remove path (removing_count > 1)", function()
local world = jecs.world()
local rel1 = world:entity()
local rel2 = world:entity()
local tag = world:component()
local target = world:entity()
local e = world:entity()
world:add(e, jecs.pair(rel1, target))
world:add(e, jecs.pair(rel2, target))
world:add(e, tag)
local removed_ids = {}
world:removed(rel1, function(_e, id) table.insert(removed_ids, id) end)
world:removed(rel2, function(_e, id) table.insert(removed_ids, id) end)
world:delete(target)
CHECK(world:contains(e))
CHECK(world:has(e, tag))
CHECK(not world:has(e, jecs.pair(rel1, target)))
CHECK(not world:has(e, jecs.pair(rel2, target)))
CHECK(#removed_ids == 2)
end)
-- FOCUS()
TEST("repro 2", function()
local sessionDeletedCount = 0
local slotDeletedCount = 0
for i = 1, 100 do
local world = jecs.world(true);
-- randomness
for _ = 1, i % 40 do
world:entity()
end
local ofMatch = world:component()
local ofTeam = world:component()
local ofRound = world:component()
local ownedBy = world:component()
local nextTeam = world:component()
local activeRound = world:component()
local team = world:component()
local session = world:component()
local round = world:component()
world:add(ofTeam, jecs.Exclusive)
world:add(ownedBy, jecs.Exclusive)
world:add(nextTeam, jecs.Exclusive)
world:add(activeRound, jecs.Exclusive)
local slotEntity = world:entity()
local matchEntity = world:entity()
world:add(matchEntity, jecs.pair(jecs.ChildOf, slotEntity))
local teams = {}
for t = 1, 2 do
local teamEntity = world:entity()
world:add(teamEntity, team)
world:add(teamEntity, jecs.pair(jecs.ChildOf, matchEntity))
world:add(teamEntity, jecs.pair(ofMatch, matchEntity))
table.insert(teams, teamEntity)
end
world:add(matchEntity, jecs.pair(nextTeam, teams[1]))
local roundEntity = world:entity()
world:add(roundEntity, round)
world:add(roundEntity, jecs.pair(jecs.ChildOf, matchEntity))
-- doing something as simple as adding this pair causes error rate to change. When this isn't here, sessionDeletedCount drops from 100% to 80%.
world:add(matchEntity, jecs.pair(activeRound, roundEntity))
local sessions = {}
for j = 1, #teams do
-- random number of players on team
for _ = 1, 1 + (i % 5) do
local player = world:entity()
local sessionEntity = world:entity()
world:add(sessionEntity, session)
world:add(sessionEntity, jecs.pair(ofMatch, matchEntity))
world:add(sessionEntity, jecs.pair(ofTeam, teams[j]))
-- not adding this next pair makes sessionDeletedCount to drop to 0%??
world:add(sessionEntity, jecs.pair(ownedBy, player))
world:add(sessionEntity, jecs.pair(ofRound, roundEntity))
table.insert(sessions, sessionEntity)
end
end
world:delete(matchEntity)
-- session should stay alive after match deletion
for _, entity in sessions do
if not world:contains(entity) then
sessionDeletedCount += 1
break
end
end
if not world:contains(slotEntity) then
slotDeletedCount += 1
break
end
end
CHECK(sessionDeletedCount == 0)
CHECK(slotDeletedCount == 0)
if (sessionDeletedCount + slotDeletedCount > 0)then
print(`repro 2 incorrect session deletion count: {sessionDeletedCount}`)
-- this has never been above 0, but it's the issue i'm having
print(`repro 2 incorrect slot deletion count: {slotDeletedCount}`)
end
end)
TEST("migrating to real records", function()
local world = jecs.world(true)
local e1 = world:entity()
local e2 = world:entity()
print(jecs.record(world, e1).row)
world:add(e1, jecs.pair(jecs.ChildOf, e2))
world:set(e1, jecs.Name, "hello")
CHECK(jecs.record(world, e1).row~=0)
CHECK(world:get(e1, jecs.Name)=="hello")
CHECK(world:has(e1, jecs.pair(jecs.ChildOf, jecs.Wildcard)))
end)
TEST("e2 is nil", function()
local world = jecs.world(true)
local e1 = world:entity(1000)
local e2 = world:entity()
CHECK(e1 and world:contains(e1))
CHECK(e2 and world:contains(e2))
end)
-- FOCUS()
TEST("reproduce idr_t nil archetype bug", function() TEST("reproduce idr_t nil archetype bug", function()
local world = jecs.world(true) local world = jecs.world(true)
@ -706,6 +538,44 @@ TEST("world:add()", function()
CHECK(world:has(e, pair(A, C)) == true) CHECK(world:has(e, pair(A, C)) == true)
end end
do CASE "exclusive relations invoke on_remove hooks that should allow side effects"
local world = jecs.world()
local A = world:component()
local B = world:component()
local C = world:component()
local D = world:component()
world:add(A, jecs.Exclusive)
local call_count = 0
world:set(A, jecs.OnRemove, function(e, id)
call_count += 1
if call_count == 1 then
world:add(e, C)
else
world:add(e, D)
end
end)
local e = world:entity()
world:add(e, pair(A, B))
world:add(e, pair(A, C))
CHECK(world:has(e, pair(A, B)) == false)
CHECK(world:has(e, pair(A, C)) == true)
CHECK(world:has(e, C))
-- We have to ensure that it actually invokes hooks everytime it
-- traverses the archetype
e = world:entity()
world:add(e, pair(A, B))
world:add(e, pair(A, C))
CHECK(world:has(e, pair(A, B)) == false)
CHECK(world:has(e, pair(A, C)) == true)
CHECK(world:has(e, D))
end
do CASE "idempotent" do CASE "idempotent"
local world = jecs.world() local world = jecs.world()
local d = dwi(world) local d = dwi(world)
@ -728,8 +598,8 @@ TEST("world:add()", function()
local e = world:entity() local e = world:entity()
-- An entity starts without an archetype or row -- An entity starts without an archetype or row
-- should therefore not need to copy over data -- should therefore not need to copy over data
CHECK(d.tbl(e) == world.ROOT_ARCHETYPE) CHECK(d.tbl(e) == nil)
CHECK(d.row(e) == 0) CHECK(d.row(e) == nil)
local archetypes = #world.archetypes local archetypes = #world.archetypes
-- This should create a new archetype -- This should create a new archetype
@ -1163,6 +1033,24 @@ TEST("world:delete()", function()
-- CHECK(B_OnRemove_called) -- CHECK(B_OnRemove_called)
end end
do CASE "idr_t//remove//on_remove//changed_archetype@3123..3126"
local world = jecs.world()
local A = world:component()
local B = world:component()
world:set(A, jecs.OnRemove, function(entity, id)
world:set(entity, B, true)
end)
local e1 = world:entity()
local e2 = world:entity()
world:add(e2, pair(A, e2))
world:set(e2, pair(A, e1), true)
world:delete(e1)
CHECK(not world:has(e2, pair(A, e1)))
end
do CASE "pair(OnDelete, Delete)" do CASE "pair(OnDelete, Delete)"
local world = jecs.world() local world = jecs.world()
local ct = world:component() local ct = world:component()
@ -1705,7 +1593,6 @@ TEST("world:added", function()
end end
end) end)
-- FOCUS()
TEST("world:range()", function() TEST("world:range()", function()
do CASE "spawn entity under min range" do CASE "spawn entity under min range"
@ -3013,6 +2900,43 @@ TEST("world:set()", function()
CHECK(world:has(e, pair(A, C)) == true) CHECK(world:has(e, pair(A, C)) == true)
end end
do CASE "exclusive relations invoke on_remove hooks that should allow side effects"
local world = jecs.world()
local A = world:component()
local B = world:component()
local C = world:component()
local D = world:component()
world:add(A, jecs.Exclusive)
local call_count = 0
world:set(A, jecs.OnRemove, function(e, id)
call_count += 1
if call_count == 1 then
world:set(e, C, true)
else
world:set(e, D, true)
end
end)
local e = world:entity()
world:set(e, pair(A, B), true)
world:set(e, pair(A, C), true)
CHECK(world:has(e, pair(A, B)) == false)
CHECK(world:has(e, pair(A, C)) == true)
CHECK(world:has(e, C))
-- We have to ensure that it actually invokes hooks everytime it
-- traverses the archetype
e = world:entity()
world:set(e, pair(A, B), true)
world:set(e, pair(A, C), true)
CHECK(world:has(e, pair(A, B)) == false)
CHECK(world:has(e, pair(A, C)) == true)
CHECK(world:has(e, D))
end
do CASE "archetype move" do CASE "archetype move"
local world = jecs.world() local world = jecs.world()
@ -3023,8 +2947,8 @@ TEST("world:set()", function()
local e = world:entity() local e = world:entity()
-- An entity starts without an archetype or row -- An entity starts without an archetype or row
-- should therefore not need to copy over data -- should therefore not need to copy over data
CHECK(d.tbl(e) == world.ROOT_ARCHETYPE) CHECK(d.tbl(e) == nil)
CHECK(d.row(e) == 0) CHECK(d.row(e) == nil)
local archetypes = #world.archetypes local archetypes = #world.archetypes
-- This should create a new archetype since it is the first -- This should create a new archetype since it is the first
@ -3360,7 +3284,7 @@ TEST("Hooks", function()
local B = world:component() local B = world:component()
local e = world:entity() local e = world:entity()
world:set(A, jecs.OnRemove, function(entity: jecs.Entity) world:set(A, jecs.OnRemove, function(entity)
world:set(entity, B, true) world:set(entity, B, true)
CHECK(world:get(entity, A)) CHECK(world:get(entity, A))
CHECK(world:get(entity, B)) CHECK(world:get(entity, B))

View file

@ -1,6 +1,6 @@
[package] [package]
name = "ukendio/jecs" name = "ukendio/jecs"
version = "0.11.0" version = "0.10.3"
registry = "https://github.com/UpliftGames/wally-index" registry = "https://github.com/UpliftGames/wally-index"
realm = "shared" realm = "shared"
license = "MIT" license = "MIT"