diff --git a/Cargo.lock b/Cargo.lock index 75ea1e8..65352fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2498,6 +2498,7 @@ version = "0.1.0" dependencies = [ "axum", "include_dir", + "rand 0.8.8", "rune", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 2812ae2..37d855a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ include_dir = "0.7" serde = { version = "1", features = ["derive"] } serde_json = "1" sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "any", "sqlite", "postgres"] } +rand = "0.8" [build-dependencies] wasm-bindgen-cli-support = "=0.2.129" diff --git a/Justfile b/Justfile index 79a990e..3eba8f7 100644 --- a/Justfile +++ b/Justfile @@ -11,7 +11,7 @@ sqlite: DATABASE_URL="sqlite://sim.db?mode=rwc" cargo run compose-up: - docker compose up --build + docker compose up --build -d compose-down: docker compose down diff --git a/src/main.rs b/src/main.rs index 585b662..766e880 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,10 @@ catch (e) { document.body.textContent = 'Failed to load the UI: ' + e; } enum Msg { Edit(String), Script(String, oneshot::Sender>), + Restart { + regenerate: bool, + reply: oneshot::Sender, + }, } #[derive(Clone)] @@ -50,7 +54,7 @@ fn run_sim( snapshot_tx: watch::Sender>, ) { let mut world = match snapshot { - Some(snapshot) => World::from_snapshot(&snapshot) + Some(snapshot) => World::from_snapshot(&snapshot, seed) .unwrap_or_else(|e| panic!("failed to restore simulation snapshot: {e}")), None => World::new(seed), }; @@ -73,6 +77,22 @@ fn run_sim( } let _ = reply.send(r); } + Msg::Restart { regenerate, reply } => { + let seed = if regenerate { + world.regenerated_seed() + } else { + world.seed() + }; + world = world.restarted(seed); + *script.lock().unwrap() = world.script().to_string(); + rev = u32::MAX; + snapshot_tx.send_replace(Some( + world + .snapshot_json() + .expect("failed to serialize restarted simulation"), + )); + let _ = reply.send(seed); + } } } if !world.paused { @@ -155,6 +175,25 @@ async fn post_script(State(app): State, body: String) -> Response { } } +async fn post_reset(State(app): State) -> Response { + restart(app, false).await +} + +async fn post_regenerate(State(app): State) -> Response { + restart(app, true).await +} + +async fn restart(app: App, regenerate: bool) -> Response { + let (reply, result) = oneshot::channel(); + if app.tx.send(Msg::Restart { regenerate, reply }).is_err() { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + match tokio::time::timeout(Duration::from_secs(5), result).await { + Ok(Ok(seed)) => seed.to_string().into_response(), + _ => StatusCode::SERVICE_UNAVAILABLE.into_response(), + } +} + async fn post_edit(State(app): State, body: String) -> StatusCode { if body.len() > 256 { return StatusCode::PAYLOAD_TOO_LARGE; @@ -218,7 +257,14 @@ async fn main() { let mut interval = tokio::time::interval(Duration::from_secs(1)); interval.tick().await; loop { - interval.tick().await; + tokio::select! { + _ = interval.tick() => {} + changed = snapshots.changed() => { + if changed.is_err() { + break; + } + } + } let Some(snapshot) = snapshots.borrow_and_update().clone() else { continue; }; @@ -258,6 +304,8 @@ async fn main() { .route("/api/terrain", get(terrain)) .route("/api/state", get(state)) .route("/api/script", get(get_script).post(post_script)) + .route("/api/reset", post(post_reset)) + .route("/api/regenerate", post(post_regenerate)) .route("/api/edit", post(post_edit)) .route("/ws", get(ws)) .route("/pkg/{*path}", get(pkg)) diff --git a/src/world.rs b/src/world.rs index 6276cd0..009d2ed 100644 --- a/src/world.rs +++ b/src/world.rs @@ -176,6 +176,7 @@ pub struct Animal { } pub struct World { + seed: u64, tiles: Vec, res: Vec<(u8, u16)>, people: Vec, @@ -201,6 +202,7 @@ pub struct World { #[derive(Serialize, Deserialize)] struct WorldSnapshot { version: u32, + seed: Option, tiles: Vec, res: Vec<(u8, u16)>, people: Vec, @@ -252,6 +254,7 @@ impl World { } } let mut w = World { + seed, tiles, res: vec![(0, 0); W * H], people: vec![], @@ -287,7 +290,7 @@ impl World { } let (hx, hy) = w.home; w.res[hy * W + hx] = (0, 0); - for _ in 0..6 { + for _ in 0..9 { w.spawn_person(); } for _ in 0..60 { @@ -731,9 +734,34 @@ impl World { Ok(()) } + pub fn restarted(&self, seed: u64) -> Self { + let mut world = Self::new(seed); + world.speed = self.speed; + world.paused = self.paused; + if self.script != DEFAULT_SCRIPT { + world + .set_script(&self.script) + .expect("saved behavior script failed to reload"); + } + world + } + + pub fn regenerated_seed(&self) -> u64 { + let mut seed = rand::random::(); + if seed == self.seed { + seed = seed.wrapping_add(1); + } + seed + } + + pub fn seed(&self) -> u64 { + self.seed + } + pub fn snapshot_json(&self) -> Result { serde_json::to_string(&WorldSnapshot { version: 1, + seed: Some(self.seed), tiles: self.tiles.clone(), res: self.res.clone(), people: self.people.clone(), @@ -760,7 +788,7 @@ impl World { &self.script } - pub fn from_snapshot(snapshot: &str) -> Result { + pub fn from_snapshot(snapshot: &str, fallback_seed: u64) -> Result { let saved: WorldSnapshot = serde_json::from_str(snapshot).map_err(|e| e.to_string())?; if saved.version != 1 { return Err(format!( @@ -773,6 +801,7 @@ impl World { } let vm = load_script(&saved.script)?; Ok(Self { + seed: saved.seed.unwrap_or(fallback_seed), tiles: saved.tiles, res: saved.res, people: saved.people, @@ -996,9 +1025,10 @@ impl World { let mut s = String::new(); let _ = write!( s, - "{{\"paused\":{},\"speed\":{},\"rev\":{},\"tick\":{},\"day\":{},\"hour\":{:.2},\"daylight\":{:.3},\"stock\":{{\"food\":{},\"wood\":{},\"stone\":{},\"tools\":{}}},\"home\":[{},{}],", + "{{\"paused\":{},\"speed\":{},\"seed\":{},\"rev\":{},\"tick\":{},\"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, @@ -1065,7 +1095,7 @@ impl World { #[cfg(test)] mod tests { - use super::World; + use super::{DAY, DEFAULT_SCRIPT, World}; #[test] fn snapshot_restores_state_and_rng() { @@ -1077,11 +1107,33 @@ mod tests { original.speed = 4; let snapshot = original.snapshot_json().unwrap(); - let mut restored = World::from_snapshot(&snapshot).unwrap(); + let mut restored = World::from_snapshot(&snapshot, 42).unwrap(); assert_eq!(original.state_json(), restored.state_json()); original.step(); restored.step(); assert_eq!(original.state_json(), restored.state_json()); } + + #[test] + fn reset_keeps_seed_and_settings_with_nine_people() { + let mut original = World::new(42); + original.apply("speed 4"); + original.paused = true; + original.set_script(DEFAULT_SCRIPT).unwrap(); + original.step(); + + let reset = original.restarted(original.seed()); + assert_eq!(reset.seed(), 42); + assert_eq!(reset.speed, 4); + assert!(reset.paused); + assert_eq!(reset.people.len(), 9); + assert_eq!(reset.tick, DAY / 4); + } + + #[test] + fn regenerated_seed_differs_from_current_seed() { + let world = World::new(42); + assert_ne!(world.regenerated_seed(), world.seed()); + } } diff --git a/ui/src/lib.rs b/ui/src/lib.rs index 5f8e863..ecce3d3 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -153,6 +153,7 @@ fn App() -> impl IntoView { view! {
{format!("Day {}, {h:02}:{m:02}", s.day)}
{format!("People: {}", s.people.len())}
+
{format!("Seed: {}", s.seed)}
{format!("Rabbits: {}", s.rabbits.len())}
{format!("Wolves: {}", s.wolves.len())}
{format!("Houses: {}", s.houses.len())}
@@ -179,6 +180,18 @@ fn App() -> impl IntoView { +
+ + +

"Tool"

diff --git a/ui/src/model.rs b/ui/src/model.rs index 8df1a56..d63779b 100644 --- a/ui/src/model.rs +++ b/ui/src/model.rs @@ -45,6 +45,7 @@ pub struct Res(pub usize, pub usize, pub u8, pub u16); pub struct State { pub paused: bool, pub speed: u32, + pub seed: u64, pub rev: u32, pub day: u64, pub hour: f32,