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