From c3a88a6fd9c45b8f51cd6d37bfe380fc3c5fbe4a Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 26 Aug 2024 12:31:58 -0400 Subject: [PATCH] Add the ability to buffer commands (#88) Co-authored-by: Marcus --- CHANGELOG.md | 12 + lib/Loop.luau | 48 +- lib/Loop.spec.luau | 24 - lib/World.luau | 507 ++++++++--------- lib/World.spec.luau | 1206 +++++++++++++++++++++-------------------- lib/component.luau | 7 + lib/init.luau | 2 +- testez-companion.toml | 1 + 8 files changed, 940 insertions(+), 867 deletions(-) create mode 100644 testez-companion.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eaf0a25..3a1f5f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ The format is based on [Keep a Changelog][kac], and this project adheres to ## [Unreleased] +### Added + +- Implemented a deferred command mode for the registry. + - The Loop turns deferring on for all worlds given to it. + - The command buffer is flushed between systems. + - Iterator invalidation is now only prevented in deferred mode. + +### Deprecated + +- Deprecated the return type of `World:remove()` because it can now be inaccurate. +- Deprecated `World:optimizeQueries()` because it no longer does anything. + ## [0.8.4] - 2024-08-15 ### Added diff --git a/lib/Loop.luau b/lib/Loop.luau index 91e3606b..c7b2a21a 100644 --- a/lib/Loop.luau +++ b/lib/Loop.luau @@ -1,4 +1,6 @@ local RunService = game:GetService("RunService") + +local World = require(script.Parent.World) local rollingAverage = require(script.Parent.rollingAverage) local topoRuntime = require(script.Parent.topoRuntime) @@ -365,16 +367,25 @@ function Loop:begin(events) generation = not generation - local dirtyWorlds: { [any]: true } = {} local profiling = self.profiling + local worlds: { World.World } = {} + for _, stateArgument in self._state do + if typeof(stateArgument) == "table" and getmetatable(stateArgument) == World then + table.insert(worlds, stateArgument) + end + end + + for _, world in worlds do + world:startDeferring() + end + for _, system in ipairs(self._orderedSystemsByEvent[eventName]) do topoRuntime.start({ system = self._systemState[system], frame = { generation = generation, deltaTime = deltaTime, - dirtyWorlds = dirtyWorlds, logs = self._systemLogs[system], }, currentSystem = system, @@ -383,13 +394,26 @@ function Loop:begin(events) if profiling then profiling[system] = nil end + return end local fn = systemFn(system) - debug.profilebegin("system: " .. systemName(system)) - - local thread = coroutine.create(fn) + local name = systemName(system) + + debug.profilebegin("system: " .. name) + local commitFailed = false + local thread = coroutine.create(function(...) + fn(...) + + for _, world in worlds do + local ok, err = pcall(world.commitCommands, world) + if not ok then + commitFailed = true + error(err) + end + end + end) local startTime = os.clock() local success, errorValue = coroutine.resume(thread, unpack(self._state, 1, self._stateLength)) @@ -431,20 +455,20 @@ function Loop:begin(events) ) end - for world in dirtyWorlds do - world:optimizeQueries() - end - table.clear(dirtyWorlds) - if not success then if os.clock() - recentErrorLastTime > 10 then recentErrorLastTime = os.clock() recentErrors = {} end - local errorString = systemName(system) + local errorString = name .. ": " - .. tostring(errorValue) + .. ( + if commitFailed + -- Strip irrelevant line numbers that point to Loop / World + then string.gsub(errorValue, "%[.+%]:%d+: ", "Failed to apply commands: ") + else errorValue + ) .. "\n" .. debug.traceback(thread) diff --git a/lib/Loop.spec.luau b/lib/Loop.spec.luau index 37ad9c96..cde05c40 100644 --- a/lib/Loop.spec.luau +++ b/lib/Loop.spec.luau @@ -1,7 +1,5 @@ local Loop = require(script.Parent.Loop) local useHookState = require(script.Parent.topoRuntime).useHookState -local World = require(script.Parent.World) -local component = require(script.Parent).component local BindableEvent = require(script.Parent.mock.BindableEvent) local bindable = BindableEvent.new() @@ -609,27 +607,5 @@ return function() expect(called[2]).to.equal(2) expect(called[3]).to.equal(3) end) - - it("should optimize queries of worlds used inside it", function() - local world = World.new() - local loop = Loop.new(world) - - local A = component() - - world:spawn(A()) - - loop:scheduleSystem(function(world) - world:query(A) - end) - - local bindable = BindableEvent.new() - loop:begin({ - default = bindable.Event, - }) - - bindable:Fire() - - expect(#world._storages).to.equal(1) - end) end) end diff --git a/lib/World.luau b/lib/World.luau index d9e63479..53cc4219 100644 --- a/lib/World.luau +++ b/lib/World.luau @@ -2,13 +2,40 @@ local Component = require(script.Parent.component) local archetypeModule = require(script.Parent.archetype) local topoRuntime = require(script.Parent.topoRuntime) -local assertValidComponentInstance = Component.assertValidComponentInstance +local assertValidComponentInstances = Component.assertValidComponentInstances local assertValidComponent = Component.assertValidComponent local assertComponentArgsProvided = Component.assertComponentArgsProvided local archetypeOf = archetypeModule.archetypeOf local negateArchetypeOf = archetypeModule.negateArchetypeOf local areArchetypesCompatible = archetypeModule.areArchetypesCompatible +local ERROR_NO_ENTITY = "Entity doesn't exist, use world:contains to check if needed" +local ERROR_EXISTING_ENTITY = + "The world already contains an entity with ID %s. Use world:replace instead if this is intentional." + +-- The old solver is not great at resolving intersections, so we redefine entityId each time. +type DespawnCommand = { type: "despawn", entityId: number } + +type InsertCommand = { + type: "insert", + entityId: number, + componentInstances: { [any]: any }, +} + +type RemoveCommand = { + type: "remove", + entityId: number, + components: { [any]: any }, +} + +type ReplaceCommand = { + type: "replace", + entityId: number, + componentInstances: { [any]: any }, +} + +type Command = DespawnCommand | InsertCommand | RemoveCommand | ReplaceCommand + local function assertEntityExists(world, id: number) assert(world:contains(id), "Entity doesn't exist, use world:contains to check if needed") end @@ -32,13 +59,13 @@ World.__index = World Creates a new World. ]=] function World.new() - local firstStorage = {} - return setmetatable({ - -- List of maps from archetype string --> entity ID --> entity data - _storages = { firstStorage }, - -- The most recent storage that has not been dirtied by an iterator - _pristineStorage = firstStorage, + -- Map from archetype string --> entity ID --> entity data + storage = {}, + + deferring = false, + commands = {} :: { Command }, + markedForDeletion = {}, -- Map from entity ID -> archetype string _entityArchetypes = {}, @@ -65,37 +92,11 @@ function World.new() }, World) end --- Searches all archetype storages for the entity with the given archetype --- Returns the storage that the entity is in if it exists, otherwise nil -function World:_getStorageWithEntity(archetype, id) - for _, storage in self._storages do - local archetypeStorage = storage[archetype] - if archetypeStorage then - if archetypeStorage[id] then - return storage - end - end - end - return nil -end - -function World:_markStorageDirty() - local newStorage = {} - table.insert(self._storages, newStorage) - self._pristineStorage = newStorage - - if topoRuntime.withinTopoContext() then - local frameState = topoRuntime.useFrameState() - - frameState.dirtyWorlds[self] = true - end -end +export type World = typeof(World.new()) function World:_getEntity(id) local archetype = self._entityArchetypes[id] - local storage = self:_getStorageWithEntity(archetype, id) - - return storage[archetype][id] + return self.storage[archetype][id] end function World:_next(last) @@ -105,9 +106,7 @@ function World:_next(last) return nil end - local storage = self:_getStorageWithEntity(archetype, entityId) - - return entityId, storage[archetype][entityId] + return entityId, self.storage[archetype][entityId] end --[=[ @@ -129,6 +128,162 @@ function World:__iter() return World._next, self end +local function executeDespawn(world: World, despawnCommand: DespawnCommand) + local id = despawnCommand.entityId + local entity = world:_getEntity(id) + + for metatable, component in pairs(entity) do + world:_trackChanged(metatable, id, component, nil) + end + + local shouldOnlyClear = world.deferring and world.markedForDeletion[id] ~= true + world._entityMetatablesCache[id] = if shouldOnlyClear then {} else nil + world:_transitionArchetype(id, if shouldOnlyClear then {} else nil) + + world._size -= 1 +end + +local function executeInsert(world: World, insertCommand: InsertCommand) + debug.profilebegin("World:insert") + + local id = insertCommand.entityId + local entity = world:_getEntity(id) + local wasNew = false + for _, componentInstance in insertCommand.componentInstances do + local metatable = getmetatable(componentInstance) + local oldComponent = entity[metatable] + + if not oldComponent then + wasNew = true + table.insert(world._entityMetatablesCache[id], metatable) + end + + world:_trackChanged(metatable, id, oldComponent, componentInstance) + entity[metatable] = componentInstance + end + + if wasNew then + world:_transitionArchetype(id, entity) + end + + debug.profileend() +end + +local function executeReplace(world: World, replaceCommand: ReplaceCommand) + local id = replaceCommand.entityId + if not world:contains(id) then + error(ERROR_NO_ENTITY, 2) + end + + local components = {} + local metatables = {} + local entity = world:_getEntity(id) + + for _, componentInstance in replaceCommand.componentInstances do + local metatable = getmetatable(componentInstance) + world:_trackChanged(metatable, id, entity[metatable], componentInstance) + + components[metatable] = componentInstance + table.insert(metatables, metatable) + end + + for metatable, component in pairs(entity) do + if not components[metatable] then + world:_trackChanged(metatable, id, component, nil) + end + end + + world._entityMetatablesCache[id] = metatables + world:_transitionArchetype(id, components) +end + +local function executeRemove(world: World, removeCommand: RemoveCommand) + local id = removeCommand.entityId + local entity = world:_getEntity(id) + + local removed = {} + for index, metatable in removeCommand.components do + local oldComponent = entity[metatable] + removed[index] = oldComponent + + world:_trackChanged(metatable, id, oldComponent, nil) + entity[metatable] = nil + end + + -- Rebuild entity metatable cache + local metatables = {} + for metatable in pairs(entity) do + table.insert(metatables, metatable) + end + + world._entityMetatablesCache[id] = metatables + world:_transitionArchetype(id, entity) +end + +local function processCommand(world: World, command: Command) + if command.type == "insert" then + executeInsert(world, command) + elseif command.type == "despawn" then + executeDespawn(world, command) + elseif command.type == "remove" then + executeRemove(world, command) + elseif command.type == "replace" then + executeReplace(world, command) + else + error(`Unknown command type: {command.type}`) + end +end + +local function bufferCommand(world: World, command: Command) + if world.deferring then + -- We want to ignore commands that succeed a deletion. + -- Spawn isn't considered a command, and so it never reaches here. + local markedForDeletion = world.markedForDeletion + if markedForDeletion[command.entityId] then + return + end + + if command.type == "despawn" then + markedForDeletion[command.entityId] = true + end + + table.insert(world.commands, command) + else + processCommand(world, command) + end +end + +--[=[ + Starts deferring entity commands. + + If you are using a [`Loop`](/api/Loop), this is done for you. +]=] +function World:startDeferring() + self.deferring = true +end + +--[=[ + Sequentially processes all of the commands in the buffer. + + If you are using a [`Loop`](/api/Loop), this is called after every system. + However, you can call it more often if you want. +]=] +function World:commitCommands() + for _, command in self.commands do + processCommand(self, command) + end + + table.clear(self.commands) +end + +--[=[ + Stops deferring entity commands and processes all commands left in the buffer. +]=] +function World:stopDeferring() + self:commitCommands() + self.deferring = false +end + --[=[ Spawns a new entity in the world with the given components. @@ -149,46 +304,27 @@ end @return number -- The same entity ID that was passed in ]=] function World:spawnAt(id, ...) - if self:contains(id) then - error( - string.format( - "The world already contains an entity with ID %d. Use World:replace instead if this is intentional.", - id - ), - 2 - ) - end - - self._size += 1 - if id >= self._nextId then self._nextId = id + 1 end - local components = {} - local metatables = {} - - for i = 1, select("#", ...) do - local newComponent = select(i, ...) - - assertValidComponentInstance(newComponent, i) - - local metatable = getmetatable(newComponent) + local componentInstances = { ... } + assertValidComponentInstances(componentInstances) - if components[metatable] then - error(("Duplicate component type at index %d"):format(i), 2) - end - - self:_trackChanged(metatable, id, nil, newComponent) - - components[metatable] = newComponent - table.insert(metatables, metatable) + local willBeDeleted = self.markedForDeletion[id] ~= nil + if self:contains(id) and not willBeDeleted then + error(string.format(ERROR_EXISTING_ENTITY, id), 2) end - self._entityMetatablesCache[id] = metatables + if not willBeDeleted then + self._size += 1 + end - self:_transitionArchetype(id, components) + self.markedForDeletion[id] = nil + self._entityMetatablesCache[id] = {} + self:_transitionArchetype(id, {}) + bufferCommand(self, { type = "insert", entityId = id, componentInstances = componentInstances }) return id end @@ -199,11 +335,9 @@ function World:_newQueryArchetype(queryArchetype) return -- Archetype isn't actually new end - for _, storage in self._storages do - for entityArchetype in storage do - if areArchetypesCompatible(queryArchetype, entityArchetype) then - self._queryCache[queryArchetype][entityArchetype] = true - end + for entityArchetype in self.storage do + if areArchetypesCompatible(queryArchetype, entityArchetype) then + self._queryCache[queryArchetype][entityArchetype] = true end end end @@ -217,16 +351,14 @@ function World:_updateQueryCache(entityArchetype) end function World:_transitionArchetype(id, components) - debug.profilebegin("transitionArchetype") + debug.profilebegin("World:transitionArchetype") + local storage = self.storage local newArchetype = nil local oldArchetype = self._entityArchetypes[id] - local oldStorage if oldArchetype then - oldStorage = self:_getStorageWithEntity(oldArchetype, id) - if not components then - oldStorage[oldArchetype][id] = nil + storage[oldArchetype][id] = nil end end @@ -234,12 +366,12 @@ function World:_transitionArchetype(id, components) newArchetype = archetypeOf(unpack(self._entityMetatablesCache[id])) if oldArchetype ~= newArchetype then - if oldStorage then - oldStorage[oldArchetype][id] = nil + if oldArchetype then + storage[oldArchetype][id] = nil end - if self._pristineStorage[newArchetype] == nil then - self._pristineStorage[newArchetype] = {} + if storage[newArchetype] == nil then + storage[newArchetype] = {} end if self._entityArchetypeCache[newArchetype] == nil then @@ -248,9 +380,10 @@ function World:_transitionArchetype(id, components) self:_updateQueryCache(newArchetype) debug.profileend() end - self._pristineStorage[newArchetype][id] = components + + storage[newArchetype][id] = components else - oldStorage[newArchetype][id] = components + storage[newArchetype][id] = components end end @@ -267,38 +400,10 @@ end @param ... ComponentInstance -- The component values to spawn the entity with. ]=] function World:replace(id, ...) - assertWorldOperationIsValid(self, id, ...) - - local components = {} - local metatables = {} - local entity = self:_getEntity(id) - - for i = 1, select("#", ...) do - local newComponent = select(i, ...) - - assertValidComponentInstance(newComponent, i) - - local metatable = getmetatable(newComponent) - - if components[metatable] then - error(("Duplicate component type at index %d"):format(i), 2) - end - - self:_trackChanged(metatable, id, entity[metatable], newComponent) + local componentInstances = { ... } + assertValidComponentInstances(componentInstances) - components[metatable] = newComponent - table.insert(metatables, metatable) - end - - for metatable, component in pairs(entity) do - if not components[metatable] then - self:_trackChanged(metatable, id, component, nil) - end - end - - self._entityMetatablesCache[id] = metatables - - self:_transitionArchetype(id, components) + bufferCommand(self, { type = "replace", entityId = id, componentInstances = componentInstances }) end --[=[ @@ -307,16 +412,11 @@ end @param id number -- The entity ID ]=] function World:despawn(id) - local entity = self:_getEntity(id) - - for metatable, component in pairs(entity) do - self:_trackChanged(metatable, id, component, nil) + if not self:contains(id) then + error(ERROR_NO_ENTITY, 2) end - self._entityMetatablesCache[id] = nil - self:_transitionArchetype(id, nil) - - self._size -= 1 + bufferCommand(self, { type = "despawn", entityId = id }) end --[=[ @@ -327,9 +427,10 @@ end ::: ]=] function World:clear() - local firstStorage = {} - self._storages = { firstStorage } - self._pristineStorage = firstStorage + self.storage = {} + self.commands = {} + self.markedForDeletion = {} + self._entityArchetypes = {} self._entityMetatablesCache = {} self._size = 0 @@ -426,7 +527,6 @@ QueryResult.__index = QueryResult function QueryResult.new(world, expand, queryArchetype, compatibleArchetypes, metatables) return setmetatable({ world = world, - seenEntities = {}, currentCompatibleArchetype = next(compatibleArchetypes), compatibleArchetypes = compatibleArchetypes, storageIndex = 1, @@ -439,53 +539,32 @@ end local function nextItem(query) local world = query.world local currentCompatibleArchetype = query.currentCompatibleArchetype - local seenEntities = query.seenEntities local compatibleArchetypes = query.compatibleArchetypes local entityId, entityData - local storages = world._storages - repeat - local nextStorage = storages[query.storageIndex] - local currently = nextStorage[currentCompatibleArchetype] - if currently then - entityId, entityData = next(currently, query.lastEntityId) - end - - while entityId == nil do - currentCompatibleArchetype = next(compatibleArchetypes, currentCompatibleArchetype) - - if currentCompatibleArchetype == nil then - query.storageIndex += 1 - - nextStorage = storages[query.storageIndex] - - if nextStorage == nil or next(nextStorage) == nil then - return - end - - currentCompatibleArchetype = nil - - if world._pristineStorage == nextStorage then - world:_markStorageDirty() - end + local storage = world.storage + local currently = storage[currentCompatibleArchetype] + if currently then + entityId, entityData = next(currently, query.lastEntityId) + end - continue - elseif nextStorage[currentCompatibleArchetype] == nil then - continue - end + while entityId == nil do + currentCompatibleArchetype = next(compatibleArchetypes, currentCompatibleArchetype) - entityId, entityData = next(nextStorage[currentCompatibleArchetype]) + if currentCompatibleArchetype == nil then + return nil + elseif storage[currentCompatibleArchetype] == nil then + continue end - query.lastEntityId = entityId + entityId, entityData = next(storage[currentCompatibleArchetype]) + end - until seenEntities[entityId] == nil + query.lastEntityId = entityId query.currentCompatibleArchetype = currentCompatibleArchetype - seenEntities[entityId] = true - return entityId, entityData end @@ -830,10 +909,6 @@ function World:query(...) return entityId, unpack(queryOutput, 1, queryLength) end - if self._pristineStorage == self._storages[1] then - self:_markStorageDirty() - end - return QueryResult.new(self, expand, archetype, compatibleArchetypes, metatables) end @@ -1013,34 +1088,10 @@ function World:insert(id, ...) assertWorldOperationIsValid(self, id, ...) - local entity = self:_getEntity(id) - - local wasNew = false - for i = 1, select("#", ...) do - local newComponent = select(i, ...) - - assertValidComponentInstance(newComponent, i) - - local metatable = getmetatable(newComponent) - - local oldComponent = entity[metatable] - - if not oldComponent then - wasNew = true - - table.insert(self._entityMetatablesCache[id], metatable) - end - - self:_trackChanged(metatable, id, oldComponent, newComponent) - - entity[metatable] = newComponent - end + local componentInstances = { ... } + assertValidComponentInstances(componentInstances) - if wasNew then -- wasNew - self:_transitionArchetype(id, entity) - end - - debug.profileend() + bufferCommand(self, { type = "insert", entityId = id, componentInstances = componentInstances }) end --[=[ @@ -1052,41 +1103,23 @@ end @param id number -- The entity ID @param ... Component -- The components to remove - @return ...ComponentInstance -- Returns the component instance values that were removed in the order they were passed. ]=] function World:remove(id, ...) assertWorldOperationIsValid(self, id, ...) - local entity = self:_getEntity(id) - - local length = select("#", ...) - local removed = {} - - for i = 1, length do - local metatable = select(i, ...) - - assertValidComponent(metatable, i) - - local oldComponent = entity[metatable] + local components = { ... } + local length = #components - removed[i] = oldComponent - - self:_trackChanged(metatable, id, oldComponent, nil) - - entity[metatable] = nil - end - - -- Rebuild entity metatable cache - local metatables = {} + local entity = self:_getEntity(id) + local removed = table.create(length, nil) + for index, component in components do + assertValidComponent(component, index) - for metatable in pairs(entity) do - table.insert(metatables, metatable) + local oldComponent = entity[component] + removed[index] = oldComponent end - self._entityMetatablesCache[id] = metatables - - self:_transitionArchetype(id, entity) - + bufferCommand(self, { type = "remove", entityId = id, components = components }) return unpack(removed, 1, length) end @@ -1110,35 +1143,9 @@ end [World:query]. While inside a query, any changes to the World are stored in a separate location from the rest of the World. Calling this function combines the separate storage back into the main storage, which speeds things up again. -]=] -function World:optimizeQueries() - if #self._storages == 1 then - return - end - local firstStorage = self._storages[1] - - for i = 2, #self._storages do - local storage = self._storages[i] - - for archetype, entities in storage do - if firstStorage[archetype] == nil then - firstStorage[archetype] = entities - else - for entityId, entityData in entities do - if firstStorage[archetype][entityId] then - error("Entity ID already exists in first storage...") - end - firstStorage[archetype][entityId] = entityData - end - end - end - end - - table.clear(self._storages) - - self._storages[1] = firstStorage - self._pristineStorage = firstStorage -end + @deprecated v0.9.0 -- With the introduction of command buffering only one storage will ever exist at a time. +]=] +function World:optimizeQueries() end return World diff --git a/lib/World.spec.luau b/lib/World.spec.luau index d1660ef5..70ce066e 100644 --- a/lib/World.spec.luau +++ b/lib/World.spec.luau @@ -41,753 +41,799 @@ end return function() describe("World", function() - it("should be iterable", function() - local world = World.new() - local A = component() - local B = component() - - local eA = world:spawn(A()) - local eB = world:spawn(B()) - local eAB = world:spawn(A(), B()) - - local count = 0 - for id, data in world do - count += 1 - if id == eA then - expect(data[A]).to.be.ok() - expect(data[B]).to.never.be.ok() - elseif id == eB then - expect(data[B]).to.be.ok() - expect(data[A]).to.never.be.ok() - elseif id == eAB then - expect(data[A]).to.be.ok() - expect(data[B]).to.be.ok() - else - error("unknown entity", id) - end + describe("buffered", function() + local function createDeferredWorld() + local world = World.new() + world:startDeferring() + + return world end - expect(count).to.equal(3) - end) + it("should spawn immediately", function() + local world = World.new() + world:startDeferring() + world:spawnAt(1) + expect(world:contains(1)).to.equal(true) + end) - it("should have correct size", function() - local world = World.new() - world:spawn() - world:spawn() - world:spawn() + it("should not despawn immediately", function() + local world = World.new() + world:startDeferring() + world:spawnAt(1) + world:despawn(1) + expect(world:contains(1)).to.equal(true) + world:commitCommands() + expect(world:contains(1)).to.equal(false) + end) - local id = world:spawn() - world:despawn(id) + it("should allow spawnAt on an entity marked for deletion", function() + local world = createDeferredWorld() + world:spawnAt(1) + world:despawn(1) + world:spawnAt(1) + world:despawn(1) + world:spawnAt(1) + world:commitCommands() - expect(world:size()).to.equal(3) + expect(world:contains(1)).to.equal(true) + end) - world:clear() + it("should not invalidate iterators when deferring", function() + local world = World.new() + local A = component() + local B = component() + local C = component() - expect(world:size()).to.equal(0) - end) + for _ = 1, 10 do + world:spawn(A(), B()) + end - it("should report contains correctly", function() - local world = World.new() - local id = world:spawn() + world:startDeferring() + local count = 0 + for id in world:query(A) do + count += 1 + world:insert(id, C()) + world:remove(id, B) + end + world:stopDeferring() + expect(count).to.equal(10) + end) - expect(world:contains(id)).to.equal(true) - expect(world:contains(1234124124124124124124)).to.equal(false) - end) + it("should handle many operations", function() + local world = createDeferredWorld() + + local A = component() + local B = component() + world:spawn(A({ + a = 1, + })) + world:spawn(A({ + a = 2, + })) + + world:commitCommands() + + world:despawn(1) + expect(world:contains(1)).to.equal(true) - it("should allow spawning entities at a specific ID", function() - local world = World.new() + world:commitCommands() - local A = component() - local id = world:spawnAt(5, A()) + expect(world:contains(1)).to.equal(false) - expect(function() - world:spawnAt(5, A()) - end).to.throw() + local b = B({}) + world:insert(2, b) + expect(world:get(2, B)).to.never.be.ok() - expect(id).to.equal(5) + world:commitCommands() - local nextId = world:spawn(A()) - expect(nextId).to.equal(6) + expect(world:get(2, B)).to.equal(b) + end) + + it("should have correct size", function() + local world = createDeferredWorld() + expect(world:size()).to.equal(0) + + world:spawnAt(1) + expect(world:size()).to.equal(1) + world:despawn(1) + expect(world:size()).to.equal(1) + world:commitCommands() + expect(world:size()).to.equal(0) + end) end) - it("should allow inserting and removing components from existing entities", function() - local world = World.new() + describe("immediate", function() + it("should be iterable", function() + local world = World.new() + local A = component() + local B = component() - local Player = component() - local Health = component() - local Poison = component() + local eA = world:spawn(A()) + local eB = world:spawn(B()) + local eAB = world:spawn(A(), B()) - local id = world:spawn(Player(), Poison()) + local count = 0 + for id, data in world do + count += 1 + if id == eA then + expect(data[A]).to.be.ok() + expect(data[B]).to.never.be.ok() + elseif id == eB then + expect(data[B]).to.be.ok() + expect(data[A]).to.never.be.ok() + elseif id == eAB then + expect(data[A]).to.be.ok() + expect(data[B]).to.be.ok() + else + error("unknown entity", id) + end + end - expect(world:query(Player):next()).to.be.ok() - expect(world:query(Health):next()).to.never.be.ok() + expect(count).to.equal(3) + end) - world:insert(id, Health()) + it("should have correct size", function() + local world = World.new() + world:spawn() + world:spawn() + world:spawn() - expect(world:query(Player):next()).to.be.ok() - expect(world:query(Health):next()).to.be.ok() - expect(world:size()).to.equal(1) + local id = world:spawn() + world:despawn(id) - local player, poison = world:remove(id, Player, Poison) + expect(world:size()).to.equal(3) - expect(getmetatable(player)).to.equal(Player) - expect(getmetatable(poison)).to.equal(Poison) + world:clear() - expect(world:query(Player):next()).to.never.be.ok() - expect(world:query(Health):next()).to.be.ok() - expect(world:size()).to.equal(1) - end) + expect(world:size()).to.equal(0) + end) - it("should not find any entities", function() - local world = World.new() + it("should report contains correctly", function() + local world = World.new() + local id = world:spawn() - local Hello = component() - local Bob = component() - local Shirley = component() + expect(world:contains(id)).to.equal(true) + expect(world:contains(1234124124124124124124)).to.equal(false) + end) - local _helloBob = world:spawn(Hello(), Bob()) - local _helloShirley = world:spawn(Hello(), Shirley()) + it("should allow spawning entities at a specific ID", function() + local world = World.new() - local withoutCount = 0 - for _ in world:query(Hello):without(Bob, Shirley) do - withoutCount += 1 - end + local A = component() + local id = world:spawnAt(5, A()) - expect(withoutCount).to.equal(0) - end) + expect(function() + world:spawnAt(5, A()) + end).to.throw() - it("should be queryable", function() - local world = World.new() - - local Player = component() - local Health = component() - local Poison = component() - - local one = world:spawn( - Player({ - name = "alice", - }), - Health({ - value = 100, - }), - Poison() - ) - - world:spawn( -- Spawn something we don't want to get back - component()(), - component()() - ) - - local two = world:spawn( - Player({ - name = "bob", - }), - Health({ - value = 99, - }) - ) + expect(id).to.equal(5) - local found = {} - local foundCount = 0 + local nextId = world:spawn(A()) + expect(nextId).to.equal(6) + end) - for entityId, player, health in world:query(Player, Health) do - foundCount += 1 - found[entityId] = { - [Player] = player, - [Health] = health, - } - end + it("should allow inserting and removing components from existing entities", function() + local world = World.new() - expect(foundCount).to.equal(2) + local Player = component() + local Health = component() + local Poison = component() - expect(found[one]).to.be.ok() - expect(found[one][Player].name).to.equal("alice") - expect(found[one][Health].value).to.equal(100) + local id = world:spawn(Player(), Poison()) - expect(found[two]).to.be.ok() - expect(found[two][Player].name).to.equal("bob") - expect(found[two][Health].value).to.equal(99) + expect(world:query(Player):next()).to.be.ok() + expect(world:query(Health):next()).to.never.be.ok() - local count = 0 - for id, player in world:query(Player) do - expect(type(player.name)).to.equal("string") - expect(type(id)).to.equal("number") - count += 1 - end - expect(count).to.equal(2) + world:insert(id, Health()) - local withoutCount = 0 - for _id, _player in world:query(Player):without(Poison) do - withoutCount += 1 - end + expect(world:query(Player):next()).to.be.ok() + expect(world:query(Health):next()).to.be.ok() + expect(world:size()).to.equal(1) - expect(withoutCount).to.equal(1) - end) + local player, poison = world:remove(id, Player, Poison) - it("should return an empty query with the same methods", function() - local world = World.new() + expect(getmetatable(player)).to.equal(Player) + expect(getmetatable(poison)).to.equal(Poison) - local Player = component() - local Enemy = component() + expect(world:query(Player):next()).to.never.be.ok() + expect(world:query(Health):next()).to.be.ok() + expect(world:size()).to.equal(1) + end) - expect(world:query(Player):next()).to.equal(nil) - expect(#world:query(Player):snapshot()).to.equal(0) + it("should not find any entities", function() + local world = World.new() - expect(world:query(Player):without(Enemy):next()).to.equal(world:query(Player):next()) + local Hello = component() + local Bob = component() + local Shirley = component() - expect(world:query(Player):view():get()).to.equal(nil) - expect(world:query(Player):view():contains()).to.equal(false) + local _helloBob = world:spawn(Hello(), Bob()) + local _helloShirley = world:spawn(Hello(), Shirley()) - local viewCount = 0 - for _ in world:query(Player):view() do - viewCount += 1 - end + local withoutCount = 0 + for _ in world:query(Hello):without(Bob, Shirley) do + withoutCount += 1 + end - expect(viewCount).to.equal(0) - end) + expect(withoutCount).to.equal(0) + end) - it("should allow getting single components", function() - local world = World.new() + it("should be queryable", function() + local world = World.new() - local Player = component() - local Health = component() - local Other = component() + local Player = component() + local Health = component() + local Poison = component() + + local one = world:spawn( + Player({ + name = "alice", + }), + Health({ + value = 100, + }), + Poison() + ) + + world:spawn( -- Spawn something we don't want to get back + component()(), + component()() + ) + + local two = world:spawn( + Player({ + name = "bob", + }), + Health({ + value = 99, + }) + ) + + local found = {} + local foundCount = 0 + + for entityId, player, health in world:query(Player, Health) do + foundCount += 1 + found[entityId] = { + [Player] = player, + [Health] = health, + } + end - local id = world:spawn(Other({ a = 1 }), Player({ b = 2 }), Health({ c = 3 })) + expect(foundCount).to.equal(2) - expect(world:get(id, Player).b).to.equal(2) - expect(world:get(id, Health).c).to.equal(3) + expect(found[one]).to.be.ok() + expect(found[one][Player].name).to.equal("alice") + expect(found[one][Health].value).to.equal(100) - local one, two = world:get(id, Health, Player) + expect(found[two]).to.be.ok() + expect(found[two][Player].name).to.equal("bob") + expect(found[two][Health].value).to.equal(99) - expect(one.c).to.equal(3) - expect(two.b).to.equal(2) - end) + local count = 0 + for id, player in world:query(Player) do + expect(type(player.name)).to.equal("string") + expect(type(id)).to.equal("number") + count += 1 + end + expect(count).to.equal(2) - it("should return existing entities when creating queryChanged", function() - local world = World.new() + local withoutCount = 0 + for _id, _player in world:query(Player):without(Poison) do + withoutCount += 1 + end - local loop = Loop.new(world) + expect(withoutCount).to.equal(1) + end) - local A = component() + it("should return an empty query with the same methods", function() + local world = World.new() - local initial = { - world:spawn(A({ - a = 1, - })), - world:spawn(A({ - b = 2, - })), - } + local Player = component() + local Enemy = component() - local third + expect(world:query(Player):next()).to.equal(nil) + expect(#world:query(Player):snapshot()).to.equal(0) - local runCount = 0 - loop:scheduleSystem(function(world) - runCount += 1 + expect(world:query(Player):without(Enemy):next()).to.equal(world:query(Player):next()) - local map = {} - local count = 0 + expect(world:query(Player):view():get()).to.equal(nil) + expect(world:query(Player):view():contains()).to.equal(false) - for entityId, record in world:queryChanged(A) do - count += 1 - map[entityId] = record + local viewCount = 0 + for _ in world:query(Player):view() do + viewCount += 1 end - if runCount == 1 then - expect(count).to.equal(2) - expect(map[initial[1]].new.a).to.equal(1) - expect(map[initial[1]].old).to.equal(nil) - expect(map[initial[2]].new.b).to.equal(2) - else - expect(count).to.equal(1) - expect(map[third].new.c).to.equal(3) - end + expect(viewCount).to.equal(0) end) - local defaultBindable = BindableEvent.new() + it("should allow getting single components", function() + local world = World.new() - loop:begin({ default = defaultBindable.Event }) + local Player = component() + local Health = component() + local Other = component() - defaultBindable:Fire() + local id = world:spawn(Other({ a = 1 }), Player({ b = 2 }), Health({ c = 3 })) - expect(runCount).to.equal(1) + expect(world:get(id, Player).b).to.equal(2) + expect(world:get(id, Health).c).to.equal(3) - third = world:spawn(A({ - c = 3, - })) + local one, two = world:get(id, Health, Player) - defaultBindable:Fire() - expect(runCount).to.equal(2) - end) + expect(one.c).to.equal(3) + expect(two.b).to.equal(2) + end) - it("should find entity without and with component", function() - local world = World.new() + it("should return existing entities when creating queryChanged", function() + local world = World.new() - local Character = component("Character") - local LocalOwned = component("LocalOwned") + local loop = Loop.new(world) - local _helloBob = world:spawn(Character(), LocalOwned()) + local A = component() - local withoutCount = 0 - for _ in world:query(Character):without(LocalOwned) do - withoutCount += 1 - end + local initial = { + world:spawn(A({ + a = 1, + })), + world:spawn(A({ + b = 2, + })), + } - expect(withoutCount).to.equal(0) + local third - world:remove(_helloBob, LocalOwned) + local runCount = 0 + loop:scheduleSystem(function(world) + runCount += 1 - local withoutCount = 0 - for _ in world:query(Character):without(LocalOwned) do - withoutCount += 1 - end + local map = {} + local count = 0 - expect(withoutCount).to.equal(1) + for entityId, record in world:queryChanged(A) do + count += 1 + map[entityId] = record + end - world:insert(_helloBob, LocalOwned()) + if runCount == 1 then + expect(count).to.equal(2) + expect(map[initial[1]].new.a).to.equal(1) + expect(map[initial[1]].old).to.equal(nil) + expect(map[initial[2]].new.b).to.equal(2) + else + expect(count).to.equal(1) + expect(map[third].new.c).to.equal(3) + end + end) - local withoutCount = 0 - for _ in world:query(Character):without(LocalOwned) do - withoutCount += 1 - end + local defaultBindable = BindableEvent.new() - expect(withoutCount).to.equal(0) - end) + loop:begin({ default = defaultBindable.Event }) + + defaultBindable:Fire() + + expect(runCount).to.equal(1) + + third = world:spawn(A({ + c = 3, + })) + + world:commitCommands() - it("should track changes", function() - local world = World.new() + defaultBindable:Fire() + expect(runCount).to.equal(2) + end) + + it("should find entity without and with component", function() + local world = World.new() + + local Character = component("Character") + local LocalOwned = component("LocalOwned") + + local _helloBob = world:spawn(Character(), LocalOwned()) + + local withoutCount = 0 + for _ in world:query(Character):without(LocalOwned) do + withoutCount += 1 + end + + expect(withoutCount).to.equal(0) + + world:remove(_helloBob, LocalOwned) + + local withoutCount = 0 + for _ in world:query(Character):without(LocalOwned) do + withoutCount += 1 + end + + expect(withoutCount).to.equal(1) - local loop = Loop.new(world) + world:insert(_helloBob, LocalOwned()) - local A = component() - local B = component() - local C = component() + local withoutCount = 0 + for _ in world:query(Character):without(LocalOwned) do + withoutCount += 1 + end + + expect(withoutCount).to.equal(0) + end) + + it("should track changes", function() + local world = World.new() + + local loop = Loop.new(world) - local expectedResults = { - nil, - { - 1, + local A = component() + local B = component() + local C = component() + + local expectedResults = { + nil, { - new = { - generation = 1, + 1, + { + new = { + generation = 1, + }, }, }, - }, - { - 1, { - new = { - generation = 2, - }, - old = { - generation = 1, + 1, + { + new = { + generation = 2, + }, + old = { + generation = 1, + }, }, }, - }, - { - 2, { - new = { - generation = 1, + 2, + { + new = { + generation = 1, + }, }, }, - }, - nil, - { - 1, + nil, { - old = { - generation = 2, + 1, + { + old = { + generation = 2, + }, }, }, - }, - { - 2, { - old = { - generation = 1, + 2, + { + old = { + generation = 1, + }, }, }, - }, - } + } - local resultIndex = 0 + local resultIndex = 0 - local additionalQuery = C - loop:scheduleSystem(function(w) - local ran = false + local additionalQuery = C + loop:scheduleSystem(function(w) + local ran = false - for entityId, record in w:queryChanged(A) do - if additionalQuery then - if w:get(entityId, additionalQuery) == nil then - continue + for entityId, record in w:queryChanged(A) do + if additionalQuery then + if w:get(entityId, additionalQuery) == nil then + continue + end end - end - ran = true - resultIndex += 1 + ran = true + resultIndex += 1 - expect(entityId).to.equal(expectedResults[resultIndex][1]) + expect(entityId).to.equal(expectedResults[resultIndex][1]) - assertDeepEqual(record, expectedResults[resultIndex][2]) - end - - if not ran then - resultIndex += 1 - expect(expectedResults[resultIndex]).to.equal(nil) - end - end) - - local infrequentCount = 0 - loop:scheduleSystem({ - system = function(w) - infrequentCount += 1 + assertDeepEqual(record, expectedResults[resultIndex][2]) + end - local count = 0 - local results = {} - for entityId, record in w:queryChanged(A) do - count += 1 - results[entityId - 1] = record + if not ran then + resultIndex += 1 + expect(expectedResults[resultIndex]).to.equal(nil) end + end) + + local infrequentCount = 0 + loop:scheduleSystem({ + system = function(w) + infrequentCount += 1 + + local count = 0 + local results = {} + for entityId, record in w:queryChanged(A) do + count += 1 + results[entityId - 1] = record + end - if count == 0 then - expect(infrequentCount).to.equal(1) - else - if infrequentCount == 2 then - expect(count).to.equal(2) - - expect(results[0].old).to.equal(nil) - expect(results[0].new.generation).to.equal(2) - expect(results[1].old).to.equal(nil) - expect(results[1].new).to.equal(nil) - elseif infrequentCount == 3 then - expect(results[0].old.generation).to.equal(2) - expect(results[0].new).to.equal(nil) - expect(count).to.equal(1) + if count == 0 then + expect(infrequentCount).to.equal(1) else - error("infrequentCount too high") + if infrequentCount == 2 then + expect(count).to.equal(2) + + expect(results[0].old).to.equal(nil) + expect(results[0].new.generation).to.equal(2) + expect(results[1].old).to.equal(nil) + expect(results[1].new).to.equal(nil) + elseif infrequentCount == 3 then + expect(results[0].old.generation).to.equal(2) + expect(results[0].new).to.equal(nil) + expect(count).to.equal(1) + else + error("infrequentCount too high") + end end - end - end, - event = "infrequent", - }) + end, + event = "infrequent", + }) - local defaultBindable = BindableEvent.new() - local infrequentBindable = BindableEvent.new() + local defaultBindable = BindableEvent.new() + local infrequentBindable = BindableEvent.new() - loop:begin({ default = defaultBindable.Event, infrequent = infrequentBindable.Event }) + loop:begin({ default = defaultBindable.Event, infrequent = infrequentBindable.Event }) - defaultBindable:Fire() - infrequentBindable:Fire() + defaultBindable:Fire() + infrequentBindable:Fire() - local entityId = world:spawn( - A({ - generation = 1, - }), - C() - ) + local entityId = world:spawn( + A({ + generation = 1, + }), + C() + ) - defaultBindable:Fire() + world:commitCommands() - additionalQuery = nil + defaultBindable:Fire() - world:insert( - entityId, - A({ - generation = 2, - }) - ) + additionalQuery = nil - world:insert( - entityId, - B({ - foo = "bar", - }) - ) + world:insert( + entityId, + A({ + generation = 2, + }) + ) - local secondEntityId = world:spawn( - A({ - generation = 1, - }), - C() - ) + world:insert( + entityId, + B({ + foo = "bar", + }) + ) - defaultBindable:Fire() - defaultBindable:Fire() + local secondEntityId = world:spawn( + A({ + generation = 1, + }), + C() + ) - world:replace(secondEntityId, B()) + world:commitCommands() - infrequentBindable:Fire() + defaultBindable:Fire() + defaultBindable:Fire() - world:despawn(entityId) + world:replace(secondEntityId, B()) - defaultBindable:Fire() + world:commitCommands() - infrequentBindable:Fire() - end) + infrequentBindable:Fire() - it("should error when passing nil to query", function() - expect(function() - World.new():query(nil) - end).to.throw() - end) + world:despawn(entityId) - it("should error when passing an invalid table", function() - local world = World.new() - local id = world:spawn() + world:commitCommands() - expect(function() - world:insert(id, {}) - end).to.throw() - end) + defaultBindable:Fire() - it("should error when passing a Component instead of Component instance", function() - expect(function() - World.new():spawn(component()) - end).to.throw() - end) + infrequentBindable:Fire() + end) - it("should error when no components are passed to world:get", function() - expect(function() - local world = World.new() - local id = world:spawn() - world:get(id) - end).to.throw() - end) + it("should error when passing nil to query", function() + expect(function() + World.new():query(nil) + end).to.throw() + end) - it("should error when no components are passed to world:insert", function() - expect(function() + it("should error when passing an invalid table", function() local world = World.new() local id = world:spawn() - world:insert(id) - end).to.throw() - end) - it("should error when no components are passed to world:replace", function() - expect(function() - local world = World.new() - local id = world:spawn() - world:replace(id) - end).to.throw() - end) + expect(function() + world:insert(id, {}) + end).to.throw() + end) - it("should error when no components are passed to world:remove", function() - expect(function() - local world = World.new() - local id = world:spawn() - world:remove(id) - end).to.throw() - end) + it("should error when passing a Component instead of Component instance", function() + expect(function() + World.new():spawn(component()) + end).to.throw() + end) - it("should error when entity does not exist (get, insert, replace, remove)", function() - expect(function() + it("should allow snapshotting a query", function() local world = World.new() - world:get(2000) - world:insert(2000) - world:replace(2000) - world:remove(2000) - - world:get() - world:insert() - world:replace() - world:remove() - end).to.throw() - end) - - it("should allow snapshotting a query", function() - local world = World.new() - - local Player = component() - local Health = component() - local Poison = component() - - local one = world:spawn( - Player({ - name = "alice", - }), - Health({ - value = 100, - }), - Poison() - ) - - world:spawn( -- Spawn something we don't want to get back - component()(), - component()() - ) - - local two = world:spawn( - Player({ - name = "bob", - }), - Health({ - value = 99, - }) - ) - - local query = world:query(Health, Player) - local snapshot = query:snapshot() - - for entityId, health, player in snapshot do - expect(type(entityId)).to.equal("number") - expect(type(player.name)).to.equal("string") - expect(type(health.value)).to.equal("number") - end + local Player = component() + local Health = component() + local Poison = component() + + local one = world:spawn( + Player({ + name = "alice", + }), + Health({ + value = 100, + }), + Poison() + ) + + world:spawn( -- Spawn something we don't want to get back + component()(), + component()() + ) + + local two = world:spawn( + Player({ + name = "bob", + }), + Health({ + value = 99, + }) + ) + + local query = world:query(Health, Player) + local snapshot = query:snapshot() + + for entityId, health, player in snapshot do + expect(type(entityId)).to.equal("number") + expect(type(player.name)).to.equal("string") + expect(type(health.value)).to.equal("number") + end - world:remove(two, Health) - world:despawn(one) + world:remove(two, Health) + world:despawn(one) - if snapshot[2][1] == 3 then - expect(snapshot[1][1]).to.equal(1) - else - expect(snapshot[2][1]).to.equal(1) - end + if snapshot[2][1] == 3 then + expect(snapshot[1][1]).to.equal(1) + else + expect(snapshot[2][1]).to.equal(1) + end - expect(#world:query(Player):without(Poison):snapshot()).to.equal(1) - end) + expect(#world:query(Player):without(Poison):snapshot()).to.equal(1) + end) - it("should contain entity in view", function() - local ComponentA = component("ComponentA") - local ComponentB = component("ComponentB") + it("should contain entity in view", function() + local ComponentA = component("ComponentA") + local ComponentB = component("ComponentB") - local world = World.new() + local world = World.new() - local entityA = world:spawn(ComponentA()) - local entityB = world:spawn(ComponentB()) + local entityA = world:spawn(ComponentA()) + local entityB = world:spawn(ComponentB()) - local viewA = world:query(ComponentA):view() - local viewB = world:query(ComponentB):view() + local viewA = world:query(ComponentA):view() + local viewB = world:query(ComponentB):view() - expect(viewA:contains(entityA)).to.equal(true) - expect(viewA:contains(entityB)).to.equal(false) - expect(viewB:contains(entityB)).to.equal(true) - expect(viewB:contains(entityA)).to.equal(false) - end) + expect(viewA:contains(entityA)).to.equal(true) + expect(viewA:contains(entityB)).to.equal(false) + expect(viewB:contains(entityB)).to.equal(true) + expect(viewB:contains(entityA)).to.equal(false) + end) - it("should get entity data from view", function() - local numComponents = 20 - local components = {} + it("should get entity data from view", function() + local numComponents = 20 + local components = {} - for i = 1, numComponents do - table.insert(components, component("Component" .. i)) - end + for i = 1, numComponents do + table.insert(components, component("Component" .. i)) + end - local world = World.new() + local world = World.new() - local componentInstances = {} + local componentInstances = {} - for _, componentFn in components do - table.insert(componentInstances, componentFn()) - end + for _, componentFn in components do + table.insert(componentInstances, componentFn()) + end - local entityA = world:spawn(table.unpack(componentInstances)) + local entityA = world:spawn(table.unpack(componentInstances)) - local viewA = world:query(table.unpack(components)):view() - local viewB = world:query(components[1]):view() + local viewA = world:query(table.unpack(components)):view() + local viewB = world:query(components[1]):view() - expect(select("#", viewA:get(entityA))).to.equal(numComponents) - expect(select("#", viewB:get(entityA))).to.equal(1) + expect(select("#", viewA:get(entityA))).to.equal(numComponents) + expect(select("#", viewB:get(entityA))).to.equal(1) - local viewAEntityAData = { viewA:get(entityA) } + local viewAEntityAData = { viewA:get(entityA) } - for index, componentData in viewAEntityAData do - expect(getmetatable(componentData)).to.equal(components[index]) - end + for index, componentData in viewAEntityAData do + expect(getmetatable(componentData)).to.equal(components[index]) + end - local viewBEntityAData = { viewB:get(entityA) } + local viewBEntityAData = { viewB:get(entityA) } - expect(getmetatable(viewBEntityAData[1])).to.equal(components[1]) - end) + expect(getmetatable(viewBEntityAData[1])).to.equal(components[1]) + end) - it("should return view results in query order", function() - local Parent = component("Parent") - local Transform = component("Transform") - local Root = component("Root") - - local world = World.new() - - local root = world:spawn(Transform({ pos = Vector2.new(3, 4) }), Root()) - local _otherRoot = world:spawn(Transform({ pos = Vector2.new(1, 2) }), Root()) - - local child = world:spawn( - Parent({ - entity = root, - fromChild = Transform({ pos = Vector2.one }), - }), - Transform.new({ pos = Vector2.zero }) - ) - - local _otherChild = world:spawn( - Parent({ - entity = root, - fromChild = Transform({ pos = Vector2.new(0, 0) }), - }), - Transform.new({ pos = Vector2.zero }) - ) - - local _grandChild = world:spawn( - Parent({ - entity = child, - fromChild = Transform({ pos = Vector2.new(-1, 0) }), - }), - Transform.new({ pos = Vector2.zero }) - ) - - local parents = world:query(Parent):view() - local roots = world:query(Transform, Root):view() - - expect(parents:contains(root)).to.equal(false) - - local orderOfIteration = {} - - for id in world:query(Transform, Parent) do - table.insert(orderOfIteration, id) - end + it("should return view results in query order", function() + local Parent = component("Parent") + local Transform = component("Transform") + local Root = component("Root") - local view = world:query(Transform, Parent):view() - local i = 0 - for id in view do - i += 1 - expect(orderOfIteration[i]).to.equal(id) - end + local world = World.new() - for id, absolute, parent in world:query(Transform, Parent) do - local relative = parent.fromChild.pos - local ancestor = parent.entity - local current = parents:get(ancestor) - while current do - relative = current.fromChild.pos * relative - ancestor = current.entity - current = parents:get(ancestor) + local root = world:spawn(Transform({ pos = Vector2.new(3, 4) }), Root()) + local _otherRoot = world:spawn(Transform({ pos = Vector2.new(1, 2) }), Root()) + + local child = world:spawn( + Parent({ + entity = root, + fromChild = Transform({ pos = Vector2.one }), + }), + Transform.new({ pos = Vector2.zero }) + ) + + local _otherChild = world:spawn( + Parent({ + entity = root, + fromChild = Transform({ pos = Vector2.new(0, 0) }), + }), + Transform.new({ pos = Vector2.zero }) + ) + + local _grandChild = world:spawn( + Parent({ + entity = child, + fromChild = Transform({ pos = Vector2.new(-1, 0) }), + }), + Transform.new({ pos = Vector2.zero }) + ) + + local parents = world:query(Parent):view() + local roots = world:query(Transform, Root):view() + + expect(parents:contains(root)).to.equal(false) + + local orderOfIteration = {} + + for id in world:query(Transform, Parent) do + table.insert(orderOfIteration, id) end - local pos = roots:get(ancestor).pos - - world:insert(id, absolute:patch({ pos = Vector2.new(pos.x + relative.x, pos.y + relative.y) })) - end + local view = world:query(Transform, Parent):view() + local i = 0 + for id in view do + i += 1 + expect(orderOfIteration[i]).to.equal(id) + end - expect(world:get(child, Transform).pos).to.equal(Vector2.new(4, 5)) - end) + for id, absolute, parent in world:query(Transform, Parent) do + local relative = parent.fromChild.pos + local ancestor = parent.entity + local current = parents:get(ancestor) + while current do + relative = current.fromChild.pos * relative + ancestor = current.entity + current = parents:get(ancestor) + end - it("should not invalidate iterators", function() - local world = World.new() - local A = component() - local B = component() - local C = component() + local pos = roots:get(ancestor).pos - for _ = 1, 10 do - world:spawn(A(), B()) - end + world:insert(id, absolute:patch({ pos = Vector2.new(pos.x + relative.x, pos.y + relative.y) })) + end - local count = 0 - for id in world:query(A) do - count += 1 - world:insert(id, C()) - world:remove(id, B) - end - expect(count).to.equal(10) + expect(world:get(child, Transform).pos).to.equal(Vector2.new(4, 5)) + end) end) end) end diff --git a/lib/component.luau b/lib/component.luau index 5aaecd3a..8b13eaf8 100644 --- a/lib/component.luau +++ b/lib/component.luau @@ -159,6 +159,12 @@ local function assertValidComponentInstance(value, position) end end +local function assertValidComponentInstances(componentInstances) + for position, componentInstance in componentInstances do + assertValidComponentInstance(componentInstance, position) + end +end + local function assertComponentArgsProvided(...) if not (...) then error(`No components passed to world:{debug.info(3, "n")}, at least one component is required`, 2) @@ -168,6 +174,7 @@ end return { newComponent = newComponent, assertValidComponentInstance = assertValidComponentInstance, + assertValidComponentInstances = assertValidComponentInstances, assertValidComponent = assertValidComponent, assertComponentArgsProvided = assertComponentArgsProvided, } diff --git a/lib/init.luau b/lib/init.luau index 9eb2b33d..644e9859 100644 --- a/lib/init.luau +++ b/lib/init.luau @@ -58,7 +58,7 @@ local Loop = require(script.Loop) local newComponent = require(script.component).newComponent local topoRuntime = require(script.topoRuntime) -export type World = typeof(World.new()) +export type World = World.World export type Loop = typeof(Loop.new()) return table.freeze({ diff --git a/testez-companion.toml b/testez-companion.toml new file mode 100644 index 00000000..9270d111 --- /dev/null +++ b/testez-companion.toml @@ -0,0 +1 @@ +roots = ["ReplicatedStorage/Matter"]