feat: add database persistence and Docker setup

This commit is contained in:
Steve Biedermann 2026-10-08 08:44:30 +02:00
parent 2daf8f30f2
commit c58c590b5d
10 changed files with 1273 additions and 23 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
.git
target
__temp
sim.db
sim.db-shm
sim.db-wal

3
.gitignore vendored
View File

@ -1 +1,4 @@
/target
sim.db
sim.db-shm
sim.db-wal

941
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

22
Dockerfile Normal file
View File

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

20
Justfile Normal file
View File

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

30
compose.yaml Normal file
View File

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

View File

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

69
src/storage.rs Normal file
View File

@ -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}")
);
}
}

View File

@ -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());
}
}