Compare commits

..

No commits in common. "main" and "v0.10.0" have entirely different histories.

16 changed files with 483 additions and 625 deletions

17
.github/workflows/publish-npm.yml vendored Executable file
View file

@ -0,0 +1,17 @@
name: publish-npm
on:
push:
branches: [main]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: "20"
- uses: JS-DevTools/npm-publish@v3
with:
token: ${{ secrets.NPM_AUTH_TOKEN }}

View file

@ -2,13 +2,7 @@ name: release
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
id-token: write
contents: write
tags: ["v*", "workflow_dispatch"]
jobs:
build:
@ -35,13 +29,15 @@ jobs:
release:
name: Release
needs: build
needs: [build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout Project
uses: actions/checkout@v4
- name: Download Build
- name: Download Jecs Build
uses: actions/download-artifact@v4
with:
name: build
@ -54,12 +50,12 @@ jobs:
uses: softprops/action-gh-release@v1
with:
name: Jecs ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
files: jecs.rbxm
files: |
jecs.rbxm
publish-wally:
name: Publish to Wally
needs: release
publish:
name: Publish
needs: [release]
runs-on: ubuntu-latest
steps:
- name: Checkout Project
@ -73,23 +69,3 @@ jobs:
- name: Publish
run: wally publish
publish-npm:
name: Publish to NPM
needs: release
runs-on: ubuntu-latest
steps:
- name: Checkout Project
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Install
run: npm install
- name: Publish
run: npm publish

View file

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

View file

@ -19,7 +19,7 @@ about the code base is lost, ability to work on it at the same level of quality
is lost. Over time code quality will decline as code size grows.
- Tacit knowledge is very hard to recover by looking at a maze of code,
and it takes a long time to do so.
and it takes
- You will often hear that "every semantic distinction deserves its own
component or tag". Sometimes this is correct. A well chosen component boundary

View file

@ -1,7 +1,7 @@
local RunService = game:GetService("RunService")
local jecs = require("@jecs")
local jabby = require("@modules/Jabby/module")
local jabby = require("@modules/jabby")
local world = jecs.world()

View file

@ -5,8 +5,8 @@ type World = jecs.World
type Id<T=any> = jecs.Id<any>
local function duplicate(query: jecs.Query<...any>): jecs.Cached_Query<...any>
local world = (query :: { world: World }).world
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
@ -152,6 +152,11 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
local entity_index = world.entity_index :: any
local terms_lookup: { [jecs.Id<any>]: boolean } = {}
for _, term in terms do
terms_lookup[term] = true
end
local callback_added: ((jecs.Entity) -> ())?
local callback_removed: ((jecs.Entity) -> ())?
@ -171,7 +176,7 @@ 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
if last_old_archetype == oldarchetype and last_entity == entity then
if last_old_archetype == oldarchetype and last_entity == entity and terms_lookup[id] then
return
end
@ -193,6 +198,7 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
if not r then return end
local src = r.archetype
if not src then return end
if not archetypes[src.id] then return end
@ -226,7 +232,7 @@ 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
if last_old_archetype == oldarchetype and last_entity == entity then
if last_old_archetype == oldarchetype and last_entity == entity and terms_lookup[id] then
return
end
@ -247,17 +253,10 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
end
local r = jecs.record(world, entity)
local src = r.archetype
if last_entity == entity and last_old_archetype == src then
return
end
if not archetypes[src.id] then
return
end
last_entity = entity
last_old_archetype = src
if archetypes[r.archetype.id] then
last_old_archetype = nil
callback_removed(entity)
end
end)
table.insert(cleanup, onadded)
table.insert(cleanup, onremoved)
@ -286,13 +285,16 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
end
local r = jecs.record(world, entity)
local archetype = r.archetype
if not archetype then
return
end
if archetypes[oldarchetype.id] and not archetypes[archetype.id] then
last_old_archetype = nil
callback_removed(entity)
end
end)
local onremoved = world:removed(rel, function(entity: jecs.Entity, id: jecs.Id, delete)
local onremoved = world:removed(rel, function(entity, id, delete)
if delete then
return
end
@ -305,7 +307,10 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
local r = jecs.record(world, entity)
local archetype = r.archetype
if last_old_archetype == archetype then
if not archetype then
return
end
if last_old_archetype == archetype and terms_lookup[id] then
return
end
@ -325,6 +330,9 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
end
local r = jecs.record(world, entity)
local archetype = r.archetype
if not archetype then
return
end
if archetypes[oldarchetype.id] and not archetypes[archetype.id] then
callback_removed(entity)
@ -339,6 +347,9 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
end
local r = jecs.record(world, entity)
local archetype = r.archetype
if not archetype then
return
end
local dst = jecs.archetype_traverse_remove(world, id, archetype)
if archetypes[dst.id] then

View file

@ -1,47 +0,0 @@
local jecs = require("@jecs")
local function ecs_ensure_entity(world: jecs.World, id: jecs.Entity<any>, ctx: {[jecs.Entity<any>]: jecs.Entity<any>})
local e = 0
local ser_id = id
local deser_id = ctx[ser_id]
if not deser_id then
if not world:exists(ser_id)
or (world:contains(ser_id) and not world:get(ser_id, jecs.Name))
then
deser_id = world:entity(id)
else
if world:contains(ser_id) then
if world:has(ser_id, jecs.Name) then
deser_id = ser_id
else
deser_id = world:entity(ser_id)
end
else
if world:exists(ser_id) then
deser_id = world:entity()
else
deser_id = world:entity(ser_id)
end
end
end
ctx[ser_id] = deser_id
end
e = deser_id
return e
end
local function ecs_deser_pairs(world: jecs.World, rel, tgt, ctx)
rel = ecs_ensure_entity(world, rel, ctx)
tgt = ecs_ensure_entity(world, tgt, ctx)
return jecs.pair(rel, tgt)
end
return {
ecs_ensure_entity = ecs_ensure_entity,
ecs_deser_pairs = ecs_deser_pairs,
}

View file

39
modules/remotes.luau Executable file
View file

@ -0,0 +1,39 @@
-- A simple way to safely type remote events without hassle
local ReplicatedStorage = require("@game/ReplicatedStorage")
local jecs = require("@jecs")
local ty = require("./")
type Remote<T...> = {
FireClient: (Remote<T...>, Player, T...) -> (),
FireAllClients: (Remote<T...>, T...) -> (),
FireServer: (Remote<T...>, T...) -> (),
OnServerEvent: RBXScriptSignal<(Player, T...)>,
OnClientEvent: RBXScriptSignal<T...>
}
local function stream_ensure(name)
local remote = ReplicatedStorage:FindFirstChild(name)
if not remote then
remote = Instance.new("RemoteEvent")
remote.Name = name
remote.Parent = ReplicatedStorage
end
return remote
end
local function datagram_ensure(name)
local remote = ReplicatedStorage:FindFirstChild(name)
if not remote then
remote = Instance.new("UnreliableRemoteEvent")
remote.Name = name
remote.Parent = ReplicatedStorage
end
return remote
end
return {
input = datagram_ensure("input") :: Remote<string>,
replication = stream_ensure("replication") :: Remote<snapshot>
}

0
modules/PerfGraph/svg.py → modules/svg.py Normal file → Executable file
View file

View file

@ -1,11 +1,11 @@
{
"name": "@rbxts/jecs",
"version": "0.11.0",
"version": "0.10.0",
"description": "Stupidly fast Entity Component System",
"main": "src/jecs.luau",
"repository": {
"type": "git",
"url": "https://github.com/Ukendio/jecs"
"url": "git+https://github.com/ukendio/jecs.git"
},
"keywords": [],
"author": "Ukendio",

View file

@ -156,7 +156,6 @@ type componentrecord = {
counts: { [i53]: number },
flags: number,
size: number,
cache: { number },
on_add: ((entity: i53, id: i53, value: any, oldarchetype: archetype) -> ())?,
on_change: ((entity: i53, id: i53, value: any, oldarchetype: archetype) -> ())?,
@ -534,11 +533,7 @@ end
local function entity_index_get_alive(entity_index: entityindex, entity: i53): i53?
local r = entity_index_try_get_any(entity_index, entity :: number)
if r then
local dense = r.dense
if dense > entity_index.alive_count then
return nil
end
return entity_index.dense_array[dense]
return entity_index.dense_array[r.dense]
end
return nil
end
@ -568,31 +563,28 @@ end
local ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY = "Entity is outside range"
local function ENTITY_INDEX_NEW_ID(world: world): i53
local entity_index = world.entity_index
local ROOT_ARCHETYPE = world.ROOT_ARCHETYPE
local function ENTITY_INDEX_NEW_ID(entity_index: entityindex): i53
local dense_array = entity_index.dense_array
local alive_count = entity_index.alive_count
local sparse_array = entity_index.sparse_array
local max_id = entity_index.max_id
local next_count = alive_count + 1
if alive_count < max_id then
local id = dense_array[next_count]
if id then
entity_index.alive_count = next_count
alive_count += 1
entity_index.alive_count = alive_count
local id = dense_array[alive_count]
return id
end
end
local id = max_id + 1
local range_end = entity_index.range_end
ecs_assert(range_end == nil or id < range_end, ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY)
entity_index.max_id = id
entity_index.alive_count = next_count
dense_array[next_count] = id
sparse_array[id] = { dense = next_count, row = 0, archetype = ROOT_ARCHETYPE }
alive_count += 1
entity_index.alive_count = alive_count
dense_array[alive_count] = id
sparse_array[id] = { dense = alive_count } :: record
return id
end
@ -919,7 +911,6 @@ local function id_record_create(
records = {},
counts = {},
flags = flags,
cache = {},
on_add = on_add,
on_change = on_change,
@ -957,7 +948,6 @@ local function archetype_append_to_records(
idr_records[archetype_id] = index
idr_counts[archetype_id] = 1
columns_map[id] = column
table.insert(idr.cache, archetype_id)
else
local max_count = idr_counts[archetype_id] + 1
idr_counts[archetype_id] = max_count
@ -1049,10 +1039,8 @@ local function world_range(world: world, range_begin: number, range_end: number?
for i = max_id + 1, range_begin do
dense_array[i] = i
sparse_array[i] = {
dense = 0,
row = 0,
archetype = world.ROOT_ARCHETYPE
}
dense = 0
} :: record
end
entity_index.max_id = range_begin
entity_index.alive_count = range_begin
@ -1092,11 +1080,10 @@ local function find_archetype_without(
): archetype
local id_types = node.types
local at = table.find(id_types, id)
if at == nil then
return node
end
local dst = table.clone(id_types)
table.remove(dst, at)
return archetype_ensure(world, dst)
end
@ -1219,13 +1206,6 @@ local function archetype_destroy(world: world, archetype: archetype)
if archetype == world.ROOT_ARCHETYPE then
return
end
-- RAII / idempotent: already destroyed or still has entities → no-op
if world.archetypes[archetype.id] ~= archetype then
return
end
if #archetype.entities > 0 then
return
end
local component_index = world.component_index
local archetype_edges = world.archetype_edges
@ -2577,7 +2557,7 @@ local function ecs_bulk_insert(world: world, entity: i53, ids: { i53 }, values:
end
local from = r.archetype
local component_index = world.component_index
if from == world.ROOT_ARCHETYPE then
if not from then
local dst_types = table.clone(ids)
table.sort(dst_types)
@ -2665,8 +2645,6 @@ local function ecs_bulk_insert(world: world, entity: i53, ids: { i53 }, values:
end
end
local ON_REMOVE_STRUCTURAL_WARN = "jecs: on_remove must not perform structural changes (world:add/world:remove); this will be removed as a lint in future versions and can cause silent failures"
local function ecs_bulk_remove(world: world, entity: i53, ids: { i53 })
local entity_index = world.entity_index
local r = entity_index_try_get(entity_index, entity)
@ -2675,13 +2653,14 @@ local function ecs_bulk_remove(world: world, entity: i53, ids: { i53 })
end
local from = r.archetype
local component_index = world.component_index
if not from then
return
end
local remove: { [i53]: boolean } = {}
local columns_map = from.columns_map
local dst_types = table.clone(from.types) :: { i53 }
for i, id in ids do
if not columns_map[id] then
continue
@ -2693,15 +2672,22 @@ local function ecs_bulk_remove(world: world, entity: i53, ids: { i53 })
local on_remove = idr.on_remove
if on_remove then
on_remove(entity, id)
if from ~= r.archetype then
error(ON_REMOVE_STRUCTURAL_WARN)
end
end
local to = r.archetype
if from ~= to then
from = to
end
local dst_types = table.clone(from.types) :: { i53 }
for id in remove do
local at = table.find(dst_types, id)
table.remove(dst_types, at)
end
local to = archetype_ensure(world, dst_types)
to = archetype_ensure(world, dst_types)
if from ~= to then
entity_move(entity_index, entity, r, to)
@ -2712,12 +2698,12 @@ local function world_new(DEBUG: boolean?)
local eindex_dense_array = {} :: { i53 }
local eindex_sparse_array = {} :: { record }
local entity_index: entityindex = {
local entity_index = {
dense_array = eindex_dense_array,
sparse_array = eindex_sparse_array,
alive_count = 0,
max_id = 0,
}
} :: entityindex
-- NOTE(marcus): with the way the component index is accessed, we want to
-- ensure that components range has fast access.
@ -2757,6 +2743,31 @@ local function world_new(DEBUG: boolean?)
signals = signals,
} :: world
local function entity_index_new_id(entity_index: entityindex): i53
local alive_count = entity_index.alive_count
local max_id = entity_index.max_id
if alive_count < max_id then
alive_count += 1
entity_index.alive_count = alive_count
local id = eindex_dense_array[alive_count]
return id
end
local id = max_id + 1
local range_end = entity_index.range_end
ecs_assert(range_end == nil or id < range_end, ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY)
entity_index.max_id = id
alive_count += 1
entity_index.alive_count = alive_count
eindex_dense_array[alive_count] = id
eindex_sparse_array[id] = { dense = alive_count } :: record
return id
end
local ROOT_ARCHETYPE = archetype_create(world, {}, "")
world.ROOT_ARCHETYPE = ROOT_ARCHETYPE
@ -2886,7 +2897,9 @@ local function world_new(DEBUG: boolean?)
return
end
local src = record.archetype
local from: archetype = record.archetype
local ROOT_ARCHETYPE = ROOT_ARCHETYPE
local src = from or ROOT_ARCHETYPE
local column = src.columns_map[id]
if column then
local idr = component_index[id]
@ -2916,105 +2929,9 @@ local function world_new(DEBUG: boolean?)
local id_types = src.types
if on_remove then
on_remove(entity, id_types[cr])
if src ~= record.archetype then
error(ON_REMOVE_STRUCTURAL_WARN)
end
end
to = exclusive_traverse_add(src, cr, id)
end
end
if not to then
to = find_archetype_with(world, id, src)
if not idr then
idr = component_index[wc]
end
edge[id] = to
archetype_edges[(to :: Archetype).id][id] = src
end
else
if bit32.btest(idr.flags, ECS_ID_IS_EXCLUSIVE) then
local on_remove = idr.on_remove
if on_remove then
local cr = idr.records[src.id]
if cr then
local id_types = src.types
on_remove(entity, id_types[cr])
if src ~= record.archetype then
error(ON_REMOVE_STRUCTURAL_WARN)
end
end
end
end
end
else
local edges = archetype_edges
local edge = edges[src.id]
to = edge[id]
if not to then
to = find_archetype_with(world, id, src)
edge[id] = to
edges[to.id][id] = src
end
idr = component_index[id]
end
local ROOT_ARCHETYPE = ROOT_ARCHETYPE
local src_is_root_archetype = src == ROOT_ARCHETYPE
if not src_is_root_archetype then
-- If there was a previous archetype, then the entity needs to move the archetype
inner_entity_move(entity, record, to)
else
new_entity(entity, record, to)
end
column = to.columns_map[id]
column[record.row] = data
local on_add = idr.on_add
if on_add then
on_add(entity, id, data, src)
end
end
end
local function world_add(
world: world,
entity: i53,
id: i53
): ()
local record = entity_index_try_get_unsafe(entity :: number)
if not record then
return
end
local src = record.archetype
if src.columns_map[id] then
return
end
local to: archetype
local idr: componentrecord
if ECS_IS_PAIR(id) then
local first = ECS_PAIR_FIRST(id)
local wc = ECS_PAIR(first, EcsWildcard)
idr = component_index[wc]
local edge = archetype_edges[src.id]
to = edge[id]
if to == nil then
if idr and (bit32.btest(idr.flags, ECS_ID_IS_EXCLUSIVE) == true) then
local cr = idr.records[src.id]
if cr then
local on_remove = idr.on_remove
local id_types = src.types
if on_remove then
on_remove(entity, id_types[cr])
if src ~= record.archetype then
error(ON_REMOVE_STRUCTURAL_WARN)
end
src = record.archetype
id_types = src.types
cr = idr.records[src.id]
end
to = exclusive_traverse_add(src, cr, id)
@ -3060,9 +2977,107 @@ local function world_new(DEBUG: boolean?)
idr = component_index[id]
end
if from then
-- If there was a previous archetype, then the entity needs to move the archetype
inner_entity_move(entity, record, to)
else
new_entity(entity, record, to)
end
column = to.columns_map[id]
column[record.row] = data
local on_add = idr.on_add
if on_add then
on_add(entity, id, data, src)
end
end
end
local function world_add(
world: world,
entity: i53,
id: i53
): ()
local record = entity_index_try_get_unsafe(entity :: number)
if not record then
return
end
local from = record.archetype
local ROOT_ARCHETYPE = ROOT_ARCHETYPE
local src_is_root_archetype = src == ROOT_ARCHETYPE
if not src_is_root_archetype then
local src = from or ROOT_ARCHETYPE
if src.columns_map[id] then
return
end
local to: archetype
local idr: componentrecord
if ECS_IS_PAIR(id) then
local first = ECS_PAIR_FIRST(id)
local wc = ECS_PAIR(first, EcsWildcard)
idr = component_index[wc]
local edge = archetype_edges[src.id]
to = edge[id]
if to == nil then
if idr and (bit32.btest(idr.flags, ECS_ID_IS_EXCLUSIVE) == true) then
local cr = idr.records[src.id]
if cr then
local on_remove = idr.on_remove
local id_types = src.types
if on_remove then
on_remove(entity, id_types[cr])
src = record.archetype
id_types = src.types
cr = idr.records[src.id]
end
to = exclusive_traverse_add(src, cr, id)
end
end
if not to then
to = find_archetype_with(world, id, src)
if not idr then
idr = component_index[wc]
end
edge[id] = to
archetype_edges[(to :: Archetype).id][id] = src
end
else
if bit32.btest(idr.flags, ECS_ID_IS_EXCLUSIVE) then
local on_remove = idr.on_remove
if on_remove then
local cr = idr.records[src.id]
if cr then
local id_types = src.types
on_remove(entity, id_types[cr])
local arche = record.archetype
if src ~= arche then
id_types = arche.types
cr = idr.records[arche.id]
to = exclusive_traverse_add(arche, cr, id)
end
end
end
end
end
else
local edges = archetype_edges
local edge = edges[src.id]
to = edge[id]
if not to then
to = find_archetype_with(world, id, src)
edge[id] = to
edges[to.id][id] = src
end
idr = component_index[id]
end
if from then
inner_entity_move(entity, record, to)
else
if #to.types > 0 then
@ -3085,6 +3100,9 @@ local function world_new(DEBUG: boolean?)
end
local archetype = record.archetype
if not archetype then
return nil
end
local columns_map = archetype.columns_map
local row = record.row
@ -3142,8 +3160,7 @@ local function world_new(DEBUG: boolean?)
table.insert(listeners, fn)
return function()
local n = #listeners
local i = table.find(listeners, fn::Listener<any>)
assert(i, "Listener not found, maybe you tried to disconnect it twice")
local i = table.find(listeners, fn)
listeners[i] = listeners[n]
listeners[n] = nil
end
@ -3188,8 +3205,7 @@ local function world_new(DEBUG: boolean?)
table.insert(listeners, fn)
return function()
local n = #listeners
local i = table.find(listeners, fn::Listener<any>)
assert(i, "Listener not found, maybe you tried to disconnect it twice")
local i = table.find(listeners, fn)
listeners[i] = listeners[n]
listeners[n] = nil
end
@ -3234,7 +3250,6 @@ local function world_new(DEBUG: boolean?)
return function()
local n = #listeners
local i = table.find(listeners, fn::Listener<any>)
assert(i, "Listener not found, maybe you tried to disconnect it twice")
listeners[i] = listeners[n]
listeners[n] = nil
end
@ -3307,13 +3322,10 @@ local function world_new(DEBUG: boolean?)
end
local function world_entity(world: world, entity: i53?): i53
local sparse_array = eindex_sparse_array
local dense_array = eindex_dense_array
if entity then
local index = ECS_ID(entity)
local alive_count = entity_index.alive_count
local r = sparse_array[index]
local r = eindex_sparse_array[index]
if r then
local dense = r.dense
@ -3323,17 +3335,17 @@ local function world_new(DEBUG: boolean?)
alive_count += 1
entity_index.alive_count = alive_count
r.dense = alive_count
dense_array[alive_count] = entity
eindex_dense_array[alive_count] = entity
return entity
end
-- If dense > 0, check if there's an existing entity at that position
local existing_entity = dense_array[dense]
local existing_entity = eindex_dense_array[dense]
if existing_entity and existing_entity ~= entity then
alive_count += 1
entity_index.alive_count = alive_count
r.dense = alive_count
dense_array[alive_count] = entity
eindex_dense_array[alive_count] = entity
return entity
end
@ -3342,41 +3354,30 @@ local function world_new(DEBUG: boolean?)
local max_id = entity_index.max_id
if index > max_id then
-- Pre-populate all intermediate IDs to keep sparse_array as an array
for i = max_id + 1, index - 1 do
sparse_array[i] = { dense = 0, row = 0, archetype = ROOT_ARCHETYPE }
if not eindex_sparse_array[i] then
-- NOTE(marcus): We have to do this check to see if
-- they exist first because world:range() may have
-- pre-populated some slots already.
end
eindex_sparse_array[i] = { dense = 0 } :: record
end
entity_index.max_id = index
end
alive_count += 1
entity_index.alive_count = alive_count
dense_array[alive_count] = entity
eindex_dense_array[alive_count] = entity
r = { dense = alive_count, row = 0, archetype = ROOT_ARCHETYPE }
sparse_array[index] = r
r = { dense = alive_count } :: record
eindex_sparse_array[index] = r
return entity
end
end
local alive_count = entity_index.alive_count
local max_id = entity_index.max_id
local next_count = alive_count + 1
if alive_count < max_id then
entity = dense_array[next_count]
if entity then
entity_index.alive_count = next_count
return entity
end
end
local id = max_id + 1
local range_end = entity_index.range_end
ecs_assert(range_end == nil or id < range_end, ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY)
entity_index.max_id = id
entity_index.alive_count = next_count
dense_array[next_count] = id
sparse_array[id] = { dense = next_count, row = 0, archetype = ROOT_ARCHETYPE }
return id
return entity_index_new_id(entity_index)
end
local function world_remove(world: world, entity: i53, id: i53)
@ -3386,6 +3387,10 @@ local function world_new(DEBUG: boolean?)
end
local from = record.archetype
if not from then
return
end
if from.columns_map[id] then
local idr = world.component_index[id]
local on_remove = idr.on_remove
@ -3463,6 +3468,7 @@ local function world_new(DEBUG: boolean?)
for i = n, 1, -1 do
world_delete(world, entities[i])
end
archetype_destroy(world, idr_archetype)
end
else
@ -3479,10 +3485,13 @@ local function world_new(DEBUG: boolean?)
local r = eindex_sparse_array[ECS_ID(e :: number)]
local from = r.archetype
if from ~= idr_archetype then
-- unfortunately the on_remove hook allows a window where `e` can have changed archetype
-- this is hypothetically not that expensive of an operation anyways
to = archetype_traverse_remove(world, entity, from)
end
inner_entity_move(e, r, to)
end
archetype_destroy(world, idr_archetype)
end
else
@ -3495,21 +3504,18 @@ local function world_new(DEBUG: boolean?)
local e = entities[i]
entity_move(entity_index, e, eindex_sparse_array[ECS_ID(e :: number)], to)
end
archetype_destroy(world, idr_archetype)
end
end
end
end
if idr_t then
local to_remove = {} :: { [i53]: componentrecord }
local cache = idr_t.cache
for i = #cache, 1, -1 do
local archetype_id = cache[i]
local archetype_ids = idr_t.records
local to_remove = {}:: { [i53]: componentrecord}
for archetype_id in archetype_ids do
local idr_t_archetype = archetypes[archetype_id]
if not idr_t_archetype then
continue
end
local idr_t_types = idr_t_archetype.types
local entities = idr_t_archetype.entities
local deleted_any = false
@ -3519,13 +3525,19 @@ local function world_new(DEBUG: boolean?)
if not ECS_IS_PAIR(id) then
continue
end
local object = entity_index_get_alive(entity_index, ECS_PAIR_SECOND(id))
local object = entity_index_get_alive(
entity_index, ECS_PAIR_SECOND(id))
if object ~= entity then
continue
end
local id_record = component_index[id]
local has_delete = bit32.btest(id_record.flags, ECS_ID_DELETE)
if has_delete then
local flags = id_record.flags
local flags_delete_mask = bit32.btest(flags, ECS_ID_DELETE)
if flags_delete_mask then
for i = #entities, 1, -1 do
local child = entities[i]
world_delete(world, child)
end
deleted_any = true
break
else
@ -3535,49 +3547,59 @@ local function world_new(DEBUG: boolean?)
end
if deleted_any then
for row = #entities, 1, -1 do
world_delete(world, entities[row])
end
archetype_destroy(world, idr_t_archetype)
continue
end
if remove_count == 1 then
local id, id_record = next(to_remove)
local to = archetype_traverse_remove(world, id::i53, idr_t_archetype)
local to_u = archetype_traverse_remove(world, id :: i53, idr_t_archetype)
local on_remove = id_record.on_remove
for row = #entities, 1, -1 do
local child = entities[row]
local r = entity_index_try_get_unsafe(child)::record
local dst = to
for i = #entities, 1, -1 do
local child = entities[i]
local r = entity_index_try_get_unsafe(child) :: record
local to = to_u
if on_remove then
on_remove(child, id :: i53)
local src = r.archetype
if src ~= idr_t_archetype then
to = archetype_traverse_remove(world, id::i53, src)
end
inner_entity_move(child, r, dst)
end
archetype_destroy(world, idr_t_archetype)
else
local dst_types = table.clone(idr_t_archetype.types)
for id in to_remove do
inner_entity_move(child, r, to)
end
elseif remove_count > 1 then
local dst_types = table.clone(idr_t_types)
for id, component_record in to_remove do
table.remove(dst_types, table.find(dst_types, id))
end
local dst = archetype_ensure(world, dst_types)
for row = #entities, 1, -1 do
local child = entities[row]
local to_u = archetype_ensure(world, dst_types)
for i = #entities, 1, -1 do
local child = entities[i]
local r = entity_index_try_get_unsafe(child) :: record
local to = to_u
for id, component_record in to_remove do
local on_remove = component_record.on_remove
if on_remove then
-- NOTE(marcus): We could be smarter with this and
-- assume hooks are deterministic and that they will
-- move to the same archetype. However users often are not reasonable people.
on_remove(child, id)
local src = r.archetype
if src ~= idr_t_archetype then
to = archetype_traverse_remove(world, id, src)
end
end
inner_entity_move(child, r, dst)
end
archetype_destroy(world, idr_t_archetype)
inner_entity_move(child, r, to)
end
end
table.clear(to_remove)
archetype_destroy(world, idr_t_archetype)
end
end
@ -3632,11 +3654,14 @@ local function world_new(DEBUG: boolean?)
local r = entity_index_try_get_unsafe(e) :: record
inner_entity_move(e, r, node)
end
archetype_destroy(world, idr_r_archetype)
end
end
end
local dense = record.dense
local i_swap = entity_index.alive_count
entity_index.alive_count = i_swap - 1
@ -3644,8 +3669,8 @@ local function world_new(DEBUG: boolean?)
local e_swap = eindex_dense_array[i_swap]
local r_swap = entity_index_try_get_any(e_swap) :: record
r_swap.dense = dense
record.archetype = ROOT_ARCHETYPE
record.row = 0
record.archetype = nil :: any
record.row = nil :: any
record.dense = i_swap
eindex_dense_array[dense] = e_swap
@ -3667,16 +3692,12 @@ local function world_new(DEBUG: boolean?)
end
end
archetype_delete(world, record.archetype, record.row)
record.archetype = world.ROOT_ARCHETYPE
record.row = 0
record.archetype = nil :: any
record.row = nil :: any
end
local function world_exists(world: world, entity: i53): boolean
local r = entity_index_try_get_any(entity)
if not r or r.dense == 0 then
return false
end
return true
return entity_index_try_get_any(entity) ~= nil
end
local function world_contains(world: world, entity: i53): boolean
@ -3827,7 +3848,7 @@ local function world_new(DEBUG: boolean?)
end
for i = 1, EcsRest do
ENTITY_INDEX_NEW_ID(world)
entity_index_new_id(entity_index)
end
for i = 1, max_component_id do
@ -3863,7 +3884,7 @@ local function world_new(DEBUG: boolean?)
world_add(world, EcsOnDeleteTarget, EcsExclusive)
for i = EcsRest + 1, ecs_max_tag_id do
ENTITY_INDEX_NEW_ID(world)
entity_index_new_id(entity_index)
end
for i, bundle in ecs_metadata do
@ -3881,7 +3902,7 @@ end
local function ecs_is_tag(world: world, entity: i53): boolean
if ECS_IS_PAIR(entity) then
return ecs_is_tag(world, ecs_pair_first(world, entity)) and ecs_is_tag(world, ecs_pair_second(world, entity))
return ecs_is_tag(world, ecs_pair_first(world, entity)) or ecs_is_tag(world, ecs_pair_second(world, entity))
end
local idr = world.component_index[entity]
if idr then
@ -3950,7 +3971,7 @@ local function entity_index_ensure(entity_index: entityindex, e: i53)
end
local function new(world: world)
local e = ENTITY_INDEX_NEW_ID(world)
local e = ENTITY_INDEX_NEW_ID(world.entity_index)
return e
end
@ -3968,7 +3989,7 @@ local function new_low_id(world: world)
end
end
if e == 0 or e >= HI_COMPONENT_ID then
e = ENTITY_INDEX_NEW_ID(world)
e = ENTITY_INDEX_NEW_ID(entity_index)
else
entity_index_ensure(entity_index, e)
end
@ -3976,7 +3997,7 @@ local function new_low_id(world: world)
end
local function new_w_id(world: world, id: i53)
local e = ENTITY_INDEX_NEW_ID(world)
local e = ENTITY_INDEX_NEW_ID(world.entity_index)
world.add(world, e, id)
return e
end

View file

@ -10,6 +10,7 @@ local mirror = require(ReplicatedStorage.mirror:Clone())
return {
ParameterGenerator = function()
local ecs = jecs.world()
ecs:range(1000, 20000)
local mcs = mirror.World.new()
return ecs, mcs
end,
@ -18,14 +19,14 @@ return {
Mirror = function(_, ecs, mcs)
for i = 1, 100 do
local _e = mcs:entity()
mcs:entity()
end
end,
Jecs = function(_, ecs, mcs)
for i = 1, 100 do
local _e = ecs:entity()
ecs:entity()
end
end,
},

View file

@ -838,45 +838,25 @@ TEST("modules/ob::monitor", function()
do CASE "monitor with wildcard pair should handle bulk_insert"
local A = world:component()
local B = world:component()
local C = world:component()
local Relation = world:component()
local entity1 = world:entity()
local entity = world:entity()
local e1 = world:entity()
local e2 = world:entity()
local e3 = world:entity()
local monitor = ob.monitor(world:query(A, B, C, jecs.pair(Relation, jecs.w)))
local monitor = ob.monitor(world:query(A, jecs.pair(B, jecs.w)))
local c = 0
monitor.added(function()
c += 1
end)
jecs.bulk_insert(world, entity, { A, B, C, jecs.pair(Relation, entity1) }, { 1, 2, 3, 4 })
local e = world:entity()
world:add(e, A)
CHECK(c == 0)
world:add(e, jecs.pair(B, e1))
CHECK(c == 1)
world:add(e, jecs.pair(B, e2))
CHECK(c == 1)
world:add(e, jecs.pair(B, e3))
CHECK(c == 1)
end
do CASE "monitor with wildcard pair: bulk_remove reports removed exactly once"
local A = world:component()
local B = world:component()
local C = world:component()
local Relation = world:component()
local entity1 = world:entity()
local entity = world:entity()
local monitor = ob.monitor(world:query(A, B, C, jecs.pair(Relation, jecs.w)))
local added_count = 0
local removed_count = 0
monitor.added(function()
added_count += 1
end)
monitor.removed(function()
removed_count += 1
end)
jecs.bulk_insert(world, entity, { A, B, C, jecs.pair(Relation, entity1) }, { 1, 2, 3, 4 })
CHECK(added_count == 1)
CHECK(removed_count == 0)
jecs.bulk_remove(world, entity, { A, B, C, jecs.pair(Relation, entity1) })
CHECK(removed_count == 1)
end
do CASE "monitor with multiple pairs should handle separate operations correctly"

View file

@ -24,174 +24,6 @@ type Id<T=unknown> = jecs.Id<T>
local entity_visualiser = require("@modules/entity_visualiser")
local dwi = entity_visualiser.stringify
-- FOCUS()
TEST("Stale to_remove", function()
local world = jecs.world()
local a = world:component()
local b = world:component()
local c = world:component()
local d = world:component()
local marker = world:component()
local target = world:entity()
local first = world:entity()
world:add(first, jecs.pair(a, target))
world:add(first, jecs.pair(b, target))
world:add(first, jecs.pair(jecs.ChildOf, target))
local second = world:entity()
world:add(second, jecs.pair(c, target))
world:add(second, jecs.pair(d, target))
world:add(second, marker)
print("-------")
world:delete(target)
print("-------")
CHECK(world:contains(second))
CHECK(world:has(second, marker))
end)
-- FOCUS()
-- Exercises idr_t multi-remove path: entity has multiple pairs with same target (no cascade); delete target → both pairs removed, on_remove each called.
TEST("Target delete: multi-remove path (removing_count > 1)", function()
local world = jecs.world()
local rel1 = world:entity()
local rel2 = world:entity()
local tag = world:component()
local target = world:entity()
local e = world:entity()
world:add(e, jecs.pair(rel1, target))
world:add(e, jecs.pair(rel2, target))
world:add(e, tag)
local removed_ids = {}
world:removed(rel1, function(_e, id) table.insert(removed_ids, id) end)
world:removed(rel2, function(_e, id) table.insert(removed_ids, id) end)
world:delete(target)
CHECK(world:contains(e))
CHECK(world:has(e, tag))
CHECK(not world:has(e, jecs.pair(rel1, target)))
CHECK(not world:has(e, jecs.pair(rel2, target)))
CHECK(#removed_ids == 2)
end)
-- FOCUS()
TEST("repro 2", function()
local sessionDeletedCount = 0
local slotDeletedCount = 0
for i = 1, 100 do
local world = jecs.world(true);
-- randomness
for _ = 1, i % 40 do
world:entity()
end
local ofMatch = world:component()
local ofTeam = world:component()
local ofRound = world:component()
local ownedBy = world:component()
local nextTeam = world:component()
local activeRound = world:component()
local team = world:component()
local session = world:component()
local round = world:component()
world:add(ofTeam, jecs.Exclusive)
world:add(ownedBy, jecs.Exclusive)
world:add(nextTeam, jecs.Exclusive)
world:add(activeRound, jecs.Exclusive)
local slotEntity = world:entity()
local matchEntity = world:entity()
world:add(matchEntity, jecs.pair(jecs.ChildOf, slotEntity))
local teams = {}
for t = 1, 2 do
local teamEntity = world:entity()
world:add(teamEntity, team)
world:add(teamEntity, jecs.pair(jecs.ChildOf, matchEntity))
world:add(teamEntity, jecs.pair(ofMatch, matchEntity))
table.insert(teams, teamEntity)
end
world:add(matchEntity, jecs.pair(nextTeam, teams[1]))
local roundEntity = world:entity()
world:add(roundEntity, round)
world:add(roundEntity, jecs.pair(jecs.ChildOf, matchEntity))
-- doing something as simple as adding this pair causes error rate to change. When this isn't here, sessionDeletedCount drops from 100% to 80%.
world:add(matchEntity, jecs.pair(activeRound, roundEntity))
local sessions = {}
for j = 1, #teams do
-- random number of players on team
for _ = 1, 1 + (i % 5) do
local player = world:entity()
local sessionEntity = world:entity()
world:add(sessionEntity, session)
world:add(sessionEntity, jecs.pair(ofMatch, matchEntity))
world:add(sessionEntity, jecs.pair(ofTeam, teams[j]))
-- not adding this next pair makes sessionDeletedCount to drop to 0%??
world:add(sessionEntity, jecs.pair(ownedBy, player))
world:add(sessionEntity, jecs.pair(ofRound, roundEntity))
table.insert(sessions, sessionEntity)
end
end
world:delete(matchEntity)
-- session should stay alive after match deletion
for _, entity in sessions do
if not world:contains(entity) then
sessionDeletedCount += 1
break
end
end
if not world:contains(slotEntity) then
slotDeletedCount += 1
break
end
end
CHECK(sessionDeletedCount == 0)
CHECK(slotDeletedCount == 0)
if (sessionDeletedCount + slotDeletedCount > 0)then
print(`repro 2 incorrect session deletion count: {sessionDeletedCount}`)
-- this has never been above 0, but it's the issue i'm having
print(`repro 2 incorrect slot deletion count: {slotDeletedCount}`)
end
end)
TEST("migrating to real records", function()
local world = jecs.world(true)
local e1 = world:entity()
local e2 = world:entity()
print(jecs.record(world, e1).row)
world:add(e1, jecs.pair(jecs.ChildOf, e2))
world:set(e1, jecs.Name, "hello")
CHECK(jecs.record(world, e1).row~=0)
CHECK(world:get(e1, jecs.Name)=="hello")
CHECK(world:has(e1, jecs.pair(jecs.ChildOf, jecs.Wildcard)))
end)
TEST("e2 is nil", function()
local world = jecs.world(true)
local e1 = world:entity(1000)
local e2 = world:entity()
CHECK(e1 and world:contains(e1))
CHECK(e2 and world:contains(e2))
end)
-- FOCUS()
TEST("reproduce idr_t nil archetype bug", function()
local world = jecs.world(true)
@ -706,6 +538,44 @@ TEST("world:add()", function()
CHECK(world:has(e, pair(A, C)) == true)
end
do CASE "exclusive relations invoke on_remove hooks that should allow side effects"
local world = jecs.world()
local A = world:component()
local B = world:component()
local C = world:component()
local D = world:component()
world:add(A, jecs.Exclusive)
local call_count = 0
world:set(A, jecs.OnRemove, function(e, id)
call_count += 1
if call_count == 1 then
world:add(e, C)
else
world:add(e, D)
end
end)
local e = world:entity()
world:add(e, pair(A, B))
world:add(e, pair(A, C))
CHECK(world:has(e, pair(A, B)) == false)
CHECK(world:has(e, pair(A, C)) == true)
CHECK(world:has(e, C))
-- We have to ensure that it actually invokes hooks everytime it
-- traverses the archetype
e = world:entity()
world:add(e, pair(A, B))
world:add(e, pair(A, C))
CHECK(world:has(e, pair(A, B)) == false)
CHECK(world:has(e, pair(A, C)) == true)
CHECK(world:has(e, D))
end
do CASE "idempotent"
local world = jecs.world()
local d = dwi(world)
@ -728,8 +598,8 @@ TEST("world:add()", function()
local e = world:entity()
-- An entity starts without an archetype or row
-- should therefore not need to copy over data
CHECK(d.tbl(e) == world.ROOT_ARCHETYPE)
CHECK(d.row(e) == 0)
CHECK(d.tbl(e) == nil)
CHECK(d.row(e) == nil)
local archetypes = #world.archetypes
-- This should create a new archetype
@ -783,71 +653,6 @@ TEST("world:children()", function()
jecs.ECS_META_RESET()
end)
-- Arauser repro: many parents, only some have routes+checkpoints; delete all parents.
-- With the bug: 1 checkpoint can remain and/or world:target returns non-alive parent.
-- Scale and structure match arauser so the test FAILS when the bug is present.
TEST("ChildOf cascade: world_target must not return non-alive parent", function()
local w = jecs.world(true)
local ParentTag = w:component()
local Anything = w:component()
local RouteTag = w:entity()
local CheckpointTag = w:entity()
local RouteOf = w:entity()
-- Like arauser: many parents (tiles) with two components, only a subset get routes + checkpoints
local nParents = 2000
local nWithChildren = 10
local parents = {}
for i = 1, nParents do
local p = w:entity()
w:set(p, ParentTag, {
row = math.floor((i - 1) / 50) + 1,
col = ((i - 1) % 50) + 1,
})
w:add(p, Anything)
parents[i] = p
end
local used = {}
local picked = 0
while picked < nWithChildren do
local idx = math.random(1, nParents)
if not used[idx] then
used[idx] = true
picked += 1
local p = parents[idx]
local route = w:entity()
w:add(route, RouteTag)
w:add(route, pair(ChildOf, p))
local checkpoint = w:entity()
w:add(checkpoint, CheckpointTag)
w:add(checkpoint, pair(ChildOf, p))
w:add(checkpoint, pair(RouteOf, route))
end
end
-- Delete all parents (collect first to avoid iterator invalidation)
local toDelete = {}
for e in w:query(ParentTag):iter() do
toDelete[#toDelete + 1] = e
end
for _, e in ipairs(toDelete) do
w:delete(e)
end
-- These must hold; with the bug one of them fails (checkpoint remains or parent not alive)
local count = 0
for checkpoint in w:query(CheckpointTag):iter() do
count += 1
CHECK(w:contains(checkpoint))
local parent = w:target(checkpoint, ChildOf)
if parent ~= nil then
CHECK(w:contains(parent))
end
end
CHECK(count == 0)
jecs.ECS_META_RESET()
end)
-- TEST("world:purge()", function()
-- do CASE "should remove all instances of specified component"
-- local world = jecs.world()
@ -1163,6 +968,24 @@ TEST("world:delete()", function()
-- CHECK(B_OnRemove_called)
end
do CASE "idr_t//remove//on_remove//changed_archetype@3123..3126"
local world = jecs.world()
local A = world:component()
local B = world:component()
world:set(A, jecs.OnRemove, function(entity, id)
world:set(entity, B, true)
end)
local e1 = world:entity()
local e2 = world:entity()
world:add(e2, pair(A, e2))
world:set(e2, pair(A, e1), true)
world:delete(e1)
CHECK(not world:has(e2, pair(A, e1)))
end
do CASE "pair(OnDelete, Delete)"
local world = jecs.world()
local ct = world:component()
@ -1705,7 +1528,6 @@ TEST("world:added", function()
end
end)
-- FOCUS()
TEST("world:range()", function()
do CASE "spawn entity under min range"
@ -3013,6 +2835,43 @@ TEST("world:set()", function()
CHECK(world:has(e, pair(A, C)) == true)
end
do CASE "exclusive relations invoke on_remove hooks that should allow side effects"
local world = jecs.world()
local A = world:component()
local B = world:component()
local C = world:component()
local D = world:component()
world:add(A, jecs.Exclusive)
local call_count = 0
world:set(A, jecs.OnRemove, function(e, id)
call_count += 1
if call_count == 1 then
world:set(e, C, true)
else
world:set(e, D, true)
end
end)
local e = world:entity()
world:set(e, pair(A, B), true)
world:set(e, pair(A, C), true)
CHECK(world:has(e, pair(A, B)) == false)
CHECK(world:has(e, pair(A, C)) == true)
CHECK(world:has(e, C))
-- We have to ensure that it actually invokes hooks everytime it
-- traverses the archetype
e = world:entity()
world:set(e, pair(A, B), true)
world:set(e, pair(A, C), true)
CHECK(world:has(e, pair(A, B)) == false)
CHECK(world:has(e, pair(A, C)) == true)
CHECK(world:has(e, D))
end
do CASE "archetype move"
local world = jecs.world()
@ -3023,8 +2882,8 @@ TEST("world:set()", function()
local e = world:entity()
-- An entity starts without an archetype or row
-- should therefore not need to copy over data
CHECK(d.tbl(e) == world.ROOT_ARCHETYPE)
CHECK(d.row(e) == 0)
CHECK(d.tbl(e) == nil)
CHECK(d.row(e) == nil)
local archetypes = #world.archetypes
-- This should create a new archetype since it is the first
@ -3360,7 +3219,7 @@ TEST("Hooks", function()
local B = world:component()
local e = world:entity()
world:set(A, jecs.OnRemove, function(entity: jecs.Entity)
world:set(A, jecs.OnRemove, function(entity)
world:set(entity, B, true)
CHECK(world:get(entity, A))
CHECK(world:get(entity, B))

View file

@ -1,6 +1,6 @@
[package]
name = "ukendio/jecs"
version = "0.11.0"
version = "0.10.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
license = "MIT"