Add query:fini and query:archetypes(override) and changes to OB

This commit is contained in:
Ukendio 2026-02-16 01:45:41 +01:00
parent d4a7f1d86c
commit 30597ed389
6 changed files with 530 additions and 235 deletions

View file

@ -47,6 +47,8 @@ end
-- local tgt = tonumber(tokens[2]) :: jecs.Entity -- local tgt = tonumber(tokens[2]) :: jecs.Entity
-- rel = ecs_ensure_entity(world, rel) -- rel = ecs_ensure_entity(world, rel)
--
-- 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
-- tgt = ecs_ensure_entity(world, tgt) -- tgt = ecs_ensure_entity(world, tgt)
-- return jecs.pair(rel, tgt) -- return jecs.pair(rel, tgt)

View file

@ -1,81 +1,81 @@
--[[ --[[
Signals let you subscribe to component add, change, and remove events with Signals let you subscribe to component add, change, and remove events with
multiple listeners per component. Unlike hooks (see 110_hooks.luau), which multiple listeners per component. Unlike hooks (see 110_hooks.luau), which
allow only one OnAdd, OnChange, and OnRemove per component, signals support allow only one OnAdd, OnChange, and OnRemove per component, signals support
any number of subscribers and each subscription returns an unsubscribe any number of subscribers and each subscription returns an unsubscribe
function so you can clean up when you no longer need to listen. 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 Use signals when you need several independent systems to react to the same
component lifecycle events, or when you want to subscribe and unsubscribe component lifecycle events, or when you want to subscribe and unsubscribe
dynamically (e.g. a UI that only cares while it's mounted). dynamically (e.g. a UI that only cares while it's mounted).
]] ]]
local jecs = require("@jecs") local jecs = require("@jecs")
local world = jecs.world() local world = jecs.world()
local Position = world:component() :: jecs.Id<{ x: number, y: number }> local Position = world:component() :: jecs.Id<{ x: number, y: number }>
--[[ --[[
world:added(component, fn) world:added(component, fn)
Subscribe to "component added" events. Your callback is invoked with: Subscribe to "component added" events. Your callback is invoked with:
(entity, id, value, oldarchetype) whenever the component is added to an entity. (entity, id, value, oldarchetype) whenever the component is added to an entity.
Returns a function; call it to unsubscribe. Returns a function; call it to unsubscribe.
]] ]]
local unsub_added = world:added(Position, function(entity, id, value, oldarchetype) local unsub_added = world:added(Position, function(entity, id, value, oldarchetype)
print(`Position added to entity {entity}: ({value.x}, {value.y})`) print(`Position added to entity {entity}: ({value.x}, {value.y})`)
end) end)
--[[ --[[
world:changed(component, fn) world:changed(component, fn)
Subscribe to "component changed" events. Your callback is invoked with: Subscribe to "component changed" events. Your callback is invoked with:
(entity, id, value, oldarchetype) whenever the component's value is updated (entity, id, value, oldarchetype) whenever the component's value is updated
on an entity (e.g. via world:set). on an entity (e.g. via world:set).
Returns a function; call it to unsubscribe. Returns a function; call it to unsubscribe.
]] ]]
local unsub_changed = world:changed(Position, function(entity, id, value, oldarchetype) local unsub_changed = world:changed(Position, function(entity, id, value, oldarchetype)
print(`Position changed on entity {entity}: ({value.x}, {value.y})`) print(`Position changed on entity {entity}: ({value.x}, {value.y})`)
end) end)
--[[ --[[
world:removed(component, fn) world:removed(component, fn)
Subscribe to "component removed" events. Your callback is invoked with: Subscribe to "component removed" events. Your callback is invoked with:
(entity, id, delete?) when the component is removed. The third argument (entity, id, delete?) when the component is removed. The third argument
`delete` is true when the entity is being deleted, false or nil when `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). only the component was removed (same semantics as OnRemove in 110_hooks).
Returns a function; call it to unsubscribe. Returns a function; call it to unsubscribe.
]] ]]
local unsub_removed = world:removed(Position, function(entity, id, delete) local unsub_removed = world:removed(Position, function(entity, id, delete)
if delete then if delete then
print(`Entity {entity} deleted (had Position)`) print(`Entity {entity} deleted (had Position)`)
else else
print(`Position removed from entity {entity}`) print(`Position removed from entity {entity}`)
end end
end) end)
local e = world:entity() local e = world:entity()
world:set(e, Position, { x = 10, y = 20 }) -- added world:set(e, Position, { x = 10, y = 20 }) -- added
world:set(e, Position, { x = 30, y = 40 }) -- changed world:set(e, Position, { x = 30, y = 40 }) -- changed
world:remove(e, Position) -- removed world:remove(e, Position) -- removed
world:added(Position, function(entity) world:added(Position, function(entity)
print("Second listener: Position added") print("Second listener: Position added")
end) end)
world:set(e, Position, { x = 0, y = 0 }) -- Multiple listeners are all invoked world:set(e, Position, { x = 0, y = 0 }) -- Multiple listeners are all invoked
-- Unsubscribe when you no longer need to listen -- Unsubscribe when you no longer need to listen
unsub_added() unsub_added()
unsub_changed() unsub_changed()
unsub_removed() unsub_removed()
world:set(e, Position, { x = 1, y = 1 }) world:set(e, Position, { x = 1, y = 1 })
world:remove(e, Position) world:remove(e, Position)

View file

@ -3,8 +3,17 @@ local jecs = require("@jecs")
type World = jecs.World type World = jecs.World
type Id<T=any> = jecs.Id<T> type Id<T=any> = jecs.Id<any>
local function duplicate(query: jecs.Query<...any>): jecs.CachedQuery<...any>
local world = (query :: jecs.Query<any> & { 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 = { export type Observer = {
disconnect: () -> (), disconnect: () -> (),
@ -20,7 +29,7 @@ local function observers_new(
query: jecs.Query<...any>, query: jecs.Query<...any>,
callback: (jecs.Entity) -> () callback: (jecs.Entity) -> ()
): Observer ): Observer
local cachedquery = query:cached() local cachedquery = duplicate(query)
local world = (cachedquery :: jecs.Query<any> & { world: World }).world local world = (cachedquery :: jecs.Query<any> & { world: World }).world
callback = callback callback = callback
@ -134,7 +143,7 @@ local function observers_new(
end end
local function monitors_new(query: jecs.Query<...any>): Monitor 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 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_added: ((jecs.Entity) -> ())?
local callback_removed: ((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_old_archetype: jecs.Archetype? = nil
local last_entity: jecs.Entity? = nil local last_entity: jecs.Entity? = nil
local function emplaced<a>( local function emplaced<a>(
entity: jecs.Entity, entity: jecs.Entity,
id: jecs.Id<a>, id: jecs.Id<a>,
value: a, value: a,
@ -173,10 +176,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
local r = jecs.entity_index_try_get_fast( local r = jecs.entity_index_try_get_fast(
entity_index, entity :: any) :: jecs.Record entity_index, entity :: any) :: jecs.Record
if not archetypes[oldarchetype.id] and archetypes[r.archetype.id] then 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 if last_old_archetype == oldarchetype and last_entity == entity and terms_lookup[id] then
return return
end end
@ -185,52 +184,31 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
last_entity = entity last_entity = entity
callback_added(entity) callback_added(entity)
else else
-- NOTE(marcus): Clear tracking when we see a different transition pattern
last_old_archetype = nil last_old_archetype = nil
last_entity = nil last_entity = nil
end end
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?) local function removed(entity: jecs.Entity, component: jecs.Component, delete:boolean?)
if callback_removed == nil then if callback_removed == nil then
return return
end 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 if last_entity == entity and last_old_archetype == src 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
return return
end end
local r = jecs.record(world, entity) last_entity = entity
local src = r.archetype last_old_archetype = src
callback_removed(entity)
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
end end
local cleanup = {} local cleanup = {}
@ -254,8 +232,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
entity_index, entity :: any) :: jecs.Record entity_index, entity :: any) :: jecs.Record
if not archetypes[oldarchetype.id] and archetypes[r.archetype.id] then 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 if last_old_archetype == oldarchetype and last_entity == entity and terms_lookup[id] then
return return
end end
@ -264,7 +240,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
last_entity = entity last_entity = entity
callback_added(entity) callback_added(entity)
else else
-- Clear tracking when we see a different transition pattern
last_old_archetype = nil last_old_archetype = nil
last_entity = nil last_entity = nil
end end
@ -314,11 +289,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
return return
end 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 if archetypes[oldarchetype.id] and not archetypes[archetype.id] then
last_old_archetype = nil last_old_archetype = nil
callback_removed(entity) callback_removed(entity)
@ -364,10 +334,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
return return
end 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 if archetypes[oldarchetype.id] and not archetypes[archetype.id] then
callback_removed(entity) callback_removed(entity)
end end
@ -386,11 +352,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
end end
local dst = jecs.archetype_traverse_remove(world, id, archetype) 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 if archetypes[dst.id] then
callback_added(entity) callback_added(entity)
end end

View file

@ -80,15 +80,17 @@ export type Id2<First, Second=nil> = ecs_id_t<First, Second>
export type Item<T...> = (self: Query<T...>) -> (Entity, T...) export type Item<T...> = (self: Query<T...>) -> (Entity, T...)
export type Iter<T...> = (query: Query<T...>) -> () -> (Entity, T...) export type Iter<T...> = (query: Query<T...>) -> () -> (Entity, T...)
export type CachedIter<T...> = (query: CachedQuery<T...>) -> () -> (Entity, T...) export type Cached_Query_Iter<T...> = (query: Cached_Query<T...>) -> () -> (Entity, T...)
type TypePack<T...> = (T...) -> never type TypePack<T...> = (T...) -> never
export type CachedQuery<T...> = typeof(setmetatable( export type Cached_Query<T...> = typeof(setmetatable(
{} :: { {} :: {
iter: CachedIter<T...>, iter: Cached_Query_Iter<T...>,
archetypes: (CachedQuery<T...>) -> { Archetype }, archetypes: (Cached_Query<T...>) -> { Archetype },
has: (CachedQuery<T...>, Entity) -> boolean, has: (Cached_Query<T...>, Entity) -> boolean,
fini: (Cached_Query<T...>) -> (),
ids: { Id<any> }, ids: { Id<any> },
filter_with: { Id<any> }?, filter_with: { Id<any> }?,
filter_without: { Id<any> }?, filter_without: { Id<any> }?,
@ -96,7 +98,7 @@ export type CachedQuery<T...> = typeof(setmetatable(
-- world: World -- world: World
}, },
{} :: { {} :: {
__iter: CachedIter<T...>, __iter: Cached_Query_Iter<T...>,
}) })
) )
@ -106,7 +108,7 @@ export type Query<T...> = typeof(setmetatable(
with: ((Query<T...>, ...Component) -> Query<T...>), with: ((Query<T...>, ...Component) -> Query<T...>),
without: ((Query<T...>, ...Component) -> Query<T...>), without: ((Query<T...>, ...Component) -> Query<T...>),
archetypes: (Query<T...>) -> { Archetype }, archetypes: (Query<T...>) -> { Archetype },
cached: (Query<T...>) -> CachedQuery<T...>, cached: (Query<T...>) -> Cached_Query<T...>,
has: (Query<T...>, Entity) -> boolean, has: (Query<T...>, Entity) -> boolean,
ids: { Id<any> }, ids: { Id<any> },
filter_with: { Id<any> }?, filter_with: { Id<any> }?,
@ -1240,9 +1242,9 @@ end
local function NOOP() 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 local compatible_archetypes = query.compatible_archetypes
if not compatible_archetypes then if not compatible_archetypes or override then
compatible_archetypes = {} compatible_archetypes = {}
query.compatible_archetypes = compatible_archetypes query.compatible_archetypes = compatible_archetypes
@ -1747,80 +1749,80 @@ local function query_iter_init(query: QueryInner): () -> (number, ...any)
col4_u = col4 col4_u = col4
col5_u = col5 col5_u = col5
col6_u = col6 col6_u = col6
col7_u = col7 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
end end
entities = archetype.entities
i_u = #entities local row = i_u
if i_u == 0 then i_u -= 1
continue
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 end
e = entities[i_u]
entities_u = entities local row = i_u
columns_map = archetype.columns_map i_u -= 1
columns_map_u = columns_map
col0 = columns_map[id0] for i = 9, ids_len do
col1 = columns_map[id1] output[i - 8] = columns_map[ids[i]::any][row]
col2 = columns_map[id2] end
col3 = columns_map[id3]
col4 = columns_map[id4] return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], col6[row], col7[row], unpack(output)
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 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
end
query.next = world_query_iter_next
query.next = world_query_iter_next return world_query_iter_next
return world_query_iter_next
end end
local function query_iter(query): () -> (number, ...any) 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_create, observer_for_create)
table.insert(query_cache_on_delete, observer_for_delete) table.insert(query_cache_on_delete, observer_for_delete)
local function cached_query_iter() local function cached_query_iter()
last_archetype_u = 1 last_archetype_u = 1
local compatible_archetypes = compatible_archetypes_u local compatible_archetypes = compatible_archetypes_u
archetype_u = compatible_archetypes[last_archetype_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 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) local r = entity_index_try_get_fast(eindex, entity)
if not r then if not r then
return false return false
@ -2429,12 +2431,31 @@ local function query_cached(query: QueryInner)
return archetypes_map[entityarchetype.id] ~= nil return archetypes_map[entityarchetype.id] ~= nil
end 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 local cached_query = query :: any
cached_query.archetypes = query_archetypes cached_query.archetypes = query_archetypes
cached_query.__iter = cached_query_iter cached_query.__iter = cached_query_iter
cached_query.iter = cached_query_iter cached_query.iter = cached_query_iter
cached_query.has = cached_query_has cached_query.has = cached_query_has
cached_query.fini = cached_query_fini
setmetatable(cached_query, cached_query) setmetatable(cached_query, cached_query)
return cached_query return cached_query
end end

View file

@ -3,7 +3,7 @@ local testkit = require("@modules/testkit")
local test = testkit.test() local test = testkit.test()
local CASE, TEST, FINISH, CHECK = test.CASE, test.TEST, test.FINISH, test.CHECK local CASE, TEST, FINISH, CHECK = test.CASE, test.TEST, test.FINISH, test.CHECK
local FOCUS = test.FOCUS local FOCUS = test.FOCUS
local ob = require("@modules/ob") local ob = require("@modules/OB/module")
TEST("modules/ob::observer", function() TEST("modules/ob::observer", function()
local world = jecs.world() local world = jecs.world()
@ -292,6 +292,36 @@ end)
TEST("modules/ob::monitor", function() TEST("modules/ob::monitor", function()
local world = jecs.world() 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 do CASE [[should not invoke monitor.added callback multiple times in a bulk_move
]] ]]
local A = world:component() local A = world:component()
@ -609,6 +639,74 @@ TEST("modules/ob::monitor", function()
CHECK(r==2) CHECK(r==2)
end 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)" do CASE "Should enter monitor at query:without(pair(R, *)) when adding pair(R, t1)"
local A = world:component() local A = world:component()
local B = world:component() local B = world:component()
@ -792,7 +890,206 @@ TEST("modules/ob::monitor", function()
CHECK(c == 2) CHECK(c == 2)
end 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" do CASE "monitor with pair should handle remove and re-add correctly"
local A = world:component() local A = world:component()
@ -888,7 +1185,6 @@ TEST("modules/ob::monitor", function()
local B = world:component() local B = world:component()
local e1 = world:entity() local e1 = world:entity()
local e2 = 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 monitor = ob.monitor(world:query(jecs.pair(A, e1), jecs.pair(A, e2)))
local c = 0 local c = 0
@ -898,15 +1194,15 @@ TEST("modules/ob::monitor", function()
local e = world:entity() local e = world:entity()
world:add(e, jecs.pair(A, e1)) world:add(e, jecs.pair(A, e1))
CHECK(c == 1) CHECK(c == 0)
world:remove(e, jecs.pair(A, e1)) world:remove(e, jecs.pair(A, e1))
world:add(e, jecs.pair(A, e2)) world:add(e, jecs.pair(A, e2))
CHECK(c == 2) CHECK(c == 0)
world:remove(e, jecs.pair(A, e2)) world:remove(e, jecs.pair(A, e2))
world:add(e, jecs.pair(A, e1)) world:add(e, jecs.pair(A, e1))
CHECK(c == 3) CHECK(c == 0)
world:add(e, jecs.pair(A, e2)) world:add(e, jecs.pair(A, e2))
CHECK(c == 4) CHECK(c == 1)
end end
do CASE "monitor with pair should handle set operations correctly" do CASE "monitor with pair should handle set operations correctly"
@ -929,12 +1225,13 @@ TEST("modules/ob::monitor", function()
CHECK(c == 1) CHECK(c == 1)
world:set(e, jecs.pair(A, e1), false) world:set(e, jecs.pair(A, e1), false)
CHECK(c == 1) CHECK(c == 1)
CHECK(r == 0)
world:set(e, jecs.pair(A, e1), true) world:set(e, jecs.pair(A, e1), true)
CHECK(c == 2) CHECK(c == 1)
world:remove(e, jecs.pair(A, e1)) world:remove(e, jecs.pair(A, e1))
CHECK(r == 1) CHECK(r == 1)
world:set(e, jecs.pair(A, e1), true) world:set(e, jecs.pair(A, e1), true)
CHECK(c == 3) CHECK(c == 2)
end end
do CASE "monitor with pair query should handle non-matching pairs" do CASE "monitor with pair query should handle non-matching pairs"

View file

@ -2146,6 +2146,20 @@ TEST("world:query()", function()
CHECK(not world:has(e1, B)) CHECK(not world:has(e1, B))
end end
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" do CASE "cached"
local world = jecs.world() local world = jecs.world()