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