feat: add same-seed reset and world regeneration
This commit is contained in:
parent
c58c590b5d
commit
238de74f5b
|
|
@ -2498,6 +2498,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"axum",
|
||||
"include_dir",
|
||||
"rand 0.8.8",
|
||||
"rune",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
2
Justfile
2
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
|
||||
|
|
|
|||
52
src/main.rs
52
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<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))
|
||||
|
|
|
|||
62
src/world.rs
62
src/world.rs
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue