feat(calendar): add configurable calendar and stage ranges

Persist calendar units and inclusive life-stage duration ranges, expose them in the UI, and preserve defaults when loading legacy snapshots. Date formatting now derives from calendar settings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-11 17:38:57 +02:00
parent 81581132ad
commit ca371bd414
5 changed files with 397 additions and 11 deletions

View File

@ -4,6 +4,8 @@ A browser-based world simulation with a Rust server and a Leptos/Wasm UI. The se
Living simulation entities (people, rabbits, and wolves) run as Picard actors. The world coordinates terrain, shared resources, and actor messages; Rune behavior scripts remain on the simulation thread because Rune's VM is not `Send`. Living simulation entities (people, rabbits, and wolves) run as Picard actors. The world coordinates terrain, shared resources, and actor messages; Rune behavior scripts remain on the simulation thread because Rune's VM is not `Send`.
The **Calendar and life-stage durations** panel configures ticks per day and the number of days, weeks, and months in larger calendar units. Each human life stage has an inclusive minimum/maximum duration in in-game days; the world samples within that range when a person enters the stage. These settings are stored with the world snapshot.
The UI is built automatically as part of `cargo run` and embedded in the server binary. There is no separate manual UI build step. The UI is built automatically as part of `cargo run` and embedded in the server binary. There is no separate manual UI build step.
## Getting Started ## Getting Started

View File

