feat(ludic.effects): timed modifiers as a mechanic package; ludic.base's q_new is queue_new

ludic.effects: effects_add / effects_add_after / effects_clear / effects_run,
effects_sum / effects_has / effects_at / effects_live, an EffectEnded queue
(ran out, pushed out by the bonus cap, crowded out of a full ring) and a
System with its own save section. Nine test blocks in tests/effects_test.ludic.

ludic.base: q_new collided with render3d's quaternion q_new the moment a game
imported both, so the queue constructor is queue_new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 04:14:09 +03:00
parent 70d05bd504
commit d0ad364ad4
12 changed files with 419 additions and 9 deletions

View file

@ -35,7 +35,7 @@ import "ludic.base"
| --- | --- |
| `Tick { dt, frame, hours }`, `tick_new(dt, frame, hours)` | what a system's tick receives |
| `PH_INPUT`, `PH_SENSE`, `PH_SIMULATE`, `PH_RESOLVE`, `PH_COMMIT`, `PH_PRESENT`, `PH_COUNT` | the phases, in the order they run |
| `Queue<T>`, `q_new<T>(name)`, `q_push(q, v)`, `q_drain(q) -> []T`, `q_len(q)`, `q_clear(q)` | facts, first in first out |
| `Queue<T>`, `queue_new<T>(name)`, `q_push(q, v)`, `q_drain(q) -> []T`, `q_len(q)`, `q_clear(q)` | facts, first in first out (`queue_new`, not `q_new`: that name is render3d's quaternion) |
| `core_undrained() -> []string` | the names of queues still holding facts; ask it at the end of a frame |
| `Rng`, `rng_new(seed)`, `rng_seed(r, seed)`, `rng_next(r)`, `rng_float(r)`, `rng_between(r, lo, hi)`, `rng_span(r, lo, hi)` | a xorshift32 stream of its own (`rng_between` is inclusive; `rng_range` is taken by `Random.range`) |
| `SaveNode { found, version, data }`, `save_tree()`, `save_section(root, key, version, v)`, `load_section(root, key) -> SaveNode`, `save_encode`, `save_decode` | the save tree: `{"fishing": {"v": 3, ...}}`; `v` is reserved in a section, and a non-object value rides under `data` |
@ -66,7 +66,7 @@ export function fishing_cast(x: float, z: float) -> bool { # its
function fishing_reset() -> void {
casts = 0
dice = rng_new(7)
caught = q_new("fishing.caught")
caught = queue_new("fishing.caught")
}
function fishing_tick(t: Tick) -> void {
while casts > 0 {

View file

@ -16,7 +16,7 @@ function qb_init() -> void {
}
# a queue; the name is only for core_undrained
export function q_new<T>(name: string) -> Queue<T> {
export function queue_new<T>(name: string) -> Queue<T> {
qb_init()
let q = new Queue<T>
q.items = new []T

View file

@ -13,7 +13,7 @@ program QueueTest {
}
function fifo_case() -> void {
let q: Queue<int> = q_new("ints")
let q: Queue<int> = queue_new("ints")
q_push(q, 3)
q_push(q, 1)
q_push(q, 2)
@ -28,7 +28,7 @@ program QueueTest {
}
function record_case() -> void {
let q: Queue<Caught> = q_new("caught")
let q: Queue<Caught> = queue_new("caught")
q_push(q, caught(2, 1.5))
q_push(q, caught(4, 0.25))
let cs = q_drain(q)
@ -37,15 +37,15 @@ program QueueTest {
}
function clear_case() -> void {
let q: Queue<string> = q_new("words")
let q: Queue<string> = queue_new("words")
q_push(q, "a")
q_clear(q)
expect_eq(q_len(q), 0)
}
function undrained_case() -> void {
let a: Queue<int> = q_new("left_behind")
let b: Queue<int> = q_new("read")
let a: Queue<int> = queue_new("left_behind")
let b: Queue<int> = queue_new("read")
q_push(a, 1)
q_push(b, 2)
q_drain(b)

View file

@ -17,7 +17,7 @@ export function fishing_cast(x: float, z: float) -> bool { # its
function fishing_reset() -> void {
casts = 0
dice = rng_new(7)
caught = q_new("fishing.caught")
caught = queue_new("fishing.caught")
}
function fishing_tick(t: Tick) -> void {
while casts > 0 {

View file

@ -0,0 +1,44 @@
# ludic.effects
Timed modifiers that run down in game time: a meal that holds the cold off for fifty minutes, a
drink that is twenty minutes of legs and then a crash, a soaking that chills you for ten. The
rules of the game ask one question - "what should I add for this kind right now?" - and the
package answers it. Uses [`ludic.base`](../ludic.base/README.md) and nothing else.
```ludic
import "ludic.effects"
```
A kind is the game's own number (`EF_WARMTH_RATE`, `EF_CARRY`, ...); the package never knows what
one means. A magnitude is in that kind's units. Time is game minutes.
## Rules
- **The same kind from the same label replaces itself.** A second soda extends the fizz, it does
not double it.
- **Bonuses are capped** (`effects_config(max, bonus_max)`, 8 and 3 by default): a bonus that would
be one too many pushes out the bonus nearest its end. A menu is a choice.
- **A full ring** drops whatever is nearest its end.
- **A delayed effect** (`effects_add_after`) counts for nothing until it begins, then runs its time.
## API
| | |
| --- | --- |
| `effects_add(kind, mag, minutes, bonus, label)` | start one now |
| `effects_add_after(after, kind, mag, minutes, bonus, label)` | start one `after` game minutes from now |
| `effects_clear()` | stop everything, silently (a new trip, a night's sleep) |
| `effects_run(minutes)` | run them down (the system's tick does this from `Tick.hours`) |
| `effects_sum(kind)`, `effects_has(kind)`, `effects_bonus_count()` | what the rules ask |
| `effects_count()`, `effects_at(i) -> Effect`, `effects_live() -> []int` | what a read-out draws (`effects_at` is a copy) |
| `effects_config(max, bonus_max)`, `effects_capacity()`, `effects_bonus_capacity()` | the ring's size |
| `effects_ended() -> Queue<EffectEnded>` | the facts: `{ kind, label, why, bonus }`, `why` one of `EFFECT_RAN_OUT`, `EFFECT_PUSHED_OUT`, `EFFECT_CROWDED_OUT` |
| `effects_system() -> System` | `"effects"`, `PH_SIMULATE`: reset, tick, and its save section (`effects_save`, `effects_load`) |
No ports: the package asks the world nothing.
## Tests
```bash
ludic build packages/ludic.effects/tests/effects_test.ludic --headless -o /tmp/effects_test && /tmp/effects_test
```

View file

@ -0,0 +1,9 @@
# ludic.effects - a small ring of timed modifiers, each a kind (the game's number), a magnitude
# in that kind's units, a time left and a label; the game asks the sum for a kind
module ludic_effects
numbers float
import "ludic.base"
import "state.ludic"
import "verbs.ludic"
import "queries.ludic"
import "system.ludic"

View file

@ -0,0 +1,5 @@
# ludic.effects - timed modifiers that run down in game time: a meal's warmth, a drink's legs,
# a soaking's chill. Uses ludic.base and nothing else. See README.md.
package "ludic.effects"
version "0.1.0"
kind source

View file

@ -0,0 +1,47 @@
# queries.ludic - what the rules ask: the sum for a kind right now, and what is running
function ef_live(e: Effect) -> bool { return e.delay < 0.01 }
# what the rules should add for a kind right now (a delayed one counts only once it begins)
export function effects_sum(kind: int) -> float {
let all = ef_all()
var t = 0.0
for i in 0 .. len(all) { if all[i].kind == kind and ef_live(all[i]) { t = t + all[i].mag } }
return t
}
export function effects_has(kind: int) -> bool {
let all = ef_all()
for i in 0 .. len(all) { if all[i].kind == kind and ef_live(all[i]) { return true } }
return false
}
export function effects_bonus_count() -> int {
let all = ef_all()
var n = 0
for i in 0 .. len(all) { if all[i].bonus { n += 1 } }
return n
}
# everything held, delayed or not, in the order added
export function effects_count() -> int { return len(ef_all()) }
# a copy, so a reader cannot change the ring
export function effects_at(i: int) -> Effect {
let src = ef_all()[i]
let e = new Effect
e.kind = src.kind
e.mag = src.mag
e.left = src.left
e.delay = src.delay
e.bonus = src.bonus
e.label = src.label
return e
}
# the indices of the ones that have begun, for a read-out
export function effects_live() -> []int {
let out = new []int
let all = ef_all()
for i in 0 .. len(all) { if ef_live(all[i]) { push(out, i) } }
return out
}

View file

@ -0,0 +1,46 @@
# state.ludic - what is running, how many may, and the facts the package reports
export property Effect {
kind: int = 0
mag: float = 0.0
left: float = 0.0
delay: float = 0.0
bonus: bool = false
label: string = ""
}
# why an effect ended: it ran out, a newer bonus pushed it past the cap, or the ring was full
export const EFFECT_RAN_OUT: int = 0
export const EFFECT_PUSHED_OUT: int = 1
export const EFFECT_CROWDED_OUT: int = 2
export property EffectEnded {
kind: int = 0
label: string = ""
why: int = 0
bonus: bool = false
}
var ef_list: []Effect = null
var ef_max: int = 8
var ef_bonus_max: int = 3
var ef_ended: Queue<EffectEnded> = null
function ef_all() -> []Effect {
if ef_list == null { ef_list = new []Effect }
return ef_list
}
# the facts: what ended and why, oldest first; the game drains it
export function effects_ended() -> Queue<EffectEnded> {
if ef_ended == null { ef_ended = queue_new("effects.ended") }
return ef_ended
}
# how many may run at once, and how many of those may be bonuses
export function effects_config(max: int, bonus_max: int) -> void {
ef_max = max
ef_bonus_max = bonus_max
}
export function effects_capacity() -> int { return ef_max }
export function effects_bonus_capacity() -> int { return ef_bonus_max }

View file

@ -0,0 +1,52 @@
# system.ludic - the effects as a system: reset, a tick in game hours, and its own save section
export function effects_reset() -> void {
effects_clear()
q_clear(effects_ended())
}
function effects_tick(t: Tick) -> void { effects_run(t.hours * 60.0) }
export function effects_save() -> Val {
let l = Value.list()
let all = ef_all()
for i in 0 .. len(all) {
let o = Value.object()
sv_put_int(o, "k", all[i].kind)
sv_put_float(o, "m", all[i].mag)
sv_put_float(o, "t", all[i].left)
sv_put_float(o, "d", all[i].delay)
sv_put_bool(o, "b", all[i].bonus)
sv_put_str(o, "l", all[i].label)
Value.add(l, o)
}
let v = Value.object()
Value.put(v, "list", l)
return v
}
export function effects_load(v: Val, version: int) -> void {
effects_clear()
if Value.has(v, "list") == 0 { return }
let l = Value.get(v, "list")
for i in 0 .. Value.count(l) {
if len(ef_all()) >= ef_max { break }
let o = Value.at(l, i)
let e = new Effect
e.kind = sv_int(o, "k", 0)
e.mag = sv_float(o, "m", 0.0)
e.left = sv_float(o, "t", 0.0)
e.delay = sv_float(o, "d", 0.0)
e.bonus = sv_bool(o, "b", false)
e.label = sv_str(o, "l", "")
push(ef_all(), e)
}
}
export function effects_system() -> System {
let s = system_new("effects", PH_SIMULATE)
s.reset = fn effects_reset
s.tick = fn effects_tick
s.save = fn effects_save
s.load = fn effects_load
return s
}

View file

@ -0,0 +1,127 @@
# effects_test.ludic - the ring: sums by kind, replacing itself, the bonus cap, a delayed start,
# running down in game time, the facts it reports, and its own save section
import "ludic.effects"
import "ludic.base"
program EffectsTest {
numbers float
const LEGS: int = 0
const WARMTH: int = 1
const CARRY: int = 2
function fresh() -> void {
effects_config(8, 3)
effects_reset()
}
function ended() -> []EffectEnded { return q_drain(effects_ended()) }
function waiting() -> int { return q_len(effects_ended()) }
function tick_minutes(m: float) -> void {
let s = effects_system()
s.tick(tick_new(0.016, 0, m / 60.0))
}
test "the sum is per kind, and a kind with nothing running is zero" {
fresh()
effects_add(LEGS, 8.0, 20.0, false, "Fizz")
effects_add(LEGS, 6.0, 45.0, false, "Trail mix")
effects_add(WARMTH, 12.0, 50.0, false, "Stew")
expect(effects_sum(LEGS) == 14.0)
expect(effects_sum(WARMTH) == 12.0)
expect(effects_sum(CARRY) == 0.0)
expect(effects_has(WARMTH))
expect(not effects_has(CARRY))
}
test "the same kind from the same label replaces itself rather than stacking" {
fresh()
effects_add(LEGS, 8.0, 20.0, false, "Fizz")
effects_add(LEGS, 8.0, 20.0, false, "Fizz")
expect_eq(effects_count(), 1)
expect(effects_sum(LEGS) == 8.0)
}
test "a fourth bonus pushes the oldest out and says so" {
fresh()
effects_add(LEGS, 1.0, 10.0, true, "a")
effects_add(LEGS, 1.0, 30.0, true, "b")
effects_add(WARMTH, 1.0, 40.0, true, "c")
effects_add(CARRY, 1.0, 50.0, true, "d")
expect_eq(effects_bonus_count(), 3)
let gone = ended()
expect_eq(len(gone), 1)
expect(gone[0].label == "a")
expect_eq(gone[0].why, EFFECT_PUSHED_OUT)
}
test "a full ring drops the one nearest its end" {
fresh()
effects_config(2, 3)
effects_add(LEGS, 1.0, 10.0, false, "short")
effects_add(WARMTH, 1.0, 90.0, false, "long")
effects_add(CARRY, 1.0, 50.0, false, "new")
expect_eq(effects_count(), 2)
expect(not effects_has(LEGS))
expect_eq(ended()[0].why, EFFECT_CROWDED_OUT)
}
test "a delayed one counts only once it has begun" {
fresh()
effects_add(LEGS, 8.0, 20.0, false, "Fizz")
effects_add_after(20.0, LEGS, -5.0, 15.0, false, "Sugar crash")
expect(effects_sum(LEGS) == 8.0)
expect_eq(len(effects_live()), 1)
tick_minutes(25.0)
expect(effects_sum(LEGS) == -5.0)
tick_minutes(20.0)
expect_eq(effects_count(), 0)
}
test "running down in game hours reports each ending" {
fresh()
effects_add(WARMTH, -10.0, 10.0, false, "Wet through")
tick_minutes(5.0)
expect_eq(waiting(), 0)
tick_minutes(6.0)
expect_eq(effects_count(), 0)
let gone = ended()
expect_eq(gone[0].why, EFFECT_RAN_OUT)
expect(gone[0].label == "Wet through")
}
test "clear stops everything and says nothing" {
fresh()
effects_add(LEGS, 1.0, 10.0, false, "a")
effects_clear()
expect_eq(effects_count(), 0)
expect_eq(waiting(), 0)
}
test "a save section reads back the same ring" {
fresh()
effects_add(LEGS, 8.5, 20.0, true, "Fizz")
effects_add_after(20.0, LEGS, -5.0, 15.0, false, "Sugar crash")
let root = save_tree()
save_section(root, "effects", 1, effects_save())
let text = save_encode(root)
effects_clear()
let n = load_section(save_decode(text), "effects")
expect(n.found)
effects_load(n.data, n.version)
expect_eq(effects_count(), 2)
let a = effects_at(0)
expect(a.mag == 8.5)
expect(a.bonus)
let b = effects_at(1)
expect(b.delay == 20.0)
expect(b.label == "Sugar crash")
}
test "the copy a reader gets does not change the ring" {
fresh()
effects_add(LEGS, 3.0, 10.0, false, "a")
let e = effects_at(0)
e.mag = 100.0
expect(effects_sum(LEGS) == 3.0)
}
}

View file

@ -0,0 +1,80 @@
# verbs.ludic - the only way the ring changes. The same kind from the same label replaces
# itself; a bonus past the cap pushes out the oldest bonus; a full ring drops the nearest end.
function ef_end(i: int, why: int) -> void {
let all = ef_all()
let e = new EffectEnded
e.kind = all[i].kind
e.label = all[i].label
e.why = why
e.bonus = all[i].bonus
q_push(effects_ended(), e)
ef_drop(i)
}
function ef_drop(i: int) -> void {
let all = ef_all()
let rest = new []Effect
for k in 0 .. len(all) { if k != i { push(rest, all[k]) } }
ef_list = rest
}
# the one with the least time left (of the bonuses only, when asked); -1 when there is none
function ef_nearest_end(bonus_only: bool) -> int {
let all = ef_all()
var at = -1
for i in 0 .. len(all) {
if bonus_only and not all[i].bonus { continue }
if at < 0 or all[i].left < all[at].left { at = i }
}
return at
}
function ef_make_room(bonus: bool) -> void {
if bonus {
while effects_bonus_count() >= ef_bonus_max and ef_nearest_end(true) >= 0 { ef_end(ef_nearest_end(true), EFFECT_PUSHED_OUT) }
}
if len(ef_all()) >= ef_max and ef_nearest_end(false) >= 0 { ef_end(ef_nearest_end(false), EFFECT_CROWDED_OUT) }
}
# begin one `after` game minutes from now, lasting `minutes` once it has begun
export function effects_add_after(after: float, kind: int, mag: float, minutes: float, bonus: bool, label: string) -> void {
let all = ef_all()
for i in 0 .. len(all) {
if all[i].kind == kind and all[i].label == label {
all[i].mag = mag
all[i].left = minutes
return
}
}
ef_make_room(bonus)
let e = new Effect
e.kind = kind
e.mag = mag
e.left = minutes
e.delay = after
e.bonus = bonus
e.label = label
push(ef_all(), e)
}
export function effects_add(kind: int, mag: float, minutes: float, bonus: bool, label: string) -> void {
effects_add_after(0.0, kind, mag, minutes, bonus, label)
}
# everything stops at once, and says nothing: a new trip or a night's sleep
export function effects_clear() -> void { ef_list = new []Effect }
# run them down by `minutes` of game time; a delayed one waits first
export function effects_run(minutes: float) -> void {
var i = 0
while i < len(ef_all()) {
let e = ef_all()[i]
if e.delay > 0.0 {
e.delay = e.delay - minutes
i += 1
continue
}
e.left = e.left - minutes
if e.left < 0.0 { ef_end(i, EFFECT_RAN_OUT) } else { i += 1 }
}
}