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>
251 lines
10 KiB
Text
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__")
|
|
}
|