feat: add same-seed reset and world regeneration

This commit is contained in:
Steve Biedermann 2026-10-08 08:58:34 +02:00
parent c58c590b5d
commit 238de74f5b
7 changed files with 124 additions and 8 deletions

1
Cargo.lock generated
View File

@ -2498,6 +2498,7 @@ version = "0.1.0"
dependencies = [
"axum",
"include_dir",
"rand 0.8.8",
"rune",
"serde",
"serde_json",

View File

@ -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"

View File

@ -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

View File

@ -29,6 +29,10 @@ catch (e) { document.body.textContent = 'Failed to load the UI: ' + e; }
enum Msg {
Edit(String),
Script(String, oneshot::Sender<Result<(), String>>),
Restart {
regenerate: bool,
reply: oneshot::Sender<u64>,
},
}
#[derive(Clone)]
@ -50,7 +54,7 @@ fn run_sim(
snapshot_tx: watch::Sender<Option<String>>,
) {
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<App>, body: String) -> Response {
}
}
async fn post_reset(State(app): State<App>) -> Response {
restart(app, false).await
}
async fn post_regenerate(State(app): State<App>) -> 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<App>, 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))

View File

@ -176,6 +176,7 @@ pub struct Animal {
}
pub struct World {
seed: u64,
tiles: Vec<u8>,
res: Vec<(u8, u16)>,
people: Vec<Person>,
@ -201,6 +202,7 @@ pub struct World {
#[derive(Serialize, Deserialize)]
struct WorldSnapshot {
version: u32,
seed: Option<u64>,
tiles: Vec<u8>,
res: Vec<(u8, u16)>,
people: Vec<Person>,
@ -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::<u64>();
if seed == self.seed {
seed = seed.wrapping_add(1);
}
seed
}
pub fn seed(&self) -> u64 {
self.seed
}
pub fn snapshot_json(&self) -> Result<String, serde_json::Error> {
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<Self, String> {
pub fn from_snapshot(snapshot: &str, fallback_seed: u64) -> Result<Self, String> {
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());
}
}

View File

@ -153,6 +153,7 @@ fn App() -> impl IntoView {
view! {
<div>{format!("Day {}, {h:02}:{m:02}", s.day)}</div>
<div>{format!("People: {}", s.people.len())}</div>
<div>{format!("Seed: {}", s.seed)}</div>
<div>{format!("Rabbits: {}", s.rabbits.len())}</div>
<div>{format!("Wolves: {}", s.wolves.len())}</div>
<div>{format!("Houses: {}", s.houses.len())}</div>
@ -179,6 +180,18 @@ fn App() -> impl IntoView {
<option value="8">"8"</option>
</select>
</div>
<div>
<button on:click=move |_| {
spawn_local(async {
let _ = api::post("/api/reset", String::new()).await;
});
} title="Restart with the current seed and nine people">"Reset"</button>
<button on:click=move |_| {
spawn_local(async {
let _ = api::post("/api/regenerate", String::new()).await;
});
} title="Generate a new world with a different seed">"Regenerate"</button>
</div>
<h3>"Tool"</h3>
<div>

View File

@ -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,