ludic/packages/ludic.base/tests/ecs_test.ludic
Orkuncakilkaya f3fec88888 feat(ecs): chunks as a grouping - IntIndex takes sparse values; ludic.things' chunk column and things_drop_chunk
A streamed world loads and lets go of a chunk's entities together, so a chunk is a first-class
group: chunk_of(x, z, size) packs a chunk's cell into an int, tb_remove_all(tb, ix, v) removes every
row an index files under one value (the observers told of each). IntIndex kept one list per value
up to the largest, which a packed chunk number (hundreds of millions) turned into hundreds of
millions of empty lists and a 12 GB test run; a value past 1024 now gets its slot through an IntMap,
so a sparse value costs one list.

ludic.things files every Thing under its 256 m chunk (TH_CHUNK), kept by every move;
things_chunk_count and things_drop_chunk (the port and the facts told of each removal, as
thing_remove tells them) are what the memory budget's streamed chunks ask. The moves are their own
file (moves.ludic). Tests: ludic.base 35, ludic.things 8, all under a 2 GB cap (24 MB peak).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:07:26 +03:00

186 lines
6.5 KiB
Text

# ecs_test.ludic - Table<T>: handles that go stale, dense rows after a removal, the setters keeping
# the grid and the kind index current, change ticks, and IntMap. Each case is a function because a
# generic call is not resolved inside a `test` body yet.
import "ludic.base"
program EcsTest {
numbers float
property Toy { name: string = "" }
const CX: int = 0
const CZ: int = 1
const KIND: int = 0
const ON: int = 1
state HookState {
added: int = 0
gone: int = 0
last: int = -1
}
function seen_add(hook_st: mut HookState, h: int) -> void {
hook_st.added += 1
hook_st.last = h
}
function seen_gone(hook_st: mut HookState, h: int) -> void { hook_st.gone += 1 }
function toy(name: string) -> Toy {
let t = new Toy
t.name = name
return t
}
function put(tb: Table<Toy>, name: string, x: float, z: float, kind: int) -> int {
let h = tb_add(tb, toy(name))
let r = tb_row(tb, h)
tb_set_xz(tb, CX, CZ, r, x, z)
tb_set_i(tb, KIND, r, kind)
tb_set_i(tb, ON, r, 1)
return h
}
function handles_case() -> void {
let tb: Table<Toy> = table_new(2, 2)
let a = put(tb, "a", 0.0, 0.0, 1)
let b = put(tb, "b", 1.0, 0.0, 1)
let c = put(tb, "c", 2.0, 0.0, 2)
expect_eq(tb_len(tb), 3)
expect(tb_remove(tb, a))
expect(not tb_alive(tb, a))
expect(not tb_remove(tb, a))
expect_eq(tb_len(tb), 2)
expect_eq(tb_rec(tb, tb_row(tb, c)).name, "c")
expect_eq(tb_rec(tb, tb_row(tb, b)).name, "b")
let d = put(tb, "d", 3.0, 0.0, 1)
expect(d != a)
expect_eq(d & ECS_SLOT, a & ECS_SLOT)
expect(not tb_alive(tb, a))
expect_eq(tb_rec(tb, tb_row(tb, d)).name, "d")
}
function grid_case() -> void {
let tb: Table<Toy> = table_new(2, 2)
let g = tb_grid(tb, CX, CZ, ON, 16.0)
let near = put(tb, "near", 5.0, 5.0, 1)
put(tb, "far", 500.0, -300.0, 1)
let fire = put(tb, "fire", -40.0, 2.0, 2)
expect_eq(tb_handle(tb, tb_nearest(tb, g, 0.0, 0.0, 0.0, -1, 0)), near)
expect_eq(tb_handle(tb, tb_nearest(tb, g, 0.0, 0.0, 0.0, KIND, 2)), fire)
expect_eq(tb_nearest(tb, g, 0.0, 0.0, 10.0, KIND, 2), -1)
tb_set_i(tb, ON, tb_row(tb, near), 0)
expect_eq(tb_handle(tb, tb_nearest(tb, g, 0.0, 0.0, 0.0, KIND, 1)), tb_handle(tb, tb_nearest(tb, g, 499.0, -300.0, 0.0, -1, 0)))
let out = words(0)
expect_eq(tb_within(tb, g, 0.0, 0.0, 50.0, -1, 0, out), 1)
tb_set_i(tb, ON, tb_row(tb, near), 1)
expect_eq(tb_within(tb, g, 0.0, 0.0, 50.0, -1, 0, out), 2)
tb_set_xz(tb, CX, CZ, tb_row(tb, fire), 900.0, 900.0)
expect_eq(tb_within(tb, g, 0.0, 0.0, 50.0, -1, 0, out), 1)
}
function index_case() -> void {
let tb: Table<Toy> = table_new(2, 2)
let ix = tb_index(tb, KIND, ON)
let a = put(tb, "a", 0.0, 0.0, 3)
put(tb, "b", 0.0, 0.0, 3)
put(tb, "c", 0.0, 0.0, 5)
expect_eq(ix_count(ix, 3), 2)
expect_eq(ix_count(ix, 5), 1)
expect_eq(ix_count(ix, 99), 0)
tb_set_i(tb, ON, tb_row(tb, a), 0)
expect_eq(ix_count(ix, 3), 1)
tb_remove(tb, a)
tb_set_i(tb, KIND, ix_first(ix, 3), 5)
expect_eq(ix_count(ix, 3), 0)
expect_eq(ix_count(ix, 5), 2)
}
function ticks_case() -> void {
let tb: Table<Toy> = table_new(2, 2)
let a = put(tb, "a", 0.0, 0.0, 1)
put(tb, "b", 0.0, 0.0, 1)
let seen = tb_tick(tb)
tb_advance(tb)
let out = words(0)
expect_eq(tb_changed_since(tb, seen, out), 0)
tb_set_f(tb, CX, tb_row(tb, a), 4.0)
expect_eq(tb_changed_since(tb, seen, out), 1)
expect_eq(out[0], tb_row(tb, a))
expect_eq(tb_added_since(tb, seen, out), 0)
}
function map_case() -> void {
let m = imap_new()
for k in 0 .. 5000 { imap_put(m, k * 7, k) }
expect_eq(imap_len(m), 5000)
expect_eq(imap_get(m, 700, -1), 100)
expect_eq(imap_get(m, 701, -1), -1)
for k in 0 .. 2500 { imap_del(m, k * 14) }
expect_eq(imap_len(m), 2500)
expect_eq(imap_get(m, 14, -1), -1)
expect_eq(imap_get(m, 21, -1), 3)
imap_put(m, 14, 9)
expect_eq(imap_get(m, 14, -1), 9)
}
function hooks_case(hook_st: HookState) -> void {
let tb: Table<Toy> = table_new(2, 2)
tb_on_add(tb, fn seen_add)
tb_on_remove(tb, fn seen_gone)
let a = put(tb, "a", 0.0, 0.0, 1)
expect_eq(hook_st.last, a)
put(tb, "b", 0.0, 0.0, 1)
tb_remove(tb, a)
tb_remove(tb, a)
expect_eq(hook_st.added, 2)
expect_eq(hook_st.gone, 1)
}
# 300 rows over five blocks; a change in one block is found, the untouched blocks skipped
function blocks_case() -> void {
let tb: Table<Toy> = table_new(2, 2)
for i in 0 .. 300 { put(tb, "x", float(i), 0.0, 1) }
let seen = tb_tick(tb)
tb_advance(tb)
let out = words(0)
expect_eq(tb_changed_since(tb, seen, out), 0)
tb_set_f(tb, CX, 200, 9.0)
tb_set_f(tb, CX, 7, 9.0)
expect_eq(tb_changed_since(tb, seen, out), 2)
expect_eq(out[0], 7)
expect_eq(out[1], 200)
let before = tb_tick(tb)
tb_advance(tb)
tb_remove(tb, tb_handle(tb, 3))
expect_eq(tb_changed_since(tb, before, out), 0) # a row moved into a gap was not written
expect_eq(tb_changed_since(tb, seen, out), 2) # a removal is told by tb_on_remove
}
# values far apart (a chunk's packed cell) each take one list, not every list up to them
function sparse_case() -> void {
let tb: Table<Toy> = table_new(2, 2)
let ix = tb_index(tb, KIND, -1)
let a = chunk_of(9000.0, -9000.0, 256.0)
let b = chunk_of(-512.0, 700.0, 256.0)
expect(a > 100000 and b > 100000)
put(tb, "a", 0.0, 0.0, a)
put(tb, "b", 0.0, 0.0, b)
let c = put(tb, "c", 0.0, 0.0, a)
put(tb, "d", 0.0, 0.0, 3)
expect_eq(ix_count(ix, a), 2)
expect_eq(ix_count(ix, b), 1)
expect_eq(ix_count(ix, 3), 1)
expect(len(ix.lists) < 1100)
tb_set_i(tb, KIND, tb_row(tb, c), b)
expect_eq(ix_count(ix, a), 1)
expect_eq(ix_count(ix, b), 2)
expect_eq(tb_remove_all(tb, ix, b), 2)
expect_eq(ix_count(ix, b), 0)
expect_eq(tb_len(tb), 2)
}
test "a sparse value takes one list" () { sparse_case() }
test "observers hear each add, and a remove once" (hook_st: HookState) { hooks_case(hook_st) }
test "change detection skips the blocks nobody wrote" () { blocks_case() }
test "a removed handle misses, rows stay dense, a reused slot is a new handle" () { handles_case() }
test "the grid answers nearest and within, gated, after moves" () { grid_case() }
test "the kind index follows the setters, the gate and removals" () { index_case() }
test "change ticks name the rows written since" () { ticks_case() }
test "IntMap puts, finds, deletes and grows" () { map_case() }
}