commit 8bba930fbd7aef95b42be0957e4be7b9a038293f Author: hheik <4469778+hheik@users.noreply.github.com> Date: Mon Mar 18 20:46:57 2024 +0200 Simple site simulation with combat 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..7c7ea60 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,190 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adventure_sim" +version = "0.1.0" +dependencies = [ + "bincode", + "enum_dispatch", + "fastrand", + "serde", + "uuid", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "enum_dispatch" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f33313078bb8d4d05a2733a94ac4c2d8a0df9a2b84424ebf4f33bfc224a890e" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fastrand" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" + +[[package]] +name = "getrandom" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "proc-macro2" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "serde" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "uuid" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00cc9702ca12d3c81455259621e676d0f7251cec66a21e98fe2e9a37db93b2a" +dependencies = [ + "getrandom", + "rand", + "serde", + "uuid-macro-internal", +] + +[[package]] +name = "uuid-macro-internal" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abb14ae1a50dad63eaa768a458ef43d298cd1bd44951677bd10b732a9ba2a2d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..65b9b78 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "adventure_sim" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +bincode = "1.3.3" +enum_dispatch = "0.3.12" +fastrand = "2.0.1" +serde = { version = "1.0.197", features = [ "serde_derive" ] } +uuid = { version = "1.7.0", features = [ "v4", "fast-rng", "macro-diagnostics", "serde" ] } diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..3d8ec24 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,372 @@ +use std::{ + collections::{HashMap, HashSet}, + fmt::Display, + num::NonZeroUsize, + ops::Sub, + time::Duration, +}; + +use enum_dispatch::enum_dispatch; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +const SAVE_FILE: &'static str = "world.bin"; + +fn main() { + let mut world = match std::fs::read(SAVE_FILE) { + Ok(data) => bincode::deserialize(&data).unwrap(), + Err(_) => { + let mut world = World::default(); + let mut site = Site::new( + "Gorbo's Keep", + fastrand::usize(1..5) + .try_into() + .expect("Generated site with size of 0"), + ); + site.populate_randomly(); + world.sites.push(site); + std::fs::write("world.bin", bincode::serialize(&world).unwrap()).unwrap(); + world + } + }; + + for site in world.sites.iter() { + println!("{site}") + } + + let mut input = String::new(); + loop { + std::io::stdin().read_line(&mut input).unwrap(); + if vec!["q", "quit", "exit"] + .iter() + .any(|quit_cmd| input.trim() == *quit_cmd) + { + break; + } + let start = std::time::Instant::now(); + world.advance_time(Duration::from_secs(3600)); + let end = std::time::Instant::now(); + println!("World tick: {}us", end.sub(start).as_micros()); + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct World { + pub sites: Vec, +} + +impl World { + pub fn advance_time(&mut self, time: Duration) { + for site in self.sites.iter_mut() { + site.advance_time(time); + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Site { + pub name: String, + areas: Vec, + accumulated_time: Duration, +} + +impl Site { + #[inline] + const fn minimum_tick() -> Duration { + Duration::from_secs(3600) + } + + fn inner_tick(&mut self) { + for area in self.areas.iter_mut() { + match resolve_combat(&area.population, 600) { + Some(results) => { + for death in results.kills { + area.population + .retain(|creature| creature.uuid != death.killed); + } + } + None => { + // If no combat, there is a chance of migration to other areas + } + } + } + println!("Tick! {}", self); + } + + pub fn areas(&self) -> &Vec { + self.areas.as_ref() + } + + pub fn areas_mut(&mut self) -> &mut Vec { + self.areas.as_mut() + } + + pub fn populate_randomly(&mut self) { + let area_count = self.areas.len(); + { + let min_count = area_count; + let max_count = (5 + (area_count - 1) * 3).max(min_count); + for _ in 0..fastrand::usize(min_count..=max_count) { + let area_t = fastrand::f32().powi(2); + let index = (area_t * (area_count - 1) as f32).round() as usize; + self.areas + .get_mut(index) + .expect("Creature generation index for depth is out of bounds") + .population + .push(Creature::new(CreatureData::Bandit(Bandit::default()))) + } + } + { + let min_count = 0; + let max_count = area_count.max(min_count); + for _ in 0..fastrand::usize(min_count..=max_count) { + let area_t = 1.0 - fastrand::f32().powi(2); + let index = (area_t * (area_count - 1) as f32).round() as usize; + self.areas + .get_mut(index) + .expect("Creature generation index for depth is out of bounds") + .population + .push(Creature::new(CreatureData::Spider(Spider::default()))) + } + } + } + + pub fn new(name: &str, size: NonZeroUsize) -> Self { + Self { + name: name.into(), + areas: vec![SiteArea::default(); size.get()], + accumulated_time: Duration::default(), + } + } + + pub fn advance_time(&mut self, time: Duration) { + self.accumulated_time += time; + while self.accumulated_time >= Self::minimum_tick() { + self.inner_tick(); + self.accumulated_time -= Self::minimum_tick(); + } + } + + /// Finds a creature with given uuid, removes it from the site and returns it with ownership + pub fn remove_by_uuid(&mut self, creature_uuid: Uuid) -> Option { + for area in self.areas.iter_mut() { + for i in 0..area.population.len() { + if area.population[i].uuid == creature_uuid { + return Some(area.population.swap_remove(i)); + } + } + } + None + } +} + +impl Display for Site { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "site \"{}\" [", self.name)?; + for (index, area) in self.areas.iter().enumerate() { + write!(f, "\n\tarea {} [", index)?; + for creature in area.population.iter() { + write!(f, "\n\t\t{}", creature)?; + } + if area.population.len() > 0 { + write!(f, "\n\t")?; + } + write!(f, "]")?; + } + if !self.areas.is_empty() { + write!(f, "\n")?; + } + write!(f, "]") + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct SiteArea { + pub population: Vec, +} + +#[derive(Clone, Eq, PartialEq, Hash, Debug, Serialize, Deserialize)] +pub struct Creature { + pub uuid: Uuid, + pub data: CreatureData, +} + +impl Display for Creature { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} [{}]", self.data.species_id(), self.uuid) + } +} + +impl Creature { + pub fn new(data: CreatureData) -> Self { + Self { + uuid: Uuid::new_v4(), + data, + } + } +} + +#[enum_dispatch(CreatureData)] +pub trait CreatureImpl { + fn species_id(&self) -> String; + + fn initiative_modifier(&self) -> i8 { + 0 + } + fn threat_rating(&self) -> u8 { + 1 + } + fn ally_predicate(&self, other: &dyn CreatureImpl) -> bool { + self.species_id() == other.species_id() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +#[enum_dispatch] +pub enum CreatureData { + Bandit(Bandit), + Spider(Spider), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct Bandit {} + +impl CreatureImpl for Bandit { + fn species_id(&self) -> String { + "bandit".into() + } + fn threat_rating(&self) -> u8 { + 2 + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct Spider {} + +impl CreatureImpl for Spider { + fn species_id(&self) -> String { + "spider".into() + } + fn threat_rating(&self) -> u8 { + 4 + } + fn initiative_modifier(&self) -> i8 { + 1 + } +} + +#[derive(Debug)] +pub struct KillData { + pub killed: Uuid, + pub killer: Uuid, +} + +#[derive(Debug)] +pub struct CombatResults { + pub kills: Vec, +} + +#[derive(Debug)] +struct CombatState { + initiative: i8, + health: u8, + damage: u8, + alive: bool, + enemies: HashSet, +} + +impl CombatState { + pub fn from_creature(creature: &Creature) -> Self { + Self { + initiative: fastrand::i8(1..=4).saturating_add(creature.data.initiative_modifier()), + health: creature.data.threat_rating(), + damage: creature.data.threat_rating(), + alive: true, + enemies: HashSet::new(), + } + } +} + +pub fn resolve_combat(combatants: &Vec, max_rounds: u16) -> Option { + let mut participants: HashMap = HashMap::new(); + for index_1 in 0..combatants.len() { + for index_2 in (index_1 + 1)..combatants.len() { + if index_1 == index_2 { + continue; + } + let c1 = &combatants[index_1]; + let c2 = &combatants[index_2]; + let is_enemy = !c1.data.ally_predicate(&c2.data) || !c2.data.ally_predicate(&c1.data); + if is_enemy { + if !participants.contains_key(&c1.uuid) { + participants.insert(c1.uuid, CombatState::from_creature(&c1)); + } + if !participants.contains_key(&c2.uuid) { + participants.insert(c2.uuid, CombatState::from_creature(&c2)); + } + participants + .get_mut(&c1.uuid) + .unwrap() + .enemies + .insert(c2.uuid); + participants + .get_mut(&c2.uuid) + .unwrap() + .enemies + .insert(c1.uuid); + } + } + } + + if participants.is_empty() { + // No enemies in group :) + return None; + } + + // Time for violence + let mut order: Vec<_> = participants.iter().collect(); + order.sort_unstable_by_key(|(_, combat_state)| -combat_state.initiative); + let order: Vec = order.iter().map(|(uuid, _)| **uuid).collect(); + let mut kills: HashMap = HashMap::new(); + + for _ in 0..max_rounds { + let should_continue = participants.iter().any(|(_, combat)| { + combat.alive && combat.enemies.iter().any(|uuid| participants[uuid].alive) + }); + if !should_continue { + break; + } + + for uuid in order.iter() { + if !participants[uuid].alive { + continue; + } + let targets: Vec<_> = participants[uuid] + .enemies + .iter() + .filter(|enemy_uuid| participants[*enemy_uuid].alive) + .collect(); + let target = match fastrand::choice(targets.iter()) { + Some(target) => **target, + None => continue, + }; + let damage = participants[uuid].damage; + let enemy = participants.get_mut(&target).unwrap(); + enemy.health = enemy.health.saturating_sub(fastrand::u8(0..=damage)); + if enemy.health == 0 { + enemy.alive = false; + kills.insert(target, *uuid); + } + } + } + + Some(CombatResults { + kills: kills + .iter() + .map(|(killed, killer)| KillData { + killed: *killed, + killer: *killer, + }) + .collect(), + }) +} diff --git a/world.bin b/world.bin new file mode 100644 index 0000000..dc631a7 Binary files /dev/null and b/world.bin differ