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>
60 lines
3.1 KiB
Text
60 lines
3.1 KiB
Text
# 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
|
|
}
|
|
}
|