A mechanic that keeps many of something keeps them as rows of a Table<T> rather than a list it scans. Removal swaps the last row in; a handle (22-bit slot, 9-bit generation) goes stale when its entity is removed. tb_set_f / tb_set_xz / tb_set_i stamp a change tick and refile the row in every index over that column in O(1): tb_grid (a doubly linked spatial hash, rings outward for nearest, rehashing as it grows), tb_index (a cached query: the rows of each value of a kind column, gated by an active column), tb_nearest_of / tb_within_of (a rare kind from its own list), tb_nearest_where (a predicate on the record), tb_within_recs (into the caller's list), tb_changed_since / tb_added_since, IntMap. No question allocates or writes: Ludic frees nothing, and the old lists' per-call copies leaked every frame. ludic.things keeps its Things as a Table<Thing> with x, z, kind and active as columns; every verb writes the record and the row together (a Thing carries its handle and table, so thing_hide(t) still needs no state), thing_set_on / thing_set_xz / thing_place_at are the silent forms the game used to do by assignment, and things_verify holds the columns against the records. things_near(_of) fill a caller's list, thing_of_kind walks a kind, things_count_of counts one, and things_tick visits only the kinds that tick. thing_find answers the first-placed by uid. Against a []Record scanned (M4 Pro): nearest 0.37 / 1.5 / 7.2 us at 10k / 100k / 1M (list 30 / 307 / 3075), by id 0.09 us at 100k (list 17), a move refiled in 11-44 ns. ecs_fuzz_test holds the grid, the kind index and the record queries against a scan through 9000 random changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
130 lines
4.8 KiB
Text
130 lines
4.8 KiB
Text
# ecs_fuzz_test.ludic - thousands of random adds, removes, moves, kind changes and gate flips, and
|
|
# after every burst the grid and the kind index answer exactly what a scan of the rows answers
|
|
import "ludic.base"
|
|
program EcsFuzzTest {
|
|
numbers float
|
|
property Toy { n: int = 0 }
|
|
const CX: int = 0
|
|
const CZ: int = 1
|
|
const KIND: int = 0
|
|
const ON: int = 1
|
|
const KINDS: int = 6
|
|
|
|
state FuzzState {
|
|
seed: int = 12345
|
|
}
|
|
|
|
function rnd(fz: mut FuzzState, n: int) -> int {
|
|
fz.seed = (fz.seed * 1103515245 + 12345) & 2147483647
|
|
return (fz.seed >> 8) % n
|
|
}
|
|
function rpos(fz: mut FuzzState) -> float { return float(rnd(fz, 40000)) / 10.0 - 2000.0 }
|
|
|
|
function step(fz: mut FuzzState, tb: Table<Toy>) -> void {
|
|
let op = rnd(fz, 10)
|
|
if op < 4 or tb_len(tb) == 0 {
|
|
let toy = new Toy
|
|
toy.n = rnd(fz, 5)
|
|
let r = tb_row(tb, tb_add(tb, toy))
|
|
tb_set_xz(tb, CX, CZ, r, rpos(fz), rpos(fz))
|
|
tb_set_i(tb, KIND, r, rnd(fz, KINDS))
|
|
tb_set_i(tb, ON, r, rnd(fz, 4) % 3)
|
|
return
|
|
}
|
|
let r = rnd(fz, tb_len(tb))
|
|
if op < 6 { tb_remove(tb, tb_handle(tb, r)) } else if op < 8 {
|
|
tb_set_xz(tb, CX, CZ, r, tb_f(tb, CX, r) + float(rnd(fz, 200)) - 100.0, tb_f(tb, CZ, r) + float(rnd(fz, 200)) - 100.0)
|
|
} else if op < 9 { tb_set_i(tb, KIND, r, rnd(fz, KINDS)) } else { tb_set_i(tb, ON, r, 1 - tb_i(tb, ON, r)) }
|
|
}
|
|
|
|
# the scan's answer: the smallest squared distance among on rows matching, or -1
|
|
function brute_d2(tb: Table<Toy>, x: float, z: float, maxr: float, kind: int) -> float {
|
|
var best = -1.0
|
|
for r in 0 .. tb_len(tb) {
|
|
if tb_i(tb, ON, r) == 0 or (kind >= 0 and tb_i(tb, KIND, r) != kind) { continue }
|
|
let dx = tb_f(tb, CX, r) - x
|
|
let dz = tb_f(tb, CZ, r) - z
|
|
let d2 = dx * dx + dz * dz
|
|
if (maxr <= 0.0 or d2 <= maxr * maxr) and (best < 0.0 or d2 < best) { best = d2 }
|
|
}
|
|
return best
|
|
}
|
|
|
|
function keep_even(t: Toy) -> bool { return t.n % 2 == 0 }
|
|
|
|
function brute_even(tb: Table<Toy>, x: float, z: float, maxr: float) -> float {
|
|
var best = -1.0
|
|
for r in 0 .. tb_len(tb) {
|
|
if tb_i(tb, ON, r) == 0 or tb_rec(tb, r).n % 2 != 0 { continue }
|
|
let dx = tb_f(tb, CX, r) - x
|
|
let dz = tb_f(tb, CZ, r) - z
|
|
let d2 = dx * dx + dz * dz
|
|
if (maxr <= 0.0 or d2 < maxr * maxr) and (best < 0.0 or d2 < best) { best = d2 }
|
|
}
|
|
return best
|
|
}
|
|
|
|
function where_check(tb: Table<Toy>, g: Grid, ix: IntIndex, x: float, z: float, maxr: float, kind: int, recs: []Toy) -> void {
|
|
let want = brute_even(tb, x, z, maxr)
|
|
let row = tb_nearest_where(tb, g, x, z, maxr, fn keep_even)
|
|
if want < 0.0 { expect_eq(row, -1) } else {
|
|
let dx = tb_f(tb, CX, row) - x
|
|
let dz = tb_f(tb, CZ, row) - z
|
|
expect(dx * dx + dz * dz == want)
|
|
}
|
|
expect_eq(tb_within_recs(tb, g, x, z, 150.0, -1, 0, recs), brute_within(tb, x, z, 150.0, -1))
|
|
if kind >= 0 { expect_eq(tb_within_of_recs(tb, g, ix, x, z, 150.0, kind, recs), brute_within(tb, x, z, 150.0, kind)) }
|
|
}
|
|
|
|
function brute_within(tb: Table<Toy>, x: float, z: float, r: float, kind: int) -> int {
|
|
var n = 0
|
|
for i in 0 .. tb_len(tb) {
|
|
if tb_i(tb, ON, i) == 0 or (kind >= 0 and tb_i(tb, KIND, i) != kind) { continue }
|
|
let dx = tb_f(tb, CX, i) - x
|
|
let dz = tb_f(tb, CZ, i) - z
|
|
if dx * dx + dz * dz <= r * r { n += 1 }
|
|
}
|
|
return n
|
|
}
|
|
|
|
function check(fz: mut FuzzState, tb: Table<Toy>, g: Grid, ix: IntIndex, out: words, recs: []Toy) -> void {
|
|
for q in 0 .. 20 {
|
|
let x = rpos(fz)
|
|
let z = rpos(fz)
|
|
let kind = rnd(fz, KINDS + 1) - 1
|
|
var maxr = 0.0
|
|
if q % 2 == 1 { maxr = float(rnd(fz, 600)) }
|
|
var mc = KIND
|
|
if kind < 0 { mc = -1 }
|
|
let want = brute_d2(tb, x, z, maxr, kind)
|
|
let row = tb_nearest(tb, g, x, z, maxr, mc, kind)
|
|
if want < 0.0 { expect_eq(row, -1) } else {
|
|
expect(row >= 0)
|
|
let dx = tb_f(tb, CX, row) - x
|
|
let dz = tb_f(tb, CZ, row) - z
|
|
expect(dx * dx + dz * dz == want)
|
|
}
|
|
where_check(tb, g, ix, x, z, maxr, kind, recs)
|
|
let rad = float(rnd(fz, 400))
|
|
expect_eq(tb_within(tb, g, x, z, rad, mc, kind, out), brute_within(tb, x, z, rad, kind))
|
|
}
|
|
for k in 0 .. KINDS { expect_eq(ix_count(ix, k), brute_within(tb, 0.0, 0.0, 100000.0, k)) }
|
|
}
|
|
|
|
function fuzz_case(fz: mut FuzzState) -> void {
|
|
let tb: Table<Toy> = table_new(2, 2)
|
|
let g = tb_grid(tb, CX, CZ, ON, 16.0)
|
|
let ix = tb_index(tb, KIND, ON)
|
|
let out = words(0)
|
|
let recs = new []Toy
|
|
for burst in 0 .. 60 {
|
|
for s in 0 .. 150 { step(fz, tb) }
|
|
check(fz, tb, g, ix, out, recs)
|
|
}
|
|
tb_clear(tb)
|
|
expect_eq(grid_count(g), 0)
|
|
expect_eq(ix_count(ix, 1), 0)
|
|
}
|
|
|
|
test "the grid and the kind index agree with a scan through 9000 random changes" (fz: mut FuzzState) { fuzz_case(fz) }
|
|
}
|