Add tests for relations

This commit is contained in:
Ukendio 2024-05-10 14:13:23 +02:00
parent 77c7a862ea
commit 2bff9b4551
3 changed files with 205 additions and 306 deletions

View file

@ -199,27 +199,168 @@ function World.new()
return self
end
local function emit(world, eventDescription)
local event = eventDescription.event
local FLAGS_PAIR = 0x8
table.insert(world.hooks[event], {
archetype = eventDescription.archetype;
ids = eventDescription.ids;
offset = eventDescription.offset;
otherArchetype = eventDescription.otherArchetype;
})
local function addFlags(flags)
local typeFlags = 0x0
if flags.isPair then
typeFlags = bit32.bor(typeFlags, FLAGS_PAIR) -- HIGHEST bit in the ID.
end
if false then
typeFlags = bit32.bor(typeFlags, 0x4) -- Set the second flag to true
end
if false then
typeFlags = bit32.bor(typeFlags, 0x2) -- Set the third flag to true
end
if false then
typeFlags = bit32.bor(typeFlags, 0x1) -- LAST BIT in the ID.
end
return typeFlags
end
local function onNotifyAdd(world, archetype, otherArchetype, row: number, added: Ty)
if #added > 0 then
emit(world, {
archetype = archetype;
event = ON_ADD;
ids = added;
offset = row;
otherArchetype = otherArchetype;
})
local ECS_ID_FLAGS_MASK = 0x10
-- ECS_ENTITY_MASK (0xFFFFFFFFull << 28)
local ECS_ENTITY_MASK = bit32.lshift(1, 24)
-- ECS_GENERATION_MASK (0xFFFFull << 24)
local ECS_GENERATION_MASK = bit32.lshift(1, 16)
local function newId(source: number, target: number)
local e = source * 2^28 + target * ECS_ID_FLAGS_MASK
return e
end
local function isPair(e: number)
return (e % 2^4) // FLAGS_PAIR ~= 0
end
function separate(entity: number)
local _typeFlags = entity % 0x10
entity //= ECS_ID_FLAGS_MASK
return entity // ECS_ENTITY_MASK, entity % ECS_GENERATION_MASK, _typeFlags
end
-- HIGH 24 bits LOW 24 bits
local function ECS_GENERATION(e: i53)
e //= 0x10
return e % ECS_GENERATION_MASK
end
local function ECS_ID(e: i53)
e //= 0x10
return e // ECS_ENTITY_MASK
end
local function ECS_GENERATION_INC(e: i53)
local id, generation, flags = separate(e)
return newId(id, generation + 1) + flags
end
-- gets the high ID
local function ECS_PAIR_FIRST(entity: i53): i24
entity //= 0x10
local first = entity % ECS_ENTITY_MASK
return first
end
-- gets the low ID
local ECS_PAIR_SECOND = ECS_ID
local function ECS_PAIR(source: number, target: number)
local id = newId(ECS_PAIR_SECOND(target), ECS_PAIR_SECOND(source)) + addFlags({ isPair = true })
return id
end
local function getAlive(entityIndex, id)
return entityIndex.dense[id]
end
local function ecs_pair_first(entityIndex, e)
assert(isPair(e))
return getAlive(entityIndex, ECS_PAIR_FIRST(e))
end
local function ecs_pair_second(entityIndex, e)
assert(isPair(e))
return getAlive(entityIndex, ECS_PAIR_SECOND(e))
end
function World.component(world: World)
local componentId = world.nextComponentId + 1
if componentId > HI_COMPONENT_ID then
-- IDs are partitioned into ranges because component IDs are not nominal,
-- so it needs to error when IDs intersect into the entity range.
error("Too many components, consider using world:entity() instead to create components.")
end
world.nextComponentId = componentId
return componentId
end
function World.entity(world: World)
local nextEntityId = world.nextEntityId + 1
world.nextEntityId = nextEntityId
local index = nextEntityId + REST
local id = newId(index, 0)
local entityIndex = world.entityIndex
entityIndex.sparse[id] = {
dense = index
} :: Record
entityIndex.dense[index] = id
return id
end
-- should reuse this logic in World.set instead of swap removing in transition archetype
local function destructColumns(columns, count, row)
if row == count then
for _, column in columns do
column[count] = nil
end
else
for _, column in columns do
column[row] = column[count]
column[count] = nil
end
end
end
local function archetypeDelete(entityIndex, record: Record, entityId: i53, destruct: boolean)
local sparse, dense = entityIndex.sparse, entityIndex.dense
local archetype = record.archetype
local row = record.row
local entities = archetype.entities
local last = #entities
local entityToMove = entities[last]
if row ~= last then
dense[record.dense] = entityToMove
sparse[entityToMove] = record
end
sparse[entityId] = nil
dense[#dense] = nil
entities[row], entities[last] = entities[last], nil
local columns = archetype.columns
if not destruct then
return
end
destructColumns(columns, last, row)
end
function World.delete(world: World, entityId: i53)
local entityIndex = world.entityIndex
local record = entityIndex.sparse[entityId]
if not record then
return
end
archetypeDelete(entityIndex, record, entityId, true)
end
export type World = typeof(World.new())
@ -300,22 +441,8 @@ local function archetypeTraverseAdd(world: World, componentId: i53, from: Archet
return add
end
local function ensureRecord(entityIndex: EntityIndex, entityId: i53): Record
local sparse = entityIndex.sparse
local dense = entityIndex.dense
local page = sparse[entityId]
if not page then
local i = #dense + 1
page = { dense = i } :: Record
sparse[entityId] = page
dense[i] = entityId
end
return page
end
function World.set(world: World, entityId: i53, componentId: i53, data: unknown)
local record = ensureRecord(world.entityIndex, entityId)
local record = world.entityIndex.sparse[entityId]
local from = record.archetype
local to = archetypeTraverseAdd(world, componentId, from)
@ -335,7 +462,6 @@ function World.set(world: World, entityId: i53, componentId: i53, data: unknown)
if #to.types > 0 then
-- When there is no previous archetype it should create the archetype
newEntity(entityId, record, to)
onNotifyAdd(world, to, from, record.row, {componentId})
end
end
@ -360,7 +486,7 @@ end
function World.remove(world: World, entityId: i53, componentId: i53)
local entityIndex = world.entityIndex
local record = ensureRecord(entityIndex, entityId)
local record = entityIndex.sparse[entityId]
local sourceArchetype = record.archetype
local destinationArchetype = archetypeTraverseRemove(world, componentId, sourceArchetype)
@ -579,129 +705,6 @@ function World.query(world: World, ...: i53): Query
return setmetatable({}, preparedQuery) :: any
end
function World.component(world: World)
local componentId = world.nextComponentId + 1
if componentId > HI_COMPONENT_ID then
-- IDs are partitioned into ranges because component IDs are not nominal,
-- so it needs to error when IDs intersect into the entity range.
error("Too many components, consider using world:entity() instead to create components.")
end
world.nextComponentId = componentId
return componentId
end
function World.entity(world: World)
local nextEntityId = world.nextEntityId + 1
world.nextEntityId = nextEntityId
return nextEntityId + REST
end
-- should reuse this logic in World.set instead of swap removing in transition archetype
local function destructColumns(columns, count, row)
if row == count then
for _, column in columns do
column[count] = nil
end
else
for _, column in columns do
column[row] = column[count]
column[count] = nil
end
end
end
local function archetypeDelete(entityIndex, record: Record, entityId: i53, destruct: boolean)
local sparse, dense = entityIndex.sparse, entityIndex.dense
local archetype = record.archetype
local row = record.row
local entities = archetype.entities
local last = #entities
local entityToMove = entities[last]
if row ~= last then
dense[record.dense] = entityToMove
sparse[entityToMove] = record
end
sparse[entityId] = nil
dense[#dense] = nil
entities[row], entities[last] = entities[last], nil
local columns = archetype.columns
if not destruct then
return
end
destructColumns(columns, last, row)
end
function World.delete(world: World, entityId: i53)
local entityIndex = world.entityIndex
local record = entityIndex.sparse[entityId]
if not record then
return
end
archetypeDelete(entityIndex, record, entityId, true)
end
function World.observer(world: World, ...)
local componentIds = {...}
local idsCount = #componentIds
local hooks = world.hooks
return {
event = function(event)
local hook = hooks[event]
hooks[event] = nil
local last, change
return function()
last, change = next(hook, last)
if not last then
return
end
local matched = false
local ids = change.ids
while not matched do
local skip = false
for _, id in ids do
if not table.find(componentIds, id) then
skip = true
break
end
end
if skip then
last, change = next(hook, last)
ids = change.ids
continue
end
matched = true
end
local queryOutput = table.create(idsCount)
local row = change.offset
local archetype = change.archetype
local columns = archetype.columns
local archetypeRecords = archetype.records
for index, id in componentIds do
queryOutput[index] = columns[archetypeRecords[id]][row]
end
return archetype.entities[row], unpack(queryOutput, 1, idsCount)
end
end;
}
end
function World.__iter(world: World): () -> (number?, unknown?)
local dense = world.entityIndex.dense
local sparse = world.entityIndex.sparse
@ -740,4 +743,12 @@ return table.freeze({
ON_ADD = ON_ADD;
ON_REMOVE = ON_REMOVE;
ON_SET = ON_SET;
ECS_ID = ECS_ID,
IS_PAIR = isPair,
ECS_PAIR = ECS_PAIR,
ECS_GENERATION = ECS_GENERATION,
ECS_GENERATION_INC = ECS_GENERATION_INC,
getAlive = getAlive,
ecs_pair_first = ecs_pair_first,
ecs_pair_second = ecs_pair_second
})

View file

@ -1,148 +0,0 @@
local testkit = require("../testkit")
local jecs = require("../lib/init")
local TEST, CASE, CHECK, FINISH, SKIP = testkit.test()
local N = 10
TEST("world:query", function()
do CASE "should query all matching entities"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local entities = {}
for i = 1, N do
local id = world:entity()
world:set(id, A, true)
if i > 5 then world:set(id, B, true) end
entities[i] = id
end
for id in world:query(A) do
table.remove(entities, CHECK(table.find(entities, id)))
end
CHECK(#entities == 0)
end
do CASE "should query all matching entities when irrelevant component is removed"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local entities = {}
for i = 1, N do
local id = world:entity()
world:set(id, A, true)
world:set(id, B, true)
if i > 5 then world:remove(id, B, true) end
entities[i] = id
end
local added = 0
for id in world:query(A) do
added += 1
table.remove(entities, CHECK(table.find(entities, id)))
end
CHECK(added == N)
end
do CASE "should query all entities without B"
local world = jecs.World.new()
local A = world:component()
local B = world:component()
local entities = {}
for i = 1, N do
local id = world:entity()
world:set(id, A, true)
if i < 5 then
entities[i] = id
else
world:set(id, B, true)
end
end
for id in world:query(A):without(B) do
table.remove(entities, CHECK(table.find(entities, id)))
end
CHECK(#entities == 0)
end
do CASE "should allow setting components in arbitrary order"
local world = jecs.World.new()
local Health = world:entity()
local Poison = world:component()
local id = world:entity()
world:set(id, Poison, 5)
world:set(id, Health, 50)
CHECK(world:get(id, Poison) == 5)
end
do CASE "Should allow deleting components"
local world = jecs.World.new()
local Health = world:entity()
local Poison = world:component()
local id = world:entity()
world:set(id, Poison, 5)
world:set(id, Health, 50)
world:delete(id)
CHECK(world:get(id, Poison) == nil)
CHECK(world:get(id, Health) == nil)
end
do CASE "Should allow iterating the whole world"
local world = jecs.World.new()
local A, B = world:entity(), world:entity()
local eA = world:entity()
world:set(eA, A, true)
local eB = world:entity()
world:set(eB, B, true)
local eAB = world:entity()
world:set(eAB, A, true)
world:set(eAB, B, true)
local count = 0
for id, data in world do
count += 1
if id == eA then
CHECK(data[A] == true)
CHECK(data[B] == nil)
elseif id == eB then
CHECK(data[B] == true)
CHECK(data[A] == nil)
elseif id == eAB then
CHECK(data[A] == true)
CHECK(data[B] == true)
else
error("unknown entity", id)
end
end
CHECK(count == 3)
end
end)
FINISH()

View file

@ -1,5 +1,12 @@
local testkit = require("../testkit")
local jecs = require("../lib/init")
local ECS_ID, ECS_GENERATION = jecs.ECS_ID, jecs.ECS_GENERATION
local ECS_GENERATION_INC = jecs.ECS_GENERATION_INC
local IS_PAIR = jecs.IS_PAIR
local ECS_PAIR = jecs.ECS_PAIR
local getAlive = jecs.getAlive
local ecs_pair_first = jecs.ecs_pair_first
local ecs_pair_second = jecs.ecs_pair_second
local TEST, CASE, CHECK, FINISH, SKIP = testkit.test()
@ -127,7 +134,7 @@ TEST("world", function()
CHECK(world:get(id, Poison) == 5)
end
do CASE "Should allow deleting components"
do CASE "should allow deleting components"
local world = jecs.World.new()
local Health = world:entity()
@ -149,6 +156,35 @@ TEST("world", function()
end
do CASE "should increment generation"
local world = jecs.World.new()
local e = world:entity()
local REST = 256 + 4
CHECK(ECS_ID(e) == 1 + REST)
CHECK(ECS_GENERATION(e) == 0) -- 0
e = ECS_GENERATION_INC(e)
CHECK(ECS_GENERATION(e) == 1) -- 1
end
do CASE "relations"
local world = jecs.World.new()
local _e = world:entity()
local e2 = world:entity()
local e3 = world:entity()
local REST = 256 + 4
CHECK(ECS_ID(e2) == 2 + REST)
CHECK(ECS_ID(e3) == 3 + REST)
CHECK(ECS_GENERATION(e2) == 0)
CHECK(ECS_GENERATION(e3) == 0)
CHECK(IS_PAIR(world:entity()) == false)
local pair = ECS_PAIR(e2, e3)
CHECK(IS_PAIR(pair) == true)
CHECK(ecs_pair_first(world.entityIndex, pair) == e2)
CHECK(ecs_pair_second(world.entityIndex, pair) == e3)
end
end)
FINISH()