Compare commits

...

22 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
Ukendio
9acec0e954 Update 2026-02-20 11:57:15 +01:00
Ukendio
f9764634e6 Ensure that world:exists uses the safe try_get_any 2026-02-20 04:42:13 +01:00
Ukendio
4b10b622bf Update npm 2026-02-19 23:28:26 +01:00
dai
29c93e5b0c
Add Trusted Publishing (#307) 2026-02-19 23:25:46 +01:00
Ukendio
6552a5d2d1 Remove the exact terms lookup set and detect bulk operation for removal of pairs 2026-02-19 23:16:18 +01:00
Ukendio
5f76674723 Fix import 2026-02-19 22:52:26 +01:00
Ukendio
622c7c9638 Update Wally 2026-02-19 22:24:01 +01:00
Ukendio
4236bd02fd Prune on cascaded deletion 2026-02-19 22:14:49 +01:00
16 changed files with 625 additions and 483 deletions

View file

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

@ -1,71 +1,95 @@
name: release
on:
push:
tags: ["v*", "workflow_dispatch"]
push:
tags:
- "v*"
workflow_dispatch:
permissions:
id-token: write
contents: write
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout Project
uses: actions/checkout@v4
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout Project
uses: actions/checkout@v4
- name: Install Rokit
uses: CompeyDev/setup-rokit@v0.1.2
- name: Install Rokit
uses: CompeyDev/setup-rokit@v0.1.2
- name: Install Dependencies
run: wally install
- name: Install Dependencies
run: wally install
- name: Build
run: rojo build --output build.rbxm default.project.json
- name: Build
run: rojo build --output build.rbxm default.project.json
- name: Upload Build Artifact
uses: actions/upload-artifact@v4
with:
name: build
path: build.rbxm
- name: Upload Build Artifact
uses: actions/upload-artifact@v4
with:
name: build
path: build.rbxm
release:
name: Release
needs: [build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout Project
uses: actions/checkout@v4
release:
name: Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout Project
uses: actions/checkout@v4
- name: Download Jecs Build
uses: actions/download-artifact@v4
with:
name: build
path: build
- name: Download Build
uses: actions/download-artifact@v4
with:
name: build
path: build
- name: Rename Build
run: mv build/build.rbxm jecs.rbxm
- name: Rename Build
run: mv build/build.rbxm jecs.rbxm
- name: Create Release
uses: softprops/action-gh-release@v1
with:
name: Jecs ${{ github.ref_name }}
files: |
jecs.rbxm
- name: Create Release
uses: softprops/action-gh-release@v1
with:
name: Jecs ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
files: jecs.rbxm
publish:
name: Publish
needs: [release]
runs-on: ubuntu-latest
steps:
- name: Checkout Project
uses: actions/checkout@v4
publish-wally:
name: Publish to Wally
needs: release
runs-on: ubuntu-latest
steps:
- name: Checkout Project
uses: actions/checkout@v4
- name: Install Rokit
uses: CompeyDev/setup-rokit@v0.1.2
- name: Install Rokit
uses: CompeyDev/setup-rokit@v0.1.2
- name: Wally Login
run: wally login --token ${{ secrets.WALLY_AUTH_TOKEN }}
- name: Wally Login
run: wally login --token ${{ secrets.WALLY_AUTH_TOKEN }}
- name: Publish
run: wally publish
- 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

@ -47,8 +47,7 @@ end
-- local tgt = tonumber(tokens[2]) :: jecs.Entity
-- rel = ecs_ensure_entity(world, rel)
--
-- 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
--
-- tgt = ecs_ensure_entity(world, tgt)
-- return jecs.pair(rel, tgt)

View file

@ -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
and it takes a long time to do so.
- 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")
local jabby = require("@modules/Jabby/module")
local world = jecs.world()

View file

@ -5,11 +5,11 @@ type World = jecs.World
type Id<T=any> = jecs.Id<any>
local function duplicate(query: jecs.Query<...any>): jecs.CachedQuery<...any>
local world = (query :: jecs.Query<any> & { world: World }).world
local function duplicate(query: jecs.Query<...any>): jecs.Cached_Query<...any>
local world = (query :: { world: World }).world
local dup = world:query()
dup.filter_with = table.clone(query.filter_with)
if query.filter_without then
if query.filter_without then
dup.filter_without = query.filter_without
end
return dup:cached()
@ -152,11 +152,6 @@ 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) -> ())?
@ -176,7 +171,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 and terms_lookup[id] then
if last_old_archetype == oldarchetype and last_entity == entity then
return
end
@ -193,13 +188,12 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
if callback_removed == nil then
return
end
local r = jecs.record(world, entity)
if not r then return end
local src = r.archetype
if not src then return end
if not archetypes[src.id] then return end
if last_entity == entity and last_old_archetype == src then
@ -232,7 +226,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 and terms_lookup[id] then
if last_old_archetype == oldarchetype and last_entity == entity then
return
end
@ -253,10 +247,17 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
end
local r = jecs.record(world, entity)
if archetypes[r.archetype.id] then
last_old_archetype = nil
callback_removed(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
callback_removed(entity)
end)
table.insert(cleanup, onadded)
table.insert(cleanup, onremoved)
@ -285,16 +286,13 @@ 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, id, delete)
local onremoved = world:removed(rel, function(entity: jecs.Entity, id: jecs.Id, delete)
if delete then
return
end
@ -307,10 +305,7 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
local r = jecs.record(world, entity)
local archetype = r.archetype
if not archetype then
return
end
if last_old_archetype == archetype and terms_lookup[id] then
if last_old_archetype == archetype then
return
end
@ -330,9 +325,6 @@ 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)
@ -347,9 +339,6 @@ 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

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",
"version": "0.10.0",
"version": "0.11.0",
"description": "Stupidly fast Entity Component System",
"main": "src/jecs.luau",
"repository": {
"type": "git",
"url": "git+https://github.com/ukendio/jecs.git"
"url": "https://github.com/Ukendio/jecs"
},
"keywords": [],
"author": "Ukendio",

