From 30597ed389daab542a1a92c15cce4146ee02dd08 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Mon, 16 Feb 2026 01:45:41 +0100 Subject: [PATCH] Add query:fini and query:archetypes(override) and changes to OB --- examples/networking/networking_recv.luau | 2 + how_to/111_signals.luau | 162 ++++++------ modules/OB/module.luau | 89 ++----- src/jecs.luau | 183 +++++++------ test/ob.luau | 315 ++++++++++++++++++++++- test/tests.luau | 14 + 6 files changed, 530 insertions(+), 235 deletions(-) diff --git a/examples/networking/networking_recv.luau b/examples/networking/networking_recv.luau index 63de53f..e39f444 100755 --- a/examples/networking/networking_recv.luau +++ b/examples/networking/networking_recv.luau @@ -47,6 +47,8 @@ end -- local tgt = tonumber(tokens[2]) :: jecs.Entity -- rel = ecs_ensure_entity(world, rel) +-- +-- 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 -- tgt = ecs_ensure_entity(world, tgt) -- return jecs.pair(rel, tgt) diff --git a/how_to/111_signals.luau b/how_to/111_signals.luau index 54d3b67..d814582 100755 --- a/how_to/111_signals.luau +++ b/how_to/111_signals.luau @@ -1,81 +1,81 @@ ---[[ - Signals let you subscribe to component add, change, and remove events with - multiple listeners per component. Unlike hooks (see 110_hooks.luau), which - allow only one OnAdd, OnChange, and OnRemove per component, signals support - any number of subscribers and each subscription returns an unsubscribe - function so you can clean up when you no longer need to listen. - - Use signals when you need several independent systems to react to the same - component lifecycle events, or when you want to subscribe and unsubscribe - dynamically (e.g. a UI that only cares while it's mounted). -]] - -local jecs = require("@jecs") -local world = jecs.world() - -local Position = world:component() :: jecs.Id<{ x: number, y: number }> - ---[[ - world:added(component, fn) - - Subscribe to "component added" events. Your callback is invoked with: - (entity, id, value, oldarchetype) whenever the component is added to an entity. - - Returns a function; call it to unsubscribe. -]] - -local unsub_added = world:added(Position, function(entity, id, value, oldarchetype) - print(`Position added to entity {entity}: ({value.x}, {value.y})`) -end) - ---[[ - world:changed(component, fn) - - Subscribe to "component changed" events. Your callback is invoked with: - (entity, id, value, oldarchetype) whenever the component's value is updated - on an entity (e.g. via world:set). - - Returns a function; call it to unsubscribe. -]] - -local unsub_changed = world:changed(Position, function(entity, id, value, oldarchetype) - print(`Position changed on entity {entity}: ({value.x}, {value.y})`) -end) - ---[[ - world:removed(component, fn) - - Subscribe to "component removed" events. Your callback is invoked with: - (entity, id, delete?) when the component is removed. The third argument - `delete` is true when the entity is being deleted, false or nil when - only the component was removed (same semantics as OnRemove in 110_hooks). - - Returns a function; call it to unsubscribe. -]] - -local unsub_removed = world:removed(Position, function(entity, id, delete) - if delete then - print(`Entity {entity} deleted (had Position)`) - else - print(`Position removed from entity {entity}`) - end -end) - -local e = world:entity() -world:set(e, Position, { x = 10, y = 20 }) -- added -world:set(e, Position, { x = 30, y = 40 }) -- changed -world:remove(e, Position) -- removed - -world:added(Position, function(entity) - print("Second listener: Position added") -end) - -world:set(e, Position, { x = 0, y = 0 }) -- Multiple listeners are all invoked - --- Unsubscribe when you no longer need to listen -unsub_added() -unsub_changed() -unsub_removed() - -world:set(e, Position, { x = 1, y = 1 }) -world:remove(e, Position) +--[[ + Signals let you subscribe to component add, change, and remove events with + multiple listeners per component. Unlike hooks (see 110_hooks.luau), which + allow only one OnAdd, OnChange, and OnRemove per component, signals support + any number of subscribers and each subscription returns an unsubscribe + function so you can clean up when you no longer need to listen. + + Use signals when you need several independent systems to react to the same + component lifecycle events, or when you want to subscribe and unsubscribe + dynamically (e.g. a UI that only cares while it's mounted). +]] + +local jecs = require("@jecs") +local world = jecs.world() + +local Position = world:component() :: jecs.Id<{ x: number, y: number }> + +--[[ + world:added(component, fn) + + Subscribe to "component added" events. Your callback is invoked with: + (entity, id, value, oldarchetype) whenever the component is added to an entity. + + Returns a function; call it to unsubscribe. +]] + +local unsub_added = world:added(Position, function(entity, id, value, oldarchetype) + print(`Position added to entity {entity}: ({value.x}, {value.y})`) +end) + +--[[ + world:changed(component, fn) + + Subscribe to "component changed" events. Your callback is invoked with: + (entity, id, value, oldarchetype) whenever the component's value is updated + on an entity (e.g. via world:set). + + Returns a function; call it to unsubscribe. +]] + +local unsub_changed = world:changed(Position, function(entity, id, value, oldarchetype) + print(`Position changed on entity {entity}: ({value.x}, {value.y})`) +end) + +--[[ + world:removed(component, fn) + + Subscribe to "component removed" events. Your callback is invoked with: + (entity, id, delete?) when the component is removed. The third argument + `delete` is true when the entity is being deleted, false or nil when + only the component was removed (same semantics as OnRemove in 110_hooks). + + Returns a function; call it to unsubscribe. +]] + +local unsub_removed = world:removed(Position, function(entity, id, delete) + if delete then + print(`Entity {entity} deleted (had Position)`) + else + print(`Position removed from entity {entity}`) + end +end) + +local e = world:entity() +world:set(e, Position, { x = 10, y = 20 }) -- added +world:set(e, Position, { x = 30, y = 40 }) -- changed +world:remove(e, Position) -- removed + +world:added(Position, function(entity) + print("Second listener: Position added") +end) + +world:set(e, Position, { x = 0, y = 0 }) -- Multiple listeners are all invoked + +-- Unsubscribe when you no longer need to listen +unsub_added() +unsub_changed() +unsub_removed() + +world:set(e, Position, { x = 1, y = 1 }) +world:remove(e, Position) diff --git a/modules/OB/module.luau b/modules/OB/module.luau index edfe0c4..bea62b9 100755 --- a/modules/OB/module.luau +++ b/modules/OB/module.luau @@ -3,8 +3,17 @@ local jecs = require("@jecs") type World = jecs.World -type Id = jecs.Id +type Id = jecs.Id +local function duplicate(query: jecs.Query<...any>): jecs.CachedQuery<...any> + local world = (query :: jecs.Query & { world: World }).world + local dup = world:query() + dup.filter_with = table.clone(query.filter_with) + if query.filter_without then + dup.filter_without = query.filter_without + end + return dup:cached() +end export type Observer = { disconnect: () -> (), @@ -20,7 +29,7 @@ local function observers_new( query: jecs.Query<...any>, callback: (jecs.Entity) -> () ): Observer - local cachedquery = query:cached() + local cachedquery = duplicate(query) local world = (cachedquery :: jecs.Query & { world: World }).world callback = callback @@ -134,7 +143,7 @@ local function observers_new( end local function monitors_new(query: jecs.Query<...any>): Monitor - local cachedquery = query:cached() + local cachedquery = duplicate(query) local world = (cachedquery :: jecs.Query<...any> & { world: World }).world :: jecs.World @@ -151,16 +160,10 @@ local function monitors_new(query: jecs.Query<...any>): Monitor local callback_added: ((jecs.Entity) -> ())? local callback_removed: ((jecs.Entity) -> ())? - -- NOTE(marcus): Track the last (entity, old archetype) pair we processed to detect bulk operations. - -- During bulk_insert from ROOT_ARCHETYPE, the entity is moved to the target archetype first, - -- then all on_add callbacks fire sequentially with the same oldarchetype for the same entity. - -- We track both entity and old archetype to distinguish between: - -- 1. Same entity, same old archetype (bulk operation - skip) - -- 2. Different entity, same old archetype (separate operation - don't skip) local last_old_archetype: jecs.Archetype? = nil local last_entity: jecs.Entity? = nil - local function emplaced( + local function emplaced( entity: jecs.Entity, id: jecs.Id, value: a, @@ -173,10 +176,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor 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 - -- NOTE(marcus): Skip if we've seen this exact (entity, old archetype) combination before - -- AND this component is in the query's terms. This detects bulk operations where - -- the same entity transitions with multiple components, while allowing different - -- entities to trigger even if they share the same old archetype. if last_old_archetype == oldarchetype and last_entity == entity and terms_lookup[id] then return end @@ -185,52 +184,31 @@ local function monitors_new(query: jecs.Query<...any>): Monitor last_entity = entity callback_added(entity) else - -- NOTE(marcus): Clear tracking when we see a different transition pattern last_old_archetype = nil last_entity = nil end end - -- Track which entity we've already processed for deletion to avoid duplicate callbacks - -- during bulk deletion where multiple components are removed with delete=true - local last_deleted_entity: jecs.Entity? = nil - local function removed(entity: jecs.Entity, component: jecs.Component, delete:boolean?) if callback_removed == nil then return end + + local r = jecs.record(world, entity) + if not r then return end + + local src = r.archetype + if not src then return end + + if not archetypes[src.id] then return end - if delete then - -- Deletion is a bulk removal - all components are removed with delete=true - -- We should only trigger the callback once per entity, not once per component - if last_deleted_entity == entity then - return - end - - local r = jecs.record(world, entity) - if r and r.archetype and archetypes[r.archetype.id] then - -- Entity was in the monitor before deletion - last_deleted_entity = entity - -- Clear tracking when entity is deleted - last_old_archetype = nil - last_entity = nil - callback_removed(entity) - end + if last_entity == entity and last_old_archetype == src then return end - local r = jecs.record(world, entity) - local src = r.archetype - - local dst = jecs.archetype_traverse_remove(world, component, src) - - if not archetypes[dst.id] then - -- Clear tracking when entity leaves the monitor to allow re-entry - last_old_archetype = nil - last_entity = nil - last_deleted_entity = nil - callback_removed(entity) - end + last_entity = entity + last_old_archetype = src + callback_removed(entity) end local cleanup = {} @@ -254,8 +232,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor entity_index, entity :: any) :: jecs.Record if not archetypes[oldarchetype.id] and archetypes[r.archetype.id] then - -- NOTE(marcus): Skip if we've seen this exact (entity, old archetype) combination before - -- AND this component is in the query's terms. if last_old_archetype == oldarchetype and last_entity == entity and terms_lookup[id] then return end @@ -264,7 +240,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor last_entity = entity callback_added(entity) else - -- Clear tracking when we see a different transition pattern last_old_archetype = nil last_entity = nil end @@ -314,11 +289,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor 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 last_old_archetype = nil callback_removed(entity) @@ -364,10 +334,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor 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 @@ -386,11 +352,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor 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 diff --git a/src/jecs.luau b/src/jecs.luau index 8ec2ce6..bcd4faf 100755 --- a/src/jecs.luau +++ b/src/jecs.luau @@ -80,15 +80,17 @@ export type Id2 = ecs_id_t export type Item = (self: Query) -> (Entity, T...) export type Iter = (query: Query) -> () -> (Entity, T...) -export type CachedIter = (query: CachedQuery) -> () -> (Entity, T...) +export type Cached_Query_Iter = (query: Cached_Query) -> () -> (Entity, T...) type TypePack = (T...) -> never -export type CachedQuery = typeof(setmetatable( +export type Cached_Query = typeof(setmetatable( {} :: { - iter: CachedIter, - archetypes: (CachedQuery) -> { Archetype }, - has: (CachedQuery, Entity) -> boolean, + iter: Cached_Query_Iter, + archetypes: (Cached_Query) -> { Archetype }, + has: (Cached_Query, Entity) -> boolean, + fini: (Cached_Query) -> (), + ids: { Id }, filter_with: { Id }?, filter_without: { Id }?, @@ -96,7 +98,7 @@ export type CachedQuery = typeof(setmetatable( -- world: World }, {} :: { - __iter: CachedIter, + __iter: Cached_Query_Iter, }) ) @@ -106,7 +108,7 @@ export type Query = typeof(setmetatable( with: ((Query, ...Component) -> Query), without: ((Query, ...Component) -> Query), archetypes: (Query) -> { Archetype }, - cached: (Query) -> CachedQuery, + cached: (Query) -> Cached_Query, has: (Query, Entity) -> boolean, ids: { Id }, filter_with: { Id }?, @@ -1240,9 +1242,9 @@ end local function NOOP() end -local function query_archetypes(query: query) +local function query_archetypes(query: query, override: boolean?) local compatible_archetypes = query.compatible_archetypes - if not compatible_archetypes then + if not compatible_archetypes or override then compatible_archetypes = {} query.compatible_archetypes = compatible_archetypes @@ -1747,80 +1749,80 @@ local function query_iter_init(query: QueryInner): () -> (number, ...any) col4_u = col4 col5_u = col5 col6_u = col6 - col7_u = col7 - end - - local row = i_u - i_u -= 1 - - return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], col6[row], col7[row] - end -else - local output = {} - local ids_len = #ids_u - function world_query_iter_next(): any - local entities = entities_u - local e = entities[i_u] - local col0 = col0_u - local col1 = col1_u - local col2 = col2_u - local col3 = col3_u - local col4 = col4_u - local col5 = col5_u - local col6 = col6_u - local col7 = col7_u - local ids = ids_u - local columns_map = columns_map_u - - while e == nil do - last_archetype_u += 1 - local compatible_archetypes = compatible_archetypes_u - local archetype = compatible_archetypes[last_archetype_u] - archetype_u = archetype - - if not archetype then - return nil + col7_u = col7 end - entities = archetype.entities - i_u = #entities - if i_u == 0 then - continue + + local row = i_u + i_u -= 1 + + return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], col6[row], col7[row] + end + else + local output = {} + local ids_len = #ids_u + function world_query_iter_next(): any + local entities = entities_u + local e = entities[i_u] + local col0 = col0_u + local col1 = col1_u + local col2 = col2_u + local col3 = col3_u + local col4 = col4_u + local col5 = col5_u + local col6 = col6_u + local col7 = col7_u + local ids = ids_u + local columns_map = columns_map_u + + while e == nil do + last_archetype_u += 1 + local compatible_archetypes = compatible_archetypes_u + local archetype = compatible_archetypes[last_archetype_u] + archetype_u = archetype + + if not archetype then + return nil + end + entities = archetype.entities + i_u = #entities + if i_u == 0 then + continue + end + e = entities[i_u] + entities_u = entities + columns_map = archetype.columns_map + columns_map_u = columns_map + col0 = columns_map[id0] + col1 = columns_map[id1] + col2 = columns_map[id2] + col3 = columns_map[id3] + col4 = columns_map[id4] + col5 = columns_map[id5] + col6 = columns_map[id6] + col7 = columns_map[id7] + col0_u = col0 + col1_u = col1 + col2_u = col2 + col3_u = col3 + col4_u = col4 + col5_u = col5 + col6_u = col6 + col7_u = col7 end - e = entities[i_u] - entities_u = entities - columns_map = archetype.columns_map - columns_map_u = columns_map - col0 = columns_map[id0] - col1 = columns_map[id1] - col2 = columns_map[id2] - col3 = columns_map[id3] - col4 = columns_map[id4] - col5 = columns_map[id5] - col6 = columns_map[id6] - col7 = columns_map[id7] - col0_u = col0 - col1_u = col1 - col2_u = col2 - col3_u = col3 - col4_u = col4 - col5_u = col5 - col6_u = col6 - col7_u = col7 + + local row = i_u + i_u -= 1 + + for i = 9, ids_len do + output[i - 8] = columns_map[ids[i]::any][row] + end + + return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], col6[row], col7[row], unpack(output) end - - local row = i_u - i_u -= 1 - - for i = 9, ids_len do - output[i - 8] = columns_map[ids[i]::any][row] - end - - return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], col6[row], col7[row], unpack(output) end -end - -query.next = world_query_iter_next -return world_query_iter_next + + query.next = world_query_iter_next + return world_query_iter_next end local function query_iter(query): () -> (number, ...any) @@ -1917,7 +1919,7 @@ local function query_cached(query: QueryInner) table.insert(query_cache_on_create, observer_for_create) table.insert(query_cache_on_delete, observer_for_delete) - local function cached_query_iter() + local function cached_query_iter() last_archetype_u = 1 local compatible_archetypes = compatible_archetypes_u archetype_u = compatible_archetypes[last_archetype_u] @@ -2416,7 +2418,7 @@ local function query_cached(query: QueryInner) local eindex = world.entity_index :: entityindex - local function cached_query_has(entity): boolean + local function cached_query_has(_, entity): boolean local r = entity_index_try_get_fast(eindex, entity) if not r then return false @@ -2429,12 +2431,31 @@ local function query_cached(query: QueryInner) return archetypes_map[entityarchetype.id] ~= nil end + + local function cached_query_fini() + local create_pos = table.find(query_cache_on_create, observer_for_create) + if create_pos then + table.remove(query_cache_on_create, create_pos) + end + + local delete_pos = table.find(query_cache_on_delete, observer_for_delete) + if delete_pos then + table.remove(query_cache_on_delete, delete_pos) + end + + compatible_archetypes_u = nil + -- NOTE(marcus): Maybe we have to be even more aggressive with cleaning + -- things up to ensure it the memory is free`d. But since most of it are + -- references we cannot be sure that someone is holding onto them making + -- it implausible to free the memory anyways + end local cached_query = query :: any cached_query.archetypes = query_archetypes cached_query.__iter = cached_query_iter cached_query.iter = cached_query_iter cached_query.has = cached_query_has + cached_query.fini = cached_query_fini setmetatable(cached_query, cached_query) return cached_query end diff --git a/test/ob.luau b/test/ob.luau index 82bc23d..d6a9bb0 100755 --- a/test/ob.luau +++ b/test/ob.luau @@ -3,7 +3,7 @@ local testkit = require("@modules/testkit") local test = testkit.test() local CASE, TEST, FINISH, CHECK = test.CASE, test.TEST, test.FINISH, test.CHECK local FOCUS = test.FOCUS -local ob = require("@modules/ob") +local ob = require("@modules/OB/module") TEST("modules/ob::observer", function() local world = jecs.world() @@ -292,6 +292,36 @@ end) TEST("modules/ob::monitor", function() local world = jecs.world() + do CASE "same query can be used for multiple monitors without error (monitors use own cached proxy)" + local A = world:component() + local q = world:query(A) + local c1, c2 = 0, 0 + ob.monitor(q).added(function() c1 += 1 end) + ob.monitor(q).added(function() c2 += 1 end) + local e = world:entity() + world:add(e, A) + CHECK(c1 == 1) + CHECK(c2 == 1) + end + + do CASE [[Monitor should only report removed entities if it was previously + apart of it]] + local A = world:component() + local B = world:component() + local C = world:component() + + local count = 0 + ob.monitor(world:query(A, C)).removed(function() + count += 1 + end) + + local e = world:entity() + jecs.bulk_insert(world, e, {A, B}, {0,0}) + CHECK(count==0) + world:remove(e, A) + CHECK(count==0) + end + do CASE [[should not invoke monitor.added callback multiple times in a bulk_move ]] local A = world:component() @@ -609,6 +639,74 @@ TEST("modules/ob::monitor", function() CHECK(r==2) end + -- Without-clause pair onremoved path: guards must stay or these fail. + -- Removing "if archetypes[dst.id]" would make this fail: we must only report added when dst matches the query. + do CASE "without(pair): removing one excluded pair only fires added when entity actually enters (dst matches query)" + local A = world:component() + local B = world:component() + local e1 = world:entity() + local e2 = world:entity() + + local added_count = 0 + local monitor = ob.monitor(world:query(A):without(jecs.pair(B, e1), jecs.pair(B, e2))) + monitor.added(function() + added_count += 1 + end) + + local e = world:entity() + world:add(e, jecs.pair(B, e1)) + world:add(e, jecs.pair(B, e2)) + world:add(e, A) + CHECK(added_count == 0) + + world:remove(e, jecs.pair(B, e1)) + CHECK(added_count == 0) + + world:remove(e, jecs.pair(B, e2)) + CHECK(added_count == 1) + end + + -- Removing "if delete then return" would make this fail: must not report added when removal is due to delete. + do CASE "without(pair): must not report added when pair is removed due to entity delete" + local A = world:component() + local B = world:component() + local e1 = world:entity() + + local added_count = 0 + local monitor = ob.monitor(world:query(A):without(jecs.pair(B, e1))) + monitor.added(function() + added_count += 1 + end) + + local e = world:entity() + world:add(e, A) + CHECK(added_count == 1) + world:add(e, jecs.pair(B, e1)) + world:delete(e) + CHECK(added_count == 1) + end + + -- Removing "if not wc and id ~= term then return" would make this fail: must only react to removal of the excluded term, not other pairs. + do CASE "without(pair): must not report added when a different pair (same relation) is removed" + local A = world:component() + local B = world:component() + local e1 = world:entity() + local e2 = world:entity() + + local added_count = 0 + local monitor = ob.monitor(world:query(A):without(jecs.pair(B, e1))) + monitor.added(function() + added_count += 1 + end) + + local e = world:entity() + world:add(e, A) + CHECK(added_count == 1) + world:add(e, jecs.pair(B, e2)) + world:remove(e, jecs.pair(B, e2)) + CHECK(added_count == 1) + end + do CASE "Should enter monitor at query:without(pair(R, *)) when adding pair(R, t1)" local A = world:component() local B = world:component() @@ -792,7 +890,206 @@ TEST("modules/ob::monitor", function() CHECK(c == 2) end - if true then return end + do CASE "entity about to leave (remove queried pair) reports to removed exactly once" + local A = world:component() + local e1 = world:entity() + + local monitor = ob.monitor(world:query(jecs.pair(A, e1))) + local removed_count = 0 + monitor.removed(function() + removed_count += 1 + end) + + local e = world:entity() + world:add(e, jecs.pair(A, e1)) + world:remove(e, jecs.pair(A, e1)) + CHECK(removed_count == 1) + end + + do CASE "removing non-queried pair (same relation, other target) must not report to removed" + local A = world:component() + local e1 = world:entity() + local e2 = world:entity() + + local monitor = ob.monitor(world:query(jecs.pair(A, e1))) + local removed_count = 0 + monitor.removed(function() + removed_count += 1 + end) + + local e = world:entity() + world:add(e, jecs.pair(A, e2)) + world:remove(e, jecs.pair(A, e2)) + CHECK(removed_count == 0) + end + + do CASE "about-to-leave reports to removed exactly once per exit; second removal (already out) must not report" + local A = 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 removed_count = 0 + monitor.removed(function() + removed_count += 1 + end) + + local e = world:entity() + world:add(e, jecs.pair(A, e1)) + world:add(e, jecs.pair(A, e2)) + world:remove(e, jecs.pair(A, e1)) + CHECK(removed_count == 1) + world:remove(e, jecs.pair(A, e2)) + CHECK(removed_count == 1) + end + + do CASE "pair term leave then re-enter gives one removed (about to leave) then one added (has entered)" + local A = world:component() + local e1 = world:entity() + + local monitor = ob.monitor(world:query(jecs.pair(A, e1))) + local added_count = 0 + local removed_count = 0 + monitor.added(function() + added_count += 1 + end) + monitor.removed(function() + removed_count += 1 + end) + + local e = world:entity() + world:add(e, jecs.pair(A, e1)) + world:remove(e, jecs.pair(A, e1)) + world:add(e, jecs.pair(A, e1)) + CHECK(added_count == 2) + CHECK(removed_count == 1) + end + + do CASE "bulk_insert causes added exactly once (entity has entered monitor)" + local A = world:component() + local B = world:component() + local C = world:component() + + local monitor = ob.monitor(world:query(A, B, C)) + local added_count = 0 + monitor.added(function() + added_count += 1 + end) + + local e = world:entity() + jecs.bulk_insert(world, e, { A, B, C }, { 1, 2, 3 }) + CHECK(added_count == 1) + end + + do CASE "bulk_insert of multiple entities each reports to added once (each has entered)" + local A = world:component() + local B = world:component() + local C = world:component() + + local monitor = ob.monitor(world:query(A, B, C)) + local added_count = 0 + monitor.added(function() + added_count += 1 + end) + + local e1 = world:entity() + local e2 = world:entity() + local e3 = world:entity() + jecs.bulk_insert(world, e1, { A, B, C }, { 1, 2, 3 }) + jecs.bulk_insert(world, e2, { A, B, C }, { 4, 5, 6 }) + jecs.bulk_insert(world, e3, { A, B, C }, { 7, 8, 9 }) + CHECK(added_count == 3) + end + + do CASE "bulk_remove causes removed exactly once (entity about to leave monitor)" + local A = world:component() + local B = world:component() + local C = world:component() + + local monitor = ob.monitor(world:query(A, B, C)) + local added_count = 0 + local removed_count = 0 + monitor.added(function() + added_count += 1 + end) + monitor.removed(function() + removed_count += 1 + end) + + local e = world:entity() + jecs.bulk_insert(world, e, { A, B, C }, { 1, 2, 3 }) + CHECK(added_count == 1) + CHECK(removed_count == 0) + + jecs.bulk_remove(world, e, { A, B, C }) + CHECK(removed_count == 1) + CHECK(not world:has(e, A)) + CHECK(not world:has(e, B)) + CHECK(not world:has(e, C)) + end + + do CASE "bulk_remove of multiple entities each reports to removed once (each about to leave)" + local A = world:component() + local B = world:component() + local C = world:component() + + local monitor = ob.monitor(world:query(A, B, C)) + local removed_count = 0 + monitor.removed(function() + removed_count += 1 + end) + + local e1 = world:entity() + local e2 = world:entity() + jecs.bulk_insert(world, e1, { A, B, C }, { 1, 2, 3 }) + jecs.bulk_insert(world, e2, { A, B, C }, { 4, 5, 6 }) + + jecs.bulk_remove(world, e1, { A, B, C }) + CHECK(removed_count == 1) + jecs.bulk_remove(world, e2, { A, B, C }) + CHECK(removed_count == 2) + end + + do CASE "bulk_remove only of query terms still reports to removed exactly once (entity about to leave)" + local A = world:component() + local B = world:component() + local C = world:component() + local D = world:component() + + local monitor = ob.monitor(world:query(A, B, C)) + local removed_count = 0 + monitor.removed(function() + removed_count += 1 + end) + + local e = world:entity() + jecs.bulk_insert(world, e, { A, B, C, D }, { 1, 2, 3, 4 }) + jecs.bulk_remove(world, e, { A, B, C }) + CHECK(removed_count == 1) + CHECK(world:has(e, D)) + end + + do CASE "bulk_insert then bulk_remove gives added once (entered) and removed once (about to leave)" + local A = world:component() + local B = world:component() + local C = world:component() + + local monitor = ob.monitor(world:query(A, B, C)) + local added_count = 0 + local removed_count = 0 + monitor.added(function() + added_count += 1 + end) + monitor.removed(function() + removed_count += 1 + end) + + local e = world:entity() + jecs.bulk_insert(world, e, { A, B, C }, { 1, 2, 3 }) + jecs.bulk_remove(world, e, { A, B, C }) + CHECK(added_count == 1) + CHECK(removed_count == 1) + end do CASE "monitor with pair should handle remove and re-add correctly" local A = world:component() @@ -888,7 +1185,6 @@ TEST("modules/ob::monitor", function() 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 @@ -898,15 +1194,15 @@ TEST("modules/ob::monitor", function() local e = world:entity() world:add(e, jecs.pair(A, e1)) - CHECK(c == 1) + CHECK(c == 0) world:remove(e, jecs.pair(A, e1)) world:add(e, jecs.pair(A, e2)) - CHECK(c == 2) + CHECK(c == 0) world:remove(e, jecs.pair(A, e2)) world:add(e, jecs.pair(A, e1)) - CHECK(c == 3) + CHECK(c == 0) world:add(e, jecs.pair(A, e2)) - CHECK(c == 4) + CHECK(c == 1) end do CASE "monitor with pair should handle set operations correctly" @@ -929,12 +1225,13 @@ TEST("modules/ob::monitor", function() CHECK(c == 1) world:set(e, jecs.pair(A, e1), false) CHECK(c == 1) + CHECK(r == 0) world:set(e, jecs.pair(A, e1), true) - CHECK(c == 2) + CHECK(c == 1) world:remove(e, jecs.pair(A, e1)) CHECK(r == 1) world:set(e, jecs.pair(A, e1), true) - CHECK(c == 3) + CHECK(c == 2) end do CASE "monitor with pair query should handle non-matching pairs" diff --git a/test/tests.luau b/test/tests.luau index 5cb2ab0..681d92f 100755 --- a/test/tests.luau +++ b/test/tests.luau @@ -2146,6 +2146,20 @@ TEST("world:query()", function() CHECK(not world:has(e1, B)) end end + + do CASE "query:archetypes(override) should create new archetypes list" + local world = jecs.world() + local A = world:component() + local B = world:component() + + local q = world:query(A, B) + local e = world:entity() + world:set(e, A, false) + world:set(e, B, true) + + CHECK(q:archetypes() == q:archetypes()) + CHECK(q:archetypes() ~= q:archetypes(true)) + end do CASE "cached" local world = jecs.world()