From 4db046b925df142d0df5313a083a5b2e8304a16b Mon Sep 17 00:00:00 2001 From: Ukendio Date: Wed, 22 Oct 2025 00:36:00 +0200 Subject: [PATCH] escape for deletion --- addons/ob.luau | 116 +++++++++++++++++--------- benches/100k.luau | 37 +++++++++ benches/visual/query.bench.luau | 78 +++++++++--------- jecs.luau | 142 ++++++++++++++++---------------- test/addons/ob.luau | 60 ++++++++++++++ test/ecr.luau | 11 +++ test/tests.luau | 8 +- 7 files changed, 301 insertions(+), 151 deletions(-) create mode 100755 benches/100k.luau create mode 100755 test/ecr.luau diff --git a/addons/ob.luau b/addons/ob.luau index 6149e65..4c02678 100755 --- a/addons/ob.luau +++ b/addons/ob.luau @@ -137,9 +137,11 @@ local function monitors_new(query: Query<...any>): Monitor local callback_added: ((jecs.Entity) -> ())? local callback_removed: ((jecs.Entity) -> ())? - local function emplaced( - entity: jecs.Entity, - id: jecs.Id + local function emplaced( + entity: jecs.Entity, + id: jecs.Id, + value: a, + oldarchetype: jecs.Archetype ) if callback_added == nil then return @@ -148,22 +150,26 @@ local function monitors_new(query: Query<...any>): Monitor local r = jecs.entity_index_try_get_fast( entity_index, entity :: any) :: jecs.Record - local archetype = r.archetype - - if archetypes[archetype.id] then + if archetypes[r.archetype.id] then callback_added(entity) end end - local function removed(entity: jecs.Entity, component) + local function removed(entity: jecs.Entity, component: jecs.Component, delete:boolean?) + if delete then + return + end if callback_removed == nil then return end local r = jecs.record(world, entity) - if not archetypes[r.archetype.id] then - return + local src = r.archetype + + local dst = jecs.archetype_traverse_remove(world, component, src) + + if not archetypes[dst.id] then + callback_removed(entity) end - callback_removed(entity) end local cleanup = {} @@ -174,7 +180,7 @@ local function monitors_new(query: Query<...any>): Monitor local tgt = jecs.ECS_PAIR_SECOND(term) local wc = tgt == jecs.w - local onadded = world:added(rel, function(entity, id) + local onadded = world:added(rel, function(entity, id, _, oldarchetype) if callback_added == nil then return end @@ -186,9 +192,10 @@ local function monitors_new(query: Query<...any>): Monitor local r = jecs.entity_index_try_get_fast( entity_index, entity :: any) :: jecs.Record - local archetype = r.archetype - - if archetypes[archetype.id] then + if archetypes[oldarchetype.id] and archetypes[r.archetype.id] then + print("???") + end + if not archetypes[oldarchetype.id] and archetypes[r.archetype.id] then callback_added(entity) end end) @@ -199,11 +206,11 @@ local function monitors_new(query: Query<...any>): Monitor if not wc and id ~= term then return end + local r = jecs.record(world, entity) - if not archetypes[r.archetype.id] then - return + if archetypes[r.archetype.id] then + callback_removed(entity) end - callback_removed(entity) end) table.insert(cleanup, onadded) table.insert(cleanup, onremoved) @@ -223,7 +230,7 @@ local function monitors_new(query: Query<...any>): Monitor local rel = jecs.ECS_PAIR_FIRST(term) local tgt = jecs.ECS_PAIR_SECOND(term) local wc = tgt == jecs.w - local onadded = world:added(rel, function(entity, id) + local onadded = world:added(rel, function(entity, id, _, oldarchetype) if callback_removed == nil then return end @@ -232,14 +239,23 @@ local function monitors_new(query: Query<...any>): Monitor end local r = jecs.record(world, entity) local archetype = r.archetype - if archetype then - local dst = jecs.archetype_traverse_remove(world, id, archetype) - if archetypes[dst.id] then - callback_removed(entity) - end + if not archetype then + return + end + + -- NOTE(marcus): This check that it was presently in + -- the query but distinctively leaves is important as + -- sometimes it could be too eager to report that it + -- removed a component even though the entity is not + -- apart of the monitor + if archetypes[oldarchetype.id] and not archetypes[archetype.id] then + callback_removed(entity) end end) - local onremoved = world:removed(rel, function(entity, id) + local onremoved = world:removed(rel, function(entity, id, delete) + if delete then + return + end if callback_added == nil then return end @@ -248,40 +264,58 @@ local function monitors_new(query: Query<...any>): Monitor end local r = jecs.record(world, entity) local archetype = r.archetype - if archetype then - local dst = jecs.archetype_traverse_remove(world, id, archetype) - if archetypes[dst.id] then - callback_added(entity) - end + if not archetype then + return + end + + local dst = jecs.archetype_traverse_remove(world, id, archetype) + + if archetypes[dst.id] then + callback_added(entity) end end) table.insert(cleanup, onadded) table.insert(cleanup, onremoved) else - local onadded = world:added(term, function(entity, id) + local onadded = world:added(term, function(entity, id, _, oldarchetype) if callback_removed == nil then return end local r = jecs.record(world, entity) local archetype = r.archetype - if archetype then - local dst = jecs.archetype_traverse_remove(world, id, archetype) - if archetypes[dst.id] then - callback_removed(entity) - end + if not archetype then + return + end + + -- NOTE(marcus): Sometimes OnAdd listeners for excluded + -- terms are too eager to report that it is leaving the + -- monitor even though the entity is not apart of it + -- already. + if archetypes[oldarchetype.id] and not archetypes[archetype.id] then + callback_removed(entity) end end) - local onremoved = world:removed(term, function(entity, id) + local onremoved = world:removed(term, function(entity, id, delete) + if delete then + return + end if callback_added == nil then return end local r = jecs.record(world, entity) local archetype = r.archetype - if archetype then - local dst = jecs.archetype_traverse_remove(world, id, archetype) - if archetypes[dst.id] then - callback_added(entity) - end + if not archetype then + return + end + local dst = jecs.archetype_traverse_remove(world, id, archetype) + + -- NOTE(marcus): Inversely with the opposite operation, you + -- only need to check if it is going to enter the query once + -- because world:remove already stipulates that it is + -- idempotent so that this hook won't be invoked if it is + -- was already removed. + if archetypes[dst.id] then + callback_added(entity) end end) table.insert(cleanup, onadded) diff --git a/benches/100k.luau b/benches/100k.luau new file mode 100755 index 0000000..8f64d52 --- /dev/null +++ b/benches/100k.luau @@ -0,0 +1,37 @@ + +--!optimize 2 +--!native + +local testkit = require("@testkit") +local BENCH, START = testkit.benchmark() +local function TITLE(title: string) + print() + print(testkit.color.white(title)) +end + +local jecs = require("@jecs") +local mirror = require("@mirror") + +do + TITLE(testkit.color.white_underline("Jecs query")) + local world = jecs.world() :: jecs.World + + local A = world:component() + + for i = 1, 100_000 do + local e = world:entity() + world:set(e, A, true) + end + + local archetypes = world:query(A):archetypes() + + BENCH("", function() + for _, archetype in archetypes do + local column = archetype.columns[1] + for row, entity in archetype.entities do + local data = column[row] + end + end + end) + +end diff --git a/benches/visual/query.bench.luau b/benches/visual/query.bench.luau index 38f9a31..cfe2c37 100755 --- a/benches/visual/query.bench.luau +++ b/benches/visual/query.bench.luau @@ -4,9 +4,9 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local jecs = require(ReplicatedStorage.Lib:Clone()) -local mirror = require(ReplicatedStorage.mirror:Clone()) -local mcs = mirror.world() +local chrono = require(ReplicatedStorage.chronoecs:Clone()) local ecs = jecs.world() +local ccs = chrono.new() local D1 = ecs:component() local D2 = ecs:component() @@ -17,75 +17,75 @@ local D6 = ecs:component() local D7 = ecs:component() local D8 = ecs:component() -local E1 = mcs:component() -local E2 = mcs:component() -local E3 = mcs:component() -local E4 = mcs:component() -local E5 = mcs:component() -local E6 = mcs:component() -local E7 = mcs:component() -local E8 = mcs:component() +local E1 = ccs:component() +local E2 = ccs:component() +local E3 = ccs:component() +local E4 = ccs:component() +local E5 = ccs:component() +local E6 = ccs:component() +local E7 = ccs:component() +local E8 = ccs:component() + +local d_components = {} +local e_components = {} + +for i = 1, 150 do + ecs:component() + ccs:component() +end local function flip() - return math.random() >= 0.3 + return math.random() >= 0.5 end local N = 2 ^ 16 - 2 for i = 1, N do local entity = ecs:entity() - local m = mcs:entity() + local m = ccs:entity() - if flip() then - ecs:add(entity, entity) - mcs:add(m, m) - end if flip() then ecs:set(entity, D1, true) - mcs:set(m, E1, true) + ccs:add(m, E1) + ccs:set(m, E1, true) end if flip() then ecs:set(entity, D2, true) - mcs:set(m, E2, true) + ccs:add(m, E2) + ccs:set(m, E2, true) end if flip() then ecs:set(entity, D3, true) - mcs:set(m, E3, true) + ccs:add(m, E3) + ccs:set(m, E3, true) end if flip() then ecs:set(entity, D4, true) - mcs:set(m, E4, true) + ccs:add(m, E4) + ccs:set(m, E4, true) end if flip() then ecs:set(entity, D5, true) - mcs:set(m, E5, true) + ccs:add(m, E4) + ccs:set(m, E5, true) end if flip() then ecs:set(entity, D6, true) - mcs:set(m, E6, true) + ccs:add(m, E6) + ccs:set(m, E6, true) end if flip() then ecs:set(entity, D7, true) - mcs:set(m, E7, true) + ccs:add(m, E7) + ccs:set(m, E7, true) end if flip() then + ccs:add(m, E8) ecs:set(entity, D8, true) - mcs:set(m, E8, true) + ccs:set(m, E8, true) end end -local count = 0 - -for _, archetype in ecs:query(D2, D4, D6, D8):archetypes() do - count += #archetype.entities -end - - -local mq = mcs:query(E2, E4, E6, E8):cached() -local jq = ecs:query(D2, D4, D6, D8):cached() - -print(count, #jq:archetypes()) - return { ParameterGenerator = function() return @@ -102,13 +102,13 @@ return { -- end -- end, -- - Mirror = function() - for entityId, firstComponent in mq do + chrono = function() + for entityId, firstComponent in ccs:view(E2, E4, E6, E8) do end end, Jecs = function() - for entityId, firstComponent in jq do + for entityId, firstComponent in ecs:query(D2, D4, D6, D8) do end end, }, diff --git a/jecs.luau b/jecs.luau index b20a027..b5d54ed 100755 --- a/jecs.luau +++ b/jecs.luau @@ -63,37 +63,20 @@ end type function ecs_id_t(first: type, second: type) local __T = types.singleton("__T") + + local p = ecs_pair_t(Entity(first), Entity(second)) if second:is("nil") then - local p = ecs_pair_t(Entity(first), Entity(second)) - - -- Create component type that matches Component structure exactly - -- This should be structurally compatible with Component - local component_type = types.newtable() - component_type:setproperty(__T, first) - - -- Union order: component first, then pair - -- This helps Luau recognize Component as assignable - -- local u = types.unionof(component_type, p) - -- return u - return component_type + return first end - local function entity(ty: type) - local e = types.newtable() - e:setproperty(__T, ty) - return e - end - - local e1 = entity(first) - local e2 = entity(second) - return ecs_pair_t(e1, e2) + return p end export type Entity = { __T: T } -export type Id2 = { __T: T } +export type Id = { __T: T } export type Pair = ecs_pair_t, Entity> export type Component = { __T: T } -export type Id = ecs_id_t +export type Id2 = ecs_id_t export type Item = (self: Query) -> (Entity, T...) export type Iter = (query: Query) -> () -> (Entity, T...) @@ -110,8 +93,8 @@ export type CachedQuery = typeof(setmetatable( cached: (self: CachedQuery) -> CachedQuery, has: (CachedQuery, Entity) -> boolean, ids: { Id }, - filter_with: { Id }?, - filter_without: { Id }?, + filter_with: { Id }?, + filter_without: { Id }?, archetypes_map: { [number]: number }, -- world: World }, @@ -129,8 +112,8 @@ export type Query = typeof(setmetatable( cached: (self: Query) -> CachedQuery, has: (Query, Entity) -> boolean, ids: { Id }, - filter_with: { Id }?, - filter_without: { Id }?, + filter_with: { Id }?, + filter_without: { Id }?, -- world: World }, {} :: { @@ -177,7 +160,7 @@ type componentrecord = { on_add: ((entity: i53, id: i53, value: any, oldarchetype: archetype) -> ())?, on_change: ((entity: i53, id: i53, value: any, oldarchetype: archetype) -> ())?, - on_remove: ((entity: i53, id: i53) -> ())?, + on_remove: ((entity: i53, id: i53, delete: boolean?) -> ())?, wildcard_pairs: { [number]: componentrecord }, } @@ -228,7 +211,7 @@ type world = { added: (world, i53, (e: i53, id: i53, value: any?) -> ()) -> () -> (), changed: (world, i53, (e: i53, id: i53, value: any?) -> ()) -> () -> (), - removed: (world, i53, (e: i53, id: i53) -> ()) -> () -> (), + removed: (world, i53, (e: i53, id: i53, delete: boolean?) -> ()) -> () -> (), } @@ -246,7 +229,7 @@ export type World = { observable: Map>, added: (World, Component, (e: Entity, id: Id, value: T, oldarchetype: Archetype) -> ()) -> () -> (), - removed: (World, Component, (e: Entity, id: Id) -> ()) -> () -> (), + removed: (World, Component, (e: Entity, id: Id, delete: boolean?) -> ()) -> () -> (), changed: (World, Component, (e: Entity, id: Id, value: T, oldarchetype: Archetype) -> ()) -> () -> (), --- Enforce a check on entities to be created within desired range @@ -2248,20 +2231,21 @@ local function ecs_bulk_insert(world: world, entity: i53, ids: { i53 }, values: local to = archetype_ensure(world, dst_types) new_entity(entity, r, to) local ROOT_ARCHETYPE = world.ROOT_ARCHETYPE + for i, id in ids do local value = values[i] + if value then + r.archetype.columns_map[id][r.row] = value + end + end + + for i, id in ids do local cdr = component_index[id] local on_add = cdr.on_add - if value then - r.archetype.columns_map[id][r.row] = value - if on_add then - on_add(entity, id, value, ROOT_ARCHETYPE) - end - else - if on_add then - on_add(entity, id, nil, ROOT_ARCHETYPE) - end + if on_add then + local value = values[i] + on_add(entity, id, value, ROOT_ARCHETYPE) end end return @@ -2287,25 +2271,43 @@ local function ecs_bulk_insert(world: world, entity: i53, ids: { i53 }, values: if from ~= to then entity_move(entity_index, entity, r, to) - end - for i, set in emplaced do - local id = ids[i] - local idr = component_index[id] + for i, id in ids do + local value = values[i] :: any - local value = values[i] :: any - - local on_add = idr.on_add - - if value ~= nil then - r.archetype.columns_map[id][r.row] = value - local on_change = idr.on_change - local hook = if set then on_change else on_add - if hook then - hook(entity, id, value :: any, from) + if value ~= nil then + r.archetype.columns_map[id][r.row] = value + end + end + + for i, exists in emplaced do + local value = values[i] + local id = ids[i] + local idr = component_index[id] + if exists then + local on_change = idr.on_change + if on_change then + on_change(entity, id, value, from) + end + else + local on_add = idr.on_add + if on_add then + on_add(entity, id, value, from) + end + end + end + else + for i, id in ids do + local value = values[i] :: any + local idr = component_index[id] + local on_change = idr.on_change + if on_change then + on_change(entity, id, value, from) + end + + if value ~= nil then + r.archetype.columns_map[id][r.row] = value end - elseif on_add then - on_add(entity, id, nil, from) end end end @@ -2783,7 +2785,9 @@ local function world_new() end end - type Listener = (e: i53, id: i53, value: T, oldarchetype: archetype) -> () + type Listener = + & ((e: i53, id: i53, value: T, oldarchetype: archetype) -> ()) + & ((e: i53, id: i53, delete: boolean?) -> ()) world.added = function(_: world, component: i53, fn: Listener) local listeners = signals.added[component] @@ -2839,7 +2843,7 @@ local function world_new() listener(entity, id, value, oldarchetype) end end - local existing_hook = world_get(world, component, EcsOnChange) :: Listener + local existing_hook = world_get(world, component, EcsOnChange) :: Listener? if existing_hook then table.insert(listeners, existing_hook) end @@ -2870,14 +2874,14 @@ local function world_new() end end - world.removed = function(_: world, component: i53, fn: (i53, i53) -> ()) + world.removed = function(_: world, component: i53, fn: (i53, i53, boolean?) -> ()) local listeners = signals.removed[component] if not listeners then listeners = {} signals.removed[component] = listeners - local function on_remove(entity, id) + local function on_remove(entity, id, delete) for _, listener in listeners :: { (...any) -> () } do - listener(entity, id) + listener(entity, id, delete) end end @@ -3088,7 +3092,7 @@ local function world_new() local idr = component_index[id] local on_remove = idr.on_remove if on_remove then - on_remove(entity, id) + on_remove(entity, id, true) end end archetype_delete(world, record.archetype, record.row) @@ -3096,11 +3100,11 @@ local function world_new() local component_index = world.component_index local archetypes = world.archetypes - local tgt = ECS_PAIR(EcsWildcard, entity::number) - local rel = ECS_PAIR(entity::number, EcsWildcard) + local tgt = ECS_PAIR(EcsWildcard, entity) + local rel = ECS_PAIR(entity, EcsWildcard) local idr_t = component_index[tgt] - local idr = component_index[entity::number] + local idr = component_index[entity] local idr_r = component_index[rel] if idr then @@ -3228,7 +3232,7 @@ local function world_new() local tr_count = counts[archetype_id] local idr_r_types = idr_r_archetype.types for i = tr, tr + tr_count - 1 do - local id = types[i] + local id = idr_r_types[i] node = archetype_traverse_remove(world, id, node) local on_remove = component_index[id].on_remove if on_remove then @@ -3444,10 +3448,10 @@ return { pair = ECS_PAIR :: (first: Entity

, second: Entity) -> Pair, IS_PAIR = ECS_IS_PAIR :: (pair: Component) -> boolean, - ECS_PAIR_FIRST = ECS_PAIR_FIRST :: (pair: Id) -> Component

, - ECS_PAIR_SECOND = ECS_PAIR_SECOND :: (pair: Id) -> Component, - pair_first = ecs_pair_first :: (world: World, pair: Id) -> Component

, - pair_second = ecs_pair_second :: (world: World, pair: Id) -> Component, + ECS_PAIR_FIRST = ECS_PAIR_FIRST :: (pair: Id

) -> Component

, + ECS_PAIR_SECOND = ECS_PAIR_SECOND :: (pair: Id

) -> Component, + pair_first = ecs_pair_first :: (world: World, pair: Id

) -> Component

, + pair_second = ecs_pair_second :: (world: World, pair: Id

) -> Component, entity_index_get_alive = entity_index_get_alive, archetype_append_to_records = archetype_append_to_records, diff --git a/test/addons/ob.luau b/test/addons/ob.luau index 3de26e2..ac44eb2 100755 --- a/test/addons/ob.luau +++ b/test/addons/ob.luau @@ -222,8 +222,68 @@ TEST("addons/ob::observer", function() end end) +FOCUS() TEST("addons/ob::monitor", function() local world = jecs.world() + + do CASE [[should not invoke monitor.added callback unless it wasn't apart + of the monitor]] + local A = world:component() + local B = world:component() + local C = world:component() + + local monitor = ob.monitor(world:query(A):without(B, C)) + local c = 0 + monitor.added(function() + c += 1 + end) + + local e = world:entity() + world:add(e, A) + CHECK(c==1) + world:add(e, B) + world:add(e, C) + -- left + CHECK(c==1) + + world:remove(e, B) + world:remove(e, C) + -- re-enters once + CHECK(c==2) + + world:remove(e, B) + CHECK(c==2) + end + + do CASE "deleted entity should only exit monitor once" + local A = world:component() + local B = world:component() + + local Destroy = world:entity() + + local c = 0 + + local monitor = ob.monitor(world:query(A, B):without(Destroy)) + + monitor.added(function() + print("enter") + end) + monitor.removed(function() + c += 1 + end) + + + local e = world:entity() + world:add(e, A) + world:add(e, B) + + world:add(e, Destroy) + + world:delete(e) + print(c) + CHECK(c==1) + end + do CASE [[should not invoke callbacks with a related but non-queried pair that while the entity still matches against the query]] local A = world:component() diff --git a/test/ecr.luau b/test/ecr.luau new file mode 100755 index 0000000..b1892d0 --- /dev/null +++ b/test/ecr.luau @@ -0,0 +1,11 @@ +local function component() + local id = 1 + local v + local function instance() + return id, v + end + return function(value) + v = value + return instance + end +end diff --git a/test/tests.luau b/test/tests.luau index 528ee7f..845f902 100755 --- a/test/tests.luau +++ b/test/tests.luau @@ -292,14 +292,18 @@ TEST("bulk", function() world:changed(c3, counter) local e = world:entity() - jecs.bulk_insert(world, e, {c1,c2,c3}, {1,2,3}) + jecs.bulk_insert(world, e, {c1,c2}, {1,2,3}) jecs.bulk_insert(world, e, {c1,c2,c3}, {4,5,6}) CHECK(world:has(e, c1, c2, c3)) - CHECK(count == 3) + CHECK(count == 2) + + jecs.bulk_insert(world, e, {c1,c2,c3}, {7,8,9}) + CHECK(count == 2+3) end + do CASE "Should bulk add with hooks moving archetypes without previous" local world = jecs.world() local c1, c2, c3 = world:component(), world:component(), world:component()