diff --git a/packages/ludic.weather/README.md b/packages/ludic.weather/README.md new file mode 100644 index 00000000..ede40095 --- /dev/null +++ b/packages/ludic.weather/README.md @@ -0,0 +1,80 @@ +# ludic.weather + +The sky as a chain of spells: clear, cloud, rain, a storm, snow - or whatever kinds the game +hands in. Each spell lasts a few game hours, eases in and out at its edges, sets the wind and +wets the ground, and gives way to the next by odds the game may lean on (a season, a wet week). +A forecast is drawn each morning, and the story can ask for a front or for a clear night. Uses +[`ludic.base`](../ludic.base/README.md) and nothing else. + +```ludic +import "ludic.weather" +``` + +A kind is the game's number: its place in the list handed to `weather_kinds`. The package knows +what a kind DOES (it is wet, it throws lightning, it holds the cold) and never what it is called +in a sentence; the game draws the sky and says the notices. + +Only the machine that owns the world runs the spells. Every machine eases the ground's wetness +and throws its own lightning out of a storm it is told of (`weather_told`), from the package's +own dice - never the world's. + +## The kinds + +```ludic +property WeatherKind { + name: string # for weather_name + clear: bool # the kind a sky eases out to (amount 0); the first one starts a trip + wet: bool # rain or snow is falling (weather_wet) + soak: float # how wet the ground gets under it, 0..1 + overcast: float # times the amount: weather_overcast + cold: float # times the amount: weather_cold, in the game's units + wind: float # the strength the wind builds toward + wind_dir: float # radians the wind blows toward; below 0, the valley's daily turn + thunder: bool # a storm: lightning every 9-34 s while the amount is over a half + spell_lo, spell_hi # game hours a spell lasts + odds: []float # weights for the next spell, one per kind +} +``` + +## The port + +```ludic +property WeatherWorld { + owns_world: fn() -> bool # does this machine run the world? (unbound: yes) + hours: fn() -> float # the hour, for the wind's daily turn (unbound: noon) + is_night: fn() -> bool # a front clearing at night is no WEATHER_CLEARED + odds: fn(int) -> []float # the next spell's weights from a kind (unbound: the kind's own) + bias: fn() -> int # what the story wants now: WEATHER_NO_BIAS, _WANT_WET, _HOLD, _WANT_CLEAR + front: fn() -> int # the kind a wanted front comes as (unbound: the first wet kind) + front_hours: fn() -> float # the least a wanted front lasts (never under 5) + seed: fn() -> int # a new trip's dice +} +``` + +The bias is asked every tick on the owner. `WANT_WET` on a dry sky forecasts a front +(`WEATHER_FRONT`), due within an hour and a half; `HOLD` keeps the current spell going at least +1.2 hours more; `WANT_CLEAR` clears within half an hour and keeps it clear. The game decides WHEN +it wants them (daylight, a tarp up, a stars job open). + +## API + +| | | +| --- | --- | +| `weather_bind(w)`, `weather_kinds(ks)`, `weather_config_wind(down, up, from, to)` | the port, the kinds, and the valley's wind: toward `down` by night, toward `up` from `from` to `to` | +| `weather_run(dt, gh)` | one step (the system's tick does this from `Tick.dt` and `Tick.hours`) | +| `weather_morning()` | a new day's forecast: seven times in ten it becomes the next spell | +| `weather_reset()` | a new trip: the first clear kind, nothing forecast, dice from the seed | +| `weather_set(kind, next, left)`, `weather_set_wind(s, dir)`, `weather_set_ground(w)`, `weather_set_flash(f)` | the sky outright (staging, tests) | +| `weather_told(kind, next, left, amount, wind, dir, forecast)` | what the owner says the sky is; a new spell is a fact here too | +| `weather_state()`, `weather_next()`, `weather_left()`, `weather_amount()`, `weather_forecast()`, `weather_forced()` | the spell | +| `weather_wet()`, `weather_overcast()`, `weather_cold()`, `weather_wind()`, `weather_dir()`, `weather_ground_wet()`, `weather_flash()` | what it does | +| `weather_after_front()`, `weather_wet_yesterday()` | the half hour of clear light after a front; whether yesterday had rain or snow in it | +| `weather_kind(k)`, `weather_kind_count()`, `weather_name(k)`, `weather_draw_from(w, from, roll)` | the kinds, and the draw itself | +| `weather_facts() -> Queue` | `{ what, kind, from }`: `WEATHER_BEGAN` (a new spell), `WEATHER_CLEARED` (a wet spell gave way to clear sky by day), `WEATHER_THUNDER`, `WEATHER_FRONT` (a wanted front is forecast) | +| `weather_system() -> System` | `"weather"`, `PH_SIMULATE`: reset, tick, and its save section (`weather_save`, `weather_load`: the spell, the forecast, the wind's direction, the ground, the dice) | + +## Tests + +```bash +ludic build packages/ludic.weather/tests/weather_test.ludic --headless -o /tmp/weather_test && /tmp/weather_test +``` diff --git a/packages/ludic.weather/bias.ludic b/packages/ludic.weather/bias.ludic new file mode 100644 index 00000000..71d8f81a --- /dev/null +++ b/packages/ludic.weather/bias.ludic @@ -0,0 +1,43 @@ +# bias.ludic - the story leaning on the sky. Weather is a chain of chances, and a goal that needs +# rain could wait a week for it; so while the world says it wants a front, one is forecast and +# arrives within the hour and a half, and while it wants clear sky, the sky clears and holds. +function wx_front_kind() -> int { + if wx_world != null and wx_world.front != null { return wx_world.front() } + return wx_first_wet() +} + +function wx_want_wet() -> void { + if wx_kinds[wx_state].wet or wx_forced { return } + let front = wx_front_kind() + wx_next = front + wx_forecast = front + wx_forced = true + if wx_left > 1.5 { wx_left = 1.5 } + wx_fact(WEATHER_FRONT, front, wx_state) +} + +function wx_hold() -> void { + if wx_left < 1.2 { + wx_left = 1.2 + wx_next = wx_state + } +} + +function wx_want_clear() -> void { + let clear = wx_clear_kind() + if wx_state != clear and not wx_forced { + if wx_left > 0.5 { wx_left = 0.5 } + wx_next = clear + } + if wx_state == clear and wx_left < 1.0 { + wx_left = 1.0 + wx_next = clear + } +} + +function wx_apply_bias() -> void { + let b = wx_bias() + if b == WEATHER_WANT_WET { wx_want_wet() } + if b == WEATHER_HOLD { wx_hold() } + if b == WEATHER_WANT_CLEAR { wx_want_clear() } +} diff --git a/packages/ludic.weather/config.ludic b/packages/ludic.weather/config.ludic new file mode 100644 index 00000000..23631d03 --- /dev/null +++ b/packages/ludic.weather/config.ludic @@ -0,0 +1,60 @@ +# config.ludic - the kinds the game hands in, the wind's daily turn, and the port's defaults +var wx_down_dir: float = 0.0 # the wind overnight and into the morning +var wx_up_dir: float = 3.1415927 # the wind while the slopes are warm +var wx_up_from: float = 10.0 +var wx_up_to: float = 19.0 + +# the kinds, in the game's order; the first `clear` one is where a new trip starts +export function weather_kinds(kinds: []WeatherKind) -> void { wx_kinds = kinds } + +export function weather_kind_count() -> int { + if wx_kinds == null { return 0 } + return len(wx_kinds) +} + +export function weather_kind(k: int) -> WeatherKind { return wx_kinds[k] } + +# a valley's wind: down it by night, up it from `up_from` to `up_to` while the slopes are warm +export function weather_config_wind(down_dir: float, up_dir: float, up_from: float, up_to: float) -> void { + wx_down_dir = down_dir + wx_up_dir = up_dir + wx_up_from = up_from + wx_up_to = up_to +} + +function wx_clear_kind() -> int { + for k in 0 .. weather_kind_count() { if wx_kinds[k].clear { return k } } + return 0 +} + +function wx_first_wet() -> int { + for k in 0 .. weather_kind_count() { if wx_kinds[k].wet { return k } } + return 0 +} + +function wx_owns() -> bool { + if wx_world == null or wx_world.owns_world == null { return true } + return wx_world.owns_world() +} + +function wx_hours() -> float { + if wx_world == null or wx_world.hours == null { return 12.0 } + return wx_world.hours() +} + +function wx_night() -> bool { + if wx_world == null or wx_world.is_night == null { return false } + return wx_world.is_night() +} + +function wx_bias() -> int { + if wx_world == null or wx_world.bias == null { return WEATHER_NO_BIAS } + return wx_world.bias() +} + +function wx_wrap(a: float) -> float { + var d = a + while d > 3.1415927 { d = d - 6.2831853 } + while d < -3.1415927 { d = d + 6.2831853 } + return d +} diff --git a/packages/ludic.weather/index.ludic b/packages/ludic.weather/index.ludic new file mode 100644 index 00000000..a1740237 --- /dev/null +++ b/packages/ludic.weather/index.ludic @@ -0,0 +1,13 @@ +# ludic.weather - the sky as a chain of spells: each kind is data the game hands in, the next is +# drawn from odds the game may lean on, and what changed is a fact on a queue +module ludic_weather +numbers float +import "ludic.base" +import "state.ludic" +import "config.ludic" +import "pick.ludic" +import "run.ludic" +import "bias.ludic" +import "queries.ludic" +import "verbs.ludic" +import "system.ludic" diff --git a/packages/ludic.weather/package.ludic b/packages/ludic.weather/package.ludic new file mode 100644 index 00000000..248d724d --- /dev/null +++ b/packages/ludic.weather/package.ludic @@ -0,0 +1,5 @@ +# ludic.weather - spells of weather the game describes as data: odds, wetness, wind, storms and a +# forecast, leaning on the story when asked. Uses ludic.base and nothing else. See README.md. +package "ludic.weather" +version "0.1.0" +kind source diff --git a/packages/ludic.weather/pick.ludic b/packages/ludic.weather/pick.ludic new file mode 100644 index 00000000..28745854 --- /dev/null +++ b/packages/ludic.weather/pick.ludic @@ -0,0 +1,31 @@ +# pick.ludic - the next spell, drawn from weights, and how long one lasts. The weights are the +# kind's own unless the world leans on them (a season, a wet week); every draw is this package's +function wx_odds(from: int) -> []float { + if wx_world != null and wx_world.odds != null { return wx_world.odds(from) } + return wx_kinds[from].odds +} + +# the kind a set of weights lands on; no weights (or all zero) keeps the kind it came from +export function weather_draw_from(w: []float, from: int, roll: float) -> int { + if w == null { return from } + var total = 0.0 + for k in 0 .. len(w) { if w[k] > 0.0 { total = total + w[k] } } + if not (total > 0.0) { return from } + var at = roll * total + for k in 0 .. len(w) { + if w[k] > 0.0 { + if at < w[k] { return k } + at = at - w[k] + } + } + return len(w) - 1 +} + +function wx_pick_next(from: int) -> int { + return weather_draw_from(wx_odds(from), from, rng_float(wx_dice)) +} + +function wx_spell_hours(k: int) -> float { + let kd = wx_kinds[k] + return rng_span(wx_dice, kd.spell_lo, kd.spell_hi) +} diff --git a/packages/ludic.weather/queries.ludic b/packages/ludic.weather/queries.ludic new file mode 100644 index 00000000..e221eb09 --- /dev/null +++ b/packages/ludic.weather/queries.ludic @@ -0,0 +1,38 @@ +# queries.ludic - what the world asks the sky +export function weather_state() -> int { return wx_state } +export function weather_next() -> int { return wx_next } +export function weather_left() -> float { return wx_left } +export function weather_amount() -> float { return wx_amount } +export function weather_forecast() -> int { return wx_forecast } +export function weather_forced() -> bool { return wx_forced } +export function weather_wind() -> float { return wx_wind } +export function weather_dir() -> float { return wx_dir } +export function weather_ground_wet() -> float { return wx_ground } +export function weather_flash() -> float { return wx_flash } + +# rain or snow is falling, and more than a spit of it +export function weather_wet() -> bool { + if weather_kind_count() == 0 { return false } + return wx_kinds[wx_state].wet and wx_amount > 0.3 +} + +export function weather_overcast() -> float { + if weather_kind_count() == 0 { return 0.0 } + return wx_kinds[wx_state].overcast * wx_amount +} + +export function weather_cold() -> float { + if weather_kind_count() == 0 { return 0.0 } + return wx_kinds[wx_state].cold * wx_amount +} + +export function weather_name(k: int) -> string { + if k < 0 or k >= weather_kind_count() { return "" } + return wx_kinds[k].name +} + +# the half hour after a front goes through, when the air is glass-clear +export function weather_after_front() -> bool { return wx_clear_t > 0.0 } + +# yesterday had rain or snow in it (the valley's fog pools after a wet day) +export function weather_wet_yesterday() -> bool { return wx_wet_yesterday } diff --git a/packages/ludic.weather/run.ludic b/packages/ludic.weather/run.ludic new file mode 100644 index 00000000..e8918fdc --- /dev/null +++ b/packages/ludic.weather/run.ludic @@ -0,0 +1,76 @@ +# run.ludic - the sky moving on. Only the machine that owns the world runs the spells; every +# machine eases the ground's wetness and makes its own lightning out of a storm it is told of. +function wx_turn() -> void { + let was = wx_state + wx_state = wx_next + wx_next = wx_pick_next(wx_state) + wx_left = wx_spell_hours(wx_state) + if wx_forced { + wx_forced = false + var least = 5.0 + if wx_world != null and wx_world.front_hours != null { least = Math.max(least, wx_world.front_hours()) } + if wx_left < least { wx_left = least } + } + if wx_state == was { return } + # a front going through and leaving clear sky behind it: the light people get up for + if wx_kinds[wx_state].clear and wx_kinds[was].wet and not wx_night() { + wx_clear_t = 0.6 + wx_fact(WEATHER_CLEARED, wx_state, was) + } + wx_fact(WEATHER_BEGAN, wx_state, was) +} + +function wx_wind_tick(dt: float) -> void { + let kd = wx_kinds[wx_state] + wx_wind = wx_wind + (kd.wind - wx_wind) * Math.min(1.0, dt * 0.3) + var want = kd.wind_dir + if want < 0.0 { + let h = wx_hours() + want = wx_down_dir + if h > wx_up_from and h < wx_up_to { want = wx_up_dir } + } + let r = dt * 0.12 + wx_dir = wx_wrap(wx_dir + Math.clamp(wx_wrap(want - wx_dir), -r, r)) +} + +# the spells: the story's lean, the clock running down, the edges eased, the wind +function wx_spells(dt: float, gh: float) -> void { + wx_apply_bias() + wx_left = wx_left - gh + if wx_left < 0.0 { wx_turn() } + var want = 1.0 + if wx_kinds[wx_state].clear { want = 0.0 } + if wx_left < 0.5 and wx_kinds[wx_next].clear { want = 0.0 } + wx_amount = wx_amount + (want - wx_amount) * Math.min(1.0, dt * 0.5) + wx_wind_tick(dt) + if wx_clear_t > 0.0 { wx_clear_t = wx_clear_t - gh } +} + +# soaking is faster than drying: a downpour wets the ground in under a minute, and it takes +# several to give it back +function wx_ground_tick(dt: float) -> void { + let want = wx_kinds[wx_state].soak * wx_amount + var k = dt * 0.10 + if want < wx_ground { k = dt * 0.020 } + wx_ground = wx_ground + (want - wx_ground) * Math.clamp(k, 0.0, 1.0) +} + +function wx_storm_tick(dt: float) -> void { + wx_flash = wx_flash * Math.max(0.0, 1.0 - dt * 9.0) + if not wx_kinds[wx_state].thunder or wx_amount < 0.5 { return } + wx_thunder_t = wx_thunder_t - dt + if wx_thunder_t < 0.0 { + wx_thunder_t = rng_span(wx_dice, 9.0, 34.0) + wx_flash = 1.0 + wx_fact(WEATHER_THUNDER, wx_state, wx_state) + } +} + +# one step: `dt` real seconds and `gh` game hours +export function weather_run(dt: float, gh: float) -> void { + if weather_kind_count() == 0 { return } + if wx_owns() { wx_spells(dt, gh) } + wx_ground_tick(dt) + wx_storm_tick(dt) + if weather_wet() { wx_wet_today = true } +} diff --git a/packages/ludic.weather/state.ludic b/packages/ludic.weather/state.ludic new file mode 100644 index 00000000..7a4e8911 --- /dev/null +++ b/packages/ludic.weather/state.ludic @@ -0,0 +1,81 @@ +# state.ludic - the port, the kinds, the facts, and what the sky holds +# what the weather asks the world. Any field may be left null: the answer is then "yes, it is +# mine", noon and daylight, the kind's own odds, no bias, the first wet kind, and a seed of 1. +export property WeatherWorld { + owns_world: fn() -> bool = null + hours: fn() -> float = null + is_night: fn() -> bool = null + odds: fn(int) -> []float = null # weights for the next spell, per kind, from this kind + bias: fn() -> int = null # what the story wants now: WEATHER_* below + front: fn() -> int = null # the kind a wanted front comes as + front_hours: fn() -> float = null # the least a wanted front lasts once it arrives + seed: fn() -> int = null +} + +# what the story may ask of the sky +export const WEATHER_NO_BIAS: int = 0 +export const WEATHER_WANT_WET: int = 1 # send a front, soon +export const WEATHER_HOLD: int = 2 # keep this spell going a while yet +export const WEATHER_WANT_CLEAR: int = 3 # clear the sky and keep it clear + +# one kind of weather; its place in the list the game hands in is its number +export property WeatherKind { + name: string = "" + clear: bool = false # the kind a sky eases out to: nothing falls, amount goes to 0 + wet: bool = false # rain or snow is falling (weather_wet) + soak: float = 0.0 # how wet the ground gets under it, 0..1 + overcast: float = 0.0 + cold: float = 0.0 # extra cold per game hour, in the game's units + wind: float = 0.2 # the strength the wind builds toward, 0..1 + wind_dir: float = -1.0 # radians the wind blows toward; below 0, the valley's daily turn + thunder: bool = false + spell_lo: float = 2.0 # game hours a spell lasts, drawn in [lo, hi) + spell_hi: float = 8.0 + odds: []float = null # weights for the next spell, per kind +} + +export const WEATHER_BEGAN: int = 0 # a spell began (kind, from) +export const WEATHER_CLEARED: int = 1 # a wet spell gave way to clear sky in daylight +export const WEATHER_THUNDER: int = 2 # a lightning strike, and its thunder +export const WEATHER_FRONT: int = 3 # a front the story asked for is forecast (kind) + +export property WeatherFact { + what: int = 0 + kind: int = 0 + from: int = 0 +} + +var wx_world: WeatherWorld = null +var wx_kinds: []WeatherKind = null +var wx_state: int = 0 +var wx_next: int = 0 +var wx_left: float = 0.0 # game hours left in this spell +var wx_amount: float = 0.0 # 0..1, how far in (eases at the edges) +var wx_forecast: int = 0 +var wx_forced: bool = false # the next spell is a front the story asked for +var wx_wind: float = 0.2 +var wx_dir: float = 0.0 # radians the wind blows toward +var wx_ground: float = 0.0 # 0 dry .. 1 soaked +var wx_flash: float = 0.0 +var wx_thunder_t: float = 0.0 # real seconds to the next strike +var wx_clear_t: float = 0.0 # game hours of after-the-front light left +var wx_wet_today: bool = false +var wx_wet_yesterday: bool = false +var wx_dice: Rng = null +var wx_facts: Queue = null + +export function weather_bind(w: WeatherWorld) -> void { wx_world = w } + +# the facts, oldest first; the game drains them into its notices and sounds +export function weather_facts() -> Queue { + if wx_facts == null { wx_facts = queue_new("weather.facts") } + return wx_facts +} + +function wx_fact(what: int, kind: int, from: int) -> void { + let f = new WeatherFact + f.what = what + f.kind = kind + f.from = from + q_push(weather_facts(), f) +} diff --git a/packages/ludic.weather/system.ludic b/packages/ludic.weather/system.ludic new file mode 100644 index 00000000..1e109362 --- /dev/null +++ b/packages/ludic.weather/system.ludic @@ -0,0 +1,50 @@ +# system.ludic - the weather as a system: PH_SIMULATE, and its own save section +function weather_tick(t: Tick) -> void { weather_run(t.dt, t.hours) } + +export function weather_save() -> Val { + let v = Value.object() + sv_put_int(v, "state", wx_state) + sv_put_int(v, "next", wx_next) + sv_put_float(v, "left", wx_left) + sv_put_int(v, "forecast", wx_forecast) + sv_put_bool(v, "forced", wx_forced) + sv_put_float(v, "dir", wx_dir) + sv_put_float(v, "ground", wx_ground) + sv_put_float(v, "clear_t", wx_clear_t) + sv_put_bool(v, "wet_today", wx_wet_today) + sv_put_bool(v, "wet_yesterday", wx_wet_yesterday) + sv_put_int(v, "dice", wx_dice.s) + return v +} + +function wx_kind_ok(k: int) -> int { + if k < 0 or k >= weather_kind_count() { return wx_clear_kind() } + return k +} + +export function weather_load(v: Val, version: int) -> void { + weather_reset() + wx_state = wx_kind_ok(sv_int(v, "state", 0)) + wx_next = wx_kind_ok(sv_int(v, "next", 0)) + wx_left = sv_float(v, "left", 10.0) + wx_forecast = wx_kind_ok(sv_int(v, "forecast", 0)) + wx_forced = sv_bool(v, "forced", false) + wx_dir = sv_float(v, "dir", 0.0) + wx_ground = sv_float(v, "ground", 0.0) + wx_clear_t = sv_float(v, "clear_t", 0.0) + wx_wet_today = sv_bool(v, "wet_today", false) + wx_wet_yesterday = sv_bool(v, "wet_yesterday", false) + wx_dice.s = sv_int(v, "dice", wx_dice.s) + if weather_kind_count() == 0 { return } + if not wx_kinds[wx_state].clear { wx_amount = 1.0 } + wx_wind = wx_kinds[wx_state].wind +} + +export function weather_system() -> System { + let s = system_new("weather", PH_SIMULATE) + s.reset = fn weather_reset + s.tick = fn weather_tick + s.save = fn weather_save + s.load = fn weather_load + return s +} diff --git a/packages/ludic.weather/tests/weather_test.ludic b/packages/ludic.weather/tests/weather_test.ludic new file mode 100644 index 00000000..03fa386e --- /dev/null +++ b/packages/ludic.weather/tests/weather_test.ludic @@ -0,0 +1,201 @@ +# weather_test.ludic - spells change on their clock and by the kinds' odds, the edges ease, the +# story leans when asked, only the owner runs the spells, and the save reads back +import "ludic.weather" +import "ludic.base" +program WeatherTest { + numbers float + var owns: bool = true + var hours: float = 12.0 + var bias: int = 0 + var lean: []float = null + + function fake_owns() -> bool { return owns } + function fake_hours() -> float { return hours } + function fake_night() -> bool { return hours < 6.0 or hours > 20.0 } + function fake_bias() -> int { return bias } + function fake_seed() -> int { return 7 } + function fake_front_hours() -> float { return 6.0 } + function fake_odds(from: int) -> []float { + if lean != null { return lean } + return weather_kind(from).odds + } + + function odds3(a: float, b: float, c: float) -> []float { + let w = new []float + push(w, a) + push(w, b) + push(w, c) + return w + } + + function kind(name: string, wet: bool, thunder: bool, odds: []float) -> WeatherKind { + let k = new WeatherKind + k.name = name + k.clear = not wet + k.wet = wet + k.thunder = thunder + k.odds = odds + if wet { + k.soak = 1.0 + k.overcast = 0.8 + k.cold = 5.0 + k.wind = 1.0 + } + k.spell_lo = 1.0 + k.spell_hi = 3.0 + return k + } + + # clear, rain and storm: clear always goes to rain, rain to storm, storm to clear + function fresh() -> void { + owns = true + hours = 12.0 + bias = WEATHER_NO_BIAS + lean = null + let ks = new []WeatherKind + push(ks, kind("clear", false, false, odds3(0.0, 1.0, 0.0))) + push(ks, kind("rain", true, false, odds3(0.0, 0.0, 1.0))) + push(ks, kind("storm", true, true, odds3(1.0, 0.0, 0.0))) + weather_kinds(ks) + let w = new WeatherWorld + w.owns_world = fn fake_owns + w.hours = fn fake_hours + w.is_night = fn fake_night + w.bias = fn fake_bias + w.seed = fn fake_seed + w.odds = fn fake_odds + w.front_hours = fn fake_front_hours + weather_bind(w) + weather_config_wind(0.5, 2.5, 10.0, 19.0) + weather_reset() + } + + function facts() -> []WeatherFact { return q_drain(weather_facts()) } + function step(dt: float, gh: float, n: int) -> void { for i in 0 .. n { weather_run(dt, gh) } } + function count(fs: []WeatherFact, what: int) -> int { + var n = 0 + for i in 0 .. len(fs) { if fs[i].what == what { n += 1 } } + return n + } + + test "a new trip is clear, dry and forecast clear" { + fresh() + expect_eq(weather_state(), 0) + expect_eq(weather_forecast(), 0) + expect(not weather_wet()) + expect(weather_cold() == 0.0) + } + + test "a spell that runs out gives way to the next, as a fact" { + fresh() + weather_set(0, 1, 0.1) + step(0.1, 0.2, 1) + expect_eq(weather_state(), 1) + expect_eq(weather_next(), 2) + let fs = facts() + expect_eq(count(fs, WEATHER_BEGAN), 1) + expect(weather_left() >= 1.0 and weather_left() < 3.0) + } + + test "the amount eases in, and wet follows it" { + fresh() + weather_set(0, 1, 0.01) + step(0.1, 0.02, 1) + expect(not weather_wet()) + step(0.1, 0.0, 200) + expect(weather_wet()) + expect(weather_cold() > 4.0) + expect(weather_ground_wet() > 0.5) + } + + test "a storm throws lightning, and a clearing in daylight is a fact" { + fresh() + weather_set(2, 0, 2.0) + step(1.0, 0.0, 60) + expect(count(facts(), WEATHER_THUNDER) > 0) + weather_set(2, 0, 0.05) + step(0.1, 0.1, 1) + expect_eq(weather_state(), 0) + expect(weather_after_front()) + expect_eq(count(facts(), WEATHER_CLEARED), 1) + } + + test "the world's odds overrule the kind's" { + fresh() + lean = odds3(0.0, 0.0, 1.0) + weather_set(0, 0, 0.01) + step(0.1, 0.1, 1) + expect_eq(weather_next(), 2) + } + + test "wanting rain forecasts a front and holds it long enough" { + fresh() + bias = WEATHER_WANT_WET + step(0.1, 0.01, 1) + expect(weather_forced()) + expect_eq(weather_forecast(), 1) + expect_eq(count(facts(), WEATHER_FRONT), 1) + step(0.1, 2.0, 1) + expect_eq(weather_state(), 1) + expect(not weather_forced()) + expect(weather_left() >= 6.0) + } + + test "wanting clear sky clears it and keeps it" { + fresh() + weather_set(1, 1, 5.0) + bias = WEATHER_WANT_CLEAR + step(0.1, 0.6, 1) + expect_eq(weather_state(), 0) + step(0.1, 0.9, 5) + expect_eq(weather_state(), 0) + } + + test "a machine that does not own the world runs no spells, but is told them" { + fresh() + owns = false + weather_set(0, 1, 0.1) + step(0.1, 5.0, 3) + expect_eq(weather_state(), 0) + weather_told(2, 0, 3.0, 1.0, 0.9, 1.0, 2) + expect_eq(weather_state(), 2) + expect_eq(count(facts(), WEATHER_BEGAN), 1) + step(1.0, 0.0, 60) + expect(count(facts(), WEATHER_THUNDER) > 0) + } + + test "the wind turns with the hours" { + fresh() + hours = 3.0 + step(1.0, 0.0, 100) + expect(Math.abs(weather_dir() - 0.5) < 0.01) + hours = 12.0 + step(1.0, 0.0, 100) + expect(Math.abs(weather_dir() - 2.5) < 0.01) + } + + test "a morning after rain remembers it was wet" { + fresh() + weather_set(1, 1, 5.0) + step(0.1, 0.0, 1) + weather_morning() + expect(weather_wet_yesterday()) + } + + test "a save section reads back the same sky" { + fresh() + weather_set(1, 2, 4.25) + weather_morning() + let fc = weather_forecast() + let root = save_tree() + save_section(root, "weather", 1, weather_save()) + let text = save_encode(root) + weather_reset() + let n = load_section(save_decode(text), "weather") + weather_load(n.data, n.version) + expect_eq(weather_state(), 1) + expect(weather_left() == 4.25) + expect_eq(weather_forecast(), fc) + expect(weather_wet_yesterday()) + } +} diff --git a/packages/ludic.weather/verbs.ludic b/packages/ludic.weather/verbs.ludic new file mode 100644 index 00000000..47083fba --- /dev/null +++ b/packages/ludic.weather/verbs.ludic @@ -0,0 +1,68 @@ +# verbs.ludic - the only way the sky changes from outside: a morning, a reset, the host's word +# a new trip: clear sky, a still-ish wind, nothing forecast; the dice from the world's seed +export function weather_reset() -> void { + var seed = 1 + if wx_world != null and wx_world.seed != null { seed = wx_world.seed() } + wx_dice = rng_new(seed) + let clear = wx_clear_kind() + wx_state = clear + wx_next = clear + wx_forecast = clear + wx_left = 30.0 + wx_amount = 0.0 + wx_forced = false + wx_wind = 0.2 + wx_dir = 0.0 + wx_ground = 0.0 + wx_flash = 0.0 + wx_thunder_t = 0.0 + wx_clear_t = 0.0 + wx_wet_today = false + wx_wet_yesterday = false + q_clear(weather_facts()) +} + +# a new day's forecast: seven times in ten it is the next spell. A front already on its way +# stays on its way. +export function weather_morning() -> void { + if weather_kind_count() == 0 { return } + wx_wet_yesterday = wx_wet_today or wx_kinds[wx_state].wet + wx_wet_today = false + if wx_forced { + wx_forecast = wx_next + return + } + wx_forecast = wx_pick_next(wx_state) + if rng_float(wx_dice) < 0.7 { wx_next = wx_forecast } +} + +# the sky outright: a spell of `kind` now, `left` hours of it, at full strength when not clear +# (staging, a test, a story beat) +export function weather_set(kind: int, next: int, left: float) -> void { + wx_state = kind + wx_next = next + wx_left = left + wx_amount = 1.0 + if wx_kinds[kind].clear { wx_amount = 0.0 } +} + +# what the machine that owns the world says the sky is; a new spell is a fact here too +export function weather_told(kind: int, next: int, left: float, amount: float, wind: float, dir: float, forecast: int) -> void { + let was = wx_state + wx_state = kind + wx_next = next + wx_left = left + wx_amount = amount + wx_wind = wind + wx_dir = dir + wx_forecast = forecast + if kind != was { wx_fact(WEATHER_BEGAN, kind, was) } +} + +export function weather_set_wind(strength: float, dir: float) -> void { + wx_wind = strength + wx_dir = dir +} + +export function weather_set_ground(wet: float) -> void { wx_ground = wet } +export function weather_set_flash(f: float) -> void { wx_flash = f }