@ -15,7 +15,112 @@ use std::time::Duration;
pub const W: usize = 96; pub const W: usize = 96;
pub const H: usize = 64; pub const H: usize = 64;
pub const DAY: u64 = 1800; pub const DEFAULT_TICKS_PER_DAY: u64 = 1800;
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
pub struct StageDuration {
pub min_days: u32,
pub max_days: u32,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct CalendarSettings {
pub ticks_per_day: u64,
pub days_per_week: u32,
pub weeks_per_month: u32,
pub months_per_year: u32,
pub toddler: StageDuration,
pub child: StageDuration,
pub teen: StageDuration,
pub young_adult: StageDuration,
pub adult: StageDuration,
pub older_adult: StageDuration,
pub elderly: StageDuration,
}
impl Default for StageDuration {
fn default() -> Self {
Self {
min_days: 1,
max_days: 2,
}
}
}
impl Default for CalendarSettings {
fn default() -> Self {
Self {
ticks_per_day: DEFAULT_TICKS_PER_DAY,
days_per_week: 7,
weeks_per_month: 4,
months_per_year: 12,
toddler: StageDuration {
min_days: 2,
max_days: 4,
},
child: StageDuration {
min_days: 4,
max_days: 8,
},
teen: StageDuration {
min_days: 4,
max_days: 8,
},
young_adult: StageDuration {
min_days: 6,
max_days: 12,
},
adult: StageDuration {
min_days: 20,
max_days: 40,
},
older_adult: StageDuration {
min_days: 10,
max_days: 20,
},
elderly: StageDuration {
min_days: 4,
max_days: 10,
},
}
}
}
impl CalendarSettings {
fn normalize(&mut self) {
self.ticks_per_day = self.ticks_per_day.clamp(1, 1_000_000);
self.days_per_week = self.days_per_week.clamp(1, 100);
self.weeks_per_month = self.weeks_per_month.clamp(1, 100);
self.months_per_year = self.months_per_year.clamp(1, 100);
for stage in [
"toddler",
"child",
"teen",
"young_adult",
"adult",
"older_adult",
"elderly",
] {
let range = self.duration_mut(stage).expect("known life stage");
range.min_days = range.min_days.clamp(1, 1_000_000);
range.max_days = range.max_days.clamp(range.min_days, 1_000_000);
}
}
fn duration_mut(&mut self, stage: &str) -> Option<&mut StageDuration> {
match stage {
"toddler" => Some(&mut self.toddler),
"child" => Some(&mut self.child),
"teen" => Some(&mut self.teen),
"young_adult" => Some(&mut self.young_adult),
"adult" => Some(&mut self.adult),
"older_adult" => Some(&mut self.older_adult),
"elderly" => Some(&mut self.elderly),
_ => None,
}
}
}
const WATER_DEEP: u8 = 0; const WATER_DEEP: u8 = 0;
const WATER: u8 = 1; const WATER: u8 = 1;
@ -258,6 +363,7 @@ pub struct World {
pub paused: bool, pub paused: bool,
pub speed: u32, pub speed: u32,
pub terrain_rev: u32, pub terrain_rev: u32,
calendar: CalendarSettings,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -283,6 +389,8 @@ struct WorldSnapshot {
paused: bool, paused: bool,
speed: u32, speed: u32,
terrain_rev: u32, terrain_rev: u32,
#[serde(default)]
calendar: CalendarSettings,
} }
fn cheb(a: (usize, usize), b: (usize, usize)) -> usize { fn cheb(a: (usize, usize), b: (usize, usize)) -> usize {
@ -331,7 +439,7 @@ impl World {
stone: 0, stone: 0,
tools: 0, tools: 0,
rng: Rng(seed.wrapping_mul(0x9E3779B97F4A7C15) | 1), rng: Rng(seed.wrapping_mul(0x9E3779B97F4A7C15) | 1),
tick: DAY / 4, tick: DEFAULT_TICKS_PER_DAY / 4,
next_id: 1, next_id: 1,
log: vec![], log: vec![],
script: DEFAULT_SCRIPT.to_string(), script: DEFAULT_SCRIPT.to_string(),
@ -339,6 +447,7 @@ impl World {
paused: false, paused: false,
speed: 1, speed: 1,
terrain_rev: 0, terrain_rev: 0,
calendar: CalendarSettings::default(),
}; };
w.home = w w.home = w
.find_land(GRASS) .find_land(GRASS)
@ -522,15 +631,19 @@ impl World {
} }
fn say(&mut self, s: String) { fn say(&mut self, s: String) {
self.log self.log.push(format!(
.push(format!("[day {}] {}", self.tick / DAY + 1, s)); "[day {}] {}",
self.tick / self.calendar.ticks_per_day + 1,
s
));
if self.log.len() > 8 { if self.log.len() > 8 {
self.log.remove(0); self.log.remove(0);
} }
} }
pub fn daylight(&self) -> f32 { pub fn daylight(&self) -> f32 {
let phase = (self.tick % DAY) as f32 / DAY as f32; let ticks_per_day = self.calendar.ticks_per_day;
let phase = (self.tick % ticks_per_day) as f32 / ticks_per_day as f32;
let v = -(phase * std::f32::consts::TAU).cos(); let v = -(phase * std::f32::consts::TAU).cos();
(0.5 + v * 0.8).clamp(0.0, 1.0) (0.5 + v * 0.8).clamp(0.0, 1.0)
} }
@ -906,6 +1019,9 @@ impl World {
let mut world = Self::new(seed); let mut world = Self::new(seed);
world.speed = self.speed; world.speed = self.speed;
world.paused = self.paused; world.paused = self.paused;
world.calendar = self.calendar.clone();
world.calendar.normalize();
world.tick = world.calendar.ticks_per_day / 4;
if self.script != DEFAULT_SCRIPT { if self.script != DEFAULT_SCRIPT {
world world
.set_script(&self.script) .set_script(&self.script)
@ -949,6 +1065,7 @@ impl World {
paused: self.paused, paused: self.paused,
speed: self.speed, speed: self.speed,
terrain_rev: self.terrain_rev, terrain_rev: self.terrain_rev,
calendar: self.calendar.clone(),
}) })
} }
@ -994,7 +1111,9 @@ impl World {
paused: saved.paused, paused: saved.paused,
speed: saved.speed, speed: saved.speed,
terrain_rev: saved.terrain_rev, terrain_rev: saved.terrain_rev,
calendar: saved.calendar,
}; };
world.calendar.normalize();
world.sync_entities(); world.sync_entities();
Ok(world) Ok(world)
} }
@ -1017,6 +1136,30 @@ impl World {
let num = |i: usize| t.get(i).and_then(|s| s.parse::<i64>().ok()); let num = |i: usize| t.get(i).and_then(|s| s.parse::<i64>().ok());
match t.first().copied() { match t.first().copied() {
Some("pause") => self.paused = num(1) != Some(0), Some("pause") => self.paused = num(1) != Some(0),
Some("calendar") => {
if let (Some(setting), Some(value)) = (t.get(1), num(2)) {
let value = value.clamp(1, 1_000_000) as u64;
match *setting {
"ticks_per_day" => self.calendar.ticks_per_day = value,
"days_per_week" => self.calendar.days_per_week = value.min(100) as u32,
"weeks_per_month" => self.calendar.weeks_per_month = value.min(100) as u32,
"months_per_year" => self.calendar.months_per_year = value.min(100) as u32,
_ => {}
}
}
}
Some("stage") => {
if let (Some(stage), Some(min_days), Some(max_days)) = (t.get(1), num(2), num(3)) {
let min_days = min_days.clamp(1, 1_000_000) as u32;
let max_days = max_days.clamp(1, 1_000_000) as u32;
if min_days <= max_days {
if let Some(range) = self.calendar.duration_mut(stage) {
range.min_days = min_days;
range.max_days = max_days;
}
}
}
}
Some("speed") => { Some("speed") => {
if let Some(n) = num(1) { if let Some(n) = num(1) {
self.speed = n.clamp(1, 8) as u32; self.speed = n.clamp(1, 8) as u32;
@ -1200,16 +1343,30 @@ impl World {
pub fn state_json(&self) -> String { pub fn state_json(&self) -> String {
let mut s = String::new(); let mut s = String::new();
let days_per_month =
self.calendar.days_per_week as u64 * self.calendar.weeks_per_month as u64;
let days_per_year = days_per_month * self.calendar.months_per_year as u64;
let day = self.tick / self.calendar.ticks_per_day;
let year = day / days_per_year + 1;
let month = (day / days_per_month) % self.calendar.months_per_year as u64 + 1;
let week =
(day / self.calendar.days_per_week as u64) % self.calendar.weeks_per_month as u64 + 1;
let date_day = day % self.calendar.days_per_week as u64 + 1;
let _ = write!( let _ = write!(
s, s,
"{{\"paused\":{},\"speed\":{},\"seed\":{},\"rev\":{},\"tick\":{},\"day\":{},\"hour\":{:.2},\"daylight\":{:.3},\"stock\":{{\"food\":{},\"wood\":{},\"stone\":{},\"tools\":{}}},\"home\":[{},{}],", "{{\"paused\":{},\"speed\":{},\"seed\":{},\"rev\":{},\"tick\":{},\"day\":{},\"year\":{},\"month\":{},\"week\":{},\"date_day\":{},\"hour\":{:.2},\"daylight\":{:.3},\"stock\":{{\"food\":{},\"wood\":{},\"stone\":{},\"tools\":{}}},\"home\":[{},{}],",
self.paused, self.paused,
self.speed, self.speed,
self.seed, self.seed,
self.terrain_rev, self.terrain_rev,
self.tick, self.tick,
self.tick / DAY + 1, day + 1,
(self.tick % DAY) as f32 / DAY as f32 * 24.0, year,
month,
week,
date_day,
(self.tick % self.calendar.ticks_per_day) as f32 / self.calendar.ticks_per_day as f32
* 24.0,
self.daylight(), self.daylight(),
self.food, self.food,
self.wood, self.wood,
@ -1218,6 +1375,36 @@ impl World {
self.home.0, self.home.0,
self.home.1 self.home.1
); );
let _ = write!(
s,
"\"calendar\":{{\"ticks_per_day\":{},\"days_per_week\":{},\"weeks_per_month\":{},\"months_per_year\":{},",
self.calendar.ticks_per_day,
self.calendar.days_per_week,
self.calendar.weeks_per_month,
self.calendar.months_per_year
);
for (index, (name, range)) in [
("toddler", self.calendar.toddler),
("child", self.calendar.child),
("teen", self.calendar.teen),
("young_adult", self.calendar.young_adult),
("adult", self.calendar.adult),
("older_adult", self.calendar.older_adult),
("elderly", self.calendar.elderly),
]
.into_iter()
.enumerate()
{
if index > 0 {
s.push(',');
}
let _ = write!(
s,
"\"{name}\":{{\"min_days\":{},\"max_days\":{}}}",
range.min_days, range.max_days
);
}
s.push_str("},");
s.push_str("\"houses\":["); s.push_str("\"houses\":[");
let j: Vec<String> = self let j: Vec<String> = self
.houses .houses
@ -1278,7 +1465,7 @@ impl Drop for World {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{DAY, DEFAULT_SCRIPT, World}; use super::{DEFAULT_SCRIPT, DEFAULT_TICKS_PER_DAY, World};
#[test] #[test]
fn snapshot_restores_state_and_rng() { fn snapshot_restores_state_and_rng() {
@ -1311,7 +1498,7 @@ mod tests {
assert_eq!(reset.speed, 4); assert_eq!(reset.speed, 4);
assert!(reset.paused); assert!(reset.paused);
assert_eq!(reset.people.len(), 9); assert_eq!(reset.people.len(), 9);
assert_eq!(reset.tick, DAY / 4); assert_eq!(reset.tick, DEFAULT_TICKS_PER_DAY / 4);
} }
#[test] #[test]
@ -1335,4 +1522,32 @@ mod tests {
world.people.len() + world.rabbits.len() + world.wolves.len() world.people.len() + world.rabbits.len() + world.wolves.len()
); );
} }
#[test]
fn calendar_and_life_stage_ranges_persist_in_snapshots() {
let mut world = World::new(42);
world.apply("calendar ticks_per_day 240");
world.apply("calendar days_per_week 5");
world.apply("stage toddler 3 9");
let snapshot = world.snapshot_json().unwrap();
let restored = World::from_snapshot(&snapshot, 42).unwrap();
assert_eq!(restored.calendar.ticks_per_day, 240);
assert_eq!(restored.calendar.days_per_week, 5);
assert_eq!(restored.calendar.toddler.min_days, 3);
assert_eq!(restored.calendar.toddler.max_days, 9);
assert_eq!(world.state_json(), restored.state_json());
}
#[test]
fn snapshots_without_calendar_use_default_settings() {
let world = World::new(42);
let mut snapshot: serde_json::Value =
serde_json::from_str(&world.snapshot_json().unwrap()).unwrap();
snapshot.as_object_mut().unwrap().remove("calendar");
let restored = World::from_snapshot(&snapshot.to_string(), 42).unwrap();
assert_eq!(restored.calendar.ticks_per_day, DEFAULT_TICKS_PER_DAY);
assert_eq!(restored.calendar.days_per_week, 7);
}
} }

View File

@ -151,7 +151,10 @@ fn App() -> impl IntoView {
{move || state.with(|s| s.as_ref().map(|s| { {move || state.with(|s| s.as_ref().map(|s| {
let (h, m) = (s.hour.floor() as u32, ((s.hour % 1.0) * 60.0) as u32); let (h, m) = (s.hour.floor() as u32, ((s.hour % 1.0) * 60.0) as u32);
view! { view! {
<div>{format!("Day {}, {h:02}:{m:02}", s.day)}</div> <div>{format!(
"Year {}, month {}, week {}, day {}, {h:02}:{m:02}",
s.year, s.month, s.week, s.date_day
)}</div>
<div>{format!("People: {}", s.people.len())}</div> <div>{format!("People: {}", s.people.len())}</div>
<div>{format!("Seed: {}", s.seed)}</div> <div>{format!("Seed: {}", s.seed)}</div>
<div>{format!("Rabbits: {}", s.rabbits.len())}</div> <div>{format!("Rabbits: {}", s.rabbits.len())}</div>
@ -193,6 +196,37 @@ fn App() -> impl IntoView {
} title="Generate a new world with a different seed">"Regenerate"</button> } title="Generate a new world with a different seed">"Regenerate"</button>
</div> </div>
<details>
<summary>"Calendar and life-stage durations"</summary>
<p>"Stage durations are random inclusive ranges in in-game days."</p>
<SettingInput state=state label="Ticks per day" setting="calendar.ticks_per_day".to_string() />
<SettingInput state=state label="Days per week" setting="calendar.days_per_week".to_string() />
<SettingInput state=state label="Weeks per month" setting="calendar.weeks_per_month".to_string() />
<SettingInput state=state label="Months per year" setting="calendar.months_per_year".to_string() />
{["toddler", "child", "teen", "young_adult", "adult", "older_adult", "elderly"]
.into_iter()
.map(|stage| {
let min_setting = format!("stage.{stage}.min_days");
let max_setting = format!("stage.{stage}.max_days");
view! {
<div class="stage-settings">
<strong>{stage.replace('_', " ")}</strong>
<SettingInput
state=state
label="min days"
setting=min_setting
/>
<SettingInput
state=state
label="max days"
setting=max_setting
/>
</div>
}
})
.collect_view()}
</details>
<h3>"Tool"</h3> <h3>"Tool"</h3>
<div> <div>
<select on:change=move |e| { <select on:change=move |e| {
@ -265,6 +299,44 @@ fn App() -> impl IntoView {
} }
} }
#[component]
fn SettingInput(
state: RwSignal<Option<State>>,
label: &'static str,
setting: String,
) -> impl IntoView {
let input_setting = setting.clone();
view! {
<label class="setting">
{label}
<input
type="number"
min="1"
max="1000000"
prop:value=move || {
state.with(|s| {
s.as_ref()
.and_then(|s| s.calendar.setting(&setting))
.unwrap_or(1)
.to_string()
})
}
on:change=move |event| {
if let Ok(value) = event_target_value(&event).parse::<u64>() {
let command = state.with_untracked(|s| {
s.as_ref()
.and_then(|s| s.calendar.command(&input_setting, value))
});
if let Some(command) = command {
api::edit(command);
}
}
}
/>
</label>
}
}
#[component] #[component]
fn PersonEditor(p: Person) -> impl IntoView { fn PersonEditor(p: Person) -> impl IntoView {
let id = p.0; let id = p.0;

View File

@ -20,6 +20,80 @@ pub struct Stock {
pub tools: u32, pub tools: u32,
} }
#[derive(Deserialize, Clone)]
pub struct StageRange {
pub min_days: u32,
pub max_days: u32,
}
#[derive(Deserialize, Clone)]
pub struct Calendar {
pub ticks_per_day: u64,
pub days_per_week: u32,
pub weeks_per_month: u32,
pub months_per_year: u32,
pub toddler: StageRange,
pub child: StageRange,
pub teen: StageRange,
pub young_adult: StageRange,
pub adult: StageRange,
pub older_adult: StageRange,
pub elderly: StageRange,
}
impl Calendar {
pub fn setting(&self, name: &str) -> Option<u64> {
match name {
"ticks_per_day" => Some(self.ticks_per_day),
"days_per_week" => Some(self.days_per_week.into()),
"weeks_per_month" => Some(self.weeks_per_month.into()),
"months_per_year" => Some(self.months_per_year.into()),
_ => {
let (stage, bound) = name.split_once('.')?;
let range = match stage {
"toddler" => &self.toddler,
"child" => &self.child,
"teen" => &self.teen,
"young_adult" => &self.young_adult,
"adult" => &self.adult,
"older_adult" => &self.older_adult,
"elderly" => &self.elderly,
_ => return None,
};
match bound {
"min_days" => Some(range.min_days.into()),
"max_days" => Some(range.max_days.into()),
_ => None,
}
}
}
}
pub fn command(&self, name: &str, value: u64) -> Option<String> {
if let Some(setting) = name.strip_prefix("calendar.") {
return Some(format!("calendar {setting} {value}"));
}
let rest = name.strip_prefix("stage.")?;
let (stage, bound) = rest.split_once('.')?;
let range = match stage {
"toddler" => &self.toddler,
"child" => &self.child,
"teen" => &self.teen,
"young_adult" => &self.young_adult,
"adult" => &self.adult,
"older_adult" => &self.older_adult,
"elderly" => &self.elderly,
_ => return None,
};
let (min_days, max_days) = match bound {
"min_days" => (value.min(u32::MAX as u64) as u32, range.max_days),
"max_days" => (range.min_days, value.min(u32::MAX as u64) as u32),
_ => return None,
};
Some(format!("stage {stage} {min_days} {max_days}"))
}
}
/// id, x, y, action, hunger, food, wood, stone /// id, x, y, action, hunger, food, wood, stone
#[derive(Deserialize, Clone)] #[derive(Deserialize, Clone)]
pub struct Person( pub struct Person(
@ -48,6 +122,10 @@ pub struct State {
pub seed: u64, pub seed: u64,
pub rev: u32, pub rev: u32,
pub day: u64, pub day: u64,
pub year: u64,
pub month: u64,
pub week: u64,
pub date_day: u64,
pub hour: f32, pub hour: f32,
pub daylight: f32, pub daylight: f32,
pub stock: Stock, pub stock: Stock,
@ -58,6 +136,7 @@ pub struct State {
pub wolves: Vec<Animal>, pub wolves: Vec<Animal>,
pub res: Vec<Res>, pub res: Vec<Res>,
pub log: Vec<String>, pub log: Vec<String>,
pub calendar: Calendar,
} }
#[derive(Deserialize, Clone)] #[derive(Deserialize, Clone)]

View File

@ -48,6 +48,24 @@ canvas {
width: 56px; width: 56px;
} }
.stage-settings {
display: flex;
align-items: center;
gap: 4px;
margin: 4px 0;
}
.stage-settings strong {
width: 92px;
}
.setting {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 3px;
}
.log div { .log div {
margin: 2px 0; margin: 2px 0;
color: #aaa; color: #aaa;