From 70e1e9d7479cbdb916eb48e28b846c8d15f50f78 Mon Sep 17 00:00:00 2001 From: hheik <4469778+hheik@users.noreply.github.com> Date: Fri, 22 Mar 2024 11:14:54 +0200 Subject: [PATCH] Reworked combat. Creatures and sites are now built from definition files. --- src/definitions.rs | 33 ++- src/definitions/parser.rs | 19 +- src/main.rs | 423 ++++----------------------------- src/sim.rs | 485 ++++++++++++++++++++++++++++++++++++++ world.bin | Bin 436 -> 0 bytes 5 files changed, 556 insertions(+), 404 deletions(-) create mode 100644 src/sim.rs delete mode 100644 world.bin diff --git a/src/definitions.rs b/src/definitions.rs index 6aaf3a3..701b7c8 100644 --- a/src/definitions.rs +++ b/src/definitions.rs @@ -1,3 +1,5 @@ +use serde::{Deserialize, Serialize}; + use self::parser::{FilePosition, ParseError, ParseQuery}; pub mod parser; @@ -21,20 +23,7 @@ impl Tag { } } -#[derive(Default, Debug)] -pub struct Definitions { - pub sites: Vec, - pub creatures: Vec, -} - -impl Definitions { - pub fn append(&mut self, mut other: Self) { - self.sites.append(&mut other.sites); - self.creatures.append(&mut other.creatures); - } -} - -#[derive(Default, Debug)] +#[derive(Clone, Default, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] pub struct SiteDef { pub id: String, pub min_size: i32, @@ -61,17 +50,17 @@ impl SiteDef { } } -#[derive(Default, Debug)] +#[derive(Clone, Default, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] pub struct CreatureDef { pub id: String, pub name_singular: String, pub name_plural: String, pub health: i32, - pub armor_class: i32, - pub to_hit_modifier: i32, + pub armor_class: i8, + pub to_hit_modifier: i8, pub min_damage: i32, pub max_damage: i32, - pub multiattack: Option, + pub multiattack: Option, pub is_humanoid: bool, pub is_animal: bool, pub patrols: bool, @@ -105,4 +94,12 @@ impl CreatureDef { } Ok(result) } + + pub fn attack_count(&self) -> u32 { + self.multiattack.unwrap_or(1) + } + + pub fn damage_roll(&self) -> i32 { + fastrand::i32(self.min_damage..=self.max_damage) + } } diff --git a/src/definitions/parser.rs b/src/definitions/parser.rs index 790a013..b18c3c2 100644 --- a/src/definitions/parser.rs +++ b/src/definitions/parser.rs @@ -1,6 +1,6 @@ use std::{borrow::BorrowMut, io::Read, str::FromStr}; -use crate::definitions::{CreatureDef, Definitions, SiteDef, Tag, TopLevelTag}; +use crate::definitions::{CreatureDef, SiteDef, Tag, TopLevelTag}; pub trait ParseQuery: Sized { fn parse(values: &Vec) -> Result; @@ -14,6 +14,10 @@ trait Parseable: Sized + FromStr { // Implement for types that tags contain impl Parseable for String {} +impl Parseable for i8 {} +impl Parseable for u8 {} +impl Parseable for i16 {} +impl Parseable for u16 {} impl Parseable for i32 {} impl Parseable for u32 {} impl Parseable for f32 {} @@ -79,7 +83,7 @@ pub enum LexerError { pub struct DefinitionParser; impl DefinitionParser { - pub fn parse(source: impl Read) -> Result { + pub fn parse(source: impl Read) -> Result<(Vec, Vec), ParseError> { let tags = match Self::lexer(source) { Ok(tags) => tags, Err(err) => return Err(ParseError::LexerError(err)), @@ -114,21 +118,20 @@ impl DefinitionParser { } } - let mut defs = Definitions::default(); + let mut sites = vec![]; + let mut creatures = vec![]; for (header, tags) in unparsed_defs { match Self::match_top_level_tag(&header.name) { Some(def_type) => match def_type { - TopLevelTag::Site => defs.sites.push(SiteDef::parse(header, &tags)?), - TopLevelTag::Creature => { - defs.creatures.push(CreatureDef::parse(header, &tags)?) - } + TopLevelTag::Site => sites.push(SiteDef::parse(header, &tags)?), + TopLevelTag::Creature => creatures.push(CreatureDef::parse(header, &tags)?), }, None => { return Err(ParseError::UnexpectedError); } } } - Ok(defs) + Ok((sites, creatures)) } fn lexer(mut source: impl Read) -> Result, LexerError> { diff --git a/src/main.rs b/src/main.rs index f2cc43b..33c5563 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,54 +1,35 @@ -use std::{ - collections::{HashMap, HashSet}, - fmt::Display, - io::BufReader, - num::NonZeroUsize, - path::PathBuf, - time::Duration, -}; +use std::{collections::HashMap, io::BufReader, path::PathBuf, time::Duration}; -use definitions::{parser::DefinitionParser, Definitions}; -use enum_dispatch::enum_dispatch; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; +use definitions::parser::DefinitionParser; +use sim::{Site, World}; pub mod definitions; +pub mod sim; const SAVE_FILE: &'static str = "world.bin"; -fn resources_path() -> PathBuf { - PathBuf::from(".").join("defs") -} - 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 - } - }; - - let mut definitions = Definitions::default(); let mut parse_error_files = vec![]; + let mut site_definitions = HashMap::new(); + let mut creature_definitions = HashMap::new(); for entry in std::fs::read_dir(resources_path()).unwrap() { match entry { Ok(entry) => { if entry.file_name().to_string_lossy().ends_with(".def") { let source = BufReader::new(std::fs::File::open(entry.path()).unwrap()); match DefinitionParser::parse(source) { - Ok(new_defs) => { - println!("Parsed\t{:?}", entry.path()); - definitions.append(new_defs); + Ok(defs) => { + for def in defs.0 { + if let Some(prev) = site_definitions.insert(def.id.clone(), def) { + eprintln!("Duplicate site definition '{}'", prev.id); + } + } + for def in defs.1 { + if let Some(prev) = creature_definitions.insert(def.id.clone(), def) + { + eprintln!("Duplicate site definition '{}'", prev.id); + } + } } Err(err) => { parse_error_files.push((err, entry.path())); @@ -67,353 +48,39 @@ fn main() { eprintln!("{} file(s) had parsing errors!", parse_error_files.len()) } - // 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); + let site_def = site_definitions.get("CAVE").unwrap(); + 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::from_def(&site_def); + site.populate_randomly(&creature_definitions.values().collect::>()); + world.sites.push(site); + std::fs::write("world.bin", bincode::serialize(&world).unwrap()).unwrap(); + world } - } -} + }; -#[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) + for site in world.sites.iter() { + println!("{site}") } - 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 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) { - 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(CreatureBehaviour::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(CreatureBehaviour::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: CreatureBehaviour, -} - -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: CreatureBehaviour) -> Self { - Self { - uuid: Uuid::new_v4(), - data, - } - } -} - -#[enum_dispatch(CreatureBehaviour)] -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 CreatureBehaviour { - Default(DefaultBehaviour), - Bandit(Bandit), - Spider(Spider), -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub struct DefaultBehaviour; - -impl CreatureImpl for DefaultBehaviour { - fn species_id(&self) -> String { - "default".into() - } -} - -#[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); - } - } + let start = std::time::Instant::now(); + world.advance_time(Duration::from_secs(3600)); + let end = std::time::Instant::now(); + println!("World tick: {}us", (end - start).as_micros()); } - - Some(CombatResults { - kills: kills - .iter() - .map(|(killed, killer)| KillData { - killed: *killed, - killer: *killer, - }) - .collect(), - }) +} + +fn resources_path() -> PathBuf { + PathBuf::from(".").join("defs") } diff --git a/src/sim.rs b/src/sim.rs new file mode 100644 index 0000000..b2bf6f1 --- /dev/null +++ b/src/sim.rs @@ -0,0 +1,485 @@ +use std::{ + collections::{HashMap, HashSet}, + fmt::Display, + num::NonZeroUsize, + time::Duration, +}; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::definitions::{CreatureDef, SiteDef}; + +pub fn roll_d20() -> i8 { + fastrand::i8(1..=20) +} + +#[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 attack in results.attacks { + println!( + "{} attacks {} [{}]: {attack}", + attack.attacker.map_or("Natural causes", |attacker| &area + .population + .iter() + .find(|creat| creat.uuid == attacker) + .unwrap() + .definition + .name_singular), + area.population + .iter() + .find(|creat| creat.uuid == attack.target) + .unwrap() + .definition + .name_singular, + attack.target + ); + if let Some(damage) = attack.damage { + let target = match area + .population + .iter_mut() + .find(|creature| creature.uuid == attack.target) + { + Some(target) => target, + None => continue, + }; + target.state.damage = target.state.damage.saturating_add(damage); + } + } + 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 from_def(site_def: &SiteDef) -> Self { + Self::new( + "Gorbo's cave", + fastrand::usize(site_def.min_size as usize..=site_def.max_size as usize) + .try_into() + .expect("Generated site with size of 0"), + ) + } + + 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, creature_defs: &[&CreatureDef]) { + 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; + let creature = + Creature::new((*fastrand::choice(creature_defs.iter()).unwrap()).clone()); + self.areas + .get_mut(index) + .expect("Creature generation index for depth is out of bounds") + .population + .push(creature) + } + } + } + + 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, Default, Eq, PartialEq, Hash, Debug, Serialize, Deserialize)] +pub struct CreatureState { + pub damage: i32, +} + +#[derive(Clone, Eq, PartialEq, Hash, Debug, Serialize, Deserialize)] +pub struct Creature { + pub definition: CreatureDef, + pub state: CreatureState, + pub uuid: Uuid, +} + +impl Creature { + pub fn new(definition: CreatureDef) -> Self { + Self { + uuid: Uuid::new_v4(), + state: CreatureState::default(), + definition, + } + } + + pub fn health(&self) -> i32 { + self.definition.health - self.state.damage + } + + pub fn is_alive(&self) -> bool { + self.health() > 0 + } +} + +impl Display for Creature { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} [{}]", self.definition.name_singular, self.uuid) + } +} + +#[derive(Debug)] +pub struct KillData { + pub killed: Uuid, + pub killer: Option, +} + +#[derive(Clone, Debug)] +pub struct Attack { + pub to_hit_modifier: i8, + pub threat_treshold: i8, + pub armor_class: i8, +} + +#[derive(Debug)] +pub enum AttackResult { + CritMiss, + Miss, + Hit, + Crit, +} + +#[derive(Clone, Debug)] +pub struct AttackRoll { + pub attack: Attack, + pub roll: i8, + pub threat_roll: Option, +} + +impl AttackRoll { + pub fn roll(attack: Attack, roll_fn: fn() -> i8) -> Self { + let roll = roll_fn(); + let mut threat_roll = None; + if roll >= attack.threat_treshold { + threat_roll = Some(roll_fn()); + } + Self { + attack, + roll, + threat_roll, + } + } + + pub fn is_crit_miss(&self) -> bool { + self.roll == 1 + } + + pub fn is_auto_hit(&self) -> bool { + self.roll == 20 + } + + pub fn is_hit(&self) -> bool { + if self.is_crit_miss() { + return false; + } + if self.is_auto_hit() { + return true; + } + (self.roll + self.attack.to_hit_modifier) >= self.attack.armor_class + } + + pub fn is_crit(&self) -> bool { + match self.threat_roll { + Some(roll) => (roll + self.attack.to_hit_modifier) >= self.attack.armor_class, + None => false, + } + } + + pub fn result(&self) -> AttackResult { + if self.is_crit_miss() { + return AttackResult::CritMiss; + } + match self.is_hit() { + true => { + if self.is_crit() { + AttackResult::Crit + } else { + AttackResult::Hit + } + } + false => AttackResult::Miss, + } + } +} + +#[derive(Debug)] +pub struct AttackData { + pub target: Uuid, + pub attacker: Option, + pub attack_roll: AttackRoll, + pub damage: Option, + pub is_deathblow: bool, +} + +impl Display for AttackData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(damage) = self.damage { + write!(f, "{damage} damage ")?; + } + match self.attack_roll.result() { + AttackResult::CritMiss => write!(f, "*Critical Miss* ")?, + AttackResult::Miss => write!(f, "*Miss* ")?, + AttackResult::Crit => write!(f, "!Critical hit! ")?, + _ => (), + }; + let mod_symbol = if self.attack_roll.attack.to_hit_modifier >= 0 { + '+' + } else { + '-' + }; + write!( + f, + ": {total} ({calc_roll} {calc_symbol} {calc_mod}) vs. {ac}", + total = self.attack_roll.roll + self.attack_roll.attack.to_hit_modifier, + calc_roll = self.attack_roll.roll, + calc_symbol = mod_symbol, + calc_mod = self.attack_roll.attack.to_hit_modifier.abs(), + ac = self.attack_roll.attack.armor_class, + )?; + write!( + f, + "{}", + if self.is_deathblow { + " - Killing blow!" + } else { + "" + } + ) + } +} + +#[derive(Debug)] +pub struct CombatResults { + pub kills: Vec, + pub attacks: Vec, +} + +#[derive(Debug)] +struct CombatState { + creature: Creature, + damage: i32, + enemies: HashSet, +} + +impl CombatState { + pub fn is_alive(&self) -> bool { + self.damage < self.creature.health() + } +} + +impl CombatState { + pub fn from_creature(creature: Creature) -> Self { + Self { + creature, + damage: 0, + enemies: HashSet::new(), + } + } +} + +pub fn resolve_combat(combatants: &[Creature], 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.definition.id != c2.definition.id; + if is_enemy { + if !participants.contains_key(&c1.uuid) { + participants.insert(c1.uuid, CombatState::from_creature(c1.clone())); + } + if !participants.contains_key(&c2.uuid) { + participants.insert(c2.uuid, CombatState::from_creature(c2.clone())); + } + 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().map(|(uuid, _)| *uuid).collect(); + fastrand::shuffle(&mut order); + let mut kills: Vec<_> = vec![]; + let mut attacks: Vec<_> = vec![]; + + for _ in 0..max_rounds { + let should_continue = participants.iter().any(|(_, combat)| { + combat.is_alive() + && combat + .enemies + .iter() + .any(|uuid| participants[uuid].is_alive()) + }); + if !should_continue { + break; + } + + for uuid in order.iter() { + for _ in 0..participants[uuid].creature.definition.attack_count() { + if !participants[uuid].is_alive() { + continue; + } + let targets: Vec<_> = participants[uuid] + .enemies + .iter() + .filter(|enemy_uuid| participants[*enemy_uuid].is_alive()) + .collect(); + let target = match fastrand::choice(targets.iter()) { + Some(target) => **target, + None => continue, + }; + + let attack = Attack { + to_hit_modifier: participants[uuid].creature.definition.to_hit_modifier, + threat_treshold: 20, + armor_class: participants[&target].creature.definition.armor_class, + }; + + let attack_roll = AttackRoll::roll(attack.clone(), roll_d20); + let damage = match attack_roll.result() { + AttackResult::CritMiss | AttackResult::Miss => None, + AttackResult::Hit => Some(participants[uuid].creature.definition.damage_roll()), + AttackResult::Crit => { + Some(participants[uuid].creature.definition.damage_roll() * 2) + } + }; + let is_deathblow = match damage { + Some(damage) => { + let enemy = participants.get_mut(&target).unwrap(); + enemy.damage = enemy.damage.saturating_add(damage); + !enemy.is_alive() + } + None => false, + }; + if is_deathblow { + kills.push(KillData { + killed: target, + killer: Some(*uuid), + }) + } + let attack_data = AttackData { + attacker: Some(*uuid), + target, + attack_roll, + damage, + is_deathblow, + }; + attacks.push(attack_data); + } + } + } + + Some(CombatResults { kills, attacks }) +} diff --git a/world.bin b/world.bin deleted file mode 100644 index dc631a72edc2663abe3a8187b1b9ee6bcd6bdbac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 436 zcmZQ%fB+sS?Vewhl&@Z_;GLRUzzh{*gVF*}`tU~nzNQej-Zs}on?u9*=YWJ^a?dYo zWXe@LbIuWC+HmYi>2YMa^J4J}YX3RN?0c_mH~o|1dSp4lU&_H^u`cy*UNNOD_`F6`Od>eOezvbJCWtYLz{G8O!Es^vTD6?@v?s&$Z}oV z<)x+D91JI47IKQ_ml9?KO2WbmX795*EV2h1eRTHse|_Pbz{rZMzoXcfJuS_lzAe)D iMS<6wIUxNoeeVt@ddN+1s+nEsd3q+pJtmMGG5`Q5hho+M