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
|
||||
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"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time"] }
|
||||
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]
|
||||
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;
|
||||
|
||||
use axum::Router;
|
||||
|
|
@ -9,7 +10,8 @@ use axum::response::{Html, IntoResponse, Response};
|
|||
use axum::routing::{get, post};
|
||||
use include_dir::{Dir, include_dir};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use storage::Storage;
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
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.
|
||||
fn run_sim(
|
||||
seed: u64,
|
||||
snapshot: Option<String>,
|
||||
mut rx: mpsc::UnboundedReceiver<Msg>,
|
||||
terrain: Arc<Mutex<String>>,
|
||||
script: Arc<Mutex<String>>,
|
||||
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 last_saved = Instant::now();
|
||||
snapshot_tx.send_replace(Some(
|
||||
world
|
||||
.snapshot_json()
|
||||
.expect("failed to serialize initial simulation snapshot"),
|
||||
));
|
||||
loop {
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
match msg {
|
||||
|
|
@ -70,6 +85,14 @@ fn run_sim(
|
|||
*terrain.lock().unwrap() = world.terrain_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));
|
||||
}
|
||||
}
|
||||
|
|
@ -179,6 +202,31 @@ async fn main() {
|
|||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(42u64);
|
||||
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 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 (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 {
|
||||
|
|
|
|||
|
|
@ -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::{Any, Context, Diagnostics, Module, Source, Sources, Vm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -147,7 +148,7 @@ fn load_script(src: &str) -> Result<Vm, String> {
|
|||
Ok(vm)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Person {
|
||||
id: u32,
|
||||
x: usize,
|
||||
|
|
@ -156,7 +157,7 @@ pub struct Person {
|
|||
food: u32,
|
||||
wood: u32,
|
||||
stone: u32,
|
||||
action: &'static str,
|
||||
action: String,
|
||||
dead: bool,
|
||||
}
|
||||
|
||||
|
|
@ -166,7 +167,7 @@ impl Person {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Animal {
|
||||
x: usize,
|
||||
y: usize,
|
||||
|
|
@ -190,12 +191,37 @@ pub struct World {
|
|||
tick: u64,
|
||||
next_id: u32,
|
||||
log: Vec<String>,
|
||||
script: String,
|
||||
vm: Vm,
|
||||
pub paused: bool,
|
||||
pub speed: 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 {
|
||||
a.0.abs_diff(b.0).max(a.1.abs_diff(b.1))
|
||||
}
|
||||
|
|
@ -241,6 +267,7 @@ impl World {
|
|||
tick: DAY / 4,
|
||||
next_id: 1,
|
||||
log: vec![],
|
||||
script: DEFAULT_SCRIPT.to_string(),
|
||||
vm: load_script(DEFAULT_SCRIPT).expect("behavior.rn failed to load"),
|
||||
paused: false,
|
||||
speed: 1,
|
||||
|
|
@ -323,7 +350,7 @@ impl World {
|
|||
food: 0,
|
||||
wood: 0,
|
||||
stone: 0,
|
||||
action: "idle",
|
||||
action: "idle".to_string(),
|
||||
dead: false,
|
||||
});
|
||||
self.next_id += 1;
|
||||
|
|
@ -550,19 +577,20 @@ impl World {
|
|||
_ => None,
|
||||
};
|
||||
match (choice.as_str(), gather, rabbit) {
|
||||
("sleep", ..) => p.action = "sleeping",
|
||||
("sleep", ..) => p.action = "sleeping".to_string(),
|
||||
("go_home" | "haul" | "eat", ..) => {
|
||||
p.action = match choice.as_str() {
|
||||
"go_home" => "going home",
|
||||
"haul" => "hauling goods",
|
||||
_ => "going to eat",
|
||||
};
|
||||
}
|
||||
.to_string();
|
||||
if !at_home {
|
||||
(p.x, p.y) = self.move_toward(pos, home);
|
||||
}
|
||||
}
|
||||
("hunt", _, Some(r)) => {
|
||||
p.action = "hunting";
|
||||
p.action = "hunting".to_string();
|
||||
let t = (self.rabbits[r].x, self.rabbits[r].y);
|
||||
if cheb(pos, t) <= 1 {
|
||||
if self.rng.chance(0.5) {
|
||||
|
|
@ -574,7 +602,7 @@ impl World {
|
|||
}
|
||||
}
|
||||
(_, Some((kind, Some(t), label)), _) => {
|
||||
p.action = label;
|
||||
p.action = label.to_string();
|
||||
let idx = pos.1 * W + pos.0;
|
||||
if pos == t && self.res[idx].0 == kind {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -697,10 +725,77 @@ impl World {
|
|||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
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)> {
|
||||
let n: Vec<i64> = (i..i + 4).filter_map(|k| t.get(k)?.parse().ok()).collect();
|
||||
if n.len() != 4 {
|
||||
|
|
@ -967,3 +1062,26 @@ impl World {
|
|||
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