feat(ludic.weather): spells of weather as a mechanic package
The kinds and their odds are data the game hands in; a port asks whether this machine owns the world, the hour, the night, the odds from a kind, what the story wants of the sky (a front, a hold, a clear night), the front's kind and length, and a seed. Its own dice; a new spell, a clearing, lightning and a wanted front are facts on weather_facts(); its own save section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a2012a5afe
commit
093ca0d260
12 changed files with 746 additions and 0 deletions
80
packages/ludic.weather/README.md
Normal file
80
packages/ludic.weather/README.md
Normal file
|
|
@ -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<WeatherFact>` | `{ 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
|
||||
```
|
||||
43
packages/ludic.weather/bias.ludic
Normal file
43
packages/ludic.weather/bias.ludic
Normal file
|
|
@ -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() }
|
||||
}
|
||||
60
packages/ludic.weather/config.ludic
Normal file
60
packages/ludic.weather/config.ludic
Normal file
|
|
@ -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
|
||||
}
|
||||
13
packages/ludic.weather/index.ludic
Normal file
13
packages/ludic.weather/index.ludic
Normal file
|
|
@ -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"
|
||||
5
packages/ludic.weather/package.ludic
Normal file
5
packages/ludic.weather/package.ludic
Normal file
|
|
@ -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
|
||||
31
packages/ludic.weather/pick.ludic
Normal file
31
packages/ludic.weather/pick.ludic
Normal file
|
|
@ -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)
|
||||
}
|
||||
38
packages/ludic.weather/queries.ludic
Normal file
38
packages/ludic.weather/queries.ludic
Normal file
|
|
@ -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 }
|
||||
76
packages/ludic.weather/run.ludic
Normal file
76
packages/ludic.weather/run.ludic
Normal file
|
|
@ -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 }
|
||||
}
|
||||
81
packages/ludic.weather/state.ludic
Normal file
81
packages/ludic.weather/state.ludic
Normal file
|
|
@ -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<WeatherFact> = 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<WeatherFact> {
|
||||
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)
|
||||
}
|
||||
50
packages/ludic.weather/system.ludic
Normal file
50
packages/ludic.weather/system.ludic
Normal file
|
|
@ -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
|
||||
}
|
||||
201
packages/ludic.weather/tests/weather_test.ludic
Normal file
201
packages/ludic.weather/tests/weather_test.ludic
Normal file
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
68
packages/ludic.weather/verbs.ludic
Normal file
68
packages/ludic.weather/verbs.ludic
Normal file
|
|
@ -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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue