Reworked combat. Creatures and sites are now built from definition files.
parent
e9a1cf17b6
commit
70e1e9d747
|
|
@ -1,3 +1,5 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use self::parser::{FilePosition, ParseError, ParseQuery};
|
use self::parser::{FilePosition, ParseError, ParseQuery};
|
||||||
|
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
|
|
@ -21,20 +23,7 @@ impl Tag {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
#[derive(Clone, Default, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Definitions {
|
|
||||||
pub sites: Vec<SiteDef>,
|
|
||||||
pub creatures: Vec<CreatureDef>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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)]
|
|
||||||
pub struct SiteDef {
|
pub struct SiteDef {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub min_size: i32,
|
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 struct CreatureDef {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name_singular: String,
|
pub name_singular: String,
|
||||||
pub name_plural: String,
|
pub name_plural: String,
|
||||||
pub health: i32,
|
pub health: i32,
|
||||||
pub armor_class: i32,
|
pub armor_class: i8,
|
||||||
pub to_hit_modifier: i32,
|
pub to_hit_modifier: i8,
|
||||||
pub min_damage: i32,
|
pub min_damage: i32,
|
||||||
pub max_damage: i32,
|
pub max_damage: i32,
|
||||||
pub multiattack: Option<i32>,
|
pub multiattack: Option<u32>,
|
||||||
pub is_humanoid: bool,
|
pub is_humanoid: bool,
|
||||||
pub is_animal: bool,
|
pub is_animal: bool,
|
||||||
pub patrols: bool,
|
pub patrols: bool,
|
||||||
|
|
@ -105,4 +94,12 @@ impl CreatureDef {
|
||||||
}
|
}
|
||||||
Ok(result)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use std::{borrow::BorrowMut, io::Read, str::FromStr};
|
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 {
|
pub trait ParseQuery: Sized {
|
||||||
fn parse(values: &Vec<String>) -> Result<Self, ()>;
|
fn parse(values: &Vec<String>) -> Result<Self, ()>;
|
||||||
|
|
@ -14,6 +14,10 @@ trait Parseable: Sized + FromStr {
|
||||||
|
|
||||||
// Implement for types that tags contain
|
// Implement for types that tags contain
|
||||||
impl Parseable for String {}
|
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 i32 {}
|
||||||
impl Parseable for u32 {}
|
impl Parseable for u32 {}
|
||||||
impl Parseable for f32 {}
|
impl Parseable for f32 {}
|
||||||
|
|
@ -79,7 +83,7 @@ pub enum LexerError {
|
||||||
pub struct DefinitionParser;
|
pub struct DefinitionParser;
|
||||||
|
|
||||||
impl DefinitionParser {
|
impl DefinitionParser {
|
||||||
pub fn parse(source: impl Read) -> Result<Definitions, ParseError> {
|
pub fn parse(source: impl Read) -> Result<(Vec<SiteDef>, Vec<CreatureDef>), ParseError> {
|
||||||
let tags = match Self::lexer(source) {
|
let tags = match Self::lexer(source) {
|
||||||
Ok(tags) => tags,
|
Ok(tags) => tags,
|
||||||
Err(err) => return Err(ParseError::LexerError(err)),
|
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 {
|
for (header, tags) in unparsed_defs {
|
||||||
match Self::match_top_level_tag(&header.name) {
|
match Self::match_top_level_tag(&header.name) {
|
||||||
Some(def_type) => match def_type {
|
Some(def_type) => match def_type {
|
||||||
TopLevelTag::Site => defs.sites.push(SiteDef::parse(header, &tags)?),
|
TopLevelTag::Site => sites.push(SiteDef::parse(header, &tags)?),
|
||||||
TopLevelTag::Creature => {
|
TopLevelTag::Creature => creatures.push(CreatureDef::parse(header, &tags)?),
|
||||||
defs.creatures.push(CreatureDef::parse(header, &tags)?)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
return Err(ParseError::UnexpectedError);
|
return Err(ParseError::UnexpectedError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(defs)
|
Ok((sites, creatures))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lexer(mut source: impl Read) -> Result<Vec<Tag>, LexerError> {
|
fn lexer(mut source: impl Read) -> Result<Vec<Tag>, LexerError> {
|
||||||
|
|
|
||||||
421
src/main.rs
421
src/main.rs
|
|
@ -1,54 +1,35 @@
|
||||||
use std::{
|
use std::{collections::HashMap, io::BufReader, path::PathBuf, time::Duration};
|
||||||
collections::{HashMap, HashSet},
|
|
||||||
fmt::Display,
|
|
||||||
io::BufReader,
|
|
||||||
num::NonZeroUsize,
|
|
||||||
path::PathBuf,
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
use definitions::{parser::DefinitionParser, Definitions};
|
use definitions::parser::DefinitionParser;
|
||||||
use enum_dispatch::enum_dispatch;
|
use sim::{Site, World};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
pub mod definitions;
|
pub mod definitions;
|
||||||
|
pub mod sim;
|
||||||
|
|
||||||
const SAVE_FILE: &'static str = "world.bin";
|
const SAVE_FILE: &'static str = "world.bin";
|
||||||
|
|
||||||
fn resources_path() -> PathBuf {
|
|
||||||
PathBuf::from(".").join("defs")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
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 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() {
|
for entry in std::fs::read_dir(resources_path()).unwrap() {
|
||||||
match entry {
|
match entry {
|
||||||
Ok(entry) => {
|
Ok(entry) => {
|
||||||
if entry.file_name().to_string_lossy().ends_with(".def") {
|
if entry.file_name().to_string_lossy().ends_with(".def") {
|
||||||
let source = BufReader::new(std::fs::File::open(entry.path()).unwrap());
|
let source = BufReader::new(std::fs::File::open(entry.path()).unwrap());
|
||||||
match DefinitionParser::parse(source) {
|
match DefinitionParser::parse(source) {
|
||||||
Ok(new_defs) => {
|
Ok(defs) => {
|
||||||
println!("Parsed\t{:?}", entry.path());
|
for def in defs.0 {
|
||||||
definitions.append(new_defs);
|
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) => {
|
Err(err) => {
|
||||||
parse_error_files.push((err, entry.path()));
|
parse_error_files.push((err, entry.path()));
|
||||||
|
|
@ -67,353 +48,39 @@ fn main() {
|
||||||
eprintln!("{} file(s) had parsing errors!", parse_error_files.len())
|
eprintln!("{} file(s) had parsing errors!", parse_error_files.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
// for site in world.sites.iter() {
|
let site_def = site_definitions.get("CAVE").unwrap();
|
||||||
// println!("{site}")
|
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::<Vec<_>>());
|
||||||
|
world.sites.push(site);
|
||||||
|
std::fs::write("world.bin", bincode::serialize(&world).unwrap()).unwrap();
|
||||||
|
world
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// let mut input = String::new();
|
for site in world.sites.iter() {
|
||||||
// loop {
|
println!("{site}")
|
||||||
// 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)]
|
let mut input = String::new();
|
||||||
pub struct World {
|
loop {
|
||||||
pub sites: Vec<Site>,
|
std::io::stdin().read_line(&mut input).unwrap();
|
||||||
}
|
if vec!["q", "quit", "exit"]
|
||||||
|
.iter()
|
||||||
impl World {
|
.any(|quit_cmd| input.trim() == *quit_cmd)
|
||||||
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<SiteArea>,
|
|
||||||
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<SiteArea> {
|
|
||||||
self.areas.as_ref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn areas_mut(&mut self) -> &mut Vec<SiteArea> {
|
|
||||||
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(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<Creature> {
|
|
||||||
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<Creature>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<KillData>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct CombatState {
|
|
||||||
initiative: i8,
|
|
||||||
health: u8,
|
|
||||||
damage: u8,
|
|
||||||
alive: bool,
|
|
||||||
enemies: HashSet<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<Creature>, max_rounds: u16) -> Option<CombatResults> {
|
|
||||||
let mut participants: HashMap<Uuid, CombatState> = 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<Uuid> = order.iter().map(|(uuid, _)| **uuid).collect();
|
|
||||||
let mut kills: HashMap<Uuid, Uuid> = 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;
|
break;
|
||||||
}
|
}
|
||||||
|
let start = std::time::Instant::now();
|
||||||
for uuid in order.iter() {
|
world.advance_time(Duration::from_secs(3600));
|
||||||
if !participants[uuid].alive {
|
let end = std::time::Instant::now();
|
||||||
continue;
|
println!("World tick: {}us", (end - start).as_micros());
|
||||||
}
|
|
||||||
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 {
|
fn resources_path() -> PathBuf {
|
||||||
kills: kills
|
PathBuf::from(".").join("defs")
|
||||||
.iter()
|
|
||||||
.map(|(killed, killer)| KillData {
|
|
||||||
killed: *killed,
|
|
||||||
killer: *killer,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<Site>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SiteArea>,
|
||||||
|
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<SiteArea> {
|
||||||
|
self.areas.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn areas_mut(&mut self) -> &mut Vec<SiteArea> {
|
||||||
|
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<Creature> {
|
||||||
|
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<Creature>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<i8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Uuid>,
|
||||||
|
pub attack_roll: AttackRoll,
|
||||||
|
pub damage: Option<i32>,
|
||||||
|
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<KillData>,
|
||||||
|
pub attacks: Vec<AttackData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct CombatState {
|
||||||
|
creature: Creature,
|
||||||
|
damage: i32,
|
||||||
|
enemies: HashSet<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CombatResults> {
|
||||||
|
let mut participants: HashMap<Uuid, CombatState> = 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<Uuid> = 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 })
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue