Scriptable properties (ECS-safe subset): @Computed and @OnSpawn

Adds two annotations that give properties/models a scriptable feel WITHOUT
reattaching behavior to data — both reduce to code the data-oriented model
already emits:

- @Computed field on a property: a derived value that is NOT stored; x.field
  expands inline to its expression (bare names read as fields of x) at each use.
  Zero storage, zero runtime dispatch. Reuses qualify_fields (now non-destructive,
  base-node based); a g_computed registry keeps derived fields out of the layout.
- @OnSpawn(Model) on a handler: a constructor that runs at each spawn of Model
  with the model's properties bound by name. Spawn statically knows the model, so
  no runtime dispatch; emit_spawn binds the properties and inlines the hook body.

examples/annotations.ludic now exercises @Handles/@Queries/@Computed/@OnSpawn
(output 3 25 0 0); test.sh 15/15, fixpoint holds, goldens byte-identical.

Deferred (need more machinery, by design): @OnDespawn (despawn doesn't statically
know the entity's model) and @OnChange (needs change-tracking).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-27 21:01:32 +03:00
parent ecead0564e
commit 1951af99e9
9 changed files with 4177 additions and 3503 deletions

View file

@ -282,12 +282,14 @@ fn already_loaded(full: ptr) -> bool {
fn parse_one_decl() -> void {
let is_export = false
let qspec: Node = ptr_null()
let onspawn_model: ptr = ptr_null()
while is_op("@") {
pi = pi + 1; let a = eat_id() # collect a leading @annotation
if streq(a, "export") { is_export = true }
if streq(a, "Queries") { qspec = parse_queries_anno() } # @Queries(these: [...], on: ...)
else { if streq(a, "Queries") { qspec = parse_queries_anno() } # @Queries(these: [...], on: ...)
else { if streq(a, "OnSpawn") { eat_op("("); onspawn_model = eat_id(); eat_op(")") } # @OnSpawn(Model)
else { if is_op("(") { let d = 0 # any other @anno(args) — parsed and skipped
while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } } }
while true { if is_op("(") { d = d + 1 }; if is_op(")") { d = d - 1 }; pi = pi + 1; if d == 0 { break } } } } } }
skipnl()
}
if is_id("import") { pi = pi + 1
@ -303,6 +305,9 @@ fn parse_one_decl() -> void {
if is_id("model") { push(prog, parse_archetype()); return }
if is_id("handler") {
let h = parse_system()
if not ptr_is_null(onspawn_model) { # @OnSpawn(Model): a spawn hook, not a phased handler
register_onspawn(onspawn_model, h.a); return
}
if not ptr_is_null(qspec) { # @Queries wraps the body in its S_QUERY
qspec.a = h.a
let wrap = node(N_BLOCK); push(wrap.kids, qspec); h.a = wrap
@ -346,6 +351,8 @@ fn maybe_splice_runtime() -> void {
fn parse_program() -> void {
prog = new []Node
g_computed = new []Node
g_onspawn = new []Node
loaded_paths = new []ptr
skipnl()
g_game_name = "Ludic"