From d0ad364ad4ccea76d114dc8c94f402d581e5250f Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Fri, 25 Sep 2026 04:14:09 +0300 Subject: [PATCH] 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 --- packages/ludic.base/README.md | 4 +- packages/ludic.base/queue.ludic | 2 +- packages/ludic.base/tests/queue_test.ludic | 10 +- .../ludic.base/tests/toys/fishing/index.ludic | 2 +- packages/ludic.effects/README.md | 44 ++++++ packages/ludic.effects/index.ludic | 9 ++ packages/ludic.effects/package.ludic | 5 + packages/ludic.effects/queries.ludic | 47 +++++++ packages/ludic.effects/state.ludic | 46 +++++++ packages/ludic.effects/system.ludic | 52 +++++++ .../ludic.effects/tests/effects_test.ludic | 127 ++++++++++++++++++ packages/ludic.effects/verbs.ludic | 80 +++++++++++ 12 files changed, 419 insertions(+), 9 deletions(-) create mode 100644 packages/ludic.effects/README.md create mode 100644 packages/ludic.effects/index.ludic create mode 100644 packages/ludic.effects/package.ludic create mode 100644 packages/ludic.effects/queries.ludic create mode 100644 packages/ludic.effects/state.ludic create mode 100644 packages/ludic.effects/system.ludic create mode 100644 packages/ludic.effects/tests/effects_test.ludic create mode 100644 packages/ludic.effects/verbs.ludic diff --git a/packages/ludic.base/README.md b/packages/ludic.base/README.md index e038793b..b9582b51 100644 --- a/packages/ludic.base/README.md +++ b/packages/ludic.base/README.md @@ -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`, `q_new(name)`, `q_push(q, v)`, `q_drain(q) -> []T`, `q_len(q)`, `q_clear(q)` | facts, first in first out | +| `Queue`, `queue_new(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 { diff --git a/packages/ludic.base/queue.ludic b/packages/ludic.base/queue.ludic index b4401cba..7c25d70b 100644 --- a/packages/ludic.base/queue.ludic +++ b/packages/ludic.base/queue.ludic @@ -16,7 +16,7 @@ function qb_init() -> void { } # a queue; the name is only for core_undrained -export function q_new(name: string) -> Queue { +export function queue_new(name: string) -> Queue { qb_init() let q = new Queue q.items = new []T diff --git a/packages/ludic.base/tests/queue_test.ludic b/packages/ludic.base/tests/queue_test.ludic index 20d2f82e..35bc4423 100644 --- a/packages/ludic.base/tests/queue_test.ludic +++ b/packages/ludic.base/tests/queue_test.ludic @@ -13,7 +13,7 @@ program QueueTest { } function fifo_case() -> void { - let q: Queue = q_new("ints") + let q: Queue = 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 = q_new("caught") + let q: Queue = 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 = q_new("words") + let q: Queue = queue_new("words") q_push(q, "a") q_clear(q) expect_eq(q_len(q), 0) } function undrained_case() -> void { - let a: Queue = q_new("left_behind") - let b: Queue = q_new("read") + let a: Queue = queue_new("left_behind") + let b: Queue = queue_new("read") q_push(a, 1) q_push(b, 2) q_drain(b) diff --git a/packages/ludic.base/tests/toys/fishing/index.ludic b/packages/ludic.base/tests/toys/fishing/index.ludic index 5871c3ef..78f84a1d 100644 --- a/packages/ludic.base/tests/toys/fishing/index.ludic +++ b/packages/ludic.base/tests/toys/fishing/index.ludic @@ -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 { diff --git a/packages/ludic.effects/README.md b/packages/ludic.effects/README.md new file mode 100644 index 00000000..596ce20c --- /dev/null +++ b/packages/ludic.effects/README.md @@ -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` | 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 +``` diff --git a/packages/ludic.effects/index.ludic b/packages/ludic.effects/index.ludic new file mode 100644 index 00000000..d65a10de --- /dev/null +++ b/packages/ludic.effects/index.ludic @@ -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" diff --git a/packages/ludic.effects/package.ludic b/packages/ludic.effects/package.ludic new file mode 100644 index 00000000..f5f8fa4d --- /dev/null +++ b/packages/ludic.effects/package.ludic @@ -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 diff --git a/packages/ludic.effects/queries.ludic b/packages/ludic.effects/queries.ludic new file mode 100644 index 00000000..2533775b --- /dev/null +++ b/packages/ludic.effects/queries.ludic @@ -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 +} diff --git a/packages/ludic.effects/state.ludic b/packages/ludic.effects/state.ludic new file mode 100644 index 00000000..0974b486 --- /dev/null +++ b/packages/ludic.effects/state.ludic @@ -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 = 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 { + 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 } diff --git a/packages/ludic.effects/system.ludic b/packages/ludic.effects/system.ludic new file mode 100644 index 00000000..38a74b0c --- /dev/null +++ b/packages/ludic.effects/system.ludic @@ -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 +} diff --git a/packages/ludic.effects/tests/effects_test.ludic b/packages/ludic.effects/tests/effects_test.ludic new file mode 100644 index 00000000..d9589670 --- /dev/null +++ b/packages/ludic.effects/tests/effects_test.ludic @@ -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) + } +} diff --git a/packages/ludic.effects/verbs.ludic b/packages/ludic.effects/verbs.ludic new file mode 100644 index 00000000..f1376329 --- /dev/null +++ b/packages/ludic.effects/verbs.ludic @@ -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 } + } +}