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 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 06:08:53 +03:00
parent cf42214974
commit 0af0ef681f
9 changed files with 402 additions and 0 deletions

View file

@ -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<FireFact>` | `{ 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
```

View file

@ -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 }

View file

@ -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"

View file

@ -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

View file

@ -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 }

View file

@ -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<FireFact> = null
# the facts, oldest first; the game drains them into its notices
export function fire_facts() -> Queue<FireFact> {
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
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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())
}