View file

@ -90,7 +90,7 @@ export type Cached_Query<T...> = typeof(setmetatable(
archetypes: (Cached_Query<T...>, override: boolean?) -> { Archetype },
has: (Cached_Query<T...>, Entity) -> boolean,
fini: (Cached_Query<T...>) -> (),
ids: { Id<any> },
filter_with: { Id<any> }?,
filter_without: { Id<any> }?,
@ -156,6 +156,7 @@ 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) -> ())?,
@ -533,7 +534,11 @@ 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
return entity_index.dense_array[r.dense]
local dense = r.dense
if dense > entity_index.alive_count then
return nil
end
return entity_index.dense_array[dense]
end
return nil
end
@ -563,17 +568,21 @@ end
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 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
alive_count += 1
entity_index.alive_count = alive_count
local id = dense_array[alive_count]
return id
local id = dense_array[next_count]
if id then
entity_index.alive_count = next_count
return id
end
end
local id = max_id + 1
@ -581,10 +590,9 @@ local function ENTITY_INDEX_NEW_ID(entity_index: entityindex): i53
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
dense_array[alive_count] = id
sparse_array[id] = { dense = alive_count } :: record
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
@ -911,6 +919,7 @@ local function id_record_create(
records = {},
counts = {},
flags = flags,
cache = {},
on_add = on_add,
on_change = on_change,
@ -948,6 +957,7 @@ 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
@ -1039,8 +1049,10 @@ 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
} :: record
dense = 0,
row = 0,
archetype = world.ROOT_ARCHETYPE
}
end
entity_index.max_id = range_begin
entity_index.alive_count = range_begin
@ -1080,10 +1092,11 @@ 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
@ -1206,6 +1219,13 @@ 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
@ -1751,10 +1771,10 @@ local function query_iter_init(query: QueryInner): () -> (number, ...any)
col6_u = col6
col7_u = col7
end
local row = i_u
i_u -= 1
return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], col6[row], col7[row]
end
else
@ -1773,13 +1793,13 @@ local function query_iter_init(query: QueryInner): () -> (number, ...any)
local col7 = col7_u
local ids = ids_u
local columns_map = columns_map_u
while e == nil do
last_archetype_u += 1
local compatible_archetypes = compatible_archetypes_u
local archetype = compatible_archetypes[last_archetype_u]
archetype_u = archetype
if not archetype then
return nil
end
@ -1809,18 +1829,18 @@ local function query_iter_init(query: QueryInner): () -> (number, ...any)
col6_u = col6
col7_u = col7
end
local row = i_u
i_u -= 1
for i = 9, ids_len do
output[i - 8] = columns_map[ids[i]::any][row]
end
return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], col6[row], col7[row], unpack(output)
end
end
query.next = world_query_iter_next
return world_query_iter_next
end
@ -2431,18 +2451,18 @@ local function query_cached(query: QueryInner)
return archetypes_map[entityarchetype.id] ~= nil
end
local function cached_query_fini()
local create_pos = table.find(query_cache_on_create, observer_for_create)
if create_pos then
if create_pos then
table.remove(query_cache_on_create, create_pos)
end
local delete_pos = table.find(query_cache_on_delete, observer_for_delete)
if delete_pos then
if delete_pos then
table.remove(query_cache_on_delete, delete_pos)
end
compatible_archetypes_u = nil
-- NOTE(marcus): Maybe we have to be even more aggressive with cleaning
-- things up to ensure it the memory is free`d. But since most of it are
@ -2557,7 +2577,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 not from then
if from == world.ROOT_ARCHETYPE then
local dst_types = table.clone(ids)
table.sort(dst_types)
@ -2645,6 +2665,8 @@ 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)
@ -2653,13 +2675,12 @@ 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
@ -2672,22 +2693,15 @@ 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
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
to = archetype_ensure(world, dst_types)
local to = archetype_ensure(world, dst_types)
if from ~= to then
entity_move(entity_index, entity, r, to)
@ -2698,12 +2712,12 @@ local function world_new(DEBUG: boolean?)
local eindex_dense_array = {} :: { i53 }
local eindex_sparse_array = {} :: { record }
local entity_index = {
local entity_index: entityindex = {
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.
@ -2743,31 +2757,6 @@ 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
@ -2897,9 +2886,7 @@ local function world_new(DEBUG: boolean?)
return
end
local from: archetype = record.archetype
local ROOT_ARCHETYPE = ROOT_ARCHETYPE
local src = from or ROOT_ARCHETYPE
local src = record.archetype
local column = src.columns_map[id]
if column then
local idr = component_index[id]
@ -2929,9 +2916,9 @@ local function world_new(DEBUG: boolean?)
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]
if src ~= record.archetype then
error(ON_REMOVE_STRUCTURAL_WARN)
end
end
to = exclusive_traverse_add(src, cr, id)
@ -2954,11 +2941,8 @@ local function world_new(DEBUG: boolean?)
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)
if src ~= record.archetype then
error(ON_REMOVE_STRUCTURAL_WARN)
end
end
end
@ -2977,7 +2961,9 @@ local function world_new(DEBUG: boolean?)
idr = component_index[id]
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
inner_entity_move(entity, record, to)
else
@ -3004,9 +2990,7 @@ local function world_new(DEBUG: boolean?)
return
end
local from = record.archetype
local ROOT_ARCHETYPE = ROOT_ARCHETYPE
local src = from or ROOT_ARCHETYPE
local src = record.archetype
if src.columns_map[id] then
return
end
@ -3028,10 +3012,9 @@ local function world_new(DEBUG: boolean?)
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]
if src ~= record.archetype then
error(ON_REMOVE_STRUCTURAL_WARN)
end
end
to = exclusive_traverse_add(src, cr, id)
@ -3077,7 +3060,9 @@ local function world_new(DEBUG: boolean?)
idr = component_index[id]
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)
else
if #to.types > 0 then
@ -3100,9 +3085,6 @@ 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
@ -3160,7 +3142,8 @@ local function world_new(DEBUG: boolean?)
table.insert(listeners, fn)
return function()
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[n] = nil
end
@ -3205,7 +3188,8 @@ local function world_new(DEBUG: boolean?)
table.insert(listeners, fn)
return function()
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[n] = nil
end
@ -3250,6 +3234,7 @@ 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
@ -3322,10 +3307,13 @@ 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 = eindex_sparse_array[index]
local r = sparse_array[index]
if r then
local dense = r.dense
@ -3335,17 +3323,17 @@ local function world_new(DEBUG: boolean?)
alive_count += 1
entity_index.alive_count = alive_count
r.dense = alive_count
eindex_dense_array[alive_count] = entity
dense_array[alive_count] = entity
return entity
end
-- 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
alive_count += 1
entity_index.alive_count = alive_count
r.dense = alive_count
eindex_dense_array[alive_count] = entity
dense_array[alive_count] = entity
return entity
end
@ -3354,30 +3342,41 @@ 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
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
sparse_array[i] = { dense = 0, row = 0, archetype = ROOT_ARCHETYPE }
end
entity_index.max_id = index
end
alive_count += 1
entity_index.alive_count = alive_count
eindex_dense_array[alive_count] = entity
dense_array[alive_count] = entity
r = { dense = alive_count } :: record
eindex_sparse_array[index] = r
r = { dense = alive_count, row = 0, archetype = ROOT_ARCHETYPE }
sparse_array[index] = r
return entity
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
local function world_remove(world: world, entity: i53, id: i53)
@ -3387,10 +3386,6 @@ 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
@ -3468,7 +3463,6 @@ 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
@ -3485,13 +3479,10 @@ 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
@ -3504,18 +3495,21 @@ 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 archetype_ids = idr_t.records
local to_remove = {}:: { [i53]: componentrecord}
for archetype_id in archetype_ids do
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 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
@ -3525,20 +3519,14 @@ 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 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
local has_delete = bit32.btest(id_record.flags, ECS_ID_DELETE)
if has_delete then
deleted_any = true
break
else
to_remove[id] = id_record
@ -3546,60 +3534,50 @@ local function world_new(DEBUG: boolean?)
end
end
if deleted_any then
continue
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 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
if on_remove then
on_remove(child, id :: i53)
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
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 r = entity_index_try_get_unsafe(child) :: record
for id, component_record in to_remove do
local on_remove = component_record.on_remove
if on_remove then
on_remove(child, id)
end
end
inner_entity_move(child, r, dst)
end
archetype_destroy(world, idr_t_archetype)
end
if remove_count == 1 then
local id, id_record = next(to_remove)
local to_u = archetype_traverse_remove(world, id :: i53, idr_t_archetype)
local on_remove = id_record.on_remove
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
end
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 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
end
inner_entity_move(child, r, to)
end
end
table.clear(to_remove)
archetype_destroy(world, idr_t_archetype)
table.clear(to_remove)
end
end
@ -3654,14 +3632,11 @@ 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
@ -3669,8 +3644,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 = nil :: any
record.row = nil :: any
record.archetype = ROOT_ARCHETYPE
record.row = 0
record.dense = i_swap
eindex_dense_array[dense] = e_swap
@ -3692,12 +3667,16 @@ local function world_new(DEBUG: boolean?)
end
end
archetype_delete(world, record.archetype, record.row)
record.archetype = nil :: any
record.row = nil :: any
record.archetype = world.ROOT_ARCHETYPE
record.row = 0
end
local function world_exists(world: world, entity: i53): boolean
return entity_index_try_get_any(entity) ~= nil
local r = entity_index_try_get_any(entity)
if not r or r.dense == 0 then
return false
end
return true
end
local function world_contains(world: world, entity: i53): boolean
@ -3738,7 +3717,7 @@ local function world_new(DEBUG: boolean?)
return max_component_id
end
world.entity = world_entity
world.query = world_query :: any
world.remove = world_remove
@ -3788,9 +3767,9 @@ local function world_new(DEBUG: boolean?)
]], 2)
end
end
local function DEBUG_ID_IS_INVALID(id: number)
if ECS_IS_PAIR(id) then
local function DEBUG_ID_IS_INVALID(id: number)
if ECS_IS_PAIR(id) then
if ECS_ID_IS_WILDCARD(id) then
error([[
You tried to pass in a wildcard pair. This is strictly
@ -3801,7 +3780,7 @@ local function world_new(DEBUG: boolean?)
end
local first = ecs_pair_first(world, id)
local second = ecs_pair_second(world, id)
assert(world:contains(first), `The first element of the pair is invalid because it is not alive in the entity index. You might be holding onto an outdated handle or may have forward declared ids via jecs.component() and jecs.tag(). In the latter case, ensure that their calls precede jecs.world() or otherwise they will not register correctly`)
assert(world:contains(second), `The second element of the pair is invalid because it is not alive in the entity index. You might be holding onto an outdated handle or may have forward declared ids via jecs.component() and jecs.tag(). In the latter case, ensure that their calls precede jecs.world() or otherwise they will not register correctly`)
else
@ -3848,7 +3827,7 @@ local function world_new(DEBUG: boolean?)
end
for i = 1, EcsRest do
entity_index_new_id(entity_index)
ENTITY_INDEX_NEW_ID(world)
end
for i = 1, max_component_id do
@ -3884,7 +3863,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(entity_index)
ENTITY_INDEX_NEW_ID(world)
end
for i, bundle in ecs_metadata do
@ -3902,7 +3881,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)) 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
local idr = world.component_index[entity]
if idr then
@ -3915,7 +3894,7 @@ local function ecs_entity_record(world: world, entity: i53)
return entity_index_try_get(world.entity_index, entity)
end
local function entity_index_ensure(entity_index: entityindex, e: i53)
local function entity_index_ensure(entity_index: entityindex, e: i53)
local eindex_sparse_array = entity_index.sparse_array
local eindex_dense_array = entity_index.dense_array
local index = ECS_ID(e)
@ -3971,33 +3950,33 @@ local function entity_index_ensure(entity_index: entityindex, e: i53)
end
local function new(world: world)
local e = ENTITY_INDEX_NEW_ID(world.entity_index)
local e = ENTITY_INDEX_NEW_ID(world)
return e
end
local function new_low_id(world: world)
local function new_low_id(world: world)
local entity_index = world.entity_index
local e = 0
if world.max_component_id < HI_COMPONENT_ID then
while true do
if world.max_component_id < HI_COMPONENT_ID then
while true do
world.max_component_id += 1
e = world.max_component_id
if not (entity_index_try_get_any(entity_index, e) ~= nil and e <= HI_COMPONENT_ID) then
if not (entity_index_try_get_any(entity_index, e) ~= nil and e <= HI_COMPONENT_ID) then
break
end
end
end
if e == 0 or e >= HI_COMPONENT_ID then
e = ENTITY_INDEX_NEW_ID(entity_index)
if e == 0 or e >= HI_COMPONENT_ID then
e = ENTITY_INDEX_NEW_ID(world)
else
entity_index_ensure(entity_index, e)
end
return e
end
local function new_w_id(world: world, id: i53)
local e = ENTITY_INDEX_NEW_ID(world.entity_index)
local function new_w_id(world: world, id: i53)
local e = ENTITY_INDEX_NEW_ID(world)
world.add(world, e, id)
return e
end

View file

@ -10,7 +10,6 @@ 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,
@ -19,14 +18,14 @@ return {
Mirror = function(_, ecs, mcs)
for i = 1, 100 do
mcs:entity()
local _e = mcs:entity()
end
end,
Jecs = function(_, ecs, mcs)
for i = 1, 100 do
ecs:entity()
local _e = ecs:entity()
end
end,
},

View file

@ -309,19 +309,19 @@ TEST("modules/ob::monitor", function()
local A = world:component()
local B = world:component()
local C = world:component()
local count = 0
ob.monitor(world:query(A, C)).removed(function()
ob.monitor(world:query(A, C)).removed(function()
count += 1
end)
local e = world:entity()
jecs.bulk_insert(world, e, {A, B}, {0,0})
CHECK(count==0)
world:remove(e, A)
CHECK(count==0)
end
do CASE [[should not invoke monitor.added callback multiple times in a bulk_move
]]
local A = world:component()
@ -838,27 +838,47 @@ TEST("modules/ob::monitor", function()
do CASE "monitor with wildcard pair should handle bulk_insert"
local A = world:component()
local B = world:component()
local e1 = world:entity()
local e2 = world:entity()
local e3 = world:entity()
local C = world:component()
local Relation = world:component()
local entity1 = world:entity()
local entity = world:entity()
local monitor = ob.monitor(world:query(A, jecs.pair(B, jecs.w)))
local monitor = ob.monitor(world:query(A, B, C, jecs.pair(Relation, jecs.w)))
local c = 0
monitor.added(function()
c += 1
end)
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))
jecs.bulk_insert(world, entity, { A, B, C, jecs.pair(Relation, entity1) }, { 1, 2, 3, 4 })
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"
local A = world:component()
local B = world:component()

View file

@ -24,6 +24,174 @@ 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)
@ -538,44 +706,6 @@ 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)
@ -598,8 +728,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) == nil)
CHECK(d.row(e) == nil)
CHECK(d.tbl(e) == world.ROOT_ARCHETYPE)
CHECK(d.row(e) == 0)
local archetypes = #world.archetypes
-- This should create a new archetype
@ -653,6 +783,71 @@ 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()
@ -968,24 +1163,6 @@ 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()
@ -1528,6 +1705,7 @@ TEST("world:added", function()
end
end)
-- FOCUS()
TEST("world:range()", function()
do CASE "spawn entity under min range"
@ -2146,17 +2324,17 @@ TEST("world:query()", function()
CHECK(not world:has(e1, B))
end
end
do CASE "query:archetypes(override) should create new archetypes list"
do CASE "query:archetypes(override) should create new archetypes list"
local world = jecs.world()
local A = world:component()
local B = world:component()
local q = world:query(A, B)
local e = world:entity()
world:set(e, A, false)
world:set(e, B, true)
CHECK(q:archetypes() == q:archetypes())
CHECK(q:archetypes() ~= q:archetypes(true))
end
@ -2835,43 +3013,6 @@ 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()
@ -2882,8 +3023,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) == nil)
CHECK(d.row(e) == nil)
CHECK(d.tbl(e) == world.ROOT_ARCHETYPE)
CHECK(d.row(e) == 0)
local archetypes = #world.archetypes
-- This should create a new archetype since it is the first
@ -3219,7 +3360,7 @@ TEST("Hooks", function()
local B = world:component()
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)
CHECK(world:get(entity, A))
CHECK(world:get(entity, B))

View file

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