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
-- rel = ecs_ensure_entity(world, rel)
--
-- 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
-- tgt = ecs_ensure_entity(world, tgt)
-- return jecs.pair(rel, tgt)

View file

@ -3,8 +3,17 @@ local jecs = require("@jecs")
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 = {
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<any> & { 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,12 +160,6 @@ 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
@ -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
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
return
end
if not r then return end
local r = jecs.record(world, entity)
local src = r.archetype
if not src then return end
local dst = jecs.archetype_traverse_remove(world, component, src)
if not archetypes[src.id] then return end
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)
if last_entity == entity and last_old_archetype == src then
return
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

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 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
export type CachedQuery<T...> = typeof(setmetatable(
export type Cached_Query<T...> = typeof(setmetatable(
{} :: {
iter: CachedIter<T...>,
archetypes: (CachedQuery<T...>) -> { Archetype },
has: (CachedQuery<T...>, Entity) -> boolean,
iter: Cached_Query_Iter<T...>,
archetypes: (Cached_Query<T...>) -> { Archetype },
has: (Cached_Query<T...>, Entity) -> boolean,
fini: (Cached_Query<T...>) -> (),
ids: { Id<any> },
filter_with: { Id<any> }?,
filter_without: { Id<any> }?,
@ -96,7 +98,7 @@ export type CachedQuery<T...> = typeof(setmetatable(
-- 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...>),
without: ((Query<T...>, ...Component) -> Query<T...>),
archetypes: (Query<T...>) -> { Archetype },
cached: (Query<T...>) -> CachedQuery<T...>,
cached: (Query<T...>) -> Cached_Query<T...>,
has: (Query<T...>, Entity) -> boolean,
ids: { Id<any> },
filter_with: { Id<any> }?,
@ -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
@ -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
@ -2430,11 +2432,30 @@ 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

View file

@ -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"

View file

@ -2147,6 +2147,20 @@ TEST("world:query()", function()
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()
local Foo = world:component()