feat(lang): 0.R1 - actions and reducers

action Name { fields } is a typed record; reducer State on Action(s: mut State, a: Action) { ... }
in the module that owns the state takes exactly that state and the action (a second state is
refused); dispatch Action { fields } queues one from anywhere, the queue supplied by the runtime.
The queue is drained at the end of every phase of the frame loop, after every phase of ludic.base's
core_tick_all, and by drain_actions(): in dispatch order, each action's reducers in the order of
their states' names, an action a reducer dispatches queued behind, a queue still growing after 64
rounds stopped with the action named. Examples actions/pack, phases, runaway; rejects for a second
state, a reducer on a non-action and an unknown dispatch; ludic.base's actions_test; LANGUAGE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 19:13:46 +03:00
parent 3a87d02696
commit 808c4a6f7a
19 changed files with 415 additions and 1 deletions

View file

@ -314,6 +314,62 @@ ludic migrate state packages <every example program> packages/ludic.lab/example/
migrate: 1804 vars into 126 states, 64 into lets; 23498 edits in 460 files
```
### Actions and reducers (`action`, `reducer`, `dispatch`)
Threading states makes a function's signature say what it touches, and it shows where one function
does everything: an input handler that reads the keys and then changes the world itself takes every
state the world has. An action separates the two. The input says WHAT happened; each module decides
what that means for its own state, and nothing else:
```ludic
program Pack {
state Bag {
items: []int = new []int
weight: int = 0
}
state Log { lines: []string = new []string }
action PickUp { item: int, kg: int = 1 } # what happened: a typed record
reducer Bag on PickUp(b: mut Bag, a: PickUp) { # in the module that owns Bag
push(b.items, a.item)
b.weight += a.kg
}
reducer Log on PickUp(l: mut Log, a: PickUp) { push(l.lines, `picked {a.item}`) }
handler Keys phase Input {
if Input.key() == 'e' { dispatch PickUp { item: 7 } } # a translator: keys to actions
}
}
```
- **`action Name { fields }`** is a record, with defaults like any. `export action` for other
modules to dispatch it.
- **`reducer State on Action(s: mut State, a: Action) { ... }`** takes exactly its state and the
action, in that order. A second state is refused (`reducer Pack on Buy: a reducer takes one state,
and w is a Wallet - what it needs to know rides in the action`), and so is a call inside it to a
function that needs another, since nothing supplies one there. What it needs to know rides in the
action, filled by whoever dispatches it. Several reducers may handle one action, one per state;
a reducer is not called by name.
- **`dispatch Action { fields }`** queues the action, from anywhere: a handler, a function, a
listener, a reducer. The queue is the runtime's, supplied like an entry point's state, so
dispatching needs no state parameter.
- **When the queue is drained:** at the end of every phase of the frame loop (so what the `Input`
phase dispatches is reduced before `Update`); after every phase of the ludic.base system runner
(`core_tick_all`); and wherever the program calls `drain_actions()` (an `entry` program, a test,
a loop of its own). Draining runs the actions in the order they were dispatched, and each action's
reducers in the order of their states' names - never the order of imports - so the same actions
make the same changes on every machine and in a replay.
- **An action a reducer dispatches** is queued behind the rest and reduced in the same drain, never
re-entrantly. A queue still growing after 64 rounds of that stops the program, naming the action:
`actions: Ping is still being dispatched after 64 rounds of reducers - a reducer dispatches what
dispatches it`.
- **Events stay** for what changes no state - a sound, a notice, telemetry - and `@On` listeners run
as the event is emitted. Actions are for changes.
`ludic deps` reports the widest function - the most states any function or entry point of the
program's own takes - and `--check` holds it as a ratchet like its other numbers
(`widest_function 12` in the baseline file).
### Types are checked before anything is emitted
Between the parse and the emitter a checker walks every function, the entry, the tests, the

11
changes/actions.md Normal file
View file

@ -0,0 +1,11 @@
bump: minor
type: feature
**Actions and reducers.** `action PickUp { item: int }` is a typed record of something that
happened; `reducer Bag on PickUp(b: mut Bag, a: PickUp) { ... }`, in the module that owns the state,
says what it means for that one state - a reducer takes exactly its state and the action, and a
second state is refused; `dispatch PickUp { item: 7 }` queues one from anywhere. The queue is drained
at the end of every phase of the frame loop, after every phase of ludic.base's `core_tick_all`, and
where a program calls `drain_actions()`: in dispatch order, each action's reducers in the order of
their states' names, an action a reducer dispatches queued behind (a queue still growing after 64
rounds stops the program, naming the action). `ludic deps` reports `widest_function` - the most
states any function or entry point of the program takes - and `--check` ratchets it.

View file

@ -0,0 +1,38 @@
# pack.ludic - 0.R: what happened is an action; what it means for a state is that state's reducer.
# The entry dispatches; drain_actions() runs every reducer of each action, in order, each given its
# own state - and an action a reducer dispatches goes behind the queue.
program PackActions {
state Pack {
items: []int = new []int
weight: int = 0
}
state Log {
lines: []string = new []string
}
action PickUp { item: int, kg: int = 1 }
action Drop { item: int }
action Overload {}
reducer Pack on PickUp(p: mut Pack, a: PickUp) {
push(p.items, a.item)
p.weight += a.kg
if p.weight > 10 { dispatch Overload {} }
}
reducer Log on PickUp(l: mut Log, a: PickUp) { push(l.lines, `picked {a.item}`) }
reducer Pack on Drop(p: mut Pack, a: Drop) {
var kept = new []int
for i in 0 .. len(p.items) { if p.items[i] != a.item { push(kept, p.items[i]) } }
p.items = kept
}
reducer Log on Overload(l: mut Log, a: Overload) { push(l.lines, "too heavy") }
entry (p: Pack, l: Log) {
dispatch PickUp { item: 7 }
dispatch PickUp { item: 9, kg: 12 }
dispatch Drop { item: 7 }
print(len(p.items)) # 0: nothing is reduced until the queue is drained
drain_actions()
print(`{len(p.items)} {p.items[0]} {p.weight}`)
for i in 0 .. len(l.lines) { print(l.lines[i]) }
}
}

View file

@ -0,0 +1,24 @@
# phases.ludic - 0.R: in the frame loop the queue is drained at the end of every phase, so what the
# Input phase dispatches is reduced before Update reads it. The input translates keys to actions and
# touches no state of its own.
program PhaseActions {
state Hero {
x: int = 0
jumps: int = 0
}
action Move { dx: int }
action Jump {}
reducer Hero on Move(h: mut Hero, a: Move) { h.x += a.dx }
reducer Hero on Jump(h: mut Hero, a: Jump) { h.jumps += 1 }
handler Keys phase Input {
let k = Input.key()
if k == 'd' { dispatch Move { dx: 1 } }
if k == 'a' { dispatch Move { dx: -1 } }
if k == ' ' { dispatch Jump {} }
if k == 'q' { quit() }
}
handler Show(h: Hero) phase Update {
print(`{h.x} {h.jumps}`)
}
}

View file

@ -0,0 +1,15 @@
# runaway.ludic - 0.R: a reducer that dispatches what dispatches it is stopped, by name, after
# 64 rounds, rather than spinning
program Runaway {
state Echo { n: int = 0 }
action Ping {}
reducer Echo on Ping(e: mut Echo, a: Ping) {
e.n += 1
dispatch Ping {}
}
entry {
dispatch Ping {}
drain_actions()
print("not reached")
}
}

View file

@ -0,0 +1,5 @@
# 0.R: only an action is dispatched
program DispatchUnknown {
action Buy { cost: int }
entry { dispatch Sell { cost: 1 } }
}

View file

@ -0,0 +1,7 @@
# 0.R: a reducer is on an action
program ReducerNotAction {
state Pack { n: int = 0 }
property Buy { cost: int = 0 }
reducer Pack on Buy(p: mut Pack, a: Buy) { p.n += 1 }
entry { print(1) }
}

View file

@ -0,0 +1,8 @@
# 0.R: a reducer takes its own state and the action; what else it needs rides in the action
program ReducerTwoStates {
state Pack { n: int = 0 }
state Wallet { cash: int = 0 }
action Buy { cost: int }
reducer Pack on Buy(p: mut Pack, w: mut Wallet) { p.n += 1 }
entry { print(1) }
}

View file

@ -63,6 +63,7 @@ export function core_tick_all(base_st: mut BaseState, t: Tick) -> void {
for i in 0 .. len(all) {
if all[i].phase == ph and all[i].tick != null { all[i].tick(t) }
}
drain_actions() # 0.R: the phase's actions, reduced before the next
}
}

View file

@ -0,0 +1,30 @@
# actions_test.ludic - 0.R: the runner drains the action queue after every phase, so what an input
# system dispatches is reduced before the simulation reads it, the same tick
import "ludic.base"
program ActionsTest {
numbers float
state Door {
open: bool = false
seen: string = ""
}
action Knock { times: int }
reducer Door on Knock(d: mut Door, a: Knock) {
if a.times >= 2 { d.open = true }
}
function input_tick(t: Tick) -> void { dispatch Knock { times: 2 } }
function sim_tick(d: mut Door, t: Tick) -> void {
if d.open { d.seen = d.seen + "o" } else { d.seen = d.seen + "c" }
}
test "an input system's action is reduced before the simulation's phase" (b: mut BaseState, d: Door) {
core_clear(b)
let i = system_new("input", PH_INPUT)
i.tick = fn input_tick
core_add(b, i)
let s = system_new("sim", PH_SIMULATE)
s.tick = fn sim_tick
core_add(b, s)
core_tick_all(b, tick_new(0.016, 1, 0.0))
expect(d.open)
expect(d.seen == "o")
}
}

View file

@ -967,6 +967,7 @@ function emit_call(e: Node) -> Val {
vis_check(fn2, name)
state_inject_generated(e, fn2) # 0.S: a call the compiler wrote gets its states
state_inject_runtime(e, fn2) # and a call into the runtime
if is_action_builtin(fn2.s) { state_inject(e, fn2) } # 0.R: the action queue
call_fill_defaults(e, fn2) # L11 - for code the checker does not walk
reorder_named(e, param_labels(fn2))
# evaluate args first (their IR is emitted before the call instruction), coercing
@ -1119,6 +1120,6 @@ function emit_expr(e: Node) -> Val {
let c = emit_bind(`icmp eq i32 {a.code}, 0`)
return val(emit_bind(`zext i1 {c} to i32`), "bool")
}
perr("cannot emit expression")
perr(`cannot emit expression (node kind {itoa(e.kind)})`)
return val("0", "int")
}

View file

@ -38,6 +38,8 @@ function vis_check(d: Node, what0: pointer) -> void {
# 0.S: a state's instance, supplied by the runtime (a component's or a view's glue, an entry's
# parameters): the code that names it only passes it on
if d.kind == N_VAR and d.uns == 1 and is_state_ty(d.ty) and len(d.s) > 6 and (d.s[0 .. 6] == "state$") { return }
# 0.R: the action queue and the reducers are the runtime's to call, from any module
if d.kind == N_FN and (is_action_builtin(d.s) or (len(d.s) > 14 and (d.s[0 .. 14] == "ludic_reduce__"))) { return }
let what = vis_plain(what0)
var here = g_err_file
if here == null { return }

View file

@ -345,6 +345,7 @@ function emit_calls_for_phase(phase: pointer) -> void {
emit_engine_systems_for_phase(phase) # engine-owned systems run after every user handler
emit_dyn_systems_for_phase(phase) # #64: mod-registered systems run last
if (phase == "Overlay") { emit_scene_menu_render() } # a scene's `shows` menu paints last
if len(g_act_names) > 0 { emit(` call void {fn_sym("drain_actions")}()\n`) } # 0.R: this phase's actions, reduced
}
# the menu of the live `scene X shows Menu`, drawn on top of everything else

View file

@ -15,6 +15,7 @@ function ck_params(f: Node, labels: []pointer, tys: []pointer) -> void {
function ck_call_fn(e: Node, name: pointer, f: Node) -> pointer {
state_inject_generated(e, f)
state_inject_runtime(e, f)
if is_action_builtin(f.s) { state_inject(e, f) } # 0.R: the queue is supplied
mg_call(e, f)
call_fill_defaults(e, f)
ck_state_args(e, f)

View file

@ -0,0 +1,202 @@
# 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 takes exactly its state 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 with the action that keeps coming.
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
const ACTION_PASSES: int = 64
# action NAME { fields } - a record
function parse_action() -> void {
let c = parse_component()
c.uns = 3
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(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() }
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 {
var i = 0
while i < len(g_red_nodes) {
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
}
var file = g_parse_file
var line = 1
if len(g_act_nodes) > 0 {
file = g_act_nodes[0].file
line = g_act_nodes[0].line
}
# 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
}
k = 0
while k < len(ps) {
let p = ps[k]
if k > 0 and is_state_ty(p.ty) {
act_err(f, `reducer {st} on {act}: a reducer takes one state, and {p.s} is a {p.ty} - what it needs to know rides in the action`)
}
k += 1
}
if len(ps) != 2 or not (ps[0].ty == st) or not (ps[1].ty == act) {
act_err(f, `reducer {st} on {act}: its parameters are its state 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) { 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}\n"
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 { 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"
var 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) {
src = src + ` {g_red_nodes[rs[r]].s}(v)\n`
r += 1
}
src = src + " }\n"
}
a += 1
}
src = src + " }\n q.kinds = new []int\n q.vals = new []pointer\n q.head = 0\n q.depth = 0\n}\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
}
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(g_red_state[x], g_red_state[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")
}

View file

@ -422,6 +422,7 @@ function mg_call(e: Node, f: Node) -> void {
if not g_migrate or f == null or g_mg_cur < 0 { return }
if e.pos < 0 { return } # a generated call gets its states when compiled
if is_runtime_file(f.file) and not is_runtime_file(e.file) { return } # the runtime's are supplied
if is_action_builtin(f.s) { return } # 0.R: the action queue is supplied too
push(g_mc_call, e)
push(g_mc_unit, g_mg_cur)
push(g_mc_callee, mg_unit(f, 0))

View file

@ -468,6 +468,7 @@ function stmt_body() -> Node {
return n
}
if (t.text == "spawn") { return parse_spawn() }
if (t.text == "dispatch") and toks[pi + 1].kind == TK_ID { return parse_dispatch() } # 0.R
if (t.text == "machine") {
pi += 1; let n = node(S_MACHINE); n.a = expr(); skipnl(); eat_op("{")
var sidx = 0 # states auto-number by declaration order
@ -1026,6 +1027,8 @@ function parse_one_decl() -> void {
if is_id("view") and (toks[pi + 1].kind == TK_ID) and ((toks[pi + 2].text == "{") or (toks[pi + 2].text == "(")) { parse_view(); return } # L11
if is_id("component") and (toks[pi + 1].kind == TK_ID) and ((toks[pi + 2].text == "{") or (toks[pi + 2].text == "(")) { parse_ui_component(); return } # L11
if is_id("state") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "{") { parse_state(); return } # 0.S
if is_id("action") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "{") { parse_action(); return } # 0.R
if is_id("reducer") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "on") { parse_reducer(); return }
if is_id("var") {
if not g_allow_globals {
perr(`a module-level var is refused: a module's changing data is its state (state Name {{ ... }}), passed to the functions that use it - or, if it never changes, a let`)
@ -1572,5 +1575,6 @@ function parse_program() -> void {
views_finish() # L11: each view gets its model and call
components_finish() # L11: each component gets its class
ui_blocks_states() # 0.S: a ui block reads a state as State.field
actions_finish() # 0.R: the reducers checked, the queue written
g_gen_nodes = false
}

View file

@ -25,6 +25,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/frontend/privates.ludic")
push(f, "selfhost/frontend/privates_types.ludic")
push(f, "selfhost/frontend/state.ludic")
push(f, "selfhost/frontend/actions.ludic")
push(f, "selfhost/frontend/migrate.ludic")
push(f, "selfhost/frontend/registry.ludic")
push(f, "selfhost/frontend/registry_finish.ludic")

View file

@ -945,6 +945,12 @@ function cmd_dev_test() -> int {
feat_case("modules/layers", "", "5", "layers.ludic (L3: `module menu in layer app uses items` - one layer's modules use each other and may go round)")
reject_case("rejected/layer_reach", "hud uses items.item_count", "a layered module is still held to its uses outside the layer")
reject_case("rejected/layer_cycle", "go round in a circle: items -> layer app -> items", "a cycle through a layer and out of it is refused")
feat_case("actions/pack", "", "0 1 9 13 picked 7 picked 9 too heavy", "pack.ludic (0.R: actions, reducers - one per state, in the order of the states' names - dispatch, drain_actions, an action a reducer dispatches goes behind)")
feat_case("actions/phases", "dd a q", "1 0 2 0 2 1 1 1 1 2 1 2", "phases.ludic (0.R: the frame loop drains the queue after every phase - Input's actions are reduced before Update)")
feat_case("actions/runaway", "", "actions: Ping is still being dispatched after 64 rounds of reducers - a reducer dispatches what dispatches it", "runaway.ludic (0.R: a reducer that dispatches what dispatches it is stopped by name)")
reject_case("rejected/reducer_two_states", "a reducer takes one state, and w is a Wallet", "a reducer takes exactly one state")
reject_case("rejected/reducer_not_action", "Buy is not an action", "a reducer is on an action")
reject_case("rejected/dispatch_unknown", "dispatch Sell: Sell is not an action", "only an action is dispatched")
feat_case("state/counter", "", "11 3 heard 5", "counter.ludic (0.S: a state, mut and read-only parameters, entry, listener and fn-value injection)")
feat_case("state/port_field", "", "40", "port_field.ludic (0.S: a port member bound to a state's field)")
spec_case("state/tested", "== 2 passed, 0 failed ==")