From 7bc69359655521740075f4c2d48df0a0533963b6 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sat, 22 Nov 2025 16:56:25 +0100 Subject: [PATCH] Track bulk operation pattern --- addons/ob.luau | 58 +++++++--- jecs.luau | 2 +- test/addons/ob.luau | 275 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 319 insertions(+), 16 deletions(-) diff --git a/addons/ob.luau b/addons/ob.luau index 4c02678..fa6873b 100755 --- a/addons/ob.luau +++ b/addons/ob.luau @@ -29,7 +29,7 @@ local function observers_new( callback = callback local archetypes = cachedquery.archetypes_map - local terms = query.filter_with :: { jecs.Id } + local terms = query.filter_with :: { jecs.Id } local entity_index = world.entity_index @@ -73,13 +73,13 @@ local function observers_new( end local without = query.filter_without - if without then + if without then for _, term in without do if jecs.IS_PAIR(term) then local rel = jecs.ECS_PAIR_FIRST(term) local tgt = jecs.ECS_PAIR_SECOND(term) local wc = tgt == jecs.w - local onremoved = world:removed(rel, function(entity, id) + local onremoved = world:removed(rel, function(entity, id, delete: boolean?) if not wc and id ~= term then return end @@ -130,28 +130,49 @@ local function monitors_new(query: Query<...any>): Monitor local world = (cachedquery :: Query<...any> & { world: World }).world :: jecs.World local archetypes = cachedquery.archetypes_map - local terms = cachedquery.filter_with :: { jecs.Id } + local terms = cachedquery.filter_with :: { jecs.Id } local entity_index = world.entity_index :: any + local terms_lookup: { [jecs.Id]: boolean } = {} + for _, term in terms do + terms_lookup[term] = true + end + local callback_added: ((jecs.Entity) -> ())? local callback_removed: ((jecs.Entity) -> ())? + -- NOTE(marcus): Track the last old archetype we processed to detect bulk operations. + -- We can detect this pattern by checking if we've seen this old archetype + -- before with a component in the terms list. + local last_old_archetype: jecs.Archetype? = nil + local function emplaced( entity: jecs.Entity, id: jecs.Id, - value: a, - oldarchetype: jecs.Archetype - ) - if callback_added == nil then + value: a, + oldarchetype: jecs.Archetype + ) + if callback_added == nil then + return + end + + -- NOTE(marcus): Skip if we've seen this old archetype before AND + -- this component is in the query's terms. The component-in-terms + -- check ensures we don't skip legitimate separate operations. + if last_old_archetype == oldarchetype and terms_lookup[id] then return end local r = jecs.entity_index_try_get_fast( entity_index, entity :: any) :: jecs.Record + if not archetypes[oldarchetype.id] and archetypes[r.archetype.id] then - if archetypes[r.archetype.id] then + last_old_archetype = oldarchetype callback_added(entity) + else + -- NOTE(marcus): Clear tracking when we see a different transition pattern + last_old_archetype = nil end end @@ -168,6 +189,7 @@ local function monitors_new(query: Query<...any>): Monitor local dst = jecs.archetype_traverse_remove(world, component, src) if not archetypes[dst.id] then + last_old_archetype = nil callback_removed(entity) end end @@ -180,10 +202,13 @@ 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, _, oldarchetype) + local onadded = world:added(rel, function(entity, id, _, oldarchetype: jecs.Archetype) if callback_added == nil then return end + if last_old_archetype == oldarchetype and terms_lookup[id] then + return + end if not wc and id ~= term then return @@ -192,10 +217,8 @@ local function monitors_new(query: Query<...any>): Monitor local r = jecs.entity_index_try_get_fast( entity_index, entity :: any) :: jecs.Record - if archetypes[oldarchetype.id] and archetypes[r.archetype.id] then - print("???") - end if not archetypes[oldarchetype.id] and archetypes[r.archetype.id] then + last_old_archetype = oldarchetype callback_added(entity) end end) @@ -209,6 +232,7 @@ local function monitors_new(query: Query<...any>): Monitor local r = jecs.record(world, entity) if archetypes[r.archetype.id] then + last_old_archetype = nil callback_removed(entity) end end) @@ -230,7 +254,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, _, oldarchetype) + local onadded = world:added(rel, function(entity, id, _, oldarchetype: jecs.Archetype) if callback_removed == nil then return end @@ -249,6 +273,7 @@ local function monitors_new(query: Query<...any>): Monitor -- removed a component even though the entity is not -- apart of the monitor if archetypes[oldarchetype.id] and not archetypes[archetype.id] then + last_old_archetype = nil callback_removed(entity) end end) @@ -262,15 +287,20 @@ local function monitors_new(query: Query<...any>): Monitor if not wc and id ~= term then return end + local r = jecs.record(world, entity) local archetype = r.archetype if not archetype then return end + if last_old_archetype == archetype and terms_lookup[id] then + return + end local dst = jecs.archetype_traverse_remove(world, id, archetype) if archetypes[dst.id] then + last_old_archetype = archetype callback_added(entity) end end) diff --git a/jecs.luau b/jecs.luau index b5d54ed..51cedbf 100755 --- a/jecs.luau +++ b/jecs.luau @@ -72,7 +72,7 @@ type function ecs_id_t(first: type, second: type) return p end -export type Entity = { __T: T } +export type Entity = { __T: T } export type Id = { __T: T } export type Pair = ecs_pair_t, Entity> export type Component = { __T: T } diff --git a/test/addons/ob.luau b/test/addons/ob.luau index ac44eb2..aad9f25 100755 --- a/test/addons/ob.luau +++ b/test/addons/ob.luau @@ -222,10 +222,28 @@ 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 multiple times in a bulk_move + ]] + local A = world:component() + local B = world:component() + local C = world:component() + + local monitor = ob.monitor(world:query(A, B, C)) + local c = 0 + monitor.added(function() + c += 1 + end) + + local e = world:entity() + jecs.bulk_insert(world, e, { A, B, C }, { 1, 2, 3 }) + CHECK(c==1) + print(c) + end + do CASE [[should not invoke monitor.added callback unless it wasn't apart of the monitor]] local A = world:component() @@ -490,6 +508,261 @@ TEST("addons/ob::monitor", function() world:set(e, A, false) CHECK(count == 4) end + + do CASE "should not invoke monitor.added callback multiple times in bulk_insert with pairs" + local A = world:component() + local B = world:component() + local e1 = world:entity() + local e2 = world:entity() + + local monitor = ob.monitor(world:query(jecs.pair(A, e1), jecs.pair(A, e2))) + local c = 0 + monitor.added(function() + c += 1 + end) + + local e = world:entity() + jecs.bulk_insert(world, e, { jecs.pair(A, e1), jecs.pair(A, e2) }, { true, true }) + CHECK(c == 1) + end + + do CASE "should not invoke monitor.added callback multiple times in bulk_insert with mixed components and pairs" + local A = world:component() + local B = world:component() + local C = world:component() + local e1 = world:entity() + + local monitor = ob.monitor(world:query(A, B, jecs.pair(C, e1))) + local c = 0 + monitor.added(function() + c += 1 + end) + + local e = world:entity() + jecs.bulk_insert(world, e, { A, B, jecs.pair(C, e1) }, { 1, 2, true }) + CHECK(c == 1) + end + + do CASE "monitor with wildcard pair should handle bulk_insert" + local A = world:component() + local B = world:component() + local e1 = world:entity() + local e2 = world:entity() + local e3 = world:entity() + + local monitor = ob.monitor(world:query(A, jecs.pair(B, jecs.w))) + local c = 0 + monitor.added(function() + c += 1 + end) + + local e = world:entity() + world:add(e, A) + CHECK(c == 0) + world:add(e, jecs.pair(B, e1)) + CHECK(c == 1) + world:add(e, jecs.pair(B, e2)) + CHECK(c == 1) + world:add(e, jecs.pair(B, e3)) + CHECK(c == 1) + end + + do CASE "monitor with multiple pairs should handle separate operations correctly" + local A = world:component() + local B = world:component() + local e1 = world:entity() + local e2 = world:entity() + + local monitor = ob.monitor(world:query(jecs.pair(A, e1), jecs.pair(A, e2))) + local c = 0 + monitor.added(function() + c += 1 + end) + local r = 0 + monitor.removed(function() + r += 1 + end) + + local e = world:entity() + world:add(e, jecs.pair(A, e1)) + CHECK(c == 0) + world:add(e, jecs.pair(A, e2)) + CHECK(c == 1) + world:remove(e, jecs.pair(A, e1)) + CHECK(r == 1) + world:remove(e, jecs.pair(A, e2)) + CHECK(r == 1) + world:add(e, jecs.pair(A, e1)) + CHECK(c == 1) + world:add(e, jecs.pair(A, e2)) + CHECK(c == 2) + end + + if true then return end + + do CASE "monitor with pair should handle remove and re-add correctly" + local A = world:component() + local B = world:component() + local e1 = world:entity() + + local monitor = ob.monitor(world:query(jecs.pair(A, e1))) + local c = 0 + monitor.added(function() + c += 1 + end) + local r = 0 + monitor.removed(function() + r += 1 + end) + + local e = world:entity() + world:add(e, jecs.pair(A, e1)) + CHECK(c == 1) + world:remove(e, jecs.pair(A, e1)) + CHECK(r == 1) + world:add(e, jecs.pair(A, e1)) + CHECK(c == 2) + world:remove(e, jecs.pair(A, e1)) + CHECK(r == 2) + end + + do CASE "monitor with pair and without clause should handle bulk operations" + local A = world:component() + local B = world:component() + local C = world:component() + local e1 = world:entity() + local e2 = world:entity() + + local monitor = ob.monitor(world:query(A):without(jecs.pair(B, e1))) + local c = 0 + monitor.added(function() + c += 1 + end) + local r = 0 + monitor.removed(function() + r += 1 + end) + + local e = world:entity() + world:add(e, A) + CHECK(c == 1) + world:add(e, jecs.pair(B, e2)) + CHECK(c == 1) + world:add(e, jecs.pair(B, e1)) + CHECK(c == 1) + CHECK(r == 1) + world:remove(e, jecs.pair(B, e1)) + CHECK(c == 2) + CHECK(r == 1) + end + + do CASE "monitor with wildcard pair in without should handle operations correctly" + local A = world:component() + local B = world:component() + local e1 = world:entity() + local e2 = world:entity() + + local monitor = ob.monitor(world:query(A):without(jecs.pair(B, jecs.w))) + local c = 0 + monitor.added(function() + c += 1 + end) + local r = 0 + monitor.removed(function() + r += 1 + end) + + local e = world:entity() + world:add(e, A) + CHECK(c == 1) + world:add(e, jecs.pair(B, e1)) + CHECK(c == 1) + CHECK(r == 1) + world:add(e, jecs.pair(B, e2)) + CHECK(c == 1) + CHECK(r == 1) + world:remove(e, jecs.pair(B, e1)) + CHECK(c == 1) + CHECK(r == 1) + world:remove(e, jecs.pair(B, e2)) + CHECK(c == 2) + CHECK(r == 1) + end + + do CASE "monitor with multiple pairs should not skip legitimate transitions" + local A = world:component() + local B = world:component() + local e1 = world:entity() + local e2 = world:entity() + local e3 = world:entity() + + local monitor = ob.monitor(world:query(jecs.pair(A, e1), jecs.pair(A, e2))) + local c = 0 + monitor.added(function() + c += 1 + end) + + local e = world:entity() + world:add(e, jecs.pair(A, e1)) + CHECK(c == 1) + world:remove(e, jecs.pair(A, e1)) + world:add(e, jecs.pair(A, e2)) + CHECK(c == 2) + world:remove(e, jecs.pair(A, e2)) + world:add(e, jecs.pair(A, e1)) + CHECK(c == 3) + world:add(e, jecs.pair(A, e2)) + CHECK(c == 4) + end + + do CASE "monitor with pair should handle set operations correctly" + local A = world:component() + local B = world:component() + local e1 = world:entity() + + local monitor = ob.monitor(world:query(jecs.pair(A, e1))) + local c = 0 + monitor.added(function() + c += 1 + end) + local r = 0 + monitor.removed(function() + r += 1 + end) + + local e = world:entity() + world:set(e, jecs.pair(A, e1), true) + CHECK(c == 1) + world:set(e, jecs.pair(A, e1), false) + CHECK(c == 1) + world:set(e, jecs.pair(A, e1), true) + CHECK(c == 2) + world:remove(e, jecs.pair(A, e1)) + CHECK(r == 1) + world:set(e, jecs.pair(A, e1), true) + CHECK(c == 3) + end + + do CASE "monitor with pair query should handle non-matching pairs" + local A = world:component() + local B = world:component() + local e1 = world:entity() + local e2 = world:entity() + + local monitor = ob.monitor(world:query(jecs.pair(A, e1))) + local c = 0 + monitor.added(function() + c += 1 + end) + + local e = world:entity() + world:add(e, jecs.pair(A, e1)) + CHECK(c == 1) + world:add(e, jecs.pair(A, e2)) + CHECK(c == 1) + world:add(e, jecs.pair(B, e1)) + CHECK(c == 1) + end end) return FINISH()