diff --git a/README.md b/README.md index 35dfefd..5e6ed5f 100644 --- a/README.md +++ b/README.md @@ -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`. +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. ## Getting Started diff --git a/src/world.rs b/src/world.rs index f9429bf..882b171 100644 --- a/src/world.rs +++ b/src/world.rs @@ -15,7 +15,112 @@ use std::time::Duration; pub const W: usize = 96; 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: u8 = 1; @@ -258,6 +363,7 @@ pub struct World { pub paused: bool, pub speed: u32, pub terrain_rev: u32, + calendar: CalendarSettings, } #[derive(Serialize, Deserialize)] @@ -283,6 +389,8 @@ struct WorldSnapshot { paused: bool, speed: u32, terrain_rev: u32, + #[serde(default)] + calendar: CalendarSettings, } fn cheb(a: (usize, usize), b: (usize, usize)) -> usize { @@ -331,7 +439,7 @@ impl World { stone: 0, tools: 0, rng: Rng(seed.wrapping_mul(0x9E3779B97F4A7C15) | 1), - tick: DAY / 4, + tick: DEFAULT_TICKS_PER_DAY / 4, next_id: 1, log: vec![], script: DEFAULT_SCRIPT.to_string(), @@ -339,6 +447,7 @@ impl World { paused: false, speed: 1, terrain_rev: 0, + calendar: CalendarSettings::default(), }; w.home = w .find_land(GRASS) @@ -522,15 +631,19 @@ impl World { } fn say(&mut self, s: String) { - self.log - .push(format!("[day {}] {}", self.tick / DAY + 1, s)); + self.log.push(format!( + "[day {}] {}", + self.tick / self.calendar.ticks_per_day + 1, + s + )); if self.log.len() > 8 { self.log.remove(0); } } 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(); (0.5 + v * 0.8).clamp(0.0, 1.0) } @@ -906,6 +1019,9 @@ impl World { let mut world = Self::new(seed); world.speed = self.speed; 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 { world .set_script(&self.script) @@ -949,6 +1065,7 @@ impl World { paused: self.paused, speed: self.speed, terrain_rev: self.terrain_rev, + calendar: self.calendar.clone(), }) } @@ -994,7 +1111,9 @@ impl World { paused: saved.paused, speed: saved.speed, terrain_rev: saved.terrain_rev, + calendar: saved.calendar, }; + world.calendar.normalize(); world.sync_entities(); Ok(world) } @@ -1017,6 +1136,30 @@ impl World { let num = |i: usize| t.get(i).and_then(|s| s.parse::().ok()); match t.first().copied() { 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") => { if let Some(n) = num(1) { self.speed = n.clamp(1, 8) as u32; @@ -1200,16 +1343,30 @@ impl World { pub fn state_json(&self) -> String { 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!( 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.speed, self.seed, self.terrain_rev, self.tick, - self.tick / DAY + 1, - (self.tick % DAY) as f32 / DAY as f32 * 24.0, + day + 1, + 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.food, self.wood, @@ -1218,6 +1375,36 @@ impl World { self.home.0, 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\":["); let j: Vec = self .houses @@ -1278,7 +1465,7 @@ impl Drop for World { #[cfg(test)] mod tests { - use super::{DAY, DEFAULT_SCRIPT, World}; + use super::{DEFAULT_SCRIPT, DEFAULT_TICKS_PER_DAY, World}; #[test] fn snapshot_restores_state_and_rng() { @@ -1311,7 +1498,7 @@ mod tests { assert_eq!(reset.speed, 4); assert!(reset.paused); assert_eq!(reset.people.len(), 9); - assert_eq!(reset.tick, DAY / 4); + assert_eq!(reset.tick, DEFAULT_TICKS_PER_DAY / 4); } #[test] @@ -1335,4 +1522,32 @@ mod tests { 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); + } } diff --git a/ui/src/lib.rs b/ui/src/lib.rs index ecce3d3..dad82aa 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -151,7 +151,10 @@ fn App() -> impl IntoView { {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); view! { -
{format!("Day {}, {h:02}:{m:02}", s.day)}
+
{format!( + "Year {}, month {}, week {}, day {}, {h:02}:{m:02}", + s.year, s.month, s.week, s.date_day + )}
{format!("People: {}", s.people.len())}
{format!("Seed: {}", s.seed)}
{format!("Rabbits: {}", s.rabbits.len())}
@@ -193,6 +196,37 @@ fn App() -> impl IntoView { } title="Generate a new world with a different seed">"Regenerate" +
+ "Calendar and life-stage durations" +

"Stage durations are random inclusive ranges in in-game days."

+ + + + + {["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! { +
+ {stage.replace('_', " ")} + + +
+ } + }) + .collect_view()} +
+

"Tool"

() { + 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); + } + } + } + /> + + } +} + #[component] fn PersonEditor(p: Person) -> impl IntoView { let id = p.0; diff --git a/ui/src/model.rs b/ui/src/model.rs index d63779b..38666c6 100644 --- a/ui/src/model.rs +++ b/ui/src/model.rs @@ -20,6 +20,80 @@ pub struct Stock { 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 { + 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 { + 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 #[derive(Deserialize, Clone)] pub struct Person( @@ -48,6 +122,10 @@ pub struct State { pub seed: u64, pub rev: u32, pub day: u64, + pub year: u64, + pub month: u64, + pub week: u64, + pub date_day: u64, pub hour: f32, pub daylight: f32, pub stock: Stock, @@ -58,6 +136,7 @@ pub struct State { pub wolves: Vec, pub res: Vec, pub log: Vec, + pub calendar: Calendar, } #[derive(Deserialize, Clone)] diff --git a/ui/style.css b/ui/style.css index b7203e2..513f332 100644 --- a/ui/style.css +++ b/ui/style.css @@ -48,6 +48,24 @@ canvas { 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 { margin: 2px 0; color: #aaa;