feat(stdlib): add Query.* — ECS spatial queries over the reflection ABI (#42)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 17s
ci / build-and-test (push) Successful in 1m11s
commit-lint / conventional-commits (push) Successful in 4s
docs / build-and-deploy (push) Successful in 18s

Completes the half of #24 that was explicitly deferred as blocked: entity-space
queries to sit alongside the grid-space Grid.*/pathfinding that shipped in
07e5a20. Query.* answers questions about the live entities that carry a
property, built directly on the EV2 reflection ABI (world_query_next/world_get):

  - Query.count(prop) -> int                 how many live entities carry prop
  - Query.first(prop) -> int                 the lowest-id bearer, or -1
  - Query.nearest(prop, pos, xf, yf, x, y)   the bearer closest to (x,y), or -1
  - Query.within(prop, pos, x, y, r, xf, yf) -> []int   every bearer within r

prop is a property id (World.prop_id); the spatial forms read a position from a
coordinate property `pos` at two int field ids (World.field_id), so `prop` can be
a discriminating tag distinct from the position component ("nearest Enemy"), or
the same id to query the coordinate component itself. Distances are exact squared
integers (no sqrt), ties break to the lower entity id, and `within` returns
entities in ascending id order — so every answer is deterministic and replay-safe.

The engine (runtime/native/query.ludic, ~55 lines of Ludic, C-free) is a linear
scan over the entity table — ample for the entity counts Ludic targets, the same
reasoning as the grid pathfinder's open set; a bucketed/quadtree index is a
future optimisation, not a correctness need. It is spliced on demand when the
parser sees Query.* (g_uses_query), which also force-emits the reflection ABI so
a Query program needs no @events of its own (previously the ABI required them).

examples/library/query.ludic asserts 18 cases over five entities at known
positions (count/first with a component filter, nearest with a separate tag vs
position property, within radii incl. r=0 and the empty-property case), wired
into x test (now 63 passed). Docs: a Query section + 4 per-symbol pages,
inventory/coverage green. Seed reseeded; the C-free bootstrap fixpoint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-31 13:19:25 +03:00
parent e4d1e95dcb
commit b25dc328a2
12 changed files with 14777 additions and 13946 deletions

View file

@ -0,0 +1,7 @@
---
id: query
title: Query
order: 30
---
Spatial and set queries over the live entities that carry a property, built on the <a href="world"><code>World</code></a> reflection ABI. <a href="query-count"><code>Query.count</code></a> and <a href="query-first"><code>Query.first</code></a> ask how many bearers there are and which is first; <a href="query-nearest"><code>Query.nearest</code></a> and <a href="query-within"><code>Query.within</code></a> add a position — read from a coordinate property's two int fields — to find the closest entity to a point or every entity inside a radius. A property id comes from <a href="world-prop_id"><code>World.prop_id</code></a> and a field id from <a href="world-field_id"><code>World.field_id</code></a>. Everything is integer and deterministic: the same world reproduces the same answers, entity order included, every run. The scan is linear over the entity table — ample for the entity counts Ludic targets — and a game that uses <code>Query.*</code> gets the reflection ABI emitted automatically, no <code>@event</code> required.

View file

@ -0,0 +1,30 @@
---
id: query-count
name: Query.count
category: query
kind: namespace-method
tokens: Query.count
sig: Query.count(prop) -> int
tip: How many live entities carry a property.
order: 1
ns: Query
member: count
---
Returns the number of live entities that carry the property <code>prop</code> (a property id from <a href="world-prop_id"><code>World.prop_id</code></a>). A quick population count — how many enemies are alive, how many pickups remain — without writing a query loop.
Parameters:
- `prop` — a property id (from `World.prop_id("Name")`)
```ludic
program Demo {
property Enemy { hp: int = 0 }
model Slime { Enemy }
entry {
spawn Slime { Enemy { hp: 3 } }
spawn Slime { Enemy { hp: 3 } }
let E = World.prop_id("Enemy")
print(Query.count(E)) # 2
}
}
```

View file

@ -0,0 +1,31 @@
---
id: query-first
name: Query.first
category: query
kind: namespace-method
tokens: Query.first
sig: Query.first(prop) -> int
tip: The lowest-id live entity carrying a property, or -1.
order: 2
ns: Query
member: first
---
Returns the first (lowest entity id) live entity that carries <code>prop</code>, or <code>-1</code> if none do. Handy for a singleton-ish lookup — the player, the current boss, the one active portal — and to test existence (<code>Query.first(prop) &gt;= 0</code>). Read its fields with <a href="world-get"><code>World.get</code></a>.
Parameters:
- `prop` — a property id (from `World.prop_id("Name")`)
```ludic
program Demo {
property Player { hp: int = 0 }
model Hero { Player }
entry {
spawn Hero { Player { hp: 100 } }
let P = World.prop_id("Player")
let hp = World.field_id(P, "hp")
let e = Query.first(P)
if e >= 0 { print(World.get(e, P, hp)) } # 100
}
}
```

View file

@ -0,0 +1,38 @@
---
id: query-nearest
name: Query.nearest
category: query
kind: namespace-method
tokens: Query.nearest
sig: Query.nearest(prop, pos, x_field, y_field, x, y) -> int
tip: The bearer of prop closest to a point, by squared distance.
order: 3
ns: Query
member: nearest
---
Returns the live entity carrying <code>prop</code> whose position is closest to <code>(x, y)</code>, or <code>-1</code> if none. Position comes from property <code>pos</code> at its integer fields <code>x_field</code> and <code>y_field</code> — pass the same id for <code>prop</code> and <code>pos</code> to query the coordinate component itself, or a separate tag for <code>prop</code> to mean "nearest <em>Enemy</em>". Distance is compared by exact squared integer distance (no square root), and ties go to the lower entity id, so the result is fully deterministic. The go-to for target acquisition, "interact with the closest thing", or nearest-neighbour AI.
Parameters:
- `prop` — the property that filters which entities are considered
- `pos` — the property holding the coordinates
- `x_field`, `y_field` — field ids of the x / y coordinate within `pos`
- `x`, `y` — the point to measure from
```ludic
program Demo {
property Position { col: int = 0, row: int = 0 }
property Enemy { hp: int = 0 }
model Slime { Position, Enemy }
entry {
spawn Slime { Position { col: 2, row: 0 }, Enemy { hp: 3 } }
spawn Slime { Position { col: 9, row: 0 }, Enemy { hp: 3 } }
let E = World.prop_id("Enemy")
let P = World.prop_id("Position")
let cx = World.field_id(P, "col")
let cy = World.field_id(P, "row")
let e = Query.nearest(E, P, cx, cy, 0, 0) # the enemy at col 2
print(World.get(e, P, cx)) # 2
}
}
```

View file

@ -0,0 +1,39 @@
---
id: query-within
name: Query.within
category: query
kind: namespace-method
tokens: Query.within
sig: Query.within(prop, pos, x, y, radius, x_field, y_field) -> []int
tip: Every bearer of prop inside a radius of a point.
order: 4
ns: Query
member: within
---
Returns the live entities carrying <code>prop</code> whose position is within <code>radius</code> of <code>(x, y)</code>, as a <code>[]int</code> of entity ids in ascending id order — index it with <code>len</code> / <code>[i]</code>. Position is read from property <code>pos</code> at <code>x_field</code>/<code>y_field</code>, exactly as in <a href="query-nearest"><code>Query.nearest</code></a>. The boundary is inclusive (distance <code>&lt;= radius</code>), measured by exact integer distance. Use it for area-of-effect damage, proximity triggers, flocking neighbours, or "what's near the cursor".
Parameters:
- `prop` — the property that filters which entities are considered
- `pos` — the property holding the coordinates
- `x`, `y` — the centre of the query
- `radius` — the inclusive radius
- `x_field`, `y_field` — field ids of the x / y coordinate within `pos`
```ludic
program Demo {
property Position { col: int = 0, row: int = 0 }
property Unit { hp: int = 0 }
model Soldier { Position, Unit }
entry {
spawn Soldier { Position { col: 1, row: 1 }, Unit { hp: 5 } }
spawn Soldier { Position { col: 8, row: 8 }, Unit { hp: 5 } }
let U = World.prop_id("Unit")
let P = World.prop_id("Position")
let cx = World.field_id(P, "col")
let cy = World.field_id(P, "row")
let hit = Query.within(U, P, 0, 0, 3, cx, cy) # just the (1,1) soldier
print(len(hit)) # 1
}
}
```

View file

@ -0,0 +1,60 @@
# query.ludic — Query.* ECS spatial queries: count / first / nearest / within
# over the live entities that carry a property, reading two int fields as (x, y).
# Five entities are spawned at known positions; each assertion that holds prints
# its number, so a full run prints:
# 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
# Deterministic: the same world reproduces the same answers (and within's entity
# order) every run. Ghost/Phantom are declared but never spawned — the empty
# case (count 0, first/nearest -1, within []).
program Query {
property Position { col: int = 0, row: int = 0 }
property Kind { tag: int = 0 }
property Ghost { g: int = 0 }
model Mob { Position, Kind }
model Rock { Position }
model Phantom { Ghost }
entry {
spawn Mob { Position { col: 0, row: 0 }, Kind { tag: 1 } } # e0
spawn Mob { Position { col: 10, row: 0 }, Kind { tag: 1 } } # e1
spawn Mob { Position { col: 3, row: 4 }, Kind { tag: 1 } } # e2
spawn Rock { Position { col: 1, row: 1 } } # e3
spawn Rock { Position { col: 20, row: 20 } } # e4
let P = World.prop_id("Position")
let cx = World.field_id(P, "col")
let cy = World.field_id(P, "row")
let K = World.prop_id("Kind")
let G = World.prop_id("Ghost")
# --- count: filter by which property an entity carries ---
if Query.count(P) == 5 { print(1) } # all five have Position
if Query.count(K) == 3 { print(2) } # only the three Mobs
if Query.count(G) == 0 { print(3) } # nothing has Ghost
# --- first: lowest-id bearer (or -1) ---
if World.get(Query.first(K), P, cx) == 0 { print(4) } # first Mob is e0 at col 0
if Query.first(G) < 0 { print(5) } # none -> -1
if World.get(Query.first(P), P, cx) == 0 { print(6) } # first bearer is e0
# --- nearest: closest bearer to a point by squared distance. `prop` filters,
# `pos` (here Position) supplies the coordinates. ---
let n0 = Query.nearest(P, P, cx, cy, 0, 0)
if World.get(n0, P, cx) == 0 { print(7) }
if World.get(n0, P, cy) == 0 { print(8) }
if World.get(Query.nearest(P, P, cx, cy, 11, 0), P, cx) == 10 { print(9) } # e1
if World.get(Query.nearest(K, P, cx, cy, 9, 0), P, cx) == 10 { print(10) } # nearest Mob (filter K, pos P)
if Query.nearest(G, P, cx, cy, 0, 0) < 0 { print(11) } # none -> -1
# --- within: every bearer inside a radius, ascending entity id ---
if len(Query.within(P, P, 0, 0, 5, cx, cy)) == 3 { print(12) } # e0, e2, e3
if len(Query.within(P, P, 0, 0, 50, cx, cy)) == 5 { print(13) } # everyone
let near0 = Query.within(P, P, 0, 0, 0, cx, cy)
if len(near0) == 1 { print(14) } # only e0 at (0,0)
if World.get(near0[0], P, cx) == 0 { print(15) } # and it is e0
if len(Query.within(G, P, 0, 0, 100, cx, cy)) == 0 { print(16) } # empty
if len(Query.within(K, P, 0, 0, 6, cx, cy)) == 2 { print(17) } # Mobs e0, e2 (filter K, pos P)
if World.get(Query.nearest(P, P, cx, cy, 20, 20), P, cx) == 20 { print(18) } # e4
}
}

View file

@ -0,0 +1,76 @@
# ============================================================================
# query.ludic — ECS spatial queries over the reflection ABI, in Ludic.
#
# The Query.* namespace (see emit_call.ludic) answers questions about the live
# entities that carry a given property: how many, the first, the nearest to a
# point, and every one inside a radius. `prop` is a property id from
# World.prop_id("Name"); the spatial forms read two int fields of that property
# (field ids from World.field_id) as an (x, y) position. Everything is integer
# and deterministic — same world + same query reproduce the same answer, entity
# order included, every run.
#
# ludicc splices this file into a game when it sees Query.* (parse.ludic), and
# force-emits the reflection ABI it stands on (emit_decl.ludic) so a game that
# uses Query needs no @events of its own. The scan is linear over the entity
# table — ample for the entity counts Ludic targets, exactly like the grid
# pathfinder's open set — and allocates nothing except the one result slice
# `within` returns. world_query_next / world_get are the bare reflection
# builtins (they lower to the generated @ludic_query_next / @ludic_get).
# ============================================================================
# how many live entities carry `prop`.
function query_count(prop: int) -> int {
var n = 0
var e = world_query_next(prop, 0)
while e >= 0 {
n = n + 1
e = world_query_next(prop, e + 1)
}
return n
}
# the first (lowest-id) live entity carrying `prop`, or -1 if none.
function query_first(prop: int) -> int {
return world_query_next(prop, 0)
}
# the entity carrying `prop` whose position is closest to (px, py) by squared
# distance, or -1 if none. Ties go to the lower entity id. The position is read
# from `pos` — the property that holds the coordinates — at its (x_field,
# y_field); pass the same id for `prop` and `pos` to query by the position
# component itself, or a separate tag for `prop` (e.g. "nearest Enemy").
function query_nearest(prop: int, pos: int, x_field: int, y_field: int, px: int, py: int) -> int {
var best = 0 - 1
var bestd = 0
var e = world_query_next(prop, 0)
while e >= 0 {
let dx = world_get(e, pos, x_field) - px
let dy = world_get(e, pos, y_field) - py
let d = dx * dx + dy * dy
if best < 0 or d < bestd {
best = e
bestd = d
}
e = world_query_next(prop, e + 1)
}
return best
}
# every live entity carrying `prop` whose position is within `radius` of
# (px, py), in ascending entity-id order. Returns a []int of entity ids — index
# it with len / [i]. The boundary is inclusive (distance <= radius). Position is
# read from `pos` at (x_field, y_field), as in query_nearest.
function query_within(prop: int, pos: int, px: int, py: int, radius: int, x_field: int, y_field: int) -> []int {
let out = new []int
let r2 = radius * radius
var e = world_query_next(prop, 0)
while e >= 0 {
let dx = world_get(e, pos, x_field) - px
let dy = world_get(e, pos, y_field) - py
if dx * dx + dy * dy <= r2 {
push(out, e)
}
e = world_query_next(prop, e + 1)
}
return out
}

View file

@ -223,6 +223,17 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "flood") { bare = "grid_flood"; push(labels, "x"); push(labels, "y"); push(labels, "wall") }
if (meth == "a_star") { bare = "path_a_star"; push(labels, "x0"); push(labels, "y0"); push(labels, "x1"); push(labels, "y1"); push(labels, "wall") }
}
# Query.* — ECS spatial queries over the reflection ABI (runtime/native/query.ludic,
# spliced on demand). `prop` is a property id (World.prop_id); the spatial forms
# read two int fields (field ids) as (x, y). nearest/first return an entity (-1 =
# none); within returns a []int of entities. A linear scan — ample for the entity
# counts Ludic targets, like the grid pathfinder's open set.
if (ns == "Query") {
if (meth == "count") { bare = "query_count"; push(labels, "prop") }
if (meth == "first") { bare = "query_first"; push(labels, "prop") }
if (meth == "nearest") { bare = "query_nearest"; push(labels, "prop"); push(labels, "pos"); push(labels, "x_field"); push(labels, "y_field"); push(labels, "x"); push(labels, "y") }
if (meth == "within") { bare = "query_within"; push(labels, "prop"); push(labels, "pos"); push(labels, "x"); push(labels, "y"); push(labels, "radius"); push(labels, "x_field"); push(labels, "y_field") }
}
if (bare == null) { perr(`unknown builtin {ns}.{meth}`) }
reorder_named(e, labels)
let id = node(E_ID); id.s = bare; e.a = id

View file

@ -81,7 +81,7 @@ function emit_program() -> void {
var i = 0
while i < len(prog) { if prog[i].kind == N_FN { emit_fn(prog[i]) }; i = i + 1 }
if len(g_events) > 0 { emit_event_fns() } # EV0: @ev_<E> event-dispatch functions
if has_ecs() and len(g_events) > 0 { emit_world_table() } # EV2: the mod reflection ABI
if has_ecs() and (len(g_events) > 0 or g_uses_query) { emit_world_table() } # EV2: the mod reflection ABI (also powers Query.*)
if has_ecs() { emit_ecs_allocator(); emit_snapshot() }
if has_ecs() { emit_net() } # N2/N3: @Sync serializers + @Owned storage (gated internally)
if has_ui() { emit_ui_build() }

View file

@ -162,6 +162,7 @@ function p_postfix() -> Node {
while true {
if is_op(".") { pi = pi + 1; let m = node(E_MEMBER); m.a = e; m.s = eat_id(); e = m
if e.a.kind == E_ID and e.a.s == "Regex" { g_uses_regex = true } # splice the regex runtime on demand
if e.a.kind == E_ID and e.a.s == "Query" { g_uses_query = true } # splice the ECS spatial-query runtime on demand
}
else { if is_op("[") { pi = pi + 1; let lo = expr()
if is_op("..") { pi = pi + 1; let sl = node(E_SLICE); sl.a = e; sl.b = lo; sl.c = expr(); eat_op("]"); e = sl } # s[a..b] substring
@ -374,6 +375,7 @@ function path_join(dir: pointer, rel: pointer) -> pointer {
var loaded_paths: []pointer
var cur_dir: pointer
var g_uses_regex: bool = false # a program mentioned Regex.* -> splice the regex runtime
var g_uses_query: bool = false # a program mentioned Query.* -> splice the query runtime + reflection ABI
function already_loaded(full: pointer) -> bool {
var i = 0
@ -525,6 +527,14 @@ function maybe_splice_runtime() -> void {
do_import("runtime/native/regex_vm.ludic")
cur_dir = saved
}
# any program that uses Query.* gets the ECS spatial-query helpers spliced in;
# they read entity state through the reflection ABI (emit_decl force-emits it
# for a Query program even when it declares no events).
if g_uses_query {
cur_dir = ""
do_import("runtime/native/query.ludic")
cur_dir = saved
}
}
function parse_program() -> void {
@ -543,6 +553,7 @@ function parse_program() -> void {
g_onlisten = new []Node
g_toggled_layers = new []pointer
g_uses_regex = false
g_uses_query = false
loaded_paths = new []pointer
skipnl()
g_game_name = "Ludic"

File diff suppressed because it is too large Load diff

View file

@ -105,6 +105,7 @@ function cmd_test() -> int {
feat_case("library/regex", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "regex.ludic (Regex match/find/groups/classes/quantifiers/replace + linear-time safety)")
feat_case("library/grid", "", "1 2 3 4 5 6 7 8 9 10 11 12 13", "grid.ludic (Grid line/flood/line_of_sight + A* pathfinding over the tilemap)")
feat_case("library/anim", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34", "anim.ludic (Anim frame/once/pingpong/cell + Tween progress/loop/yoyo/ease/number/round/point/tint)")
feat_case("library/query", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "query.ludic (Query count/first/nearest/within — ECS spatial queries over the reflection ABI)")
feat_case("library/logging", "", "0 5 2 1", "logging.ludic (Log levels, set_level/level threshold, structured fields)")
# Os known-folders/arch and Fs.list read the BSD utsname/dirent layout, so
# their asserted values are macOS-specific; skip off Darwin (see is_darwin).