From 5760d097f72789d18f4fa70119866caa7ae79c2a Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Wed, 7 Oct 2026 18:04:37 +0200 Subject: [PATCH] feat: create the world simulation server --- .gitignore | 1 + Cargo.lock | 46 ++++ Cargo.toml | 7 + src/index.html | 192 ++++++++++++++ src/main.rs | 47 ++++ src/world.rs | 671 +++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 964 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/index.html create mode 100644 src/main.rs create mode 100644 src/world.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..26d8725 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,46 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "sim" +version = "0.1.0" +dependencies = [ + "tiny_http", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6f67156 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "sim" +version = "0.1.0" +edition = "2024" + +[dependencies] +tiny_http = "0.12" diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..bea97a8 --- /dev/null +++ b/src/index.html @@ -0,0 +1,192 @@ + + + + + + World Simulation + + + + + +
+

World

+
+

Selected

+
click a tile or drag to select a region
+

Events

+
+

Legend

+
yellow: person, white: rabbit, red: wolf
green dot: tree, pink: berries, grey: stone
brown square: house +
+
+ + + + \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c11797c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,47 @@ +mod world; + +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; +use tiny_http::{Header, Response, Server}; +use world::World; + +const INDEX: &str = include_str!("index.html"); + +fn main() { + let seed = std::env::args() + .nth(1) + .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 world = Arc::new(Mutex::new(World::new(seed))); + + { + let world = Arc::clone(&world); + thread::spawn(move || { + loop { + world.lock().unwrap().step(); + thread::sleep(Duration::from_millis(100)); + } + }); + } + + let server = Server::http(&addr).expect("failed to bind"); + println!("Simulation running at http://{addr}"); + for req in server.incoming_requests() { + let world = Arc::clone(&world); + thread::spawn(move || { + let (body, ctype) = match req.url() { + "/" => (INDEX.to_string(), "text/html; charset=utf-8"), + "/api/terrain" => (world.lock().unwrap().terrain_json(), "application/json"), + "/api/state" => (world.lock().unwrap().state_json(), "application/json"), + _ => { + let _ = req.respond(Response::from_string("not found").with_status_code(404)); + return; + } + }; + let header = Header::from_bytes("Content-Type", ctype).unwrap(); + let _ = req.respond(Response::from_string(body).with_header(header)); + }); + } +} diff --git a/src/world.rs b/src/world.rs new file mode 100644 index 0000000..18b8773 --- /dev/null +++ b/src/world.rs @@ -0,0 +1,671 @@ +use std::fmt::Write; + +pub const W: usize = 96; +pub const H: usize = 64; +pub const DAY: u64 = 1800; + +const WATER_DEEP: u8 = 0; +const WATER: u8 = 1; +const SAND: u8 = 2; +const GRASS: u8 = 3; +const FOREST: u8 = 4; +const HILLS: u8 = 5; +const MOUNTAIN: u8 = 6; + +const TREE: u8 = 1; +const BERRY: u8 = 2; +const STONE: u8 = 3; + +pub struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545F4914F6CDD1D) + } + fn f(&mut self) -> f32 { + (self.next() >> 40) as f32 / (1u64 << 24) as f32 + } + fn chance(&mut self, p: f32) -> bool { + self.f() < p + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } +} + +fn hash(x: i32, y: i32, seed: u64) -> f32 { + let mut h = (x as u64).wrapping_mul(0x9E3779B97F4A7C15) + ^ (y as u64).wrapping_mul(0xC2B2AE3D27D4EB4F) + ^ seed.wrapping_mul(0x165667B19E3779F9); + h ^= h >> 33; + h = h.wrapping_mul(0xFF51AFD7ED558CCD); + h ^= h >> 33; + (h >> 40) as f32 / (1u64 << 24) as f32 +} + +fn value_noise(x: f32, y: f32, seed: u64) -> f32 { + let (xi, yi) = (x.floor() as i32, y.floor() as i32); + let (fx, fy) = (x - xi as f32, y - yi as f32); + let s = |t: f32| t * t * (3.0 - 2.0 * t); + let (u, v) = (s(fx), s(fy)); + let a = hash(xi, yi, seed); + let b = hash(xi + 1, yi, seed); + let c = hash(xi, yi + 1, seed); + let d = hash(xi + 1, yi + 1, seed); + let top = a + (b - a) * u; + let bot = c + (d - c) * u; + top + (bot - top) * v +} + +fn fbm(x: f32, y: f32, seed: u64) -> f32 { + let (mut sum, mut amp, mut freq, mut norm) = (0.0, 1.0, 1.0 / 16.0, 0.0); + for o in 0..4 { + sum += amp * value_noise(x * freq, y * freq, seed + o); + norm += amp; + amp *= 0.5; + freq *= 2.0; + } + sum / norm +} + +#[derive(Clone)] +pub struct Person { + id: u32, + x: usize, + y: usize, + hunger: f32, + food: u32, + wood: u32, + stone: u32, + action: &'static str, + dead: bool, +} + +impl Person { + fn carry(&self) -> u32 { + self.food + self.wood + self.stone + } +} + +#[derive(Clone)] +pub struct Animal { + x: usize, + y: usize, + energy: f32, + dead: bool, +} + +pub struct World { + tiles: Vec, + res: Vec<(u8, u16)>, + people: Vec, + rabbits: Vec, + wolves: Vec, + houses: Vec<(usize, usize)>, + home: (usize, usize), + food: u32, + wood: u32, + stone: u32, + tools: u32, + rng: Rng, + tick: u64, + next_id: u32, + log: Vec, +} + +fn cheb(a: (usize, usize), b: (usize, usize)) -> usize { + a.0.abs_diff(b.0).max(a.1.abs_diff(b.1)) +} + +impl World { + pub fn new(seed: u64) -> World { + let mut tiles = vec![0u8; W * H]; + for y in 0..H { + for x in 0..W { + let dx = (x as f32 / W as f32 - 0.5) * 2.0; + let dy = (y as f32 / H as f32 - 0.5) * 2.0; + let d = (dx * dx + dy * dy).min(1.5); + let h = fbm(x as f32, y as f32, seed) + 0.25 - 0.55 * d; + let m = fbm(x as f32 + 500.0, y as f32 + 500.0, seed + 99); + tiles[y * W + x] = if h < 0.28 { + WATER_DEEP + } else if h < 0.36 { + WATER + } else if h < 0.41 { + SAND + } else if h < 0.68 { + if m > 0.52 { FOREST } else { GRASS } + } else if h < 0.78 { + HILLS + } else { + MOUNTAIN + }; + } + } + let mut w = World { + tiles, + res: vec![(0, 0); W * H], + people: vec![], + rabbits: vec![], + wolves: vec![], + houses: vec![], + home: (W / 2, H / 2), + food: 4, + wood: 0, + stone: 0, + tools: 0, + rng: Rng(seed.wrapping_mul(0x9E3779B97F4A7C15) | 1), + tick: DAY / 4, + next_id: 1, + log: vec![], + }; + w.home = w + .find_land(GRASS) + .unwrap_or_else(|| w.find_land(SAND).unwrap_or((W / 2, H / 2))); + for i in 0..W * H { + let r = w.rng.f(); + w.res[i] = match w.tiles[i] { + FOREST if r < 0.6 => (TREE, 5), + GRASS if r < 0.04 => (BERRY, 4), + HILLS if r < 0.15 => (STONE, 6), + _ => (0, 0), + }; + } + let (hx, hy) = w.home; + w.res[hy * W + hx] = (0, 0); + for _ in 0..6 { + w.spawn_person(); + } + for _ in 0..60 { + if let Some(p) = w.random_land() { + w.rabbits.push(Animal { + x: p.0, + y: p.1, + energy: 70.0, + dead: false, + }); + } + } + for _ in 0..4 { + if let Some(p) = w.random_land() { + if cheb(p, w.home) > 15 { + w.wolves.push(Animal { + x: p.0, + y: p.1, + energy: 70.0, + dead: false, + }); + } + } + } + w + } + + fn find_land(&self, kind: u8) -> Option<(usize, usize)> { + let c = (W / 2, H / 2); + let mut best: Option<(usize, usize)> = None; + for y in 0..H { + for x in 0..W { + if self.tiles[y * W + x] == kind + && best.is_none_or(|b| cheb((x, y), c) < cheb(b, c)) + { + best = Some((x, y)); + } + } + } + best + } + + fn random_land(&mut self) -> Option<(usize, usize)> { + for _ in 0..50 { + let (x, y) = (self.rng.below(W), self.rng.below(H)); + if self.walkable(x as i32, y as i32) { + return Some((x, y)); + } + } + None + } + + fn spawn_person(&mut self) { + let (x, y) = self.home; + self.people.push(Person { + id: self.next_id, + x, + y, + hunger: 10.0, + food: 0, + wood: 0, + stone: 0, + action: "idle", + dead: false, + }); + self.next_id += 1; + } + + fn say(&mut self, s: String) { + self.log + .push(format!("[day {}] {}", self.tick / DAY + 1, s)); + if self.log.len() > 8 { + self.log.remove(0); + } + } + + pub fn daylight(&self) -> f32 { + let phase = (self.tick % DAY) as f32 / DAY as f32; + let v = -(phase * std::f32::consts::TAU).cos(); + (0.5 + v * 0.8).clamp(0.0, 1.0) + } + + fn walkable(&self, x: i32, y: i32) -> bool { + if x < 0 || y < 0 || x >= W as i32 || y >= H as i32 { + return false; + } + matches!( + self.tiles[y as usize * W + x as usize], + SAND | GRASS | FOREST | HILLS + ) + } + + fn move_dir(&mut self, x: usize, y: usize, dx: i32, dy: i32) -> (usize, usize) { + for (a, b) in [(dx, dy), (dx, 0), (0, dy)] { + if (a, b) != (0, 0) && self.walkable(x as i32 + a, y as i32 + b) { + return ((x as i32 + a) as usize, (y as i32 + b) as usize); + } + } + self.wander(x, y) + } + + fn wander(&mut self, x: usize, y: usize) -> (usize, usize) { + for _ in 0..4 { + let (a, b) = (self.rng.below(3) as i32 - 1, self.rng.below(3) as i32 - 1); + if (a, b) != (0, 0) && self.walkable(x as i32 + a, y as i32 + b) { + return ((x as i32 + a) as usize, (y as i32 + b) as usize); + } + } + (x, y) + } + + fn move_toward(&mut self, from: (usize, usize), to: (usize, usize)) -> (usize, usize) { + let dx = (to.0 as i32 - from.0 as i32).signum(); + let dy = (to.1 as i32 - from.1 as i32).signum(); + self.move_dir(from.0, from.1, dx, dy) + } + + fn nearest_res(&self, from: (usize, usize), kind: u8, r: usize) -> Option<(usize, usize)> { + let mut best: Option<(usize, usize)> = None; + let (x0, x1) = (from.0.saturating_sub(r), (from.0 + r).min(W - 1)); + let (y0, y1) = (from.1.saturating_sub(r), (from.1 + r).min(H - 1)); + for y in y0..=y1 { + for x in x0..=x1 { + if self.res[y * W + x].0 == kind + && best.is_none_or(|b| cheb((x, y), from) < cheb(b, from)) + { + best = Some((x, y)); + } + } + } + best + } + + fn nearest_animal(list: &[Animal], from: (usize, usize), r: usize) -> Option { + let mut best: Option<(usize, usize)> = None; + for (i, a) in list.iter().enumerate() { + let d = cheb((a.x, a.y), from); + if !a.dead && d <= r && best.is_none_or(|(_, bd)| d < bd) { + best = Some((i, d)); + } + } + best.map(|b| b.0) + } + + pub fn step(&mut self) { + self.tick += 1; + self.regrow(); + if self.tick % 2 == 0 { + for i in 0..self.people.len() { + self.tick_person(i); + } + for i in 0..self.rabbits.len() { + self.tick_rabbit(i); + } + } else { + for i in 0..self.wolves.len() { + self.tick_wolf(i); + } + } + self.people.retain(|p| !p.dead); + self.rabbits.retain(|a| !a.dead); + self.wolves.retain(|a| !a.dead); + self.population(); + } + + fn regrow(&mut self) { + for _ in 0..20 { + let i = self.rng.below(W * H); + if self.res[i].0 != 0 { + continue; + } + let p = self.rng.f(); + match self.tiles[i] { + FOREST if p < 0.02 => self.res[i] = (TREE, 5), + GRASS if p < 0.02 => self.res[i] = (BERRY, 4), + HILLS if p < 0.004 => self.res[i] = (STONE, 6), + _ => {} + } + } + } + + fn population(&mut self) { + if self.tick % 400 == 0 && self.food >= 10 && self.people.len() < 8 + self.houses.len() * 3 + { + self.food -= 6; + self.spawn_person(); + self.say("A child was born".into()); + } + if self.tick % 50 == 0 { + if self.rabbits.len() < 15 { + if let Some(p) = self.random_land() { + self.rabbits.push(Animal { + x: p.0, + y: p.1, + energy: 70.0, + dead: false, + }); + } + } + if self.wolves.len() < 2 && self.rabbits.len() > 40 { + if let Some(p) = self.random_land() { + if cheb(p, self.home) > 15 { + self.wolves.push(Animal { + x: p.0, + y: p.1, + energy: 70.0, + dead: false, + }); + } + } + } + } + } + + fn tick_person(&mut self, i: usize) { + let mut p = self.people[i].clone(); + let night = self.daylight() < 0.2; + let pos = (p.x, p.y); + let at_home = cheb(pos, self.home) <= 1; + p.hunger += if night && at_home { 0.04 } else { 0.1 }; + if p.hunger >= 100.0 { + p.dead = true; + self.say(format!("Person #{} starved", p.id)); + self.people[i] = p; + return; + } + if at_home { + self.food += p.food; + self.wood += p.wood; + self.stone += p.stone; + p.food = 0; + p.wood = 0; + p.stone = 0; + if p.hunger > 40.0 && self.food > 0 { + self.food -= 1; + p.hunger = (p.hunger - 25.0).max(0.0); + } + if self.wood >= 3 && self.stone >= 1 && self.tools < self.people.len() as u32 { + self.wood -= 3; + self.stone -= 1; + self.tools += 1; + } + if self.wood >= 12 && self.stone >= 4 && self.houses.len() < 10 { + let (hx, hy) = self.home; + let x = (hx as i32 + self.rng.below(13) as i32 - 6).clamp(0, W as i32 - 1); + let y = (hy as i32 + self.rng.below(13) as i32 - 6).clamp(0, H as i32 - 1); + if self.walkable(x, y) + && self.res[y as usize * W + x as usize].0 == 0 + && !self.houses.contains(&(x as usize, y as usize)) + { + self.wood -= 12; + self.stone -= 4; + self.houses.push((x as usize, y as usize)); + self.say("A new house was built".into()); + } + } + } + if p.hunger > 50.0 && p.food > 0 { + p.food -= 1; + p.hunger = (p.hunger - 25.0).max(0.0); + } + + let home = self.home; + let yield_n = if self.tools >= self.people.len() as u32 { + 2 + } else { + 1 + }; + let mut target: Option<(usize, usize)> = None; + let mut gather: Option = None; + let mut hunt = false; + + if night { + if at_home { + p.action = "sleeping"; + } else { + p.action = "going home"; + target = Some(home); + } + } else if p.hunger > 50.0 && p.food == 0 && self.food > 0 { + p.action = "going to eat"; + target = Some(home); + } else if p.carry() >= 6 { + p.action = "hauling goods"; + target = Some(home); + } else { + let order: [u8; 3] = if self.food < 12 || p.hunger > 50.0 { + [BERRY, TREE, STONE] + } else if self.wood <= self.stone * 2 + 8 { + [TREE, STONE, BERRY] + } else { + [STONE, TREE, BERRY] + }; + for k in order { + if let Some(t) = self.nearest_res(pos, k, 25) { + target = Some(t); + gather = Some(k); + p.action = match k { + BERRY => "foraging", + TREE => "chopping wood", + _ => "mining stone", + }; + break; + } + } + if (self.food < 12 || p.hunger > 50.0) + && (gather != Some(BERRY) || self.rng.chance(0.3)) + { + if let Some(r) = Self::nearest_animal(&self.rabbits, pos, 25) { + target = Some((self.rabbits[r].x, self.rabbits[r].y)); + hunt = true; + gather = None; + p.action = "hunting"; + } + } + } + + if let Some(t) = target { + if hunt { + if cheb(pos, t) <= 1 { + if let Some(r) = Self::nearest_animal(&self.rabbits, pos, 1) { + if self.rng.chance(0.5) { + self.rabbits[r].dead = true; + p.food += 3; + } + } + } else { + (p.x, p.y) = self.move_toward(pos, t); + } + } else if let Some(k) = gather { + let idx = pos.1 * W + pos.0; + if pos == t && self.res[idx].0 == k { + let take = (yield_n as u16).min(self.res[idx].1); + self.res[idx].1 -= take; + if self.res[idx].1 == 0 { + self.res[idx].0 = 0; + } + match k { + BERRY => p.food += take as u32, + TREE => p.wood += take as u32, + _ => p.stone += take as u32, + } + } else { + (p.x, p.y) = self.move_toward(pos, t); + } + } else if !at_home || p.action == "going to eat" && cheb(pos, t) > 1 { + (p.x, p.y) = self.move_toward(pos, t); + } + } else if p.action != "sleeping" { + p.action = "wandering"; + (p.x, p.y) = self.wander(p.x, p.y); + } + self.people[i] = p; + } + + fn tick_rabbit(&mut self, i: usize) { + let mut a = self.rabbits[i].clone(); + a.energy -= 0.05; + if matches!(self.tiles[a.y * W + a.x], GRASS | FOREST) { + a.energy = (a.energy + 0.2).min(100.0); + } + let pos = (a.x, a.y); + if let Some(w) = Self::nearest_animal(&self.wolves, pos, 4) { + if self.rng.chance(0.8) { + let dx = (a.x as i32 - self.wolves[w].x as i32).signum(); + let dy = (a.y as i32 - self.wolves[w].y as i32).signum(); + (a.x, a.y) = self.move_dir(a.x, a.y, dx, dy); + } + } else if self.rng.chance(0.5) { + (a.x, a.y) = self.wander(a.x, a.y); + } + if a.energy > 80.0 && self.rabbits.len() < 120 && self.rng.chance(0.01) { + a.energy -= 30.0; + self.rabbits.push(Animal { + x: a.x, + y: a.y, + energy: 50.0, + dead: false, + }); + } + if a.energy <= 0.0 { + a.dead = true; + } + self.rabbits[i] = a; + } + + fn tick_wolf(&mut self, i: usize) { + let mut a = self.wolves[i].clone(); + a.energy -= 0.15; + let pos = (a.x, a.y); + if let Some(r) = Self::nearest_animal(&self.rabbits, pos, 10) { + let t = (self.rabbits[r].x, self.rabbits[r].y); + if cheb(pos, t) <= 1 { + if self.rng.chance(0.4) { + self.rabbits[r].dead = true; + a.energy = (a.energy + 50.0).min(100.0); + } + } else { + (a.x, a.y) = self.move_toward(pos, t); + } + } else { + (a.x, a.y) = self.wander(a.x, a.y); + } + if a.energy > 90.0 && self.wolves.len() < 10 && self.rng.chance(0.02) { + a.energy -= 40.0; + self.wolves.push(Animal { + x: a.x, + y: a.y, + energy: 50.0, + dead: false, + }); + } + if a.energy <= 0.0 { + a.dead = true; + } + self.wolves[i] = a; + } + + pub fn terrain_json(&self) -> String { + let tiles: String = self.tiles.iter().map(|t| (b'0' + t) as char).collect(); + format!("{{\"w\":{},\"h\":{},\"tiles\":\"{}\"}}", W, H, tiles) + } + + pub fn state_json(&self) -> String { + let mut s = String::new(); + let _ = write!( + s, + "{{\"tick\":{},\"day\":{},\"hour\":{:.2},\"daylight\":{:.3},\"stock\":{{\"food\":{},\"wood\":{},\"stone\":{},\"tools\":{}}},\"home\":[{},{}],", + self.tick, + self.tick / DAY + 1, + (self.tick % DAY) as f32 / DAY as f32 * 24.0, + self.daylight(), + self.food, + self.wood, + self.stone, + self.tools, + self.home.0, + self.home.1 + ); + s.push_str("\"houses\":["); + let j: Vec = self + .houses + .iter() + .map(|h| format!("[{},{}]", h.0, h.1)) + .collect(); + s.push_str(&j.join(",")); + s.push_str("],\"people\":["); + let j: Vec = self + .people + .iter() + .map(|p| { + format!( + "[{},{},{},\"{}\",{:.0},{},{},{}]", + p.id, p.x, p.y, p.action, p.hunger, p.food, p.wood, p.stone + ) + }) + .collect(); + s.push_str(&j.join(",")); + s.push_str("],\"rabbits\":["); + let j: Vec = self + .rabbits + .iter() + .map(|a| format!("[{},{},{:.0}]", a.x, a.y, a.energy)) + .collect(); + s.push_str(&j.join(",")); + s.push_str("],\"wolves\":["); + let j: Vec = self + .wolves + .iter() + .map(|a| format!("[{},{},{:.0}]", a.x, a.y, a.energy)) + .collect(); + s.push_str(&j.join(",")); + s.push_str("],\"res\":["); + let mut first = true; + for (i, r) in self.res.iter().enumerate() { + if r.0 != 0 { + if !first { + s.push(','); + } + first = false; + let _ = write!(s, "[{},{},{},{}]", i % W, i / W, r.0, r.1); + } + } + s.push_str("],\"log\":["); + let j: Vec = self.log.iter().map(|l| format!("\"{}\"", l)).collect(); + s.push_str(&j.join(",")); + s.push_str("]}"); + s + } +}