feat: add database persistence and Docker setup
This commit is contained in:
parent
2daf8f30f2
commit
c58c590b5d
|
|
@ -0,0 +1,6 @@
|
||||||
|
.git
|
||||||
|
target
|
||||||
|
__temp
|
||||||
|
sim.db
|
||||||
|
sim.db-shm
|
||||||
|
sim.db-wal
|
||||||
|
|
@ -1 +1,4 @@
|
||||||
/target
|
/target
|
||||||
|
sim.db
|
||||||
|
sim.db-shm
|
||||||
|
sim.db-wal
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -11,6 +11,9 @@ rune = "0.14.2"
|
||||||
axum = { version = "0.8", features = ["ws"] }
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time"] }
|
||||||
include_dir = "0.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"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
wasm-bindgen-cli-support = "=0.2.129"
|
wasm-bindgen-cli-support = "=0.2.129"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
FROM rust:1-bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
RUN rustup target add wasm32-unknown-unknown
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release --bin sim
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& mkdir -p /data
|
||||||
|
|
||||||
|
COPY --from=builder /src/target/release/sim /usr/local/bin/sim
|
||||||
|
|
||||||
|
ENV SIM_ADDR=0.0.0.0:7878
|
||||||
|
ENV DATABASE_URL=sqlite:///data/sim.db?mode=rwc
|
||||||
|
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 7878
|
||||||
|
CMD ["sim"]
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
default:
|
||||||
|
@just --list
|
||||||
|
|
||||||
|
run:
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
run-release:
|
||||||
|
cargo run --release
|
||||||
|
|
||||||
|
sqlite:
|
||||||
|
DATABASE_URL="sqlite://sim.db?mode=rwc" cargo run
|
||||||
|
|
||||||
|
compose-up:
|
||||||
|
docker compose up --build
|
||||||
|
|
||||||
|
compose-down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
docker build -t sim .
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
SIM_ADDR: 0.0.0.0:7878
|
||||||
|
DATABASE_URL: postgres://sim:sim@db:5432/sim
|
||||||
|
ports:
|
||||||
|
- "7878:7878"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: sim
|
||||||
|
POSTGRES_USER: sim
|
||||||
|
POSTGRES_PASSWORD: sim
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U sim -d sim"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
64
src/main.rs
64
src/main.rs
|
|
@ -1,3 +1,4 @@
|
||||||
|
mod storage;
|
||||||
mod world;
|
mod world;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
@ -9,7 +10,8 @@ use axum::response::{Html, IntoResponse, Response};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use include_dir::{Dir, include_dir};
|
use include_dir::{Dir, include_dir};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
use storage::Storage;
|
||||||
use tokio::sync::{mpsc, oneshot, watch};
|
use tokio::sync::{mpsc, oneshot, watch};
|
||||||
use world::{DEFAULT_SCRIPT, World};
|
use world::{DEFAULT_SCRIPT, World};
|
||||||
|
|
||||||
|
|
@ -40,13 +42,26 @@ struct App {
|
||||||
/// The Rune VM is not Send, so the world lives on this thread and publishes JSON snapshots.
|
/// The Rune VM is not Send, so the world lives on this thread and publishes JSON snapshots.
|
||||||
fn run_sim(
|
fn run_sim(
|
||||||
seed: u64,
|
seed: u64,
|
||||||
|
snapshot: Option<String>,
|
||||||
mut rx: mpsc::UnboundedReceiver<Msg>,
|
mut rx: mpsc::UnboundedReceiver<Msg>,
|
||||||
terrain: Arc<Mutex<String>>,
|
terrain: Arc<Mutex<String>>,
|
||||||
script: Arc<Mutex<String>>,
|
script: Arc<Mutex<String>>,
|
||||||
state_tx: watch::Sender<Arc<str>>,
|
state_tx: watch::Sender<Arc<str>>,
|
||||||
|
snapshot_tx: watch::Sender<Option<String>>,
|
||||||
) {
|
) {
|
||||||
let mut world = World::new(seed);
|
let mut world = match snapshot {
|
||||||
|
Some(snapshot) => World::from_snapshot(&snapshot)
|
||||||
|
.unwrap_or_else(|e| panic!("failed to restore simulation snapshot: {e}")),
|
||||||
|
None => World::new(seed),
|
||||||
|
};
|
||||||
|
*script.lock().unwrap() = world.script().to_string();
|
||||||
let mut rev = u32::MAX;
|
let mut rev = u32::MAX;
|
||||||
|
let mut last_saved = Instant::now();
|
||||||
|
snapshot_tx.send_replace(Some(
|
||||||
|
world
|
||||||
|
.snapshot_json()
|
||||||
|
.expect("failed to serialize initial simulation snapshot"),
|
||||||
|
));
|
||||||
loop {
|
loop {
|
||||||
while let Ok(msg) = rx.try_recv() {
|
while let Ok(msg) = rx.try_recv() {
|
||||||
match msg {
|
match msg {
|
||||||
|
|
@ -70,6 +85,14 @@ fn run_sim(
|
||||||
*terrain.lock().unwrap() = world.terrain_json();
|
*terrain.lock().unwrap() = world.terrain_json();
|
||||||
}
|
}
|
||||||
state_tx.send_replace(Arc::from(world.state_json()));
|
state_tx.send_replace(Arc::from(world.state_json()));
|
||||||
|
if last_saved.elapsed() >= Duration::from_secs(1) {
|
||||||
|
snapshot_tx.send_replace(Some(
|
||||||
|
world
|
||||||
|
.snapshot_json()
|
||||||
|
.expect("failed to serialize simulation snapshot"),
|
||||||
|
));
|
||||||
|
last_saved = Instant::now();
|
||||||
|
}
|
||||||
std::thread::sleep(Duration::from_millis(100));
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -179,6 +202,31 @@ async fn main() {
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(42u64);
|
.unwrap_or(42u64);
|
||||||
let addr = std::env::var("SIM_ADDR").unwrap_or_else(|_| "127.0.0.1:7878".into());
|
let addr = std::env::var("SIM_ADDR").unwrap_or_else(|_| "127.0.0.1:7878".into());
|
||||||
|
let database_url =
|
||||||
|
std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://sim.db?mode=rwc".into());
|
||||||
|
let storage = Storage::connect(&database_url)
|
||||||
|
.await
|
||||||
|
.expect("failed to connect to simulation database");
|
||||||
|
let saved_snapshot = storage
|
||||||
|
.load()
|
||||||
|
.await
|
||||||
|
.expect("failed to load simulation snapshot");
|
||||||
|
let (snapshot_tx, snapshot_rx) = watch::channel(None::<String>);
|
||||||
|
let snapshot_storage = storage.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut snapshots = snapshot_rx;
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
||||||
|
interval.tick().await;
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let Some(snapshot) = snapshots.borrow_and_update().clone() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Err(error) = snapshot_storage.save(&snapshot).await {
|
||||||
|
eprintln!("failed to persist simulation snapshot: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let terrain_buf = Arc::new(Mutex::new(String::new()));
|
let terrain_buf = Arc::new(Mutex::new(String::new()));
|
||||||
let script = Arc::new(Mutex::new(DEFAULT_SCRIPT.to_string()));
|
let script = Arc::new(Mutex::new(DEFAULT_SCRIPT.to_string()));
|
||||||
|
|
@ -186,7 +234,17 @@ async fn main() {
|
||||||
let (state_tx, state_rx) = watch::channel(Arc::<str>::from("{}"));
|
let (state_tx, state_rx) = watch::channel(Arc::<str>::from("{}"));
|
||||||
{
|
{
|
||||||
let (terrain_buf, script) = (Arc::clone(&terrain_buf), Arc::clone(&script));
|
let (terrain_buf, script) = (Arc::clone(&terrain_buf), Arc::clone(&script));
|
||||||
std::thread::spawn(move || run_sim(seed, rx, terrain_buf, script, state_tx));
|
std::thread::spawn(move || {
|
||||||
|
run_sim(
|
||||||
|
seed,
|
||||||
|
saved_snapshot,
|
||||||
|
rx,
|
||||||
|
terrain_buf,
|
||||||
|
script,
|
||||||
|
state_tx,
|
||||||
|
snapshot_tx,
|
||||||
|
)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let app = App {
|
let app = App {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
use sqlx::any::AnyPoolOptions;
|
||||||
|
use sqlx::{AnyPool, Row};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Storage {
|
||||||
|
pool: AnyPool,
|
||||||
|
postgres: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Storage {
|
||||||
|
pub async fn connect(database_url: &str) -> Result<Self, sqlx::Error> {
|
||||||
|
sqlx::any::install_default_drivers();
|
||||||
|
let postgres =
|
||||||
|
database_url.starts_with("postgres://") || database_url.starts_with("postgresql://");
|
||||||
|
let pool = AnyPoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect(database_url)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS sim_state (id INTEGER PRIMARY KEY, snapshot TEXT NOT NULL)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
Ok(Self { pool, postgres })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load(&self) -> Result<Option<String>, sqlx::Error> {
|
||||||
|
let row = sqlx::query("SELECT snapshot FROM sim_state WHERE id = 1")
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(|row| row.try_get("snapshot")).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save(&self, snapshot: &str) -> Result<(), sqlx::Error> {
|
||||||
|
let query = if self.postgres {
|
||||||
|
"INSERT INTO sim_state (id, snapshot) VALUES (1, $1) ON CONFLICT (id) DO UPDATE SET snapshot = excluded.snapshot"
|
||||||
|
} else {
|
||||||
|
"INSERT INTO sim_state (id, snapshot) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET snapshot = excluded.snapshot"
|
||||||
|
};
|
||||||
|
sqlx::query(query)
|
||||||
|
.bind(snapshot)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::Storage;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sqlite_storage_round_trips_snapshots() {
|
||||||
|
let storage = Storage::connect("sqlite::memory:").await.unwrap();
|
||||||
|
assert_eq!(storage.load().await.unwrap(), None);
|
||||||
|
|
||||||
|
storage.save("{\"tick\":12}").await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
storage.load().await.unwrap().as_deref(),
|
||||||
|
Some("{\"tick\":12}")
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.save("{\"tick\":13}").await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
storage.load().await.unwrap().as_deref(),
|
||||||
|
Some("{\"tick\":13}")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/world.rs
138
src/world.rs
|
|
@ -1,5 +1,6 @@
|
||||||
use rune::termcolor::Buffer;
|
use rune::termcolor::Buffer;
|
||||||
use rune::{Any, Context, Diagnostics, Module, Source, Sources, Vm};
|
use rune::{Any, Context, Diagnostics, Module, Source, Sources, Vm};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|
@ -147,7 +148,7 @@ fn load_script(src: &str) -> Result<Vm, String> {
|
||||||
Ok(vm)
|
Ok(vm)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct Person {
|
pub struct Person {
|
||||||
id: u32,
|
id: u32,
|
||||||
x: usize,
|
x: usize,
|
||||||
|
|
@ -156,7 +157,7 @@ pub struct Person {
|
||||||
food: u32,
|
food: u32,
|
||||||
wood: u32,
|
wood: u32,
|
||||||
stone: u32,
|
stone: u32,
|
||||||
action: &'static str,
|
action: String,
|
||||||
dead: bool,
|
dead: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,7 +167,7 @@ impl Person {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct Animal {
|
pub struct Animal {
|
||||||
x: usize,
|
x: usize,
|
||||||
y: usize,
|
y: usize,
|
||||||
|
|
@ -190,12 +191,37 @@ pub struct World {
|
||||||
tick: u64,
|
tick: u64,
|
||||||
next_id: u32,
|
next_id: u32,
|
||||||
log: Vec<String>,
|
log: Vec<String>,
|
||||||
|
script: String,
|
||||||
vm: Vm,
|
vm: Vm,
|
||||||
pub paused: bool,
|
pub paused: bool,
|
||||||
pub speed: u32,
|
pub speed: u32,
|
||||||
pub terrain_rev: u32,
|
pub terrain_rev: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct WorldSnapshot {
|
||||||
|
version: u32,
|
||||||
|
tiles: Vec<u8>,
|
||||||
|
res: Vec<(u8, u16)>,
|
||||||
|
people: Vec<Person>,
|
||||||
|
rabbits: Vec<Animal>,
|
||||||
|
wolves: Vec<Animal>,
|
||||||
|
houses: Vec<(usize, usize)>,
|
||||||
|
home: (usize, usize),
|
||||||
|
food: u32,
|
||||||
|
wood: u32,
|
||||||
|
stone: u32,
|
||||||
|
tools: u32,
|
||||||
|
rng: u64,
|
||||||
|
tick: u64,
|
||||||
|
next_id: u32,
|
||||||
|
log: Vec<String>,
|
||||||
|
script: String,
|
||||||
|
paused: bool,
|
||||||
|
speed: u32,
|
||||||
|
terrain_rev: u32,
|
||||||
|
}
|
||||||
|
|
||||||
fn cheb(a: (usize, usize), b: (usize, usize)) -> usize {
|
fn cheb(a: (usize, usize), b: (usize, usize)) -> usize {
|
||||||
a.0.abs_diff(b.0).max(a.1.abs_diff(b.1))
|
a.0.abs_diff(b.0).max(a.1.abs_diff(b.1))
|
||||||
}
|
}
|
||||||
|
|
@ -241,6 +267,7 @@ impl World {
|
||||||
tick: DAY / 4,
|
tick: DAY / 4,
|
||||||
next_id: 1,
|
next_id: 1,
|
||||||
log: vec![],
|
log: vec![],
|
||||||
|
script: DEFAULT_SCRIPT.to_string(),
|
||||||
vm: load_script(DEFAULT_SCRIPT).expect("behavior.rn failed to load"),
|
vm: load_script(DEFAULT_SCRIPT).expect("behavior.rn failed to load"),
|
||||||
paused: false,
|
paused: false,
|
||||||
speed: 1,
|
speed: 1,
|
||||||
|
|
@ -323,7 +350,7 @@ impl World {
|
||||||
food: 0,
|
food: 0,
|
||||||
wood: 0,
|
wood: 0,
|
||||||
stone: 0,
|
stone: 0,
|
||||||
action: "idle",
|
action: "idle".to_string(),
|
||||||
dead: false,
|
dead: false,
|
||||||
});
|
});
|
||||||
self.next_id += 1;
|
self.next_id += 1;
|
||||||
|
|
@ -550,19 +577,20 @@ impl World {
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
match (choice.as_str(), gather, rabbit) {
|
match (choice.as_str(), gather, rabbit) {
|
||||||
("sleep", ..) => p.action = "sleeping",
|
("sleep", ..) => p.action = "sleeping".to_string(),
|
||||||
("go_home" | "haul" | "eat", ..) => {
|
("go_home" | "haul" | "eat", ..) => {
|
||||||
p.action = match choice.as_str() {
|
p.action = match choice.as_str() {
|
||||||
"go_home" => "going home",
|
"go_home" => "going home",
|
||||||
"haul" => "hauling goods",
|
"haul" => "hauling goods",
|
||||||
_ => "going to eat",
|
_ => "going to eat",
|
||||||
};
|
}
|
||||||
|
.to_string();
|
||||||
if !at_home {
|
if !at_home {
|
||||||
(p.x, p.y) = self.move_toward(pos, home);
|
(p.x, p.y) = self.move_toward(pos, home);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
("hunt", _, Some(r)) => {
|
("hunt", _, Some(r)) => {
|
||||||
p.action = "hunting";
|
p.action = "hunting".to_string();
|
||||||
let t = (self.rabbits[r].x, self.rabbits[r].y);
|
let t = (self.rabbits[r].x, self.rabbits[r].y);
|
||||||
if cheb(pos, t) <= 1 {
|
if cheb(pos, t) <= 1 {
|
||||||
if self.rng.chance(0.5) {
|
if self.rng.chance(0.5) {
|
||||||
|
|
@ -574,7 +602,7 @@ impl World {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(_, Some((kind, Some(t), label)), _) => {
|
(_, Some((kind, Some(t), label)), _) => {
|
||||||
p.action = label;
|
p.action = label.to_string();
|
||||||
let idx = pos.1 * W + pos.0;
|
let idx = pos.1 * W + pos.0;
|
||||||
if pos == t && self.res[idx].0 == kind {
|
if pos == t && self.res[idx].0 == kind {
|
||||||
let take = yield_n.min(self.res[idx].1);
|
let take = yield_n.min(self.res[idx].1);
|
||||||
|
|
@ -592,7 +620,7 @@ impl World {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
p.action = "wandering";
|
p.action = "wandering".to_string();
|
||||||
(p.x, p.y) = self.wander(p.x, p.y);
|
(p.x, p.y) = self.wander(p.x, p.y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -697,10 +725,77 @@ impl World {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_script(&mut self, src: &str) -> Result<(), String> {
|
pub fn set_script(&mut self, src: &str) -> Result<(), String> {
|
||||||
self.vm = load_script(src)?;
|
let vm = load_script(src)?;
|
||||||
|
self.vm = vm;
|
||||||
|
self.script = src.to_string();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn snapshot_json(&self) -> Result<String, serde_json::Error> {
|
||||||
|
serde_json::to_string(&WorldSnapshot {
|
||||||
|
version: 1,
|
||||||
|
tiles: self.tiles.clone(),
|
||||||
|
res: self.res.clone(),
|
||||||
|
people: self.people.clone(),
|
||||||
|
rabbits: self.rabbits.clone(),
|
||||||
|
wolves: self.wolves.clone(),
|
||||||
|
houses: self.houses.clone(),
|
||||||
|
home: self.home,
|
||||||
|
food: self.food,
|
||||||
|
wood: self.wood,
|
||||||
|
stone: self.stone,
|
||||||
|
tools: self.tools,
|
||||||
|
rng: self.rng.0,
|
||||||
|
tick: self.tick,
|
||||||
|
next_id: self.next_id,
|
||||||
|
log: self.log.clone(),
|
||||||
|
script: self.script.clone(),
|
||||||
|
paused: self.paused,
|
||||||
|
speed: self.speed,
|
||||||
|
terrain_rev: self.terrain_rev,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn script(&self) -> &str {
|
||||||
|
&self.script
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_snapshot(snapshot: &str) -> Result<Self, String> {
|
||||||
|
let saved: WorldSnapshot = serde_json::from_str(snapshot).map_err(|e| e.to_string())?;
|
||||||
|
if saved.version != 1 {
|
||||||
|
return Err(format!(
|
||||||
|
"unsupported world snapshot version {}",
|
||||||
|
saved.version
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if saved.tiles.len() != W * H || saved.res.len() != W * H {
|
||||||
|
return Err("world snapshot has invalid terrain dimensions".into());
|
||||||
|
}
|
||||||
|
let vm = load_script(&saved.script)?;
|
||||||
|
Ok(Self {
|
||||||
|
tiles: saved.tiles,
|
||||||
|
res: saved.res,
|
||||||
|
people: saved.people,
|
||||||
|
rabbits: saved.rabbits,
|
||||||
|
wolves: saved.wolves,
|
||||||
|
houses: saved.houses,
|
||||||
|
home: saved.home,
|
||||||
|
food: saved.food,
|
||||||
|
wood: saved.wood,
|
||||||
|
stone: saved.stone,
|
||||||
|
tools: saved.tools,
|
||||||
|
rng: Rng(saved.rng),
|
||||||
|
tick: saved.tick,
|
||||||
|
next_id: saved.next_id,
|
||||||
|
log: saved.log,
|
||||||
|
script: saved.script,
|
||||||
|
vm,
|
||||||
|
paused: saved.paused,
|
||||||
|
speed: saved.speed,
|
||||||
|
terrain_rev: saved.terrain_rev,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn rect(t: &[&str], i: usize) -> Option<(usize, usize, usize, usize)> {
|
fn rect(t: &[&str], i: usize) -> Option<(usize, usize, usize, usize)> {
|
||||||
let n: Vec<i64> = (i..i + 4).filter_map(|k| t.get(k)?.parse().ok()).collect();
|
let n: Vec<i64> = (i..i + 4).filter_map(|k| t.get(k)?.parse().ok()).collect();
|
||||||
if n.len() != 4 {
|
if n.len() != 4 {
|
||||||
|
|
@ -967,3 +1062,26 @@ impl World {
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::World;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_restores_state_and_rng() {
|
||||||
|
let mut original = World::new(42);
|
||||||
|
for _ in 0..10 {
|
||||||
|
original.step();
|
||||||
|
}
|
||||||
|
original.paused = true;
|
||||||
|
original.speed = 4;
|
||||||
|
|
||||||
|
let snapshot = original.snapshot_json().unwrap();
|
||||||
|
let mut restored = World::from_snapshot(&snapshot).unwrap();
|
||||||
|
assert_eq!(original.state_json(), restored.state_json());
|
||||||
|
|
||||||
|
original.step();
|
||||||
|
restored.step();
|
||||||
|
assert_eq!(original.state_json(), restored.state_json());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue