From 0af0ef681fb159d35a2fce0aa39660abaf706692 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Fri, 25 Sep 2026 06:08:53 +0300 Subject: [PATCH] ludic.fire: a camp fire's fuel, lit and out, the rain on it, and its reach Minutes of fuel that burn down in game time, twice as fast in the rain with nothing over the ring, and the hours it held under cover. Lighting and feeding are decided (fire_decide), costed in wood (fire_cost) and applied (fire_apply) apart, so a machine that does not own the world can ask the one that does. Warmth and cooking by distance are queries. FIRE_LIT, FIRE_WENT_OUT and FIRE_LOW_FUEL are facts; the rain, the tarp, the ban and who owns the world come through the FireWorld port. Its own save section. Uses ludic_base only; 8 tests. Co-Authored-By: Claude Opus 5.5 --- packages/ludic.fire/README.md | 46 +++++++ packages/ludic.fire/decide.ludic | 31 +++++ packages/ludic.fire/index.ludic | 10 ++ packages/ludic.fire/package.ludic | 5 + packages/ludic.fire/queries.ludic | 16 +++ packages/ludic.fire/state.ludic | 68 ++++++++++ packages/ludic.fire/system.ludic | 26 ++++ packages/ludic.fire/tests/fire_test.ludic | 145 ++++++++++++++++++++++ packages/ludic.fire/verbs.ludic | 55 ++++++++ 9 files changed, 402 insertions(+) create mode 100644 packages/ludic.fire/README.md create mode 100644 packages/ludic.fire/decide.ludic create mode 100644 packages/ludic.fire/index.ludic create mode 100644 packages/ludic.fire/package.ludic create mode 100644 packages/ludic.fire/queries.ludic create mode 100644 packages/ludic.fire/state.ludic create mode 100644 packages/ludic.fire/system.ludic create mode 100644 packages/ludic.fire/tests/fire_test.ludic create mode 100644 packages/ludic.fire/verbs.ludic diff --git a/packages/ludic.fire/README.md b/packages/ludic.fire/README.md new file mode 100644 index 00000000..f44585f6 --- /dev/null +++ b/packages/ludic.fire/README.md @@ -0,0 +1,46 @@ +# ludic.fire + +A camp fire: minutes of fuel that burn down in game time, twice as fast in the rain with nothing +over the ring, the hours it held under cover, the warmth it gives at a distance and whether it +will cook. Uses [`ludic.base`](../ludic.base/README.md) and nothing else. + +```ludic +import "ludic.fire" +``` + +The package never draws the fire, never says a line and never touches a pack: lighting and +feeding are three steps so a machine that does not own the world can ask the one that does - +`fire_decide(wood)` says what it would come to, `fire_cost(how)` what it costs in wood, and +`fire_apply(how)` does it (only on the owner). The game takes the wood and says the line. + +## The port + +```ludic +export port FireWorld { # every member has a default, so any may be left out + owns_world: fn() -> bool # does this machine burn it? (unbound: yes) + is_wet: fn() -> bool # rain or snow is falling on the ring (unbound: no) + sheltered: fn() -> bool # something is over the ring (unbound: no) + allowed: fn() -> bool # no fire ban (unbound: yes) +} +``` + +## API + +| | | +| --- | --- | +| `fire_config(light_wood, light_minutes, log_minutes, low_minutes)`, `fire_config_reach(warm, cook)` | the numbers (3 logs, 120 minutes, 60 a log, low under 15; warms within 3.6 m, cooks within 3.5 m) | +| `fire_decide(wood) -> int`, `fire_cost(how) -> int`, `fire_apply(how)`, `fire_light_wood()` | `FIRE_NO_WOOD`, `FIRE_NO_LOG`, `FIRE_BANNED`, `FIRE_SOAKED`, `FIRE_LIGHT`, `FIRE_FEED` | +| `fire_lit()`, `fire_fuel()`, `fire_low()`, `fire_rain_hours()` | the fire (fuel in game minutes) | +| `fire_warms(dist)`, `fire_cooks(dist)` | lit, and a body that close is warmed / can cook on it | +| `fire_run(gh)` | `gh` game hours of burning (the system's tick) | +| `fire_out()`, `fire_set_burning(fuel)`, `fire_set_rain_hours(h)` | quietly out, or burning, or the rain hours outright (the night, staging, tests) | +| `fire_told(lit, fuel, say)` | what the owner says the fire is; going out is a fact when `say` | +| `fire_morning()`, `fire_reset()` | the rain hours start again; a new trip | +| `fire_facts() -> Queue` | `{ what, fuel }`: `FIRE_LIT`, `FIRE_WENT_OUT`, `FIRE_LOW_FUEL` | +| `fire_system() -> System` | `"fire"`, `PH_SIMULATE`: reset, tick, and its save section (`fire_save`, `fire_load`: lit, fuel, the rain hours) | + +## Tests + +```bash +ludic test packages/ludic.fire +``` diff --git a/packages/ludic.fire/decide.ludic b/packages/ludic.fire/decide.ludic new file mode 100644 index 00000000..69250371 --- /dev/null +++ b/packages/ludic.fire/decide.ludic @@ -0,0 +1,31 @@ +# decide.ludic - lighting and feeding in three steps, so a machine that does not own the fire can +# ask the one that does: what it would come to (fire_decide), what it costs in wood +# (fire_cost), and doing it (fire_apply - only ever on the machine that owns the world) +export function fire_decide(wood: int) -> int { + if fr_lit { + if wood < 1 { return FIRE_NO_LOG } + return FIRE_FEED + } + if not FireWorld.allowed() { return FIRE_BANNED } + if wood < fr_light_wood { return FIRE_NO_WOOD } + if FireWorld.is_wet() and not FireWorld.sheltered() { return FIRE_SOAKED } + return FIRE_LIGHT +} + +export function fire_cost(how: int) -> int { + if how == FIRE_LIGHT { return fr_light_wood } + if how == FIRE_FEED { return 1 } + return 0 +} + +export function fire_apply(how: int) -> void { + if how == FIRE_FEED { fr_fuel = fr_fuel + fr_log_minutes } + if how == FIRE_LIGHT { + fr_lit = true + fr_fuel = fr_light_minutes + fr_fact(FIRE_LIT) + } +} + +# the logs it takes to light one +export function fire_light_wood() -> int { return fr_light_wood } diff --git a/packages/ludic.fire/index.ludic b/packages/ludic.fire/index.ludic new file mode 100644 index 00000000..aed199b0 --- /dev/null +++ b/packages/ludic.fire/index.ludic @@ -0,0 +1,10 @@ +# ludic.fire - one fire: minutes of fuel that burn down in game time, faster in the rain with +# nothing over it; lighting and feeding are decided, applied and paid for as three steps +module ludic_fire uses ludic_base +numbers float +import "ludic.base" +import "state.ludic" +import "decide.ludic" +import "queries.ludic" +import "verbs.ludic" +import "system.ludic" diff --git a/packages/ludic.fire/package.ludic b/packages/ludic.fire/package.ludic new file mode 100644 index 00000000..bf875647 --- /dev/null +++ b/packages/ludic.fire/package.ludic @@ -0,0 +1,5 @@ +# ludic.fire - a camp fire: its fuel, lit and out, how fast it burns, the rain on it, the warmth +# it gives at a distance and whether it will cook. Uses ludic.base and nothing else. +package "ludic.fire" +version "0.1.0" +kind source diff --git a/packages/ludic.fire/queries.ludic b/packages/ludic.fire/queries.ludic new file mode 100644 index 00000000..f9a7a4fc --- /dev/null +++ b/packages/ludic.fire/queries.ludic @@ -0,0 +1,16 @@ +# queries.ludic - what the world asks the fire +export function fire_lit() -> bool { return fr_lit } + +# game minutes of fuel left +export function fire_fuel() -> float { return fr_fuel } + +export function fire_low() -> bool { return fr_lit and fr_fuel < fr_low_minutes } + +# game hours today it has burned in the rain with something over it +export function fire_rain_hours() -> float { return fr_rain_h } + +# does it warm a body `dist` metres from it? +export function fire_warms(dist: float) -> bool { return fr_lit and dist < fr_warm_radius } + +# will it cook for someone `dist` metres from it? (a station that stands on it asks only fire_lit) +export function fire_cooks(dist: float) -> bool { return fr_lit and dist < fr_cook_radius } diff --git a/packages/ludic.fire/state.ludic b/packages/ludic.fire/state.ludic new file mode 100644 index 00000000..d5184146 --- /dev/null +++ b/packages/ludic.fire/state.ludic @@ -0,0 +1,68 @@ +# state.ludic - the port, the numbers a game may change, the facts, and what the fire holds +# what the fire asks the world. Unbound: this machine runs it, it is dry, there is nothing over +# it, and there is no ban. +export port FireWorld { + owns_world: fn() -> bool = fn fr_yes + is_wet: fn() -> bool = fn fr_no # rain or snow is falling on the ring + sheltered: fn() -> bool = fn fr_no # something is over the ring (a tarp) + allowed: fn() -> bool = fn fr_yes # no fire ban +} +function fr_yes() -> bool { return true } +function fr_no() -> bool { return false } + +var fr_light_wood: int = 3 # logs to light one +var fr_light_minutes: float = 120.0 # game minutes of fuel a new fire has +var fr_log_minutes: float = 60.0 # game minutes a log adds +var fr_low_minutes: float = 15.0 # under this, the fire is burning low +var fr_warm_radius: float = 3.6 # metres it warms a body +var fr_cook_radius: float = 3.5 # metres it cooks from + +# what lighting or feeding would come to, as the world stands +export const FIRE_NO_WOOD: int = 0 # not enough to light one +export const FIRE_NO_LOG: int = 1 # nothing to put on one already lit +export const FIRE_BANNED: int = 2 +export const FIRE_SOAKED: int = 3 # wet and nothing over the ring +export const FIRE_LIGHT: int = 4 +export const FIRE_FEED: int = 5 + +# what happened +export const FIRE_LIT: int = 0 +export const FIRE_WENT_OUT: int = 1 +export const FIRE_LOW_FUEL: int = 2 + +export property FireFact { + what: int = 0 + fuel: float = 0.0 +} + +var fr_lit: bool = false +var fr_fuel: float = 0.0 # game minutes left +var fr_rain_h: float = 0.0 # game hours today it burned in the rain under cover +var fr_facts: Queue = null + +# the facts, oldest first; the game drains them into its notices +export function fire_facts() -> Queue { + if fr_facts == null { fr_facts = queue_new("fire.facts") } + return fr_facts +} + +function fr_fact(what: int) -> void { + let f = new FireFact + f.what = what + f.fuel = fr_fuel + q_push(fire_facts(), f) +} + +# a game's numbers: logs to light, minutes a new fire and a log are worth, when it is low +export function fire_config(light_wood: int, light_minutes: float, log_minutes: float, low_minutes: float) -> void { + fr_light_wood = light_wood + fr_light_minutes = light_minutes + fr_log_minutes = log_minutes + fr_low_minutes = low_minutes +} + +# how far it warms a body and how far it cooks from, in metres +export function fire_config_reach(warm: float, cook: float) -> void { + fr_warm_radius = warm + fr_cook_radius = cook +} diff --git a/packages/ludic.fire/system.ludic b/packages/ludic.fire/system.ludic new file mode 100644 index 00000000..e6dd92e1 --- /dev/null +++ b/packages/ludic.fire/system.ludic @@ -0,0 +1,26 @@ +# system.ludic - the fire as a system: PH_SIMULATE, and its own save section +function fire_tick(t: Tick) -> void { fire_run(t.hours) } + +export function fire_save() -> Val { + let v = Value.object() + sv_put_bool(v, "lit", fr_lit) + sv_put_float(v, "fuel", fr_fuel) + sv_put_float(v, "rain_h", fr_rain_h) + return v +} + +export function fire_load(v: Val, version: int) -> void { + fire_reset() + fr_lit = sv_bool(v, "lit", false) + fr_fuel = sv_float(v, "fuel", 0.0) + fr_rain_h = sv_float(v, "rain_h", 0.0) +} + +export function fire_system() -> System { + let s = system_new("fire", PH_SIMULATE) + s.reset = fn fire_reset + s.tick = fn fire_tick + s.save = fn fire_save + s.load = fn fire_load + return s +} diff --git a/packages/ludic.fire/tests/fire_test.ludic b/packages/ludic.fire/tests/fire_test.ludic new file mode 100644 index 00000000..dee2078d --- /dev/null +++ b/packages/ludic.fire/tests/fire_test.ludic @@ -0,0 +1,145 @@ +# fire_test.ludic - lighting and feeding decided, paid for and applied; burning down in game time, +# faster in the rain; low fuel and going out as facts; warmth and cooking by distance; the save +import "ludic.fire" +import "ludic.base" +program FireTest { + numbers float + var owns: bool = true + var wet: bool = false + var tarp: bool = false + var ban: bool = false + + function fake_owns() -> bool { return owns } + function fake_wet() -> bool { return wet } + function fake_tarp() -> bool { return tarp } + function fake_allowed() -> bool { return not ban } + + bind FireWorld { + owns_world: fn fake_owns + is_wet: fn fake_wet + sheltered: fn fake_tarp + allowed: fn fake_allowed + } + + function fresh() -> void { + fire_config(3, 120.0, 60.0, 15.0) + fire_config_reach(3.6, 3.5) + fire_reset() + } + + function facts() -> []FireFact { return q_drain(fire_facts()) } + function count(fs: []FireFact, what: int) -> int { + var n = 0 + for i in 0 .. len(fs) { if fs[i].what == what { n += 1 } } + return n + } + + function light() -> void { fire_apply(fire_decide(3)) } + + test "three logs light it, and that is a fact" { + fresh() + expect_eq(fire_decide(2), FIRE_NO_WOOD) + expect_eq(fire_decide(3), FIRE_LIGHT) + expect_eq(fire_cost(FIRE_LIGHT), 3) + light() + expect(fire_lit()) + expect(fire_fuel() == 120.0) + expect_eq(count(facts(), FIRE_LIT), 1) + } + + test "a lit fire takes a log for an hour" { + fresh() + light() + expect_eq(fire_decide(0), FIRE_NO_LOG) + expect_eq(fire_decide(1), FIRE_FEED) + expect_eq(fire_cost(FIRE_FEED), 1) + fire_apply(FIRE_FEED) + expect(fire_fuel() == 180.0) + } + + test "a ban and a soaked ring refuse, a tarp keeps it dry" { + fresh() + ban = true + expect_eq(fire_decide(5), FIRE_BANNED) + ban = false + wet = true + expect_eq(fire_decide(5), FIRE_SOAKED) + expect_eq(fire_cost(FIRE_SOAKED), 0) + tarp = true + expect_eq(fire_decide(5), FIRE_LIGHT) + } + + test "it burns a minute a minute, runs low, and goes out as facts" { + fresh() + light() + facts() + fire_run(1.5) + expect(fire_fuel() == 30.0) + expect(not fire_low()) + fire_run(0.3) + expect(fire_low()) + expect_eq(count(facts(), FIRE_LOW_FUEL), 1) + fire_run(0.5) + expect(not fire_lit()) + expect(fire_fuel() == 0.0) + expect_eq(count(facts(), FIRE_WENT_OUT), 1) + } + + test "the rain burns it twice as fast, and under a tarp it counts the hours" { + fresh() + light() + wet = true + fire_run(0.5) + expect(fire_fuel() == 60.0) + tarp = true + fire_run(0.5) + expect(fire_fuel() == 30.0) + expect(fire_rain_hours() == 0.5) + fire_morning() + expect(fire_rain_hours() == 0.0) + } + + test "a machine that does not own the world burns nothing, but is told" { + fresh() + light() + owns = false + fire_run(5.0) + expect(fire_fuel() == 120.0) + facts() + fire_told(false, 0.0, true) + expect(not fire_lit()) + expect_eq(count(facts(), FIRE_WENT_OUT), 1) + fire_told(true, 50.0, true) + fire_told(false, 0.0, false) + expect_eq(q_len(fire_facts()), 0) + } + + test "it warms and cooks close, and only while lit" { + fresh() + expect(not fire_warms(1.0)) + light() + expect(fire_warms(3.5)) + expect(not fire_warms(3.7)) + expect(fire_cooks(3.4)) + expect(not fire_cooks(3.55)) + fire_out() + expect(not fire_cooks(0.0)) + } + + test "a save section reads back the same fire" { + fresh() + light() + wet = true + tarp = true + fire_run(0.25) + let root = save_tree() + save_section(root, "fire", 1, fire_save()) + let text = save_encode(root) + fire_reset() + let n = load_section(save_decode(text), "fire") + fire_load(n.data, n.version) + expect(fire_lit()) + expect(fire_fuel() == 105.0) + expect(fire_rain_hours() == 0.25) + } +} diff --git a/packages/ludic.fire/verbs.ludic b/packages/ludic.fire/verbs.ludic new file mode 100644 index 00000000..22d48dfa --- /dev/null +++ b/packages/ludic.fire/verbs.ludic @@ -0,0 +1,55 @@ +# verbs.ludic - the fire burning down, and the only other ways it changes +function fr_out() -> void { + fr_lit = false + fr_fuel = 0.0 + fr_fact(FIRE_WENT_OUT) +} + +# `gh` game hours of burning; in the rain it burns twice as fast with nothing over the ring, +# and under cover it counts the hours it held. Only the machine that owns the world burns it. +export function fire_run(gh: float) -> void { + if not fr_lit or not FireWorld.owns_world() { return } + let was = fr_fuel + fr_fuel = fr_fuel - gh * 60.0 + if FireWorld.is_wet() { + if FireWorld.sheltered() { fr_rain_h = fr_rain_h + gh } else { fr_fuel = fr_fuel - gh * 60.0 } + } + if fr_fuel < 0.0 { + fr_out() + return + } + if was >= fr_low_minutes and fr_fuel < fr_low_minutes { fr_fact(FIRE_LOW_FUEL) } +} + +# put out, and nothing said (the night burns it down) +export function fire_out() -> void { + fr_lit = false + fr_fuel = 0.0 +} + +# burning with `fuel` minutes, and nothing said (staging, a test) +export function fire_set_burning(fuel: float) -> void { + fr_lit = true + fr_fuel = fuel +} + +# what the machine that owns the world says the fire is; going out is a fact when `say` +export function fire_told(lit: bool, fuel: float, say: bool) -> void { + let was = fr_lit + fr_lit = lit + fr_fuel = fuel + if was and not lit and say { fr_fact(FIRE_WENT_OUT) } +} + +# the rain hours outright (staging, a test) +export function fire_set_rain_hours(h: float) -> void { fr_rain_h = h } + +# a new day: the rain hours start again +export function fire_morning() -> void { fr_rain_h = 0.0 } + +export function fire_reset() -> void { + fr_lit = false + fr_fuel = 0.0 + fr_rain_h = 0.0 + q_clear(fire_facts()) +}