Compare commits

..

14 commits

Author SHA1 Message Date
Ukendio
19823453aa Explicit error message for double disconnect
Some checks failed
analysis / Run Luau Analyze (push) Has been cancelled
build-studio-docs / Build Studio Docs (push) Has been cancelled
unit-testing / Run Luau Tests (push) Has been cancelled
2026-03-10 03:14:31 +01:00
Ukendio
7170dbf6a1 Throw error at structural changes within on_remove hooks 2026-03-10 03:11:43 +01:00
Ukendio
4d76e28425 emplace the iD 2026-03-05 22:26:41 +01:00
Ukendio
99c2b1b56e Update temperance 2026-03-01 19:45:38 +01:00
Ukendio
8ea8f1f235 Check that both elements of the pair are tags 2026-03-01 19:37:19 +01:00
Ukendio
a0f6a9a632 Consolidate PerfGraph 2026-03-01 19:36:45 +01:00
Ukendio
96dfde0d2e Allow contents to be written 2026-02-22 17:04:51 +01:00
Ukendio
043bff1ff8 Remove auth token and avoid overriding permissions 2026-02-22 16:59:54 +01:00
Ukendio
74254717f1 Add deserialize 2026-02-22 16:47:38 +01:00
Ukendio
0a8c827573 Url is case sensitive 2026-02-22 16:47:02 +01:00
Ukendio
683c7f28aa workflow_dispatch needs to be its own key 2026-02-22 16:46:30 +01:00
Ukendio
e2bfe80bb8 Delete tag 2026-02-20 12:08:43 +01:00
Ukendio
360423b634 Fix repo url in package.json 2026-02-20 12:04:06 +01:00
Ukendio
73019547bd Revoke token from example 2026-02-20 12:02:21 +01:00
13 changed files with 450 additions and 424 deletions

View file

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

View file

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

View file

@ -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. 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, - Tacit knowledge is very hard to recover by looking at a maze of code,
and it takes and it takes a long time to do so.
- You will often hear that "every semantic distinction deserves its own - You will often hear that "every semantic distinction deserves its own
component or tag". Sometimes this is correct. A well chosen component boundary component or tag". Sometimes this is correct. A well chosen component boundary

View file

@ -193,7 +193,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
if not r then return end if not r then return end
local src = r.archetype local src = r.archetype
if not src then return end
if not archetypes[src.id] then return end if not archetypes[src.id] then return end
@ -287,9 +286,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
end end
local r = jecs.record(world, entity) local r = jecs.record(world, entity)
local archetype = r.archetype local archetype = r.archetype
if not archetype then
return
end
if archetypes[oldarchetype.id] and not archetypes[archetype.id] then if archetypes[oldarchetype.id] and not archetypes[archetype.id] then
last_old_archetype = nil last_old_archetype = nil
@ -309,9 +305,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
local r = jecs.record(world, entity) local r = jecs.record(world, entity)
local archetype = r.archetype local archetype = r.archetype
if not archetype then
return
end
if last_old_archetype == archetype then if last_old_archetype == archetype then
return return
end end
@ -332,9 +325,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
end end
local r = jecs.record(world, entity) local r = jecs.record(world, entity)
local archetype = r.archetype local archetype = r.archetype
if not archetype then
return
end
if archetypes[oldarchetype.id] and not archetypes[archetype.id] then if archetypes[oldarchetype.id] and not archetypes[archetype.id] then
callback_removed(entity) callback_removed(entity)
@ -349,9 +339,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
end end
local r = jecs.record(world, entity) local r = jecs.record(world, entity)
local archetype = r.archetype local archetype = r.archetype
if not archetype then
return
end
local dst = jecs.archetype_traverse_remove(world, id, archetype) local dst = jecs.archetype_traverse_remove(world, id, archetype)
if archetypes[dst.id] then if archetypes[dst.id] then

View file

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

47
modules/deserialize.luau Normal file
View file

@ -0,0 +1,47 @@
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

@ -1,39 +0,0 @@
-- 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>
}

View file

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

View file

@ -156,6 +156,7 @@ type componentrecord = {
counts: { [i53]: number }, counts: { [i53]: number },
flags: number, flags: number,
size: number, size: number,
cache: { number },
on_add: ((entity: i53, id: i53, value: any, oldarchetype: archetype) -> ())?, on_add: ((entity: i53, id: i53, value: any, oldarchetype: archetype) -> ())?,
on_change: ((entity: i53, id: i53, value: any, oldarchetype: archetype) -> ())?, on_change: ((entity: i53, id: i53, value: any, oldarchetype: archetype) -> ())?,
@ -567,28 +568,31 @@ end
local ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY = "Entity is outside range" local ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY = "Entity is outside range"
local function ENTITY_INDEX_NEW_ID(entity_index: entityindex): i53 local function ENTITY_INDEX_NEW_ID(world: world): i53
local entity_index = world.entity_index
local ROOT_ARCHETYPE = world.ROOT_ARCHETYPE
local dense_array = entity_index.dense_array local dense_array = entity_index.dense_array
local alive_count = entity_index.alive_count local alive_count = entity_index.alive_count
local sparse_array = entity_index.sparse_array local sparse_array = entity_index.sparse_array
local max_id = entity_index.max_id local max_id = entity_index.max_id
local next_count = alive_count + 1
if alive_count < max_id then if alive_count < max_id then
alive_count += 1 local id = dense_array[next_count]
entity_index.alive_count = alive_count if id then
local id = dense_array[alive_count] entity_index.alive_count = next_count
return id return id
end end
end
local id = max_id + 1 local id = max_id + 1
local range_end = entity_index.range_end local range_end = entity_index.range_end
ecs_assert(range_end == nil or id < range_end, ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY) ecs_assert(range_end == nil or id < range_end, ECS_INTERNAL_ERROR_INCOMPATIBLE_ENTITY)
entity_index.max_id = id entity_index.max_id = id
alive_count += 1 entity_index.alive_count = next_count
entity_index.alive_count = alive_count dense_array[next_count] = id
dense_array[alive_count] = id sparse_array[id] = { dense = next_count, row = 0, archetype = ROOT_ARCHETYPE }
sparse_array[id] = { dense = alive_count } :: record
return id return id
end end
@ -915,6 +919,7 @@ local function id_record_create(
records = {}, records = {},
counts = {}, counts = {},
flags = flags, flags = flags,
cache = {},
on_add = on_add, on_add = on_add,
on_change = on_change, on_change = on_change,
@ -952,6 +957,7 @@ local function archetype_append_to_records(
idr_records[archetype_id] = index idr_records[archetype_id] = index
idr_counts[archetype_id] = 1 idr_counts[archetype_id] = 1
columns_map[id] = column columns_map[id] = column
table.insert(idr.cache, archetype_id)
else else
local max_count = idr_counts[archetype_id] + 1 local max_count = idr_counts[archetype_id] + 1
idr_counts[archetype_id] = max_count idr_counts[archetype_id] = max_count
@ -1043,8 +1049,10 @@ local function world_range(world: world, range_begin: number, range_end: number?
for i = max_id + 1, range_begin do for i = max_id + 1, range_begin do
dense_array[i] = i dense_array[i] = i
sparse_array[i] = { sparse_array[i] = {
dense = 0 dense = 0,
} :: record row = 0,
archetype = world.ROOT_ARCHETYPE
}
end end
entity_index.max_id = range_begin entity_index.max_id = range_begin
entity_index.alive_count = range_begin entity_index.alive_count = range_begin
@ -1084,10 +1092,11 @@ local function find_archetype_without(
): archetype ): archetype
local id_types = node.types local id_types = node.types
local at = table.find(id_types, id) local at = table.find(id_types, id)
if at == nil then
return node
end
local dst = table.clone(id_types) local dst = table.clone(id_types)
table.remove(dst, at) table.remove(dst, at)
return archetype_ensure(world, dst) return archetype_ensure(world, dst)
end end
@ -1210,6 +1219,13 @@ local function archetype_destroy(world: world, archetype: archetype)
if archetype == world.ROOT_ARCHETYPE then if archetype == world.ROOT_ARCHETYPE then
return return
end 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 component_index = world.component_index
local archetype_edges = world.archetype_edges local archetype_edges = world.archetype_edges
@ -2561,7 +2577,7 @@ local function ecs_bulk_insert(world: world, entity: i53, ids: { i53 }, values:
end end
local from = r.archetype local from = r.archetype
local component_index = world.component_index local component_index = world.component_index
if not from then if from == world.ROOT_ARCHETYPE then
local dst_types = table.clone(ids) local dst_types = table.clone(ids)
table.sort(dst_types) table.sort(dst_types)
@ -2649,6 +2665,8 @@ local function ecs_bulk_insert(world: world, entity: i53, ids: { i53 }, values:
end end
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 function ecs_bulk_remove(world: world, entity: i53, ids: { i53 })
local entity_index = world.entity_index local entity_index = world.entity_index
local r = entity_index_try_get(entity_index, entity) local r = entity_index_try_get(entity_index, entity)
@ -2657,14 +2675,13 @@ local function ecs_bulk_remove(world: world, entity: i53, ids: { i53 })
end end
local from = r.archetype local from = r.archetype
local component_index = world.component_index local component_index = world.component_index
if not from then
return
end
local remove: { [i53]: boolean } = {} local remove: { [i53]: boolean } = {}
local columns_map = from.columns_map local columns_map = from.columns_map
local dst_types = table.clone(from.types) :: { i53 }
for i, id in ids do for i, id in ids do
if not columns_map[id] then if not columns_map[id] then
continue continue
@ -2676,22 +2693,15 @@ local function ecs_bulk_remove(world: world, entity: i53, ids: { i53 })
local on_remove = idr.on_remove local on_remove = idr.on_remove
if on_remove then if on_remove then
on_remove(entity, id) on_remove(entity, id)
if from ~= r.archetype then
error(ON_REMOVE_STRUCTURAL_WARN)
end end
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) local at = table.find(dst_types, id)
table.remove(dst_types, at) table.remove(dst_types, at)
end end
to = archetype_ensure(world, dst_types) local to = archetype_ensure(world, dst_types)
if from ~= to then if from ~= to then
entity_move(entity_index, entity, r, to) entity_move(entity_index, entity, r, to)
@ -2702,12 +2712,12 @@ local function world_new(DEBUG: boolean?)
local eindex_dense_array = {} :: { i53 } local eindex_dense_array = {} :: { i53 }
local eindex_sparse_array = {} :: { record } local eindex_sparse_array = {} :: { record }
local entity_index = { local entity_index: entityindex = {
dense_array = eindex_dense_array, dense_array = eindex_dense_array,
sparse_array = eindex_sparse_array, sparse_array = eindex_sparse_array,
alive_count = 0, alive_count = 0,
max_id = 0, max_id = 0,
} :: entityindex }
-- NOTE(marcus): with the way the component index is accessed, we want to -- NOTE(marcus): with the way the component index is accessed, we want to
-- ensure that components range has fast access. -- ensure that components range has fast access.
@ -2747,31 +2757,6 @@ local function world_new(DEBUG: boolean?)
signals = signals, signals = signals,
} :: world } :: 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, {}, "") local ROOT_ARCHETYPE = archetype_create(world, {}, "")
world.ROOT_ARCHETYPE = ROOT_ARCHETYPE world.ROOT_ARCHETYPE = ROOT_ARCHETYPE
@ -2901,9 +2886,7 @@ local function world_new(DEBUG: boolean?)
return return
end end
local from: archetype = record.archetype local src = record.archetype
local ROOT_ARCHETYPE = ROOT_ARCHETYPE
local src = from or ROOT_ARCHETYPE
local column = src.columns_map[id] local column = src.columns_map[id]
if column then if column then
local idr = component_index[id] local idr = component_index[id]
@ -2933,9 +2916,9 @@ local function world_new(DEBUG: boolean?)
local id_types = src.types local id_types = src.types
if on_remove then if on_remove then
on_remove(entity, id_types[cr]) on_remove(entity, id_types[cr])
src = record.archetype if src ~= record.archetype then
id_types = src.types error(ON_REMOVE_STRUCTURAL_WARN)
cr = idr.records[src.id] end
end end
to = exclusive_traverse_add(src, cr, id) to = exclusive_traverse_add(src, cr, id)
@ -2958,11 +2941,8 @@ local function world_new(DEBUG: boolean?)
if cr then if cr then
local id_types = src.types local id_types = src.types
on_remove(entity, id_types[cr]) on_remove(entity, id_types[cr])
local arche = record.archetype if src ~= record.archetype then
if src ~= arche then error(ON_REMOVE_STRUCTURAL_WARN)
id_types = arche.types
cr = idr.records[arche.id]
to = exclusive_traverse_add(arche, cr, id)
end end
end end
end end
@ -2981,7 +2961,9 @@ local function world_new(DEBUG: boolean?)
idr = component_index[id] idr = component_index[id]
end end
if from then 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 -- If there was a previous archetype, then the entity needs to move the archetype
inner_entity_move(entity, record, to) inner_entity_move(entity, record, to)
else else
@ -3008,9 +2990,7 @@ local function world_new(DEBUG: boolean?)
return return
end end
local from = record.archetype local src = record.archetype
local ROOT_ARCHETYPE = ROOT_ARCHETYPE
local src = from or ROOT_ARCHETYPE
if src.columns_map[id] then if src.columns_map[id] then
return return
end end
@ -3032,10 +3012,9 @@ local function world_new(DEBUG: boolean?)
local id_types = src.types local id_types = src.types
if on_remove then if on_remove then
on_remove(entity, id_types[cr]) on_remove(entity, id_types[cr])
if src ~= record.archetype then
src = record.archetype error(ON_REMOVE_STRUCTURAL_WARN)
id_types = src.types end
cr = idr.records[src.id]
end end
to = exclusive_traverse_add(src, cr, id) to = exclusive_traverse_add(src, cr, id)
@ -3081,7 +3060,9 @@ local function world_new(DEBUG: boolean?)
idr = component_index[id] idr = component_index[id]
end end
if from then local ROOT_ARCHETYPE = ROOT_ARCHETYPE
local src_is_root_archetype = src == ROOT_ARCHETYPE
if not src_is_root_archetype then
inner_entity_move(entity, record, to) inner_entity_move(entity, record, to)
else else
if #to.types > 0 then if #to.types > 0 then
@ -3104,9 +3085,6 @@ local function world_new(DEBUG: boolean?)
end end
local archetype = record.archetype local archetype = record.archetype
if not archetype then
return nil
end
local columns_map = archetype.columns_map local columns_map = archetype.columns_map
local row = record.row local row = record.row
@ -3164,7 +3142,8 @@ local function world_new(DEBUG: boolean?)
table.insert(listeners, fn) table.insert(listeners, fn)
return function() return function()
local n = #listeners local n = #listeners
local i = table.find(listeners, fn) 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[i] = listeners[n]
listeners[n] = nil listeners[n] = nil
end end
@ -3209,7 +3188,8 @@ local function world_new(DEBUG: boolean?)
table.insert(listeners, fn) table.insert(listeners, fn)
return function() return function()
local n = #listeners local n = #listeners
local i = table.find(listeners, fn) 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[i] = listeners[n]
listeners[n] = nil listeners[n] = nil
end end
@ -3254,6 +3234,7 @@ local function world_new(DEBUG: boolean?)
return function() return function()
local n = #listeners local n = #listeners
local i = table.find(listeners, fn::Listener<any>) 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[i] = listeners[n]
listeners[n] = nil listeners[n] = nil
end end
@ -3326,10 +3307,13 @@ local function world_new(DEBUG: boolean?)
end end
local function world_entity(world: world, entity: i53?): i53 local function world_entity(world: world, entity: i53?): i53
local sparse_array = eindex_sparse_array
local dense_array = eindex_dense_array
if entity then if entity then
local index = ECS_ID(entity) local index = ECS_ID(entity)
local alive_count = entity_index.alive_count local alive_count = entity_index.alive_count
local r = eindex_sparse_array[index] local r = sparse_array[index]
if r then if r then
local dense = r.dense local dense = r.dense
@ -3339,17 +3323,17 @@ local function world_new(DEBUG: boolean?)
alive_count += 1 alive_count += 1
entity_index.alive_count = alive_count entity_index.alive_count = alive_count
r.dense = alive_count r.dense = alive_count
eindex_dense_array[alive_count] = entity dense_array[alive_count] = entity
return entity return entity
end end
-- If dense > 0, check if there's an existing entity at that position -- If dense > 0, check if there's an existing entity at that position
local existing_entity = eindex_dense_array[dense] local existing_entity = dense_array[dense]
if existing_entity and existing_entity ~= entity then if existing_entity and existing_entity ~= entity then
alive_count += 1 alive_count += 1
entity_index.alive_count = alive_count entity_index.alive_count = alive_count
r.dense = alive_count r.dense = alive_count
eindex_dense_array[alive_count] = entity dense_array[alive_count] = entity
return entity return entity
end end
@ -3358,30 +3342,41 @@ local function world_new(DEBUG: boolean?)
local max_id = entity_index.max_id local max_id = entity_index.max_id
if index > max_id then 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 for i = max_id + 1, index - 1 do
if not eindex_sparse_array[i] then sparse_array[i] = { dense = 0, row = 0, archetype = ROOT_ARCHETYPE }
-- 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 end
entity_index.max_id = index entity_index.max_id = index
end end
alive_count += 1 alive_count += 1
entity_index.alive_count = alive_count entity_index.alive_count = alive_count
eindex_dense_array[alive_count] = entity dense_array[alive_count] = entity
r = { dense = alive_count } :: record r = { dense = alive_count, row = 0, archetype = ROOT_ARCHETYPE }
eindex_sparse_array[index] = r sparse_array[index] = r
return entity return entity
end end
end end
return entity_index_new_id(entity_index)
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
end end
local function world_remove(world: world, entity: i53, id: i53) local function world_remove(world: world, entity: i53, id: i53)
@ -3391,10 +3386,6 @@ local function world_new(DEBUG: boolean?)
end end
local from = record.archetype local from = record.archetype
if not from then
return
end
if from.columns_map[id] then if from.columns_map[id] then
local idr = world.component_index[id] local idr = world.component_index[id]
local on_remove = idr.on_remove local on_remove = idr.on_remove
@ -3472,7 +3463,6 @@ local function world_new(DEBUG: boolean?)
for i = n, 1, -1 do for i = n, 1, -1 do
world_delete(world, entities[i]) world_delete(world, entities[i])
end end
archetype_destroy(world, idr_archetype) archetype_destroy(world, idr_archetype)
end end
else else
@ -3489,13 +3479,10 @@ local function world_new(DEBUG: boolean?)
local r = eindex_sparse_array[ECS_ID(e :: number)] local r = eindex_sparse_array[ECS_ID(e :: number)]
local from = r.archetype local from = r.archetype
if from ~= idr_archetype then 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) to = archetype_traverse_remove(world, entity, from)
end end
inner_entity_move(e, r, to) inner_entity_move(e, r, to)
end end
archetype_destroy(world, idr_archetype) archetype_destroy(world, idr_archetype)
end end
else else
@ -3508,7 +3495,6 @@ local function world_new(DEBUG: boolean?)
local e = entities[i] local e = entities[i]
entity_move(entity_index, e, eindex_sparse_array[ECS_ID(e :: number)], to) entity_move(entity_index, e, eindex_sparse_array[ECS_ID(e :: number)], to)
end end
archetype_destroy(world, idr_archetype) archetype_destroy(world, idr_archetype)
end end
end end
@ -3516,11 +3502,10 @@ local function world_new(DEBUG: boolean?)
end end
if idr_t then if idr_t then
local archetype_ids = idr_t.records local to_remove = {} :: { [i53]: componentrecord }
local to_remove = {}:: { [i53]: componentrecord} local cache = idr_t.cache
local did_cascade_delete = false for i = #cache, 1, -1 do
local archetype_id = cache[i]
for archetype_id in archetype_ids do
local idr_t_archetype = archetypes[archetype_id] local idr_t_archetype = archetypes[archetype_id]
if not idr_t_archetype then if not idr_t_archetype then
continue continue
@ -3534,19 +3519,13 @@ local function world_new(DEBUG: boolean?)
if not ECS_IS_PAIR(id) then if not ECS_IS_PAIR(id) then
continue continue
end end
local object = entity_index_get_alive( local object = entity_index_get_alive(entity_index, ECS_PAIR_SECOND(id))
entity_index, ECS_PAIR_SECOND(id))
if object ~= entity then if object ~= entity then
continue continue
end end
local id_record = component_index[id] local id_record = component_index[id]
local flags = id_record.flags local has_delete = bit32.btest(id_record.flags, ECS_ID_DELETE)
local flags_delete_mask = bit32.btest(flags, ECS_ID_DELETE) if has_delete then
if flags_delete_mask then
for i = #entities, 1, -1 do
local child = entities[i]
world_delete(world, child)
end
deleted_any = true deleted_any = true
break break
else else
@ -3556,71 +3535,49 @@ local function world_new(DEBUG: boolean?)
end end
if deleted_any then if deleted_any then
did_cascade_delete = true for row = #entities, 1, -1 do
world_delete(world, entities[row])
end
archetype_destroy(world, idr_t_archetype)
continue continue
end end
if remove_count == 1 then if remove_count == 1 then
local id, id_record = next(to_remove) local id, id_record = next(to_remove)
local to_u = archetype_traverse_remove(world, id :: i53, idr_t_archetype) local to = archetype_traverse_remove(world, id::i53, idr_t_archetype)
local on_remove = id_record.on_remove local on_remove = id_record.on_remove
for i = #entities, 1, -1 do for row = #entities, 1, -1 do
local child = entities[i] local child = entities[row]
local r = entity_index_try_get_unsafe(child) :: record local r = entity_index_try_get_unsafe(child)::record
local to = to_u local dst = to
if on_remove then if on_remove then
on_remove(child, id :: i53) on_remove(child, id :: i53)
local src = r.archetype
if src ~= idr_t_archetype then
to = archetype_traverse_remove(world, id::i53, src)
end end
inner_entity_move(child, r, dst)
end end
archetype_destroy(world, idr_t_archetype)
inner_entity_move(child, r, to) else
end local dst_types = table.clone(idr_t_archetype.types)
elseif remove_count > 1 then for id in to_remove do
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)) table.remove(dst_types, table.find(dst_types, id))
end end
local to_u = archetype_ensure(world, dst_types) local dst = 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 row = #entities, 1, -1 do
local child = entities[row]
local r = entity_index_try_get_unsafe(child) :: record
for id, component_record in to_remove do for id, component_record in to_remove do
local on_remove = component_record.on_remove local on_remove = component_record.on_remove
if on_remove then if on_remove then
on_remove(child, id) on_remove(child, id)
local src = r.archetype
if src ~= idr_t_archetype then
to = archetype_traverse_remove(world, id, src)
end end
end end
inner_entity_move(child, r, dst)
end end
archetype_destroy(world, idr_t_archetype)
inner_entity_move(child, r, to)
end
end end
table.clear(to_remove) table.clear(to_remove)
archetype_destroy(world, idr_t_archetype)
end
if did_cascade_delete then
for archetype_id in archetype_ids do
local idr_t_archetype = archetypes[archetype_id]
if not idr_t_archetype then
continue
end
local entities = idr_t_archetype.entities
for i = #entities, 1, -1 do
world_delete(world, entities[i])
end
archetype_destroy(world, idr_t_archetype)
end
end end
end end
@ -3675,14 +3632,11 @@ local function world_new(DEBUG: boolean?)
local r = entity_index_try_get_unsafe(e) :: record local r = entity_index_try_get_unsafe(e) :: record
inner_entity_move(e, r, node) inner_entity_move(e, r, node)
end end
archetype_destroy(world, idr_r_archetype) archetype_destroy(world, idr_r_archetype)
end end
end end
end end
local dense = record.dense local dense = record.dense
local i_swap = entity_index.alive_count local i_swap = entity_index.alive_count
entity_index.alive_count = i_swap - 1 entity_index.alive_count = i_swap - 1
@ -3690,8 +3644,8 @@ local function world_new(DEBUG: boolean?)
local e_swap = eindex_dense_array[i_swap] local e_swap = eindex_dense_array[i_swap]
local r_swap = entity_index_try_get_any(e_swap) :: record local r_swap = entity_index_try_get_any(e_swap) :: record
r_swap.dense = dense r_swap.dense = dense
record.archetype = nil :: any record.archetype = ROOT_ARCHETYPE
record.row = nil :: any record.row = 0
record.dense = i_swap record.dense = i_swap
eindex_dense_array[dense] = e_swap eindex_dense_array[dense] = e_swap
@ -3713,8 +3667,8 @@ local function world_new(DEBUG: boolean?)
end end
end end
archetype_delete(world, record.archetype, record.row) archetype_delete(world, record.archetype, record.row)
record.archetype = nil :: any record.archetype = world.ROOT_ARCHETYPE
record.row = nil :: any record.row = 0
end end
local function world_exists(world: world, entity: i53): boolean local function world_exists(world: world, entity: i53): boolean
@ -3873,7 +3827,7 @@ local function world_new(DEBUG: boolean?)
end end
for i = 1, EcsRest do for i = 1, EcsRest do
entity_index_new_id(entity_index) ENTITY_INDEX_NEW_ID(world)
end end
for i = 1, max_component_id do for i = 1, max_component_id do
@ -3909,7 +3863,7 @@ local function world_new(DEBUG: boolean?)
world_add(world, EcsOnDeleteTarget, EcsExclusive) world_add(world, EcsOnDeleteTarget, EcsExclusive)
for i = EcsRest + 1, ecs_max_tag_id do for i = EcsRest + 1, ecs_max_tag_id do
entity_index_new_id(entity_index) ENTITY_INDEX_NEW_ID(world)
end end
for i, bundle in ecs_metadata do for i, bundle in ecs_metadata do
@ -3927,7 +3881,7 @@ end
local function ecs_is_tag(world: world, entity: i53): boolean local function ecs_is_tag(world: world, entity: i53): boolean
if ECS_IS_PAIR(entity) then if ECS_IS_PAIR(entity) then
return ecs_is_tag(world, ecs_pair_first(world, entity)) or ecs_is_tag(world, ecs_pair_second(world, entity)) return ecs_is_tag(world, ecs_pair_first(world, entity)) and ecs_is_tag(world, ecs_pair_second(world, entity))
end end
local idr = world.component_index[entity] local idr = world.component_index[entity]
if idr then if idr then
@ -3996,7 +3950,7 @@ local function entity_index_ensure(entity_index: entityindex, e: i53)
end end
local function new(world: world) local function new(world: world)
local e = ENTITY_INDEX_NEW_ID(world.entity_index) local e = ENTITY_INDEX_NEW_ID(world)
return e return e
end end
@ -4014,7 +3968,7 @@ local function new_low_id(world: world)
end end
end end
if e == 0 or e >= HI_COMPONENT_ID then if e == 0 or e >= HI_COMPONENT_ID then
e = ENTITY_INDEX_NEW_ID(entity_index) e = ENTITY_INDEX_NEW_ID(world)
else else
entity_index_ensure(entity_index, e) entity_index_ensure(entity_index, e)
end end
@ -4022,7 +3976,7 @@ local function new_low_id(world: world)
end end
local function new_w_id(world: world, id: i53) local function new_w_id(world: world, id: i53)
local e = ENTITY_INDEX_NEW_ID(world.entity_index) local e = ENTITY_INDEX_NEW_ID(world)
world.add(world, e, id) world.add(world, e, id)
return e return e
end end

