feat(ludic.clock): the hours, the day, the moon and a calendar as a mechanic package
A ClockWorld port (owns_world, rest_scale, seed), verbs to set and advance it, clock_new_day, a DayTurned queue (stayed up past midnight, or slept), a calendar whose month, year and season are pure functions of the day, and a System (PH_INPUT) with its own save section. Nine test blocks in tests/clock_test.ludic with fakes for the port. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b1ccaf08bd
commit
05b09b4c98
9 changed files with 400 additions and 0 deletions
49
packages/ludic.clock/README.md
Normal file
49
packages/ludic.clock/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# ludic.clock
|
||||
|
||||
One clock for a whole world: the hour, the day, the moon's place in its month, and where the day
|
||||
falls in a calendar the game describes - month, year and season as a pure function of the day.
|
||||
Uses [`ludic.base`](../ludic.base/README.md) and nothing else.
|
||||
|
||||
```ludic
|
||||
import "ludic.clock"
|
||||
```
|
||||
|
||||
The clock never runs the morning. A day turning - past midnight, or by `clock_new_day` after a
|
||||
night slept - is a `DayTurned` fact on `clock_days()`, and the game drains it into whatever its
|
||||
morning is (weather, restocking, the animals' day). Only the machine that owns the world moves
|
||||
the hour; another machine is told it (`clock_set_hours`, `clock_set_day`, `clock_set_moon`).
|
||||
|
||||
## The port
|
||||
|
||||
```ludic
|
||||
property ClockWorld {
|
||||
owns_world: fn() -> bool # does this machine run the world? (unbound: yes)
|
||||
rest_scale: fn() -> float # how much everyone's rest speeds the clock; 1 when nobody rests
|
||||
seed: fn() -> int # a seed for a new trip's moon
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `clock_bind(w)` | the port |
|
||||
| `clock_config(start_hours, night_from, night_to)` | when a trip starts, and which hours are night |
|
||||
| `clock_config_moon(days, lo, hi)` | the moon's month in days, and the part of it a new trip starts in |
|
||||
| `clock_calendar(month_days, season_of_month)` | a month's length, and each month's season (the list's length is the year) |
|
||||
| `clock_set_scale(s)`, `clock_scale()` | game seconds per real second |
|
||||
| `clock_rate()`, `clock_hours_in(dt)` | the rate with the rest included, and the game hours `dt` real seconds are worth |
|
||||
| `clock_advance(gh)` | run the hours on (the system's tick does this from `Tick.hours`); past midnight the day turns |
|
||||
| `clock_new_day()` | turn the day now (the caller sets the hour first) |
|
||||
| `clock_reset()` | day one at the start hour, the moon from the seed |
|
||||
| `clock_day()`, `clock_hours()`, `clock_moon()`, `clock_hhmm()`, `clock_is_night()` | what the world asks |
|
||||
| `clock_month()`, `clock_year()`, `clock_day_of_month()`, `clock_season()`, `clock_days_to_change()` | today in the calendar |
|
||||
| `clock_month_of(d)`, `clock_year_of(d)`, `clock_day_of_month_of(d)`, `clock_season_of(d)`, `clock_days_to_change_from(d)`, `clock_first_day_of(season)` | any day in the calendar |
|
||||
| `clock_days() -> Queue<DayTurned>` | the facts: `{ day, how }`, `how` one of `DAY_STAYED_UP`, `DAY_SLEPT` |
|
||||
| `clock_system() -> System` | `"clock"`, `PH_INPUT` (first, so every later system reads this frame's hour): reset, tick, and its save section (`clock_save`, `clock_load`: day, hours, moon) |
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
ludic build packages/ludic.clock/tests/clock_test.ludic --headless -o /tmp/clock_test && /tmp/clock_test
|
||||
```
|
||||
48
packages/ludic.clock/calendar.ludic
Normal file
48
packages/ludic.clock/calendar.ludic
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# calendar.ludic - where a day falls: its month, its year, and its season, a pure function of the
|
||||
# day. The game says how long a month is, how many make a year, and which season each month is.
|
||||
var cal_month_days: int = 30
|
||||
var cal_year_months: int = 12
|
||||
var cal_season: []int = null
|
||||
|
||||
export function clock_calendar(month_days: int, season_of_month: []int) -> void {
|
||||
cal_month_days = month_days
|
||||
cal_year_months = len(season_of_month)
|
||||
cal_season = season_of_month
|
||||
}
|
||||
|
||||
export function clock_month_days() -> int { return cal_month_days }
|
||||
export function clock_year_months() -> int { return cal_year_months }
|
||||
|
||||
# the month a day falls in, 0 .. months - 1
|
||||
export function clock_month_of(day: int) -> int { return ((day - 1) / cal_month_days) % cal_year_months }
|
||||
export function clock_year_of(day: int) -> int { return (day - 1) / (cal_month_days * cal_year_months) + 1 }
|
||||
export function clock_day_of_month_of(day: int) -> int { return (day - 1) % cal_month_days + 1 }
|
||||
|
||||
export function clock_season_of(day: int) -> int {
|
||||
if cal_season == null { return 0 }
|
||||
return cal_season[clock_month_of(day)]
|
||||
}
|
||||
|
||||
export function clock_month() -> int { return clock_month_of(ck_day) }
|
||||
export function clock_year() -> int { return clock_year_of(ck_day) }
|
||||
export function clock_day_of_month() -> int { return clock_day_of_month_of(ck_day) }
|
||||
export function clock_season() -> int { return clock_season_of(ck_day) }
|
||||
|
||||
# days from `day` until the season is another; 0 when a year has only one
|
||||
export function clock_days_to_change_from(day: int) -> int {
|
||||
let here = clock_season_of(day)
|
||||
for d in 1 .. cal_month_days * cal_year_months {
|
||||
if clock_season_of(day + d) != here { return d }
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function clock_days_to_change() -> int { return clock_days_to_change_from(ck_day) }
|
||||
|
||||
# the first day (counted from day one) that falls in a season; -1 when none does
|
||||
export function clock_first_day_of(season: int) -> int {
|
||||
for d in 1 .. cal_month_days * cal_year_months + 1 {
|
||||
if clock_season_of(d) == season { return d }
|
||||
}
|
||||
return -1
|
||||
}
|
||||
10
packages/ludic.clock/index.ludic
Normal file
10
packages/ludic.clock/index.ludic
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# ludic.clock - one clock for a whole world: the hour, the day, the moon's place in its month and
|
||||
# where the day falls in a calendar the game describes; a day turning is a fact on a queue
|
||||
module ludic_clock
|
||||
numbers float
|
||||
import "ludic.base"
|
||||
import "state.ludic"
|
||||
import "verbs.ludic"
|
||||
import "queries.ludic"
|
||||
import "calendar.ludic"
|
||||
import "system.ludic"
|
||||
5
packages/ludic.clock/package.ludic
Normal file
5
packages/ludic.clock/package.ludic
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# ludic.clock - the hours, the days, the moon and a calendar of seasons, as a pure function of the
|
||||
# day. Uses ludic.base and nothing else. See README.md.
|
||||
package "ludic.clock"
|
||||
version "0.1.0"
|
||||
kind source
|
||||
24
packages/ludic.clock/queries.ludic
Normal file
24
packages/ludic.clock/queries.ludic
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# queries.ludic - what the world asks the clock
|
||||
export function clock_day() -> int { return ck_day }
|
||||
export function clock_hours() -> float { return ck_hours }
|
||||
export function clock_moon() -> float { return ck_moon }
|
||||
export function clock_scale() -> float { return ck_scale }
|
||||
|
||||
# game seconds per real second right now, the rest speed-up included
|
||||
export function clock_rate() -> float { return ck_scale * ck_rest() }
|
||||
|
||||
# the game hours `dt` real seconds are worth at the current rate
|
||||
export function clock_hours_in(dt: float) -> float { return dt * clock_rate() / 3600.0 }
|
||||
|
||||
export function clock_is_night() -> bool { return ck_hours > ck_night_from or ck_hours < ck_night_to }
|
||||
|
||||
# "07:05"
|
||||
export function clock_hhmm() -> string {
|
||||
let h = int(Math.floor(ck_hours))
|
||||
let m = int(Math.floor((ck_hours - float(h)) * 60.0))
|
||||
var hs = `{h}`
|
||||
var ms = `{m}`
|
||||
if h < 10 { hs = `0{h}` }
|
||||
if m < 10 { ms = `0{m}` }
|
||||
return hs + ":" + ms
|
||||
}
|
||||
63
packages/ludic.clock/state.ludic
Normal file
63
packages/ludic.clock/state.ludic
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# state.ludic - the clock's port, its facts, and what it holds
|
||||
# what the clock asks the world: whether this machine runs it, how much the rest speeds it (1 when
|
||||
# nobody rests), and a seed for a new trip's moon
|
||||
export property ClockWorld {
|
||||
owns_world: fn() -> bool = null
|
||||
rest_scale: fn() -> float = null
|
||||
seed: fn() -> int = null
|
||||
}
|
||||
|
||||
# how a day turned: the hour ran past midnight, or a verb turned it (a night slept, a collapse)
|
||||
export const DAY_STAYED_UP: int = 0
|
||||
export const DAY_SLEPT: int = 1
|
||||
|
||||
export property DayTurned {
|
||||
day: int = 0
|
||||
how: int = 0
|
||||
}
|
||||
|
||||
var ck_world: ClockWorld = null
|
||||
var ck_day: int = 1
|
||||
var ck_hours: float = 0.0
|
||||
var ck_moon: float = 0.0
|
||||
var ck_scale: float = 60.0 # game seconds per real second
|
||||
var ck_start_hours: float = 8.0
|
||||
var ck_night_from: float = 20.0
|
||||
var ck_night_to: float = 6.0
|
||||
var ck_moon_days: float = 29.5
|
||||
var ck_moon_lo: float = 0.0
|
||||
var ck_moon_hi: float = 1.0
|
||||
var ck_dice: Rng = null
|
||||
var ck_days: Queue<DayTurned> = null
|
||||
|
||||
export function clock_bind(w: ClockWorld) -> void { ck_world = w }
|
||||
|
||||
# the facts: each day that turned, in order; the game drains it and runs its morning
|
||||
export function clock_days() -> Queue<DayTurned> {
|
||||
if ck_days == null { ck_days = queue_new("clock.days") }
|
||||
return ck_days
|
||||
}
|
||||
|
||||
function ck_owns() -> bool {
|
||||
if ck_world == null or ck_world.owns_world == null { return true }
|
||||
return ck_world.owns_world()
|
||||
}
|
||||
|
||||
function ck_rest() -> float {
|
||||
if ck_world == null or ck_world.rest_scale == null { return 1.0 }
|
||||
return ck_world.rest_scale()
|
||||
}
|
||||
|
||||
# when a trip starts, when the night is, and how long the moon's month is
|
||||
export function clock_config(start_hours: float, night_from: float, night_to: float) -> void {
|
||||
ck_start_hours = start_hours
|
||||
ck_night_from = night_from
|
||||
ck_night_to = night_to
|
||||
}
|
||||
|
||||
# a new trip's moon falls in [lo, hi) of its month; the month is `days` long
|
||||
export function clock_config_moon(days: float, lo: float, hi: float) -> void {
|
||||
ck_moon_days = days
|
||||
ck_moon_lo = lo
|
||||
ck_moon_hi = hi
|
||||
}
|
||||
27
packages/ludic.clock/system.ludic
Normal file
27
packages/ludic.clock/system.ludic
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# system.ludic - the clock as a system: it runs first (PH_INPUT) so every later system reads this
|
||||
# frame's hour, and it saves its own section
|
||||
function clock_tick(t: Tick) -> void { clock_advance(t.hours) }
|
||||
|
||||
export function clock_save() -> Val {
|
||||
let v = Value.object()
|
||||
sv_put_int(v, "day", ck_day)
|
||||
sv_put_float(v, "hours", ck_hours)
|
||||
sv_put_float(v, "moon", ck_moon)
|
||||
return v
|
||||
}
|
||||
|
||||
export function clock_load(v: Val, version: int) -> void {
|
||||
ck_day = sv_int(v, "day", 1)
|
||||
clock_set_hours(sv_float(v, "hours", ck_start_hours))
|
||||
ck_moon = sv_float(v, "moon", 0.5)
|
||||
q_clear(clock_days())
|
||||
}
|
||||
|
||||
export function clock_system() -> System {
|
||||
let s = system_new("clock", PH_INPUT)
|
||||
s.reset = fn clock_reset
|
||||
s.tick = fn clock_tick
|
||||
s.save = fn clock_save
|
||||
s.load = fn clock_load
|
||||
return s
|
||||
}
|
||||
129
packages/ludic.clock/tests/clock_test.ludic
Normal file
129
packages/ludic.clock/tests/clock_test.ludic
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# clock_test.ludic - the hours run at the rate the world's rest allows, only on the machine that
|
||||
# owns it; a day turns past midnight or by a verb, as a fact; the calendar is a function of the day
|
||||
import "ludic.clock"
|
||||
import "ludic.base"
|
||||
program ClockTest {
|
||||
numbers float
|
||||
var owns: bool = true
|
||||
var rest: float = 1.0
|
||||
|
||||
function fake_owns() -> bool { return owns }
|
||||
function fake_rest() -> float { return rest }
|
||||
function fake_seed() -> int { return 42 }
|
||||
|
||||
function fresh() -> void {
|
||||
owns = true
|
||||
rest = 1.0
|
||||
let w = new ClockWorld
|
||||
w.owns_world = fn fake_owns
|
||||
w.rest_scale = fn fake_rest
|
||||
w.seed = fn fake_seed
|
||||
clock_bind(w)
|
||||
clock_config(8.0, 20.0, 6.0)
|
||||
clock_config_moon(29.5, 0.2, 0.8)
|
||||
clock_set_scale(60.0)
|
||||
let seasons = new []int
|
||||
for m in 0 .. 12 { push(seasons, m / 3) }
|
||||
clock_calendar(10, seasons)
|
||||
clock_reset()
|
||||
}
|
||||
|
||||
function turned() -> []DayTurned { return q_drain(clock_days()) }
|
||||
function waiting() -> int { return q_len(clock_days()) }
|
||||
function run_hours(h: float) -> void {
|
||||
let s = clock_system()
|
||||
s.tick(tick_new(0.016, 0, h))
|
||||
}
|
||||
|
||||
test "a trip starts on day one at the start hour, the moon inside its range" {
|
||||
fresh()
|
||||
expect_eq(clock_day(), 1)
|
||||
expect(clock_hours() == 8.0)
|
||||
expect(clock_moon() >= 0.2 and clock_moon() < 0.8)
|
||||
expect(clock_hhmm() == "08:00")
|
||||
}
|
||||
|
||||
test "the same seed gives the same moon" {
|
||||
fresh()
|
||||
let a = clock_moon()
|
||||
clock_reset()
|
||||
expect(clock_moon() == a)
|
||||
}
|
||||
|
||||
test "a real minute is a game hour at scale 60, and resting speeds it" {
|
||||
fresh()
|
||||
expect(clock_hours_in(60.0) == 1.0)
|
||||
rest = 4.0
|
||||
expect(clock_rate() == 240.0)
|
||||
}
|
||||
|
||||
test "past midnight the day turns, once, as a fact" {
|
||||
fresh()
|
||||
run_hours(15.0)
|
||||
expect_eq(waiting(), 0)
|
||||
run_hours(2.5)
|
||||
expect_eq(clock_day(), 2)
|
||||
expect(clock_hours() == 1.5)
|
||||
let t = turned()
|
||||
expect_eq(len(t), 1)
|
||||
expect_eq(t[0].day, 2)
|
||||
expect_eq(t[0].how, DAY_STAYED_UP)
|
||||
}
|
||||
|
||||
test "a machine that does not own the world does not move it" {
|
||||
fresh()
|
||||
owns = false
|
||||
run_hours(20.0)
|
||||
expect(clock_hours() == 8.0)
|
||||
expect_eq(clock_day(), 1)
|
||||
}
|
||||
|
||||
test "a night slept turns the day and moves the moon a month's share" {
|
||||
fresh()
|
||||
let m0 = clock_moon()
|
||||
clock_set_hours(6.5)
|
||||
clock_new_day()
|
||||
expect_eq(clock_day(), 2)
|
||||
expect(clock_moon() > m0)
|
||||
expect_eq(turned()[0].how, DAY_SLEPT)
|
||||
}
|
||||
|
||||
test "night is outside the day's hours, and the hour wraps" {
|
||||
fresh()
|
||||
clock_set_hours(21.0)
|
||||
expect(clock_is_night())
|
||||
clock_set_hours(12.0)
|
||||
expect(not clock_is_night())
|
||||
clock_set_hours(25.0)
|
||||
expect(clock_hours() == 1.0)
|
||||
}
|
||||
|
||||
test "the calendar is a function of the day" {
|
||||
fresh()
|
||||
expect_eq(clock_season_of(1), 0)
|
||||
expect_eq(clock_month_of(31), 3)
|
||||
expect_eq(clock_season_of(31), 1)
|
||||
expect_eq(clock_day_of_month_of(35), 5)
|
||||
expect_eq(clock_year_of(121), 2)
|
||||
expect_eq(clock_first_day_of(2), 61)
|
||||
clock_set_day(28)
|
||||
expect_eq(clock_days_to_change(), 3)
|
||||
expect_eq(clock_season(), 0)
|
||||
}
|
||||
|
||||
test "a save section reads back the same clock" {
|
||||
fresh()
|
||||
clock_set_day(17)
|
||||
clock_set_hours(13.25)
|
||||
clock_set_moon(0.375)
|
||||
let root = save_tree()
|
||||
save_section(root, "clock", 1, clock_save())
|
||||
let text = save_encode(root)
|
||||
clock_reset()
|
||||
let n = load_section(save_decode(text), "clock")
|
||||
clock_load(n.data, n.version)
|
||||
expect_eq(clock_day(), 17)
|
||||
expect(clock_hours() == 13.25)
|
||||
expect(clock_moon() == 0.375)
|
||||
}
|
||||
}
|
||||
45
packages/ludic.clock/verbs.ludic
Normal file
45
packages/ludic.clock/verbs.ludic
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# verbs.ludic - the only way the clock changes
|
||||
export function clock_set_scale(s: float) -> void { ck_scale = s }
|
||||
export function clock_set_day(d: int) -> void { ck_day = d }
|
||||
export function clock_set_moon(m: float) -> void { ck_moon = m }
|
||||
|
||||
# the hour, wrapped into [0, 24); setting it never turns the day - that is clock_new_day's
|
||||
export function clock_set_hours(h: float) -> void {
|
||||
var x = h % 24.0
|
||||
if x < 0.0 { x = x + 24.0 }
|
||||
ck_hours = x
|
||||
}
|
||||
|
||||
function ck_turn(how: int) -> void {
|
||||
ck_day += 1
|
||||
ck_moon = (ck_moon + 1.0 / ck_moon_days) % 1.0
|
||||
let d = new DayTurned
|
||||
d.day = ck_day
|
||||
d.how = how
|
||||
q_push(clock_days(), d)
|
||||
}
|
||||
|
||||
# a new day however it came (a night slept, a collapse); the caller sets the hour first
|
||||
export function clock_new_day() -> void { ck_turn(DAY_SLEPT) }
|
||||
|
||||
# run the hours on by `gh` game hours; past midnight the day turns. Only the machine that owns
|
||||
# the world moves it - another machine is told the time.
|
||||
export function clock_advance(gh: float) -> void {
|
||||
if not ck_owns() { return }
|
||||
ck_hours = ck_hours + gh
|
||||
while not (ck_hours < 24.0) {
|
||||
ck_hours = ck_hours - 24.0
|
||||
ck_turn(DAY_STAYED_UP)
|
||||
}
|
||||
}
|
||||
|
||||
# a new trip: day one at the start hour, the moon somewhere in its month
|
||||
export function clock_reset() -> void {
|
||||
var seed = 1
|
||||
if ck_world != null and ck_world.seed != null { seed = ck_world.seed() }
|
||||
ck_dice = rng_new(seed)
|
||||
ck_day = 1
|
||||
ck_hours = ck_start_hours
|
||||
ck_moon = rng_span(ck_dice, ck_moon_lo, ck_moon_hi) % 1.0
|
||||
q_clear(clock_days())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue