feat(ecs): ludic.base Table<T> - dense rows of hot columns, generational handles, a spatial grid, kind indexes and an id map kept current by the setters; ludic.things on it
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>
This commit is contained in:
parent
e3813b1ea4
commit
f802bdd3ec
23 changed files with 1367 additions and 125 deletions
|
|
@ -50,6 +50,49 @@ import "ludic.base"
|
|||
| `def Systems key { ... }`, `SYS_<KEY>`, `SYS_COUNT` | a system declared from any module; the list starts from these |
|
||||
| `core_init_all()`, `core_reset_all()`, `core_tick_all(t)`, `core_save_all() -> Val`, `core_load_all(v)` | the runner: in the order added, tick phase by phase |
|
||||
|
||||
## Entities: `Table<T>` and its indexes
|
||||
|
||||
A mechanic that keeps many of something (Things on the ground, animals, drops) keeps them as rows
|
||||
of a `Table<T>` in its state rather than as a list it scans. The table is data-oriented and
|
||||
allocates nothing per query:
|
||||
|
||||
- **Rows are dense.** Hot data is columns - `tb.f[c]` (floats) and `tb.i[c]` (ints), one value per
|
||||
row - and `tb.rec[row]` is a record `T` for everything cold. A removal moves the last row into
|
||||
the gap (`tb_remove`), so a sweep over `0 .. tb_len(tb)` touches contiguous memory.
|
||||
- **Handles go stale.** `tb_add` returns a handle: a 22-bit slot and a 9-bit generation. `tb_row(tb,
|
||||
h)` is -1 once the entity is removed, even after the slot is reused. Keep handles, never rows.
|
||||
- **Indexes are kept by the setters.** `tb_set_f`, `tb_set_xz`, `tb_set_i` (or writing `tb.f` /
|
||||
`tb.i` directly and then `tb_refile`) stamp the row's change tick and refile it in every index
|
||||
that reads that column, in O(1). An index is never a frame behind.
|
||||
- `tb_grid(tb, cx, cz, gate, cell)` - a spatial hash over two float columns, gated by an int
|
||||
column (a row is filed while it is non-zero: "active"). `tb_nearest(tb, g, x, z, maxr, mc, mv)`
|
||||
searches rings outward and stops at the first ring that cannot hold anything nearer;
|
||||
`tb_within(..., out)` fills a caller's `words`. Buckets double as the rows grow.
|
||||
- `tb_index(tb, col, gate)` - a cached query: the rows holding each value of an int column (a
|
||||
kind). `ix_rows(ix, v)`, `ix_count(ix, v)`, `ix_first(ix, v)`.
|
||||
- `tb_nearest_of` / `tb_within_of` plan between the two: a rare kind is scanned from its own
|
||||
list, a common one searched by rings.
|
||||
- **Change detection.** `tb_advance(tb)` moves the table to its next tick; `tb_changed_since(tb,
|
||||
tick, out)` and `tb_added_since` name the rows written since - what a save or a message needs to
|
||||
send a delta instead of everything.
|
||||
- **Stable ids.** `IntMap` (`imap_new`, `imap_put`, `imap_get(m, k, none)`, `imap_del`) maps an
|
||||
id kept in a save or a message to a handle without a scan.
|
||||
|
||||
Ludic frees nothing a safe program allocates, so a query that built a list per call leaked every
|
||||
frame. Every question here writes into a buffer the caller keeps. What each costs, against a
|
||||
`[]Record` list scanned (`M4 Pro`, one thread):
|
||||
|
||||
| | 10 000 | 100 000 | 1 000 000 |
|
||||
| --- | --- | --- | --- |
|
||||
| nearest, any | 0.37 us (list 30) | 1.5 us (list 307) | 7.2 us (list 3075) |
|
||||
| nearest of a kind (1 in 40) | 1.4 us (list 5.8) | 3.3 us (list 56) | 12 us (list 864) |
|
||||
| by id | 0.1 us (list 1.7) | 0.09 us (list 17) | 1.5 us (list 324) |
|
||||
| within 30 m | 0.9 us | 1.5 us | 6.9 us |
|
||||
| a move, refiled | 11 ns | 14 ns | 44 ns |
|
||||
|
||||
`tests/ecs_fuzz_test.ludic` holds the grid and the kind index against a scan through thousands of
|
||||
random adds, removes, moves, kind changes and gate flips.
|
||||
|
||||
## A toy mechanic
|
||||
|
||||
```ludic
|
||||
|
|
|
|||
79
packages/ludic.base/ecs_grid.ludic
Normal file
79
packages/ludic.base/ecs_grid.ludic
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# ludic.base/ecs_grid.ludic - a spatial hash over two float columns: square cells hashed into
|
||||
# buckets, each a doubly linked list of rows, so filing, moving and unfiling a row is O(1)
|
||||
export property Grid {
|
||||
cx: int = 0
|
||||
cz: int = 1
|
||||
gate: int = -1 # an int column: a row is filed only while it is non-zero (-1: always)
|
||||
inv: float = 0.0625 # 1 / the cell's side
|
||||
mask: int = 255 # buckets - 1, a power of two less one
|
||||
head: words = null # bucket -> row + 1, 0 empty
|
||||
next: words = null # row -> the next row in its bucket + 1
|
||||
prev: words = null # row -> the previous row + 1
|
||||
key: words = null # row -> its packed cell, -1 while not filed
|
||||
count: int = 0
|
||||
}
|
||||
|
||||
function grid_cell(g: Grid, v: float) -> int { return int(Math.floor(v * g.inv)) }
|
||||
function grid_key(ix: int, iz: int) -> int { return ((ix & 32767) << 15) | (iz & 32767) }
|
||||
function grid_bucket(g: Grid, ix: int, iz: int) -> int { return ((ix * 73856093) ^ (iz * 19349663)) & g.mask }
|
||||
|
||||
function grid_make(cx: int, cz: int, gate: int, cell: float, rows: int) -> Grid {
|
||||
let g = new Grid
|
||||
g.cx = cx
|
||||
g.cz = cz
|
||||
g.gate = gate
|
||||
g.inv = 1.0 / cell
|
||||
g.head = words(g.mask + 1)
|
||||
g.next = words(0)
|
||||
g.prev = words(0)
|
||||
g.key = words(0)
|
||||
for r in 0 .. rows { grid_grow(g) }
|
||||
return g
|
||||
}
|
||||
|
||||
# index a table spatially by columns cx and cz, in cells `cell` wide; its rows are filed at once
|
||||
export function tb_grid<T>(tb: Table<T>, cx: int, cz: int, gate: int, cell: float) -> Grid {
|
||||
let g = grid_make(cx, cz, gate, cell, tb.n)
|
||||
push(tb.grids, g)
|
||||
for r in 0 .. tb.n { tb_grid_refile(tb, g, r) }
|
||||
return g
|
||||
}
|
||||
|
||||
export function grid_count(g: Grid) -> int { return g.count }
|
||||
|
||||
function grid_grow(g: Grid) -> void {
|
||||
push(g.next, 0)
|
||||
push(g.prev, 0)
|
||||
push(g.key, -1)
|
||||
}
|
||||
|
||||
function grid_shrink(g: Grid) -> void {
|
||||
List.pop(g.next)
|
||||
List.pop(g.prev)
|
||||
List.pop(g.key)
|
||||
}
|
||||
|
||||
function grid_link(g: Grid, r: int, k: int, b: int) -> void {
|
||||
let h = g.head[b]
|
||||
g.next[r] = h
|
||||
g.prev[r] = 0
|
||||
if h > 0 { g.prev[h - 1] = r + 1 }
|
||||
g.head[b] = r + 1
|
||||
g.key[r] = k
|
||||
g.count += 1
|
||||
}
|
||||
|
||||
function grid_unlink(g: Grid, r: int) -> void {
|
||||
let k = g.key[r]
|
||||
if k < 0 { return }
|
||||
let p = g.prev[r]
|
||||
let n = g.next[r]
|
||||
if p > 0 { g.next[p - 1] = n } else { g.head[grid_bucket(g, grid_key_x(k), grid_key_z(k))] = n }
|
||||
if n > 0 { g.prev[n - 1] = p }
|
||||
g.key[r] = -1
|
||||
g.count -= 1
|
||||
}
|
||||
|
||||
# a packed key back to its cell (sign-extended from 15 bits)
|
||||
function grid_key_x(k: int) -> int { return ((k >> 15) & 32767) - (((k >> 29) & 1) * 32768) }
|
||||
function grid_key_z(k: int) -> int { return (k & 32767) - (((k >> 14) & 1) * 32768) }
|
||||
40
packages/ludic.base/ecs_grid_file.ludic
Normal file
40
packages/ludic.base/ecs_grid_file.ludic
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# ludic.base/ecs_grid_file.ludic - where a row is filed follows its columns: a row that has not
|
||||
# left its cell is not touched, a moved one is relinked, and the buckets double as the rows grow
|
||||
function grid_refile(g: Grid, r: int, x: float, z: float, on: bool) -> void {
|
||||
if not on {
|
||||
grid_unlink(g, r)
|
||||
return
|
||||
}
|
||||
let ix = grid_cell(g, x)
|
||||
let iz = grid_cell(g, z)
|
||||
let k = grid_key(ix, iz)
|
||||
if g.key[r] == k { return }
|
||||
grid_unlink(g, r)
|
||||
grid_link(g, r, k, grid_bucket(g, ix, iz))
|
||||
if g.count > (g.mask + 1) * 2 { grid_rehash(g) }
|
||||
}
|
||||
|
||||
# row `from` becomes row `to` (which is not filed): its neighbours and its bucket follow it
|
||||
function grid_moved(g: Grid, from: int, to: int) -> void {
|
||||
let k = g.key[from]
|
||||
g.key[to] = k
|
||||
if k < 0 { return }
|
||||
let p = g.prev[from]
|
||||
let n = g.next[from]
|
||||
g.prev[to] = p
|
||||
g.next[to] = n
|
||||
if p > 0 { g.next[p - 1] = to + 1 } else { g.head[grid_bucket(g, grid_key_x(k), grid_key_z(k))] = to + 1 }
|
||||
if n > 0 { g.prev[n - 1] = to + 1 }
|
||||
g.key[from] = -1
|
||||
}
|
||||
|
||||
# twice the buckets, every filed row linked again where its key now hashes
|
||||
function grid_rehash(g: Grid) -> void {
|
||||
g.mask = g.mask * 2 + 1
|
||||
g.head = words(g.mask + 1)
|
||||
g.count = 0
|
||||
for r in 0 .. len(g.key) {
|
||||
let k = g.key[r]
|
||||
if k >= 0 { grid_link(g, r, k, grid_bucket(g, grid_key_x(k), grid_key_z(k))) }
|
||||
}
|
||||
}
|
||||
93
packages/ludic.base/ecs_grid_query.ludic
Normal file
93
packages/ludic.base/ecs_grid_query.ludic
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# ludic.base/ecs_grid_query.ludic - the nearest row: rings of cells outward from the point, stopping
|
||||
# at the first ring that cannot hold anything nearer. A match is an int column equal to a value
|
||||
# (column -1: any), or a predicate on the row's record. A question: it writes nothing but locals.
|
||||
const GRID_RINGS: int = 24 # past this many rings a scan of the filed rows is cheaper
|
||||
const GRID_FAR: float = 1000000000000.0
|
||||
|
||||
# the nearest filed row to (x, z) within maxr (0: any distance) matching (mc, mv), or -1
|
||||
export function tb_nearest<T>(tb: Table<T>, g: Grid, x: float, z: float, maxr: float, mc: int, mv: int) -> int {
|
||||
if g.count == 0 { return -1 }
|
||||
var mcol: words = null
|
||||
if mc >= 0 { mcol = tb.i[mc] }
|
||||
let xs = tb.f[g.cx]
|
||||
let zs = tb.f[g.cz]
|
||||
var best = -1
|
||||
var bd = grid_limit(maxr)
|
||||
let cs = 1.0 / g.inv
|
||||
let ix0 = grid_cell(g, x)
|
||||
let iz0 = grid_cell(g, z)
|
||||
var ring = 0
|
||||
while ring <= GRID_RINGS {
|
||||
if ring > 0 and grid_ring_past(ring, cs, bd) { return best }
|
||||
let span = grid_ring_n(ring)
|
||||
for k in 0 .. span {
|
||||
best = grid_visit(g, xs, zs, mcol, mv, x, z, grid_ring_x(ix0, ring, k), grid_ring_z(iz0, ring, k), best, bd)
|
||||
if best >= 0 { bd = grid_d2(xs, zs, best, x, z) }
|
||||
}
|
||||
ring += 1
|
||||
}
|
||||
for row in 0 .. len(g.key) {
|
||||
if g.key[row] >= 0 and (mcol == null or mcol[row] == mv) and grid_d2(xs, zs, row, x, z) < bd {
|
||||
best = row
|
||||
bd = grid_d2(xs, zs, row, x, z)
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function grid_limit(maxr: float) -> float {
|
||||
if maxr > 0.0 { return maxr * maxr }
|
||||
return GRID_FAR
|
||||
}
|
||||
# the nearest a point of ring `ring` can be is (ring - 1) cells: past the best, nothing there wins
|
||||
function grid_ring_past(ring: int, cs: float, bd: float) -> bool {
|
||||
let near = float(ring - 1) * cs
|
||||
return near * near > bd
|
||||
}
|
||||
function grid_d2(xs: floats, zs: floats, row: int, x: float, z: float) -> float {
|
||||
let dx = xs[row] - x
|
||||
let dz = zs[row] - z
|
||||
return dx * dx + dz * dz
|
||||
}
|
||||
|
||||
# a ring's cells in a fixed order: 1 for ring 0, else 8 * ring round the square's edge
|
||||
function grid_ring_n(ring: int) -> int {
|
||||
if ring == 0 { return 1 }
|
||||
return 8 * ring
|
||||
}
|
||||
function grid_ring_x(ix0: int, ring: int, k: int) -> int {
|
||||
if ring == 0 { return ix0 }
|
||||
let side = 2 * ring
|
||||
if k < side { return ix0 - ring + k }
|
||||
if k < 2 * side { return ix0 + ring }
|
||||
if k < 3 * side { return ix0 + ring - (k - 2 * side) }
|
||||
return ix0 - ring
|
||||
}
|
||||
function grid_ring_z(iz0: int, ring: int, k: int) -> int {
|
||||
if ring == 0 { return iz0 }
|
||||
let side = 2 * ring
|
||||
if k < side { return iz0 - ring }
|
||||
if k < 2 * side { return iz0 - ring + (k - side) }
|
||||
if k < 3 * side { return iz0 + ring }
|
||||
return iz0 + ring - (k - 3 * side)
|
||||
}
|
||||
|
||||
# one cell: its bucket's rows that are really in it and match; the better of them and `best`
|
||||
function grid_visit(g: Grid, xs: floats, zs: floats, mcol: words, mv: int, x: float, z: float, ix: int, iz: int, best: int, bd: float) -> int {
|
||||
let k = grid_key(ix, iz)
|
||||
var b = best
|
||||
var d = bd
|
||||
var r = g.head[grid_bucket(g, ix, iz)]
|
||||
while r > 0 {
|
||||
let row = r - 1
|
||||
if g.key[row] == k and (mcol == null or mcol[row] == mv) {
|
||||
let d2 = grid_d2(xs, zs, row, x, z)
|
||||
if d2 < d {
|
||||
d = d2
|
||||
b = row
|
||||
}
|
||||
}
|
||||
r = g.next[row]
|
||||
}
|
||||
return b
|
||||
}
|
||||
101
packages/ludic.base/ecs_grid_where.ludic
Normal file
101
packages/ludic.base/ecs_grid_where.ludic
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# ludic.base/ecs_grid_where.ludic - the grid's questions put to the rows' records: the nearest one a
|
||||
# predicate keeps, and every record within a radius into the caller's list. The predicate is called
|
||||
# with the record only; it may read anything, and must change nothing the grid indexes.
|
||||
export function tb_nearest_where<T>(tb: Table<T>, g: Grid, x: float, z: float, maxr: float, keep: fn(T) -> bool) -> int {
|
||||
if g.count == 0 { return -1 }
|
||||
let xs = tb.f[g.cx]
|
||||
let zs = tb.f[g.cz]
|
||||
var best = -1
|
||||
var bd = grid_limit(maxr)
|
||||
let cs = 1.0 / g.inv
|
||||
let ix0 = grid_cell(g, x)
|
||||
let iz0 = grid_cell(g, z)
|
||||
var ring = 0
|
||||
while ring <= GRID_RINGS {
|
||||
if ring > 0 and grid_ring_past(ring, cs, bd) { return best }
|
||||
for k in 0 .. grid_ring_n(ring) {
|
||||
best = grid_visit_where(tb, g, xs, zs, x, z, grid_ring_x(ix0, ring, k), grid_ring_z(iz0, ring, k), best, bd, keep)
|
||||
if best >= 0 { bd = grid_d2(xs, zs, best, x, z) }
|
||||
}
|
||||
ring += 1
|
||||
}
|
||||
for row in 0 .. len(g.key) {
|
||||
if g.key[row] >= 0 and grid_d2(xs, zs, row, x, z) < bd and keep(tb.rec[row]) {
|
||||
best = row
|
||||
bd = grid_d2(xs, zs, row, x, z)
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function grid_visit_where<T>(tb: Table<T>, g: Grid, xs: floats, zs: floats, x: float, z: float, ix: int, iz: int, best: int, bd: float, keep: fn(T) -> bool) -> int {
|
||||
let k = grid_key(ix, iz)
|
||||
var b = best
|
||||
var d = bd
|
||||
var r = g.head[grid_bucket(g, ix, iz)]
|
||||
while r > 0 {
|
||||
let row = r - 1
|
||||
if g.key[row] == k {
|
||||
let d2 = grid_d2(xs, zs, row, x, z)
|
||||
if d2 < d and keep(tb.rec[row]) {
|
||||
d = d2
|
||||
b = row
|
||||
}
|
||||
}
|
||||
r = g.next[row]
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
# every filed record within r of (x, z) matching (mc, mv), appended to `out` (cleared first) - the
|
||||
# caller's list, kept in the caller's state and reused, so a frame's question allocates nothing
|
||||
export function tb_within_recs<T>(tb: Table<T>, g: Grid, x: float, z: float, r: float, mc: int, mv: int, out: []T) -> int {
|
||||
List.clear(out)
|
||||
if g.count == 0 { return 0 }
|
||||
var mcol: words = null
|
||||
if mc >= 0 { mcol = tb.i[mc] }
|
||||
let xs = tb.f[g.cx]
|
||||
let zs = tb.f[g.cz]
|
||||
let ix0 = grid_cell(g, x - r)
|
||||
let ix1 = grid_cell(g, x + r)
|
||||
let iz0 = grid_cell(g, z - r)
|
||||
let iz1 = grid_cell(g, z + r)
|
||||
let cells = (ix1 - ix0 + 1) * (iz1 - iz0 + 1)
|
||||
if cells > g.count or cells < 0 {
|
||||
for row in 0 .. len(g.key) {
|
||||
if g.key[row] >= 0 and (mcol == null or mcol[row] == mv) and grid_d2(xs, zs, row, x, z) <= r * r { push(out, tb.rec[row]) }
|
||||
}
|
||||
return len(out)
|
||||
}
|
||||
for ix in ix0 .. ix1 + 1 {
|
||||
for iz in iz0 .. iz1 + 1 { grid_recs_cell(tb, g, xs, zs, mcol, mv, x, z, r * r, ix, iz, out) }
|
||||
}
|
||||
return len(out)
|
||||
}
|
||||
|
||||
function grid_recs_cell<T>(tb: Table<T>, g: Grid, xs: floats, zs: floats, mcol: words, mv: int, x: float, z: float, r2: float, ix: int, iz: int, out: []T) -> void {
|
||||
let k = grid_key(ix, iz)
|
||||
var r = g.head[grid_bucket(g, ix, iz)]
|
||||
while r > 0 {
|
||||
let row = r - 1
|
||||
if g.key[row] == k and (mcol == null or mcol[row] == mv) and grid_d2(xs, zs, row, x, z) <= r2 { push(out, tb.rec[row]) }
|
||||
r = g.next[row]
|
||||
}
|
||||
}
|
||||
|
||||
# every record of kind v (an index over column ix.col) within r, into `out`: a rare kind from its list
|
||||
export function tb_within_of_recs<T>(tb: Table<T>, g: Grid, ix: IntIndex, x: float, z: float, r: float, v: int, out: []T) -> int {
|
||||
let n = ix_count(ix, v)
|
||||
if n > PLAN_LIST_MAX { return tb_within_recs(tb, g, x, z, r, ix.col, v, out) }
|
||||
List.clear(out)
|
||||
if n == 0 { return 0 }
|
||||
let rows = ix.lists[v]
|
||||
let xs = tb.f[g.cx]
|
||||
let zs = tb.f[g.cz]
|
||||
for k in 0 .. n {
|
||||
let row = rows[k]
|
||||
if g.gate >= 0 and tb.i[g.gate][row] == 0 { continue }
|
||||
if grid_d2(xs, zs, row, x, z) <= r * r { push(out, tb.rec[row]) }
|
||||
}
|
||||
return len(out)
|
||||
}
|
||||
48
packages/ludic.base/ecs_grid_within.ludic
Normal file
48
packages/ludic.base/ecs_grid_within.ludic
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# ludic.base/ecs_grid_within.ludic - every filed row within r of (x, z) matching (mc, mv), into
|
||||
# `out` (cleared first), in no order; the count is returned. A radius wider than the rows are many
|
||||
# is answered by one scan of them instead of by cells.
|
||||
export function tb_within<T>(tb: Table<T>, g: Grid, x: float, z: float, r: float, mc: int, mv: int, out: words) -> int {
|
||||
List.clear(out)
|
||||
if g.count == 0 { return 0 }
|
||||
var mcol: words = null
|
||||
if mc >= 0 { mcol = tb.i[mc] }
|
||||
let xs = tb.f[g.cx]
|
||||
let zs = tb.f[g.cz]
|
||||
let ix0 = grid_cell(g, x - r)
|
||||
let ix1 = grid_cell(g, x + r)
|
||||
let iz0 = grid_cell(g, z - r)
|
||||
let iz1 = grid_cell(g, z + r)
|
||||
let cells = (ix1 - ix0 + 1) * (iz1 - iz0 + 1)
|
||||
if cells > g.count or cells < 0 {
|
||||
grid_within_scan(g, xs, zs, mcol, mv, x, z, r * r, out)
|
||||
return len(out)
|
||||
}
|
||||
for ix in ix0 .. ix1 + 1 {
|
||||
for iz in iz0 .. iz1 + 1 { grid_within_cell(g, xs, zs, mcol, mv, x, z, r * r, ix, iz, out) }
|
||||
}
|
||||
return len(out)
|
||||
}
|
||||
|
||||
function grid_within_cell(g: Grid, xs: floats, zs: floats, mcol: words, mv: int, x: float, z: float, r2: float, ix: int, iz: int, out: words) -> void {
|
||||
let k = grid_key(ix, iz)
|
||||
var r = g.head[grid_bucket(g, ix, iz)]
|
||||
while r > 0 {
|
||||
let row = r - 1
|
||||
if g.key[row] == k and (mcol == null or mcol[row] == mv) {
|
||||
let dx = xs[row] - x
|
||||
let dz = zs[row] - z
|
||||
if dx * dx + dz * dz <= r2 { push(out, row) }
|
||||
}
|
||||
r = g.next[row]
|
||||
}
|
||||
}
|
||||
|
||||
function grid_within_scan(g: Grid, xs: floats, zs: floats, mcol: words, mv: int, x: float, z: float, r2: float, out: words) -> void {
|
||||
for row in 0 .. len(g.key) {
|
||||
if g.key[row] >= 0 and (mcol == null or mcol[row] == mv) {
|
||||
let dx = xs[row] - x
|
||||
let dz = zs[row] - z
|
||||
if dx * dx + dz * dz <= r2 { push(out, row) }
|
||||
}
|
||||
}
|
||||
}
|
||||
83
packages/ludic.base/ecs_index.ludic
Normal file
83
packages/ludic.base/ecs_index.ludic
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# ludic.base/ecs_index.ludic - a cached query: for each value of an int column (a kind), the rows
|
||||
# holding it, kept current by the table's setters, so "every fire" is a list and "how many" a length
|
||||
export property IntIndex {
|
||||
col: int = 0
|
||||
gate: int = -1 # an int column: a row is filed only while it is non-zero (-1: always)
|
||||
lists: []words = null # value -> its rows
|
||||
pos: words = null # row -> where it stands in its value's list, -1 not filed
|
||||
val: words = null # row -> the value it is filed under
|
||||
empty: words = null # what a value with no rows answers
|
||||
}
|
||||
|
||||
export function tb_index<T>(tb: Table<T>, col: int, gate: int) -> IntIndex {
|
||||
let ix = new IntIndex
|
||||
ix.col = col
|
||||
ix.gate = gate
|
||||
ix.lists = new []words
|
||||
ix.pos = words(0)
|
||||
ix.val = words(0)
|
||||
ix.empty = words(0)
|
||||
for r in 0 .. tb.n { ix_grow(ix) }
|
||||
push(tb.idx, ix)
|
||||
for r in 0 .. tb.n { tb_ix_refile(tb, ix, r) }
|
||||
return ix
|
||||
}
|
||||
|
||||
# the rows filed under v - the index's own list: read it, never push to it
|
||||
export function ix_rows(ix: IntIndex, v: int) -> words {
|
||||
if v < 0 or v >= len(ix.lists) { return ix.empty }
|
||||
return ix.lists[v]
|
||||
}
|
||||
export function ix_count(ix: IntIndex, v: int) -> int {
|
||||
if v < 0 or v >= len(ix.lists) { return 0 }
|
||||
return len(ix.lists[v])
|
||||
}
|
||||
# the first row filed under v, or -1
|
||||
export function ix_first(ix: IntIndex, v: int) -> int {
|
||||
if ix_count(ix, v) == 0 { return -1 }
|
||||
return ix.lists[v][0]
|
||||
}
|
||||
|
||||
function ix_grow(ix: IntIndex) -> void {
|
||||
push(ix.pos, -1)
|
||||
push(ix.val, -1)
|
||||
}
|
||||
|
||||
function ix_shrink(ix: IntIndex) -> void {
|
||||
List.pop(ix.pos)
|
||||
List.pop(ix.val)
|
||||
}
|
||||
|
||||
function ix_file(ix: IntIndex, r: int, v: int) -> void {
|
||||
if ix.pos[r] >= 0 and ix.val[r] == v { return }
|
||||
ix_unfile(ix, r)
|
||||
if v < 0 { return }
|
||||
while len(ix.lists) <= v { push(ix.lists, words(0)) }
|
||||
ix.pos[r] = len(ix.lists[v])
|
||||
ix.val[r] = v
|
||||
push(ix.lists[v], r)
|
||||
}
|
||||
|
||||
# out of its list: the list's last row takes its place
|
||||
function ix_unfile(ix: IntIndex, r: int) -> void {
|
||||
let p = ix.pos[r]
|
||||
if p < 0 { return }
|
||||
let l = ix.lists[ix.val[r]]
|
||||
let last = List.pop(l)
|
||||
if last != r {
|
||||
l[p] = last
|
||||
ix.pos[last] = p
|
||||
}
|
||||
ix.pos[r] = -1
|
||||
ix.val[r] = -1
|
||||
}
|
||||
|
||||
# row `from` becomes row `to` (which is not filed)
|
||||
function ix_moved(ix: IntIndex, from: int, to: int) -> void {
|
||||
let p = ix.pos[from]
|
||||
ix.pos[to] = p
|
||||
ix.val[to] = ix.val[from]
|
||||
if p >= 0 { ix.lists[ix.val[from]][p] = to }
|
||||
ix.pos[from] = -1
|
||||
ix.val[from] = -1
|
||||
}
|
||||
79
packages/ludic.base/ecs_map.ludic
Normal file
79
packages/ludic.base/ecs_map.ludic
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# ludic.base/ecs_map.ludic - IntMap: a key (0 or more) to a value, open addressing with linear
|
||||
# probing; what turns a stable id kept in a save or a message into a handle without a scan
|
||||
export property IntMap {
|
||||
keys: words = null # -1 empty, -2 a key that was deleted
|
||||
vals: words = null
|
||||
mask: int = 63
|
||||
n: int = 0
|
||||
used: int = 0 # live and deleted slots: what makes probing long
|
||||
}
|
||||
|
||||
export function imap_new() -> IntMap {
|
||||
let m = new IntMap
|
||||
imap_alloc(m, 64)
|
||||
return m
|
||||
}
|
||||
|
||||
function imap_alloc(m: IntMap, cap: int) -> void {
|
||||
m.keys = words(cap)
|
||||
m.vals = words(cap)
|
||||
for i in 0 .. cap { m.keys[i] = -1 }
|
||||
m.mask = cap - 1
|
||||
m.n = 0
|
||||
m.used = 0
|
||||
}
|
||||
|
||||
function imap_slot(m: IntMap, k: int) -> int { return ((k * -1640531535) >> 7) & m.mask }
|
||||
|
||||
# the value under k, or `none` when there is none
|
||||
export function imap_get(m: IntMap, k: int, none: int) -> int {
|
||||
var s = imap_slot(m, k)
|
||||
while m.keys[s] != -1 {
|
||||
if m.keys[s] == k { return m.vals[s] }
|
||||
s = (s + 1) & m.mask
|
||||
}
|
||||
return none
|
||||
}
|
||||
|
||||
export function imap_put(m: IntMap, k: int, v: int) -> void {
|
||||
if (m.used + 1) * 4 > (m.mask + 1) * 3 { imap_grow(m) }
|
||||
var s = imap_slot(m, k)
|
||||
var tomb = -1
|
||||
while m.keys[s] != -1 {
|
||||
if m.keys[s] == k {
|
||||
m.vals[s] = v
|
||||
return
|
||||
}
|
||||
if m.keys[s] == -2 and tomb < 0 { tomb = s }
|
||||
s = (s + 1) & m.mask
|
||||
}
|
||||
if tomb >= 0 { s = tomb } else { m.used += 1 }
|
||||
m.keys[s] = k
|
||||
m.vals[s] = v
|
||||
m.n += 1
|
||||
}
|
||||
|
||||
export function imap_del(m: IntMap, k: int) -> bool {
|
||||
var s = imap_slot(m, k)
|
||||
while m.keys[s] != -1 {
|
||||
if m.keys[s] == k {
|
||||
m.keys[s] = -2
|
||||
m.n -= 1
|
||||
return true
|
||||
}
|
||||
s = (s + 1) & m.mask
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function imap_len(m: IntMap) -> int { return m.n }
|
||||
export function imap_clear(m: IntMap) -> void { imap_alloc(m, m.mask + 1) }
|
||||
|
||||
function imap_grow(m: IntMap) -> void {
|
||||
let ok = m.keys
|
||||
let ov = m.vals
|
||||
var cap = m.mask + 1
|
||||
if m.n * 2 > cap { cap = cap * 2 }
|
||||
imap_alloc(m, cap)
|
||||
for i in 0 .. len(ok) { if ok[i] >= 0 { imap_put(m, ok[i], ov[i]) } }
|
||||
}
|
||||
46
packages/ludic.base/ecs_plan.ludic
Normal file
46
packages/ludic.base/ecs_plan.ludic
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# ludic.base/ecs_plan.ludic - a query that has both a grid and a kind index picks the cheaper way:
|
||||
# a rare kind's own list scanned, a common one found by rings with the kind as the match
|
||||
const PLAN_LIST_MAX: int = 192 # a kind with at most this many rows is scanned from its list
|
||||
|
||||
export function tb_nearest_of<T>(tb: Table<T>, g: Grid, ix: IntIndex, x: float, z: float, maxr: float, v: int) -> int {
|
||||
let n = ix_count(ix, v)
|
||||
if n == 0 { return -1 }
|
||||
if n > PLAN_LIST_MAX { return tb_nearest(tb, g, x, z, maxr, ix.col, v) }
|
||||
let rows = ix.lists[v]
|
||||
let xs = tb.f[g.cx]
|
||||
let zs = tb.f[g.cz]
|
||||
var best = -1
|
||||
var bd = 1000000000000.0
|
||||
if maxr > 0.0 { bd = maxr * maxr }
|
||||
for k in 0 .. n {
|
||||
let r = rows[k]
|
||||
if g.gate >= 0 and tb.i[g.gate][r] == 0 { continue }
|
||||
let dx = xs[r] - x
|
||||
let dz = zs[r] - z
|
||||
let d2 = dx * dx + dz * dz
|
||||
if d2 < bd or (d2 == bd and best < 0) {
|
||||
bd = d2
|
||||
best = r
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
# within r of (x, z), of kind v, into `out`
|
||||
export function tb_within_of<T>(tb: Table<T>, g: Grid, ix: IntIndex, x: float, z: float, r: float, v: int, out: words) -> int {
|
||||
let n = ix_count(ix, v)
|
||||
if n > PLAN_LIST_MAX { return tb_within(tb, g, x, z, r, ix.col, v, out) }
|
||||
List.clear(out)
|
||||
if n == 0 { return 0 }
|
||||
let rows = ix.lists[v]
|
||||
let xs = tb.f[g.cx]
|
||||
let zs = tb.f[g.cz]
|
||||
for k in 0 .. n {
|
||||
let row = rows[k]
|
||||
if g.gate >= 0 and tb.i[g.gate][row] == 0 { continue }
|
||||
let dx = xs[row] - x
|
||||
let dz = zs[row] - z
|
||||
if dx * dx + dz * dz <= r * r { push(out, row) }
|
||||
}
|
||||
return len(out)
|
||||
}
|
||||
79
packages/ludic.base/ecs_table.ludic
Normal file
79
packages/ludic.base/ecs_table.ludic
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# ludic.base/ecs_table.ludic - a Table<T>: entities as dense rows of hot columns (floats, words)
|
||||
# and one record each for what is cold. A handle is a slot and a generation, so a stale one misses.
|
||||
export const ECS_SLOT: int = 4194303 # the low 22 bits of a handle: its slot
|
||||
export const ECS_GEN: int = 511 # the next 9: how many times the slot was reused
|
||||
export const ECS_NONE: int = -1
|
||||
|
||||
export property Table<T> {
|
||||
n: int = 0
|
||||
rec: []T = null
|
||||
ent: words = null # row -> handle
|
||||
f: []floats = null # hot float columns, a value per row each
|
||||
i: []words = null # hot int columns
|
||||
added: words = null # row -> the tick it was added at
|
||||
changed: words = null # row -> the tick a setter last wrote it at
|
||||
tick: int = 1
|
||||
slot_gen: words = null # slot -> generation
|
||||
slot_row: words = null # slot -> row, -1 while free
|
||||
free: words = null
|
||||
grids: []Grid = null
|
||||
idx: []IntIndex = null
|
||||
}
|
||||
|
||||
# a table of nf float and ni int columns, every value 0 in a new row
|
||||
export function table_new<T>(nf: int, ni: int) -> Table<T> {
|
||||
let tb = new Table<T>
|
||||
tb.rec = new []T
|
||||
tb.ent = words(0)
|
||||
tb.f = new []floats
|
||||
tb.i = new []words
|
||||
for c in 0 .. nf { push(tb.f, floats(0)) }
|
||||
for c in 0 .. ni { push(tb.i, words(0)) }
|
||||
tb.added = words(0)
|
||||
tb.changed = words(0)
|
||||
tb.slot_gen = words(0)
|
||||
tb.slot_row = words(0)
|
||||
tb.free = words(0)
|
||||
tb.grids = new []Grid
|
||||
tb.idx = new []IntIndex
|
||||
return tb
|
||||
}
|
||||
|
||||
export function tb_len<T>(tb: Table<T>) -> int { return tb.n }
|
||||
export function tb_rec<T>(tb: Table<T>, row: int) -> T { return tb.rec[row] }
|
||||
export function tb_handle<T>(tb: Table<T>, row: int) -> int { return tb.ent[row] }
|
||||
export function tb_tick<T>(tb: Table<T>) -> int { return tb.tick }
|
||||
# the next tick: what is written from here on is newer than anything before
|
||||
export function tb_advance<T>(tb: Table<T>) -> void { tb.tick += 1 }
|
||||
|
||||
# the row a handle names, or -1 when it was removed (or the slot has been reused since)
|
||||
export function tb_row<T>(tb: Table<T>, h: int) -> int {
|
||||
if h < 0 { return -1 }
|
||||
let s = h & ECS_SLOT
|
||||
if s >= len(tb.slot_gen) or tb.slot_gen[s] != ((h >> 22) & ECS_GEN) { return -1 }
|
||||
return tb.slot_row[s]
|
||||
}
|
||||
|
||||
export function tb_alive<T>(tb: Table<T>, h: int) -> bool { return tb_row(tb, h) >= 0 }
|
||||
|
||||
# a new row holding `r`, every column 0 and filed in no index until a setter says where it is
|
||||
export function tb_add<T>(tb: Table<T>, r: T) -> int {
|
||||
var s = 0
|
||||
if len(tb.free) > 0 { s = List.pop(tb.free) } else {
|
||||
s = len(tb.slot_gen)
|
||||
push(tb.slot_gen, 0)
|
||||
push(tb.slot_row, -1)
|
||||
}
|
||||
tb.slot_row[s] = tb.n
|
||||
let h = s | (tb.slot_gen[s] << 22)
|
||||
push(tb.rec, r)
|
||||
push(tb.ent, h)
|
||||
push(tb.added, tb.tick)
|
||||
push(tb.changed, tb.tick)
|
||||
for c in 0 .. len(tb.f) { push(tb.f[c], 0.0) }
|
||||
for c in 0 .. len(tb.i) { push(tb.i[c], 0) }
|
||||
tb.n += 1
|
||||
for g in 0 .. len(tb.grids) { grid_grow(tb.grids[g]) }
|
||||
for k in 0 .. len(tb.idx) { ix_grow(tb.idx[k]) }
|
||||
return h
|
||||
}
|
||||
45
packages/ludic.base/ecs_table_remove.ludic
Normal file
45
packages/ludic.base/ecs_table_remove.ludic
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# ludic.base/ecs_table_remove.ludic - removing a row moves the last one into its place, so the
|
||||
# rows stay dense; every index is told, and the slot's generation moves on
|
||||
export function tb_remove<T>(tb: Table<T>, h: int) -> bool {
|
||||
let r = tb_row(tb, h)
|
||||
if r < 0 { return false }
|
||||
for g in 0 .. len(tb.grids) { grid_unlink(tb.grids[g], r) }
|
||||
for k in 0 .. len(tb.idx) { ix_unfile(tb.idx[k], r) }
|
||||
let last = tb.n - 1
|
||||
if r != last { tb_move(tb, last, r) }
|
||||
tb_pop(tb)
|
||||
let s = h & ECS_SLOT
|
||||
tb.slot_row[s] = -1
|
||||
tb.slot_gen[s] = (tb.slot_gen[s] + 1) & ECS_GEN
|
||||
push(tb.free, s)
|
||||
return true
|
||||
}
|
||||
|
||||
function tb_move<T>(tb: Table<T>, from: int, to: int) -> void {
|
||||
tb.rec[to] = tb.rec[from]
|
||||
tb.ent[to] = tb.ent[from]
|
||||
tb.added[to] = tb.added[from]
|
||||
tb.changed[to] = tb.changed[from]
|
||||
for c in 0 .. len(tb.f) { tb.f[c][to] = tb.f[c][from] }
|
||||
for c in 0 .. len(tb.i) { tb.i[c][to] = tb.i[c][from] }
|
||||
tb.slot_row[tb.ent[to] & ECS_SLOT] = to
|
||||
for g in 0 .. len(tb.grids) { grid_moved(tb.grids[g], from, to) }
|
||||
for k in 0 .. len(tb.idx) { ix_moved(tb.idx[k], from, to) }
|
||||
}
|
||||
|
||||
function tb_pop<T>(tb: Table<T>) -> void {
|
||||
List.pop(tb.rec)
|
||||
List.pop(tb.ent)
|
||||
List.pop(tb.added)
|
||||
List.pop(tb.changed)
|
||||
for c in 0 .. len(tb.f) { List.pop(tb.f[c]) }
|
||||
for c in 0 .. len(tb.i) { List.pop(tb.i[c]) }
|
||||
for g in 0 .. len(tb.grids) { grid_shrink(tb.grids[g]) }
|
||||
for k in 0 .. len(tb.idx) { ix_shrink(tb.idx[k]) }
|
||||
tb.n -= 1
|
||||
}
|
||||
|
||||
# every row gone; the slots keep their generations, so an old handle still misses
|
||||
export function tb_clear<T>(tb: Table<T>) -> void {
|
||||
while tb.n > 0 { tb_remove(tb, tb.ent[tb.n - 1]) }
|
||||
}
|
||||
66
packages/ludic.base/ecs_table_set.ludic
Normal file
66
packages/ludic.base/ecs_table_set.ludic
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# ludic.base/ecs_table_set.ludic - the setters: a write stamps the row's tick and refiles it in
|
||||
# every index that reads that column, so an index is never a frame behind what it indexes
|
||||
export function tb_f<T>(tb: Table<T>, c: int, row: int) -> float { return tb.f[c][row] }
|
||||
export function tb_i<T>(tb: Table<T>, c: int, row: int) -> int { return tb.i[c][row] }
|
||||
|
||||
export function tb_set_f<T>(tb: Table<T>, c: int, row: int, v: float) -> void {
|
||||
tb.f[c][row] = v
|
||||
tb.changed[row] = tb.tick
|
||||
for g in 0 .. len(tb.grids) {
|
||||
let gr = tb.grids[g]
|
||||
if gr.cx == c or gr.cz == c { tb_grid_refile(tb, gr, row) }
|
||||
}
|
||||
}
|
||||
|
||||
# two float columns at once, filed once: a move
|
||||
export function tb_set_xz<T>(tb: Table<T>, cx: int, cz: int, row: int, x: float, z: float) -> void {
|
||||
tb.f[cx][row] = x
|
||||
tb.f[cz][row] = z
|
||||
tb.changed[row] = tb.tick
|
||||
for g in 0 .. len(tb.grids) {
|
||||
let gr = tb.grids[g]
|
||||
if gr.cx == cx or gr.cz == cz or gr.cx == cz or gr.cz == cx { tb_grid_refile(tb, gr, row) }
|
||||
}
|
||||
}
|
||||
|
||||
export function tb_set_i<T>(tb: Table<T>, c: int, row: int, v: int) -> void {
|
||||
tb.i[c][row] = v
|
||||
tb.changed[row] = tb.tick
|
||||
for g in 0 .. len(tb.grids) { if tb.grids[g].gate == c { tb_grid_refile(tb, tb.grids[g], row) } }
|
||||
for k in 0 .. len(tb.idx) {
|
||||
let ix = tb.idx[k]
|
||||
if ix.col == c or ix.gate == c { tb_ix_refile(tb, ix, row) }
|
||||
}
|
||||
}
|
||||
|
||||
# a row written without a setter (tb.f / tb.i directly, for speed) is refiled everywhere by this
|
||||
export function tb_refile<T>(tb: Table<T>, row: int) -> void {
|
||||
tb.changed[row] = tb.tick
|
||||
for g in 0 .. len(tb.grids) { tb_grid_refile(tb, tb.grids[g], row) }
|
||||
for k in 0 .. len(tb.idx) { tb_ix_refile(tb, tb.idx[k], row) }
|
||||
}
|
||||
|
||||
export function tb_mark<T>(tb: Table<T>, row: int) -> void { tb.changed[row] = tb.tick }
|
||||
|
||||
function tb_gate_on<T>(tb: Table<T>, gate: int, row: int) -> bool { return gate < 0 or tb.i[gate][row] != 0 }
|
||||
|
||||
function tb_grid_refile<T>(tb: Table<T>, g: Grid, row: int) -> void {
|
||||
grid_refile(g, row, tb.f[g.cx][row], tb.f[g.cz][row], tb_gate_on(tb, g.gate, row))
|
||||
}
|
||||
|
||||
function tb_ix_refile<T>(tb: Table<T>, ix: IntIndex, row: int) -> void {
|
||||
if tb_gate_on(tb, ix.gate, row) { ix_file(ix, row, tb.i[ix.col][row]) } else { ix_unfile(ix, row) }
|
||||
}
|
||||
|
||||
# change detection: the rows a setter wrote after `since`, into `out` (cleared first); no allocation
|
||||
export function tb_changed_since<T>(tb: Table<T>, since: int, out: words) -> int {
|
||||
List.clear(out)
|
||||
for r in 0 .. tb.n { if tb.changed[r] > since { push(out, r) } }
|
||||
return len(out)
|
||||
}
|
||||
|
||||
export function tb_added_since<T>(tb: Table<T>, since: int, out: words) -> int {
|
||||
List.clear(out)
|
||||
for r in 0 .. tb.n { if tb.added[r] > since { push(out, r) } }
|
||||
return len(out)
|
||||
}
|
||||
|
|
@ -8,3 +8,14 @@ import "rng.ludic"
|
|||
import "save.ludic"
|
||||
import "save_fields.ludic"
|
||||
import "system.ludic"
|
||||
import "ecs_table.ludic"
|
||||
import "ecs_table_remove.ludic"
|
||||
import "ecs_table_set.ludic"
|
||||
import "ecs_grid.ludic"
|
||||
import "ecs_grid_file.ludic"
|
||||
import "ecs_grid_query.ludic"
|
||||
import "ecs_grid_within.ludic"
|
||||
import "ecs_grid_where.ludic"
|
||||
import "ecs_index.ludic"
|
||||
import "ecs_map.ludic"
|
||||
import "ecs_plan.ludic"
|
||||
|
|
|
|||
130
packages/ludic.base/tests/ecs_fuzz_test.ludic
Normal file
130
packages/ludic.base/tests/ecs_fuzz_test.ludic
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# 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) }
|
||||
}
|
||||
116
packages/ludic.base/tests/ecs_test.ludic
Normal file
116
packages/ludic.base/tests/ecs_test.ludic
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# 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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue