ludic/selfhost/frontend/actions.ludic
Orkuncakilkaya e4265f5fdb feat(lang): 27.1 - a state machine as data: @Machine(Record.field) on a registry of transitions
A registry marked `@Machine(Deer.mood)` is the transitions of a machine over that enum field of the
records a state's Table<Deer> holds. Its record has from and to (the enum's variants), on: string (an
action's name, "" for a transition the tick asks), guard: fn(Row<Deer>, reads...) -> bool and
enter: fn(Row<Deer>, reads...) -> void; the states are the enum's variants and the start is the
field's default. The rows are data (an .lres or defs), the names the studio already edits.

Written by the compiler (machines.ludic, machines_write.ludic): for each action an `on` names, a row
reducer in the registry's file (named ..__machine__DeerSteps, so it sits beside the program's own
row reducer on the same action, after it): the row's state, the first transition from it on that
action whose guard passes, the field set, enter run - guards and enters called by name. When a row
leaves a state on a guard alone, `state DeerStepsMachine` (the kept row view) and
deer_steps_tick(m: mut DeerStepsMachine, s: mut Herd, reads...), one transition a row a tick.
Nothing allocates.

The table is the whole machine: the field written anywhere else - an assignment, or a `machine`
block's become over it - is a type error (check_stmt.ludic, ck_machine_write). Guards and enters take
the row first, are the record's module's, keep a row reducer's rules (and may be handed the row);
a guard writes nothing through it. The graph is checked, each error at its row (in the .lres when
the rows are there): a state never reached from the start, a state with no way out, an `on` naming
no action or an action with no @Target, a self-transition with no guard, two ways out of a state on
one trigger behind an unguarded first. Also refused: @Machine off a registry, a field that is not a
plain enum with a default, a @Column field, no table (or two) of the record, a transitions record of
another shape, a machine outside its table's state's module.

ludic schema's code section gains `machines` (registry, record, field, enum, table, start, states,
actions, tick, module, at); ludic deps names a machine's reducer `reducer Deer in Herd.deer on Spook
(machine DeerSteps)`. vocab @Machine; docs annot-machine, kw-machine; LANGUAGE.md "A machine as
data"; examples actions/machine (+ deer_steps.lres) and ten rejects; test.ludic feat, reject and
schema cases (not run); changes/machines.md. Reseeded; bootstrap-cfree fixpoint holds (317642
lines); Maroon Lake's `ludic build --check` is clean against this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:37:37 +03:00

251 lines
10 KiB
Text

# actions.ludic — 0.R: actions and reducers. What happened is an action, a typed record; what it
# means for a state is that state's reducer, in the module that owns it:
#
# action PickUp { item: int }
# reducer Pack on PickUp(p: mut Pack, a: PickUp) { push(p.items, a.item) }
# ...
# dispatch PickUp { item: 3 }
#
# `dispatch` queues the action; the queue is drained - every reducer of each action run in turn,
# each supplied its own state - at the end of every phase of the frame loop, after each ludic.base
# phase, or where the program calls drain_actions(). A reducer writes exactly its own state and may
# READ others, declared between it and the action. Actions a reducer dispatches go behind the queue,
# never re-entrant, and a queue still growing after ACTION_PASSES rounds stops the program.
var g_act_names: []pointer = new []pointer # the actions, in the order read
var g_act_nodes: []Node = new []Node
var g_red_nodes: []Node = new []Node # the reducers
var g_red_state: []pointer = new []pointer
var g_red_action: []pointer = new []pointer
var g_dsp_ids: []Node = new []Node # each dispatch's action number, filled at the end
var g_dsp_names: []pointer = new []pointer
var g_dsp_at: []Node = new []Node
var g_act_main: pointer = null # the program's own file, where the drain is written
const ACTION_PASSES: int = 64
# action NAME { fields } - a record
function parse_action() -> void {
let c = parse_component()
c.uns = 3
c.vis = 1 # the program's own file makes and keeps its records
push(prog, c)
push(g_act_names, c.s)
push(g_act_nodes, c)
}
# reducer STATE on ACTION(s: mut STATE, a: ACTION) { ... }
function parse_reducer() -> void {
let st = toks[pi + 1].text
if toks[pi + 1].kind != TK_ID or not (toks[pi + 2].text == "on") or toks[pi + 3].kind != TK_ID {
perr("a reducer is `reducer State on Action(s: mut State, a: Action) { ... }`")
}
let act = toks[pi + 3].text
pi += 2
toks[pi].text = "function" # read the rest as a function named for the action
let f = parse_fn()
f.s = `ludic_reduce__{act}__{st}`
f.vis = 1
push(g_red_nodes, f)
push(g_red_state, st)
push(g_red_action, act)
push(g_red_rec, null) # a state's reducer: no row (actions_rows.ludic)
push(g_red_mc, null) # ... and no machine wrote it (machines.ludic)
push(g_red_tbl, null)
push(g_red_tgt, null)
push(prog, f)
}
# dispatch ACTION { fields } - a statement: the action made and queued
function parse_dispatch() -> Node {
let at = toks[pi]
pi += 1
let name = eat_id()
let nw = node(E_NEW)
nw.s = name
if is_op("{") { nw.a = record() }
# its record is the queue's: one of the action's kept records, filled as `new` fills a fresh one
let take = node(E_CALL)
let tc = node(E_ID)
tc.s = `ludic_act_new__{name}`
tc.pos = -1
take.a = tc
take.pos = -1
nw.b = take
let call = node(E_CALL)
let callee = node(E_ID)
callee.s = "ludic_act_push"
callee.pos = -1
call.a = callee
call.pos = -1 # the compiler's call: the queue is supplied
let id = node(E_INT)
id.pos = -1
push(call.kids, id)
push(call.kids, nw)
push(g_dsp_ids, id)
push(g_dsp_names, name)
push(g_dsp_at, nw)
let s = node(S_EXPR)
s.a = call
s.line = at.line
return s
}
function act_index(name: pointer) -> int {
var i = 0
while i < len(g_act_names) {
if (g_act_names[i] == name) { return i }
i += 1
}
return -1
}
function act_err(n: Node, msg: pointer) -> void {
g_err_file = n.file
g_err_line = n.line
g_parsing = false
perr(msg)
}
# after the program is read: the reducers checked, each dispatch numbered, and the queue, its push
# and its drain written
function actions_finish() -> void {
machines_finish() # 27.1: each @Machine checked and its reducers written
rows_finish_checks() # 27.3: @Target, @RowVerb (actions_rows.ludic)
var i = 0
while i < len(g_red_nodes) {
if g_red_rec[i] != null { rr_check(i) } else { red_check(i) }
i += 1
}
i = 0
while i < len(g_dsp_ids) {
let k = act_index(g_dsp_names[i])
if k < 0 { act_err(g_dsp_at[i], `dispatch {g_dsp_names[i]}: {g_dsp_names[i]} is not an action - declare it with action {g_dsp_names[i]} {{ ... }}`) }
g_dsp_ids[i].ival = k
i += 1
}
# the queue and the drain are the program's, written in its own file: in the file of the first
# action they would belong to its module, and it would call every module's reducers
var file = g_parse_file
if g_act_main != null { file = g_act_main }
let line = 1
# no actions: drain_actions() is still there (a ludic.base runner calls it), and does nothing
if len(g_act_names) == 0 {
vw_parse("export function drain_actions() -> void {\n}\n", file, line, 1)
return
}
vw_parse(act_src(), file, line, 1)
}
function red_check(i: int) -> void {
let f = g_red_nodes[i]
let st = g_red_state[i]
let act = g_red_action[i]
if act_index(act) < 0 { act_err(f, `reducer {st} on {act}: {act} is not an action - declare it with action {act} {{ ... }}`) }
if not is_state_ty(st) { act_err(f, `reducer {st} on {act}: {st} is not a state`) }
var ps = new []Node
var k = 0
while k < len(f.kids) {
if f.kids[k].kind == N_PARAM { push(ps, f.kids[k]) }
k += 1
}
# between its state and the action: states it only READS, never another to write
k = 1
while k < len(ps) - 1 {
let p = ps[k]
if not is_state_ty(p.ty) { act_err(f, `reducer {st} on {act}: {p.s} is a {p.ty} - between its state and the action a reducer takes only states it reads; what it needs to know rides in the action`) }
if p.uns == 1 { act_err(f, `reducer {st} on {act}: a reducer writes one state, and {p.s} is a mut {p.ty} - read it ({p.s}: {p.ty}), or dispatch an action {p.ty}'s own reducer takes`) }
k += 1
}
if len(ps) < 2 or not (ps[0].ty == st) or not (ps[len(ps) - 1].ty == act) {
act_err(f, `reducer {st} on {act}: its parameters are its state, any states it reads, and the action - ({reg_lower(st)}: mut {st}, a: {act})`)
}
var j = 0
while j < i {
if (g_red_state[j] == st) and (g_red_action[j] == act) and g_red_rec[j] == null { act_err(f, `reducer {st} on {act} is declared twice (first at {g_red_nodes[j].file}:{itoa(g_red_nodes[j].line)})`) }
j += 1
}
}
# the queue, its push and its drain: each action's reducers in the order of their states' names
function act_src() -> pointer {
var src = "export state LudicActions {\n kinds: []int = new []int\n vals: []pointer = new []pointer\n head: int = 0\n depth: int = 0\n"
var a = 0
while a < len(g_act_names) {
src = src + ` kept{itoa(a)}: []{g_act_names[a]} = new []{g_act_names[a]}\n used{itoa(a)}: int = 0\n`
a += 1
}
src = src + rr_fields_src() + "}\n" # 27.3: a row reducer's kept view
src = src + act_new_src()
src = src + rr_wrappers_src()
src = src + "export function ludic_act_push(q: mut LudicActions, k: int, v: pointer) -> void {\n push(q.kinds, k)\n push(q.vals, v)\n}\n"
src = src + "export function drain_actions(q: mut LudicActions) -> void {\n if q.depth > 0 or len(q.kinds) == 0 { return }\n q.depth = 1\n var pass = 0\n var round_end = len(q.kinds)\n"
src = src + " while q.head < len(q.kinds) {\n if q.head == round_end {\n pass += 1\n round_end = len(q.kinds)\n"
src = src + ` if pass >= {itoa(ACTION_PASSES)} {{ ludic_act_runaway(q.kinds[q.head]) }}\n }}\n`
src = src + " let k = q.kinds[q.head]\n let v = q.vals[q.head]\n q.head += 1\n"
a = 0
while a < len(g_act_names) {
let rs = red_sorted(g_act_names[a])
if len(rs) > 0 {
src = src + ` if k == {itoa(a)} {{\n`
var r = 0
while r < len(rs) {
var callee = g_red_nodes[rs[r]].s
if g_red_rec[rs[r]] != null { callee = rr_wrap_name(rs[r]) } # the handle resolved first
src = src + ` {callee}(v)\n`
r += 1
}
src = src + " }\n"
}
a += 1
}
src = src + " }\n List.clear(q.kinds)\n List.clear(q.vals)\n q.head = 0\n q.depth = 0\n" # in place: a drain a phase, seven a frame, allocates nothing
a = 0
while a < len(g_act_names) {
src = src + ` q.used{itoa(a)} = 0\n` # every record read: each is free again
a += 1
}
src = src + "}\n"
src = src + "export function ludic_act_runaway(k: int) -> void {\n var name = \"?\"\n"
a = 0
while a < len(g_act_names) {
src = src + ` if k == {itoa(a)} {{ name = "{g_act_names[a]}" }}\n`
a += 1
}
src = src + " print(`actions: {name} is still being dispatched after " + itoa(ACTION_PASSES) + " rounds of reducers - a reducer dispatches what dispatches it`)\n exit(1)\n}\n"
return src
}
# each action's next kept record, made only when all it has are queued: `dispatch` fills it in
# place, and the drain hands them all back once every reducer has read them
function act_new_src() -> pointer {
var src = ""
var a = 0
while a < len(g_act_names) {
let n = g_act_names[a]
let k = itoa(a)
src = src + `export function ludic_act_new__{n}(q: mut LudicActions) -> {n} {{\n if q.used{k} >= len(q.kept{k}) {{ push(q.kept{k}, new {n}) }}\n q.used{k} += 1\n return q.kept{k}[q.used{k} - 1]\n}}\n`
a += 1
}
return src
}
# a reducer's place among its action's: its state's name, then (a row reducer) its table's path
function red_key(i: int) -> pointer {
if g_red_tbl[i] == null { return g_red_state[i] }
return `{g_red_state[i]}\t{g_red_tbl[i]}`
}
function red_sorted(act: pointer) -> []int {
let out = new []int
var i = 0
while i < len(g_red_nodes) {
if (g_red_action[i] == act) { push(out, i) }
i += 1
}
var a = 1
while a < len(out) {
let x = out[a]
var b = a - 1
while b >= 0 and reg_str_less(red_key(x), red_key(out[b])) {
out[b + 1] = out[b]
b -= 1
}
out[b + 1] = x
a += 1
}
return out
}
# a call to the queue from anywhere: the queue is the runtime's to supply, like a state an entry point
# is given (drain_actions() from a ludic.base runner or a program's own loop)
function is_action_builtin(name: pointer) -> bool {
return (name == "drain_actions") or (name == "ludic_act_push") or str_starts(name, "ludic_act_new__")
}