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

@ -2,7 +2,13 @@ name: release
on: on:
push: push:
tags: ["v*", "workflow_dispatch"] tags:
- "v*"
workflow_dispatch:
permissions:
id-token: write
contents: write
jobs: jobs:
build: build:
@ -29,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
@ -50,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: publish-wally:
name: Publish name: Publish to Wally
needs: [release] needs: release
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Project - name: Checkout Project
@ -69,3 +73,23 @@ jobs:
- name: Publish - name: Publish
run: wally publish run: wally publish
publish-npm:
name: Publish to NPM
needs: release
runs-on: ubuntu-latest
steps:
- name: Checkout Project
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Install
run: npm install
- name: Publish
run: npm publish

View file

@ -48,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

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

View file

@ -5,8 +5,8 @@ type World = jecs.World
type Id<T=any> = jecs.Id<any> type Id<T=any> = jecs.Id<any>
local function duplicate(query: jecs.Query<...any>): jecs.CachedQuery<...any> local function duplicate(query: jecs.Query<...any>): jecs.Cached_Query<...any>
local world = (query :: jecs.Query<any> & { world: World }).world local world = (query :: { world: World }).world
local dup = world:query() local dup = world:query()
dup.filter_with = table.clone(query.filter_with) dup.filter_with = table.clone(query.filter_with)
if query.filter_without then if query.filter_without then
@ -152,11 +152,6 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
local entity_index = world.entity_index :: any 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_added: ((jecs.Entity) -> ())?
local callback_removed: ((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( local r = jecs.entity_index_try_get_fast(
entity_index, entity :: any) :: jecs.Record entity_index, entity :: any) :: jecs.Record
if not archetypes[oldarchetype.id] and archetypes[r.archetype.id] then 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 return
end end
@ -198,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
@ -232,7 +226,7 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
entity_index, entity :: any) :: jecs.Record entity_index, entity :: any) :: jecs.Record
if not archetypes[oldarchetype.id] and archetypes[r.archetype.id] then 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 return
end end
@ -253,10 +247,17 @@ local function monitors_new(query: jecs.Query<...any>): Monitor
end end
local r = jecs.record(world, entity) local r = jecs.record(world, entity)
if archetypes[r.archetype.id] then local src = r.archetype
last_old_archetype = nil
callback_removed(entity) if last_entity == entity and last_old_archetype == src then
return
end end
if not archetypes[src.id] then
return
end
last_entity = entity
last_old_archetype = src
callback_removed(entity)
end) end)
table.insert(cleanup, onadded) table.insert(cleanup, onadded)
table.insert(cleanup, onremoved) table.insert(cleanup, onremoved)
@ -285,16 +286,13 @@ 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
callback_removed(entity) callback_removed(entity)
end end
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 if delete then
return return
end end
@ -307,10 +305,7 @@ 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 if last_old_archetype == archetype then
return
end
if last_old_archetype == archetype and terms_lookup[id] then
return return
end end
@ -330,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)
@ -347,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.0", "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) -> ())?,
@ -533,7 +534,11 @@ end
local function entity_index_get_alive(entity_index: entityindex, entity: i53): i53? local function entity_index_get_alive(entity_index: entityindex, entity: i53): i53?
local r = entity_index_try_get_any(entity_index, entity :: number) local r = entity_index_try_get_any(entity_index, entity :: number)
if r then 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 end
return nil return nil
end end
@ -563,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
@ -911,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,
@ -948,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
@ -1039,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
@ -1080,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
@ -1206,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
@ -2557,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)
@ -2645,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)
@ -2653,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
@ -2672,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)
@ -2698,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.
@ -2743,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
@ -2897,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]
@ -2929,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)
@ -2954,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
@ -2977,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
@ -3004,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
@ -3028,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)
@ -3077,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
@ -3100,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
@ -3160,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
@ -3205,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
@ -3250,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
@ -3322,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
@ -3335,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
@ -3354,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)
@ -3387,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
@ -3468,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
@ -3485,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
@ -3504,18 +3495,21 @@ 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
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] local idr_t_archetype = archetypes[archetype_id]
if not idr_t_archetype then
continue
end
local idr_t_types = idr_t_archetype.types local idr_t_types = idr_t_archetype.types
local entities = idr_t_archetype.entities local entities = idr_t_archetype.entities
local deleted_any = false local deleted_any = false
@ -3525,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
@ -3547,59 +3535,49 @@ local function world_new(DEBUG: boolean?)
end end
if deleted_any then if deleted_any then
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
-- 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) 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 end
end end
@ -3654,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
@ -3669,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
@ -3692,12 +3667,16 @@ 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
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 end
local function world_contains(world: world, entity: i53): boolean local function world_contains(world: world, entity: i53): boolean
@ -3848,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
@ -3884,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
@ -3902,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
@ -3971,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
@ -3989,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
@ -3997,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

@ -838,27 +838,47 @@ TEST("modules/ob::monitor", function()
do CASE "monitor with wildcard pair should handle bulk_insert" do CASE "monitor with wildcard pair should handle bulk_insert"
local A = world:component() local A = world:component()
local B = world:component() local B = world:component()
local e1 = world:entity() local C = world:component()
local e2 = world:entity() local Relation = world:component()
local e3 = world:entity() 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 local c = 0
monitor.added(function() monitor.added(function()
c += 1 c += 1
end) end)
local e = world:entity() jecs.bulk_insert(world, entity, { A, B, C, jecs.pair(Relation, entity1) }, { 1, 2, 3, 4 })
world:add(e, A)
CHECK(c == 0)
world:add(e, jecs.pair(B, e1))
CHECK(c == 1)
world:add(e, jecs.pair(B, e2))
CHECK(c == 1)
world:add(e, jecs.pair(B, e3))
CHECK(c == 1) CHECK(c == 1)
end 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" do CASE "monitor with multiple pairs should handle separate operations correctly"
local A = world:component() local A = world:component()
local B = 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 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
@ -653,6 +783,71 @@ TEST("world:children()", function()
jecs.ECS_META_RESET() jecs.ECS_META_RESET()
end) 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() -- TEST("world:purge()", function()
-- do CASE "should remove all instances of specified component" -- do CASE "should remove all instances of specified component"
-- local world = jecs.world() -- local world = jecs.world()
@ -968,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()
@ -1528,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"
@ -2835,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()
@ -2882,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
@ -3219,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.0" 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"