View file

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

View file

@ -24,6 +24,174 @@ type Id<T=unknown> = jecs.Id<T>
local entity_visualiser = require("@modules/entity_visualiser") local entity_visualiser = require("@modules/entity_visualiser")
local dwi = entity_visualiser.stringify 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() TEST("reproduce idr_t nil archetype bug", function()
local world = jecs.world(true) local world = jecs.world(true)
@ -538,44 +706,6 @@ TEST("world:add()", function()
CHECK(world:has(e, pair(A, C)) == true) CHECK(world:has(e, pair(A, C)) == true)
end 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" do CASE "idempotent"
local world = jecs.world() local world = jecs.world()
local d = dwi(world) local d = dwi(world)
@ -598,8 +728,8 @@ TEST("world:add()", function()
local e = world:entity() local e = world:entity()
-- An entity starts without an archetype or row -- An entity starts without an archetype or row
-- should therefore not need to copy over data -- should therefore not need to copy over data
CHECK(d.tbl(e) == nil) CHECK(d.tbl(e) == world.ROOT_ARCHETYPE)
CHECK(d.row(e) == nil) CHECK(d.row(e) == 0)
local archetypes = #world.archetypes local archetypes = #world.archetypes
-- This should create a new archetype -- This should create a new archetype
@ -1033,24 +1163,6 @@ TEST("world:delete()", function()
-- CHECK(B_OnRemove_called) -- CHECK(B_OnRemove_called)
end 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)" do CASE "pair(OnDelete, Delete)"
local world = jecs.world() local world = jecs.world()
local ct = world:component() local ct = world:component()
@ -1593,6 +1705,7 @@ TEST("world:added", function()
end end
end) end)
-- FOCUS()
TEST("world:range()", function() TEST("world:range()", function()
do CASE "spawn entity under min range" do CASE "spawn entity under min range"
@ -2900,43 +3013,6 @@ TEST("world:set()", function()
CHECK(world:has(e, pair(A, C)) == true) CHECK(world:has(e, pair(A, C)) == true)
end 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" do CASE "archetype move"
local world = jecs.world() local world = jecs.world()
@ -2947,8 +3023,8 @@ TEST("world:set()", function()
local e = world:entity() local e = world:entity()
-- An entity starts without an archetype or row -- An entity starts without an archetype or row
-- should therefore not need to copy over data -- should therefore not need to copy over data
CHECK(d.tbl(e) == nil) CHECK(d.tbl(e) == world.ROOT_ARCHETYPE)
CHECK(d.row(e) == nil) CHECK(d.row(e) == 0)
local archetypes = #world.archetypes local archetypes = #world.archetypes
-- This should create a new archetype since it is the first -- This should create a new archetype since it is the first
@ -3284,7 +3360,7 @@ TEST("Hooks", function()
local B = world:component() local B = world:component()
local e = world:entity() local e = world:entity()
world:set(A, jecs.OnRemove, function(entity) world:set(A, jecs.OnRemove, function(entity: jecs.Entity)
world:set(entity, B, true) world:set(entity, B, true)
CHECK(world:get(entity, A)) CHECK(world:get(entity, A))
CHECK(world:get(entity, B)) CHECK(world:get(entity, B))

View file

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