Added report system, information sharing, made NPCs act on sentiments

This commit is contained in:
Joshua Barretto 2023-04-06 18:25:33 +01:00
parent 08338436ea
commit 2fbddafd0a
24 changed files with 422 additions and 58 deletions

View File

@ -227,6 +227,8 @@ pub enum NpcAction {
// TODO: Use some sort of structured, language-independent value that frontends can translate // TODO: Use some sort of structured, language-independent value that frontends can translate
// instead // instead
Say(Option<Actor>, Cow<'static, str>), Say(Option<Actor>, Cow<'static, str>),
/// Attack the given target
Attack(Actor),
} }
// Note: the `serde(name = "...")` is to minimise the length of field // Note: the `serde(name = "...")` is to minimise the length of field

View File

@ -1,10 +1,14 @@
use crate::{ use crate::{
data::npc::{Controller, Npc, NpcId}, data::{
npc::{Controller, Npc, NpcId},
ReportId, Sentiments,
},
RtState, RtState,
}; };
use common::resources::{Time, TimeOfDay}; use common::resources::{Time, TimeOfDay};
use hashbrown::HashSet;
use rand_chacha::ChaChaRng; use rand_chacha::ChaChaRng;
use std::{any::Any, marker::PhantomData, ops::ControlFlow}; use std::{any::Any, collections::VecDeque, marker::PhantomData, ops::ControlFlow};
use world::{IndexRef, World}; use world::{IndexRef, World};
/// The context provided to an [`Action`] while it is being performed. It should /// The context provided to an [`Action`] while it is being performed. It should
@ -21,6 +25,9 @@ pub struct NpcCtx<'a> {
pub npc_id: NpcId, pub npc_id: NpcId,
pub npc: &'a Npc, pub npc: &'a Npc,
pub controller: &'a mut Controller, pub controller: &'a mut Controller,
pub inbox: &'a mut VecDeque<ReportId>, // TODO: Allow more inbox items
pub sentiments: &'a mut Sentiments,
pub known_reports: &'a mut HashSet<ReportId>,
pub rng: ChaChaRng, pub rng: ChaChaRng,
} }

View File

@ -1,4 +1,6 @@
pub use common::rtsim::{Actor, FactionId}; use crate::data::Sentiments;
use common::rtsim::Actor;
pub use common::rtsim::FactionId;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use slotmap::HopSlotMap; use slotmap::HopSlotMap;
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
@ -6,8 +8,19 @@ use vek::*;
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct Faction { pub struct Faction {
pub seed: u32,
pub leader: Option<Actor>, pub leader: Option<Actor>,
pub good_or_evil: bool, // TODO: Very stupid, get rid of this pub good_or_evil: bool, // TODO: Very stupid, get rid of this
#[serde(default)]
pub sentiments: Sentiments,
}
impl Faction {
pub fn cleanup(&mut self) {
self.sentiments
.cleanup(crate::data::sentiment::FACTION_MAX_SENTIMENTS);
}
} }
#[derive(Clone, Default, Serialize, Deserialize)] #[derive(Clone, Default, Serialize, Deserialize)]

View File

@ -1,6 +1,7 @@
pub mod faction; pub mod faction;
pub mod nature; pub mod nature;
pub mod npc; pub mod npc;
pub mod report;
pub mod sentiment; pub mod sentiment;
pub mod site; pub mod site;
@ -8,6 +9,8 @@ pub use self::{
faction::{Faction, FactionId, Factions}, faction::{Faction, FactionId, Factions},
nature::Nature, nature::Nature,
npc::{Npc, NpcId, Npcs}, npc::{Npc, NpcId, Npcs},
report::{Report, ReportId, ReportKind, Reports},
sentiment::{Sentiment, Sentiments},
site::{Site, SiteId, Sites}, site::{Site, SiteId, Sites},
}; };
@ -30,6 +33,8 @@ pub struct Data {
pub sites: Sites, pub sites: Sites,
#[serde(default)] #[serde(default)]
pub factions: Factions, pub factions: Factions,
#[serde(default)]
pub reports: Reports,
#[serde(default)] #[serde(default)]
pub tick: u64, pub tick: u64,

View File

@ -1,4 +1,8 @@
use crate::{ai::Action, data::sentiment::Sentiments, gen::name}; use crate::{
ai::Action,
data::{ReportId, Reports, Sentiments},
gen::name,
};
pub use common::rtsim::{NpcId, Profession}; pub use common::rtsim::{NpcId, Profession};
use common::{ use common::{
character::CharacterId, character::CharacterId,
@ -11,7 +15,7 @@ use common::{
terrain::TerrainChunkSize, terrain::TerrainChunkSize,
vol::RectVolSize, vol::RectVolSize,
}; };
use hashbrown::HashMap; use hashbrown::{HashMap, HashSet};
use rand::prelude::*; use rand::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use slotmap::HopSlotMap; use slotmap::HopSlotMap;
@ -73,6 +77,10 @@ impl Controller {
pub fn say(&mut self, target: impl Into<Option<Actor>>, msg: impl Into<Cow<'static, str>>) { pub fn say(&mut self, target: impl Into<Option<Actor>>, msg: impl Into<Cow<'static, str>>) {
self.actions.push(NpcAction::Say(target.into(), msg.into())); self.actions.push(NpcAction::Say(target.into(), msg.into()));
} }
pub fn attack(&mut self, target: impl Into<Actor>) {
self.actions.push(NpcAction::Attack(target.into()));
}
} }
pub struct Brain { pub struct Brain {
@ -92,6 +100,9 @@ pub struct Npc {
pub faction: Option<FactionId>, pub faction: Option<FactionId>,
pub riding: Option<Riding>, pub riding: Option<Riding>,
/// The [`Report`]s that the NPC is aware of.
pub known_reports: HashSet<ReportId>,
#[serde(default)] #[serde(default)]
pub personality: Personality, pub personality: Personality,
#[serde(default)] #[serde(default)]
@ -105,6 +116,8 @@ pub struct Npc {
#[serde(skip)] #[serde(skip)]
pub controller: Controller, pub controller: Controller,
#[serde(skip)]
pub inbox: VecDeque<ReportId>,
/// Whether the NPC is in simulated or loaded mode (when rtsim is run on the /// Whether the NPC is in simulated or loaded mode (when rtsim is run on the
/// server, loaded corresponds to being within a loaded chunk). When in /// server, loaded corresponds to being within a loaded chunk). When in
@ -126,6 +139,7 @@ impl Clone for Npc {
home: self.home, home: self.home,
faction: self.faction, faction: self.faction,
riding: self.riding.clone(), riding: self.riding.clone(),
known_reports: self.known_reports.clone(),
body: self.body, body: self.body,
personality: self.personality, personality: self.personality,
sentiments: self.sentiments.clone(), sentiments: self.sentiments.clone(),
@ -133,6 +147,7 @@ impl Clone for Npc {
chunk_pos: None, chunk_pos: None,
current_site: Default::default(), current_site: Default::default(),
controller: Default::default(), controller: Default::default(),
inbox: Default::default(),
mode: Default::default(), mode: Default::default(),
brain: Default::default(), brain: Default::default(),
} }
@ -148,15 +163,17 @@ impl Npc {
seed, seed,
wpos, wpos,
body, body,
personality: Personality::default(), personality: Default::default(),
sentiments: Sentiments::default(), sentiments: Default::default(),
profession: None, profession: None,
home: None, home: None,
faction: None, faction: None,
riding: None, riding: None,
known_reports: Default::default(),
chunk_pos: None, chunk_pos: None,
current_site: None, current_site: None,
controller: Controller::default(), controller: Default::default(),
inbox: Default::default(),
mode: SimulationMode::Simulated, mode: SimulationMode::Simulated,
brain: None, brain: None,
} }
@ -201,6 +218,16 @@ impl Npc {
pub fn rng(&self, perm: u32) -> impl Rng { RandomPerm::new(self.seed.wrapping_add(perm)) } pub fn rng(&self, perm: u32) -> impl Rng { RandomPerm::new(self.seed.wrapping_add(perm)) }
pub fn get_name(&self) -> String { name::generate(&mut self.rng(Self::PERM_NAME)) } pub fn get_name(&self) -> String { name::generate(&mut self.rng(Self::PERM_NAME)) }
pub fn cleanup(&mut self, reports: &Reports) {
// Clear old or superfluous sentiments
self.sentiments
.cleanup(crate::data::sentiment::NPC_MAX_SENTIMENTS);
// Clear reports that have been forgotten
self.known_reports
.retain(|report| reports.contains_key(*report));
// TODO: Clear old inbox items
}
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]

68
rtsim/src/data/report.rs Normal file
View File

@ -0,0 +1,68 @@
use common::{resources::TimeOfDay, rtsim::Actor};
use serde::{Deserialize, Serialize};
use slotmap::HopSlotMap;
use std::ops::Deref;
use vek::*;
slotmap::new_key_type! { pub struct ReportId; }
/// Represents a single piece of information known by an rtsim entity.
///
/// Reports are the medium through which rtsim represents information sharing
/// between NPCs, factions, and sites. They can represent deaths, attacks,
/// changes in diplomacy, or any other piece of information representing a
/// singular event that might be communicated.
///
/// Note that they should not be used to communicate sentiments like 'this actor
/// is friendly': the [`crate::data::Sentiment`] system should be used for that.
/// Some events might generate both a report and a change in sentiment. For
/// example, the murder of an NPC might generate both a murder report and highly
/// negative sentiments.
#[derive(Clone, Serialize, Deserialize)]
pub struct Report {
pub kind: ReportKind,
pub at: TimeOfDay,
}
impl Report {
/// The time, in in-game seconds, for which the report will be remembered
fn remember_for(&self) -> f64 {
const DAYS: f64 = 60.0 * 60.0 * 24.0;
match &self.kind {
ReportKind::Death { killer, .. } => {
if killer.is_some() {
// Murder is less easy to forget
DAYS * 15.0
} else {
DAYS * 5.0
}
},
}
}
}
#[derive(Copy, Clone, Serialize, Deserialize)]
pub enum ReportKind {
Death { actor: Actor, killer: Option<Actor> },
}
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct Reports {
pub reports: HopSlotMap<ReportId, Report>,
}
impl Reports {
pub fn create(&mut self, report: Report) -> ReportId { self.reports.insert(report) }
pub fn cleanup(&mut self, current_time: TimeOfDay) {
// Forget reports that are too old
self.reports
.retain(|_, report| (current_time.0 - report.at.0).max(0.0) < report.remember_for());
}
}
impl Deref for Reports {
type Target = HopSlotMap<ReportId, Report>;
fn deref(&self) -> &Self::Target { &self.reports }
}

View File

@ -2,15 +2,18 @@ use common::{
character::CharacterId, character::CharacterId,
rtsim::{Actor, FactionId, NpcId}, rtsim::{Actor, FactionId, NpcId},
}; };
use hashbrown::HashMap;
use rand::prelude::*; use rand::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
// Factions have a larger 'social memory' than individual NPCs and so we allow // Factions have a larger 'social memory' than individual NPCs and so we allow
// them to have more sentiments // them to have more sentiments
pub const FACTION_MAX_SENTIMENTS: usize = 1024; pub const FACTION_MAX_SENTIMENTS: usize = 1024;
pub const NPC_MAX_SENTIMENTS: usize = 128; pub const NPC_MAX_SENTIMENTS: usize = 128;
// Magic factor used to control sentiment decay speed
const DECAY_FACTOR: f32 = 6.0;
/// The target that a sentiment is felt toward. /// The target that a sentiment is felt toward.
// NOTE: More could be added to this! For example: // NOTE: More could be added to this! For example:
// - Animal species (dislikes spiders?) // - Animal species (dislikes spiders?)
@ -55,39 +58,39 @@ impl Sentiments {
self.map.get(&target.into()).copied().unwrap_or_default() self.map.get(&target.into()).copied().unwrap_or_default()
} }
pub fn change_by(&mut self, target: impl Into<Target>, change: f32) { /// Change the sentiment toward the given target by the given amount,
/// capping out at the given value.
pub fn change_by(&mut self, target: impl Into<Target>, change: f32, cap: f32) {
let target = target.into(); let target = target.into();
self.map.entry(target).or_default().change_by(change); self.map.entry(target).or_default().change_by(change, cap);
} }
/// Progressively decay the sentiment back to a neutral sentiment. /// Progressively decay the sentiment back to a neutral sentiment.
/// ///
/// Note that sentiment get decay gets slower the harsher the sentiment is. /// Note that sentiment get decay gets slower the harsher the sentiment is.
/// You can calculate the **average** number of ticks required for a /// You can calculate the **average** number of seconds required for a
/// sentiment to decay with the following formula: /// sentiment to neutral decay with the following formula:
/// ///
/// ``` /// ```
/// ticks_until_neutrality = ((sentiment_value * 127 * 32) ^ 2) / 2 /// seconds_until_neutrality = ((sentiment_value * 127 * DECAY_FACTOR) ^ 2) / 2
/// ``` /// ```
/// ///
/// For example, a positive (see [`Sentiment::POSITIVE`]) sentiment has a /// For example, a positive (see [`Sentiment::POSITIVE`]) sentiment has a
/// value of `0.2`, so we get /// value of `0.2`, so we get
/// ///
/// ``` /// ```
/// ticks_until_neutrality = ((0.1 * 127 * 32) ^ 2) / 2 = ~82,580 ticks /// seconds_until_neutrality = ((0.1 * 127 * DECAY_FACTOR) ^ 2) / 2 = ~2,903 seconds, or 48 minutes
/// ``` /// ```
/// ///
/// Assuming a TPS of 30, that's ~46 minutes.
///
/// Some 'common' sentiment decay times are as follows: /// Some 'common' sentiment decay times are as follows:
/// ///
/// - `POSITIVE`/`NEGATIVE`: ~46 minutes /// - `POSITIVE`/`NEGATIVE`: ~48 minutes
/// - `ALLY`/`RIVAL`: ~6.9 hours /// - `ALLY`/`RIVAL`: ~7 hours
/// - `FRIEND`/`ENEMY`: ~27.5 hours /// - `FRIEND`/`ENEMY`: ~29 hours
/// - `HERO`/`VILLAIN`: ~48.9 hours /// - `HERO`/`VILLAIN`: ~65 hours
pub fn decay(&mut self, rng: &mut impl Rng) { pub fn decay(&mut self, rng: &mut impl Rng, dt: f32) {
self.map.retain(|_, sentiment| { self.map.retain(|_, sentiment| {
sentiment.decay(rng); sentiment.decay(rng, dt);
// We can eliminate redundant sentiments that don't need remembering // We can eliminate redundant sentiments that don't need remembering
!sentiment.is_redundant() !sentiment.is_redundant()
}); });
@ -152,26 +155,30 @@ impl Sentiment {
/// generally try to harm the actor in any way they can. /// generally try to harm the actor in any way they can.
pub const VILLAIN: f32 = -0.8; pub const VILLAIN: f32 = -0.8;
fn value(&self) -> f32 { self.positivity as f32 / 127.0 } fn value(&self) -> f32 { self.positivity as f32 / 126.0 }
fn change_by(&mut self, change: f32) { fn change_by(&mut self, change: f32, cap: f32) {
// There's a bit of ceremony here for two reasons: // There's a bit of ceremony here for two reasons:
// 1) Very small changes should not be rounded to 0 // 1) Very small changes should not be rounded to 0
// 2) Sentiment should never (over/under)flow // 2) Sentiment should never (over/under)flow
if change != 0.0 { if change != 0.0 {
let abs = (change * 127.0).abs().clamp(1.0, 127.0) as i8; let abs = (change * 126.0).abs().clamp(1.0, 126.0) as i8;
let cap = (cap.abs().min(1.0) * 126.0) as i8;
self.positivity = if change > 0.0 { self.positivity = if change > 0.0 {
self.positivity.saturating_add(abs) self.positivity.saturating_add(abs).min(cap)
} else { } else {
self.positivity.saturating_sub(abs) self.positivity.saturating_sub(abs).max(-cap)
}; };
} }
} }
fn decay(&mut self, rng: &mut impl Rng) { fn decay(&mut self, rng: &mut impl Rng, dt: f32) {
if self.positivity != 0 { if self.positivity != 0 {
// TODO: Make dt-independent so we can slow tick rates // TODO: Make dt-independent so we can slow tick rates
if rng.gen_range(0..self.positivity.unsigned_abs() as u32 * 1024) == 0 { // 36 = 6 * 6
if rng.gen_bool(
(1.0 / (self.positivity.unsigned_abs() as f32 * DECAY_FACTOR.powi(2) * dt)) as f64,
) {
self.positivity -= self.positivity.signum(); self.positivity -= self.positivity.signum();
} }
} }

View File

@ -1,3 +1,4 @@
use crate::data::{ReportId, Reports};
pub use common::rtsim::SiteId; pub use common::rtsim::SiteId;
use common::{ use common::{
rtsim::{FactionId, NpcId}, rtsim::{FactionId, NpcId},
@ -12,9 +13,14 @@ use world::site::Site as WorldSite;
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct Site { pub struct Site {
pub seed: u32,
pub wpos: Vec2<i32>, pub wpos: Vec2<i32>,
pub faction: Option<FactionId>, pub faction: Option<FactionId>,
/// The [`Report`]s that the site tracks (you can imagine them being on a
/// noticeboard or something).
pub known_reports: HashSet<ReportId>,
/// The site generated during initial worldgen that this site corresponds /// The site generated during initial worldgen that this site corresponds
/// to. /// to.
/// ///
@ -40,6 +46,12 @@ impl Site {
self.faction = faction.into(); self.faction = faction.into();
self self
} }
pub fn cleanup(&mut self, reports: &Reports) {
// Clear reports that have been forgotten
self.known_reports
.retain(|report| reports.contains_key(*report));
}
} }
#[derive(Clone, Default, Serialize, Deserialize)] #[derive(Clone, Default, Serialize, Deserialize)]

View File

@ -3,6 +3,7 @@ use common::{
resources::{Time, TimeOfDay}, resources::{Time, TimeOfDay},
rtsim::Actor, rtsim::Actor,
}; };
use vek::*;
use world::{IndexRef, World}; use world::{IndexRef, World};
pub trait Event: Clone + 'static {} pub trait Event: Clone + 'static {}
@ -31,6 +32,7 @@ impl Event for OnTick {}
#[derive(Clone)] #[derive(Clone)]
pub struct OnDeath { pub struct OnDeath {
pub actor: Actor, pub actor: Actor,
pub wpos: Option<Vec3<f32>>,
pub killer: Option<Actor>, pub killer: Option<Actor>,
} }
impl Event for OnDeath {} impl Event for OnDeath {}

View File

@ -5,8 +5,10 @@ use world::{IndexRef, World};
impl Faction { impl Faction {
pub fn generate(_world: &World, _index: IndexRef, rng: &mut impl Rng) -> Self { pub fn generate(_world: &World, _index: IndexRef, rng: &mut impl Rng) -> Self {
Self { Self {
seed: rng.gen(),
leader: None, leader: None,
good_or_evil: rng.gen(), good_or_evil: rng.gen(),
sentiments: Default::default(),
} }
} }
} }

View File

@ -3,9 +3,9 @@ pub mod name;
pub mod site; pub mod site;
use crate::data::{ use crate::data::{
faction::{Faction, Factions}, faction::Faction,
npc::{Npc, Npcs, Profession, Vehicle}, npc::{Npc, Npcs, Profession, Vehicle},
site::{Site, Sites}, site::Site,
Data, Nature, Data, Nature,
}; };
use common::{ use common::{
@ -37,13 +37,9 @@ impl Data {
npc_grid: Grid::new(Vec2::zero(), Default::default()), npc_grid: Grid::new(Vec2::zero(), Default::default()),
character_map: Default::default(), character_map: Default::default(),
}, },
sites: Sites { sites: Default::default(),
sites: Default::default(), factions: Default::default(),
world_site_map: Default::default(), reports: Default::default(),
},
factions: Factions {
factions: Default::default(),
},
tick: 0, tick: 0,
time_of_day: TimeOfDay(settings.start_time), time_of_day: TimeOfDay(settings.start_time),
@ -72,6 +68,7 @@ impl Data {
index, index,
&initial_factions, &initial_factions,
&this.factions, &this.factions,
&mut rng,
); );
this.sites.create(site); this.sites.create(site);
} }

View File

@ -1,6 +1,6 @@
use crate::data::{FactionId, Factions, Site}; use crate::data::{FactionId, Factions, Site};
use common::store::Id; use common::store::Id;
use hashbrown::HashSet; use rand::prelude::*;
use vek::*; use vek::*;
use world::{ use world::{
site::{Site as WorldSite, SiteKind}, site::{Site as WorldSite, SiteKind},
@ -14,6 +14,7 @@ impl Site {
index: IndexRef, index: IndexRef,
nearby_factions: &[(Vec2<i32>, FactionId)], nearby_factions: &[(Vec2<i32>, FactionId)],
factions: &Factions, factions: &Factions,
rng: &mut impl Rng,
) -> Self { ) -> Self {
let world_site = index.sites.get(world_site_id); let world_site = index.sites.get(world_site_id);
let wpos = world_site.get_origin(); let wpos = world_site.get_origin();
@ -36,6 +37,7 @@ impl Site {
}; };
Self { Self {
seed: rng.gen(),
wpos, wpos,
world_site: Some(world_site_id), world_site: Some(world_site_id),
faction: good_or_evil.and_then(|good_or_evil| { faction: good_or_evil.and_then(|good_or_evil| {
@ -49,7 +51,8 @@ impl Site {
.min_by_key(|(faction_wpos, _)| faction_wpos.distance_squared(wpos)) .min_by_key(|(faction_wpos, _)| faction_wpos.distance_squared(wpos))
.map(|(_, faction)| *faction) .map(|(_, faction)| *faction)
}), }),
population: HashSet::new(), population: Default::default(),
known_reports: Default::default(),
} }
} }
} }

View File

@ -62,9 +62,11 @@ impl RtState {
info!("Starting default rtsim rules..."); info!("Starting default rtsim rules...");
self.start_rule::<rule::migrate::Migrate>(); self.start_rule::<rule::migrate::Migrate>();
self.start_rule::<rule::replenish_resources::ReplenishResources>(); self.start_rule::<rule::replenish_resources::ReplenishResources>();
self.start_rule::<rule::report::ReportEvents>();
self.start_rule::<rule::sync_npcs::SyncNpcs>(); self.start_rule::<rule::sync_npcs::SyncNpcs>();
self.start_rule::<rule::simulate_npcs::SimulateNpcs>(); self.start_rule::<rule::simulate_npcs::SimulateNpcs>();
self.start_rule::<rule::npc_ai::NpcAi>(); self.start_rule::<rule::npc_ai::NpcAi>();
self.start_rule::<rule::cleanup::CleanUp>();
} }
pub fn start_rule<R: Rule>(&mut self) { pub fn start_rule<R: Rule>(&mut self) {

View File

@ -1,6 +1,8 @@
pub mod cleanup;
pub mod migrate; pub mod migrate;
pub mod npc_ai; pub mod npc_ai;
pub mod replenish_resources; pub mod replenish_resources;
pub mod report;
pub mod simulate_npcs; pub mod simulate_npcs;
pub mod sync_npcs; pub mod sync_npcs;

55
rtsim/src/rule/cleanup.rs Normal file
View File

@ -0,0 +1,55 @@
use crate::{event::OnTick, RtState, Rule, RuleError};
use rand::prelude::*;
use rand_chacha::ChaChaRng;
/// Prevent performing cleanup for every NPC every tick
const NPC_SENTIMENT_TICK_SKIP: u64 = 30;
const NPC_CLEANUP_TICK_SKIP: u64 = 100;
const FACTION_CLEANUP_TICK_SKIP: u64 = 30;
const SITE_CLEANUP_TICK_SKIP: u64 = 30;
/// A rule that cleans up data structures in rtsim: removing old reports,
/// irrelevant sentiments, etc.
///
/// Also performs sentiment decay (although this should be moved elsewhere)
pub struct CleanUp;
impl Rule for CleanUp {
fn start(rtstate: &mut RtState) -> Result<Self, RuleError> {
rtstate.bind::<Self, OnTick>(|ctx| {
let data = &mut *ctx.state.data_mut();
let mut rng = ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>());
for (_, npc) in data.npcs
.iter_mut()
// Only cleanup NPCs every few ticks
.filter(|(_, npc)| (npc.seed as u64 + ctx.event.tick) % NPC_SENTIMENT_TICK_SKIP == 0)
{
npc.sentiments.decay(&mut rng, ctx.event.dt * NPC_SENTIMENT_TICK_SKIP as f32);
}
// Clean up entities
data.npcs
.iter_mut()
.filter(|(_, npc)| (npc.seed as u64 + ctx.event.tick) % NPC_CLEANUP_TICK_SKIP == 0)
.for_each(|(_, npc)| npc.cleanup(&data.reports));
// Clean up factions
data.factions
.iter_mut()
.filter(|(_, faction)| (faction.seed as u64 + ctx.event.tick) % FACTION_CLEANUP_TICK_SKIP == 0)
.for_each(|(_, faction)| faction.cleanup());
// Clean up sites
data.sites
.iter_mut()
.filter(|(_, site)| (site.seed as u64 + ctx.event.tick) % SITE_CLEANUP_TICK_SKIP == 0)
.for_each(|(_, site)| site.cleanup(&data.reports));
// Clean up old reports
data.reports.cleanup(data.time_of_day);
});
Ok(Self)
}
}

View File

@ -1,4 +1,6 @@
use crate::{data::Site, event::OnSetup, RtState, Rule, RuleError}; use crate::{data::Site, event::OnSetup, RtState, Rule, RuleError};
use rand::prelude::*;
use rand_chacha::ChaChaRng;
use tracing::warn; use tracing::warn;
/// This rule runs at rtsim startup and broadly acts to perform some primitive /// This rule runs at rtsim startup and broadly acts to perform some primitive
@ -10,6 +12,9 @@ impl Rule for Migrate {
fn start(rtstate: &mut RtState) -> Result<Self, RuleError> { fn start(rtstate: &mut RtState) -> Result<Self, RuleError> {
rtstate.bind::<Self, OnSetup>(|ctx| { rtstate.bind::<Self, OnSetup>(|ctx| {
let data = &mut *ctx.state.data_mut(); let data = &mut *ctx.state.data_mut();
let mut rng = ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>());
// Delete rtsim sites that don't correspond to a world site // Delete rtsim sites that don't correspond to a world site
data.sites.sites.retain(|site_id, site| { data.sites.sites.retain(|site_id, site| {
if let Some((world_site_id, _)) = ctx if let Some((world_site_id, _)) = ctx
@ -55,6 +60,7 @@ impl Rule for Migrate {
ctx.index, ctx.index,
&[], &[],
&data.factions, &data.factions,
&mut rng,
)); ));
} }
} }

View File

@ -4,7 +4,7 @@ use crate::{
ai::{casual, choose, finish, important, just, now, seq, until, Action, NpcCtx}, ai::{casual, choose, finish, important, just, now, seq, until, Action, NpcCtx},
data::{ data::{
npc::{Brain, PathData, SimulationMode}, npc::{Brain, PathData, SimulationMode},
Sites, ReportKind, Sentiment, Sites,
}, },
event::OnTick, event::OnTick,
RtState, Rule, RuleError, RtState, Rule, RuleError,
@ -219,10 +219,13 @@ impl Rule for NpcAi {
.filter(|(_, npc)| matches!(npc.mode, SimulationMode::Loaded) || (npc.seed as u64 + ctx.event.tick) % SIMULATED_TICK_SKIP == 0) .filter(|(_, npc)| matches!(npc.mode, SimulationMode::Loaded) || (npc.seed as u64 + ctx.event.tick) % SIMULATED_TICK_SKIP == 0)
.map(|(npc_id, npc)| { .map(|(npc_id, npc)| {
let controller = std::mem::take(&mut npc.controller); let controller = std::mem::take(&mut npc.controller);
let inbox = std::mem::take(&mut npc.inbox);
let sentiments = std::mem::take(&mut npc.sentiments);
let known_reports = std::mem::take(&mut npc.known_reports);
let brain = npc.brain.take().unwrap_or_else(|| Brain { let brain = npc.brain.take().unwrap_or_else(|| Brain {
action: Box::new(think().repeat()), action: Box::new(think().repeat()),
}); });
(npc_id, controller, brain) (npc_id, controller, inbox, sentiments, known_reports, brain)
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
}; };
@ -233,7 +236,7 @@ impl Rule for NpcAi {
npc_data npc_data
.par_iter_mut() .par_iter_mut()
.for_each(|(npc_id, controller, brain)| { .for_each(|(npc_id, controller, inbox, sentiments, known_reports, brain)| {
let npc = &data.npcs[*npc_id]; let npc = &data.npcs[*npc_id];
brain.action.tick(&mut NpcCtx { brain.action.tick(&mut NpcCtx {
@ -245,6 +248,9 @@ impl Rule for NpcAi {
npc, npc,
npc_id: *npc_id, npc_id: *npc_id,
controller, controller,
inbox,
known_reports,
sentiments,
rng: ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>()), rng: ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>()),
}); });
}); });
@ -252,9 +258,12 @@ impl Rule for NpcAi {
// Reinsert NPC brains // Reinsert NPC brains
let mut data = ctx.state.data_mut(); let mut data = ctx.state.data_mut();
for (npc_id, controller, brain) in npc_data { for (npc_id, controller, inbox, sentiments, known_reports, brain) in npc_data {
data.npcs[npc_id].controller = controller; data.npcs[npc_id].controller = controller;
data.npcs[npc_id].brain = Some(brain); data.npcs[npc_id].brain = Some(brain);
data.npcs[npc_id].inbox = inbox;
data.npcs[npc_id].sentiments = sentiments;
data.npcs[npc_id].known_reports = known_reports;
} }
}); });
@ -871,6 +880,50 @@ fn captain() -> impl Action {
.map(|_| ()) .map(|_| ())
} }
fn check_inbox(ctx: &mut NpcCtx) -> Option<impl Action> {
loop {
match ctx.inbox.pop_front() {
Some(report_id) if !ctx.known_reports.contains(&report_id) => {
#[allow(clippy::single_match)]
match ctx.state.data().reports.get(report_id).map(|r| r.kind) {
Some(ReportKind::Death { killer, .. }) => {
// TODO: Sentiment should be positive if we didn't like actor that died
// TODO: Don't report self
let phrases = if let Some(killer) = killer {
// TODO: Don't hard-code sentiment change
ctx.sentiments.change_by(killer, -0.7, Sentiment::VILLAIN);
&["Murderer!", "How could you do this?", "Aaargh!"][..]
} else {
&["No!", "This is terrible!", "Oh my goodness!"][..]
};
let phrase = *phrases.iter().choose(&mut ctx.rng).unwrap(); // Can't fail
ctx.known_reports.insert(report_id);
break Some(just(move |ctx| ctx.controller.say(killer, phrase)));
},
None => {}, // Stale report, ignore
}
},
Some(_) => {}, // Reports we already know of are ignored
None => break None,
}
}
}
fn check_for_enemies(ctx: &mut NpcCtx) -> Option<impl Action> {
ctx.state
.data()
.npcs
.nearby(Some(ctx.npc_id), ctx.npc.wpos, 24.0)
.find(|actor| ctx.sentiments.toward(*actor).is(Sentiment::ENEMY))
.map(|enemy| just(move |ctx| ctx.controller.attack(enemy)))
}
fn react_to_events(ctx: &mut NpcCtx) -> Option<impl Action> {
check_inbox(ctx)
.map(|action| action.boxed())
.or_else(|| check_for_enemies(ctx).map(|action| action.boxed()))
}
fn humanoid() -> impl Action { fn humanoid() -> impl Action {
choose(|ctx| { choose(|ctx| {
if let Some(riding) = &ctx.npc.riding { if let Some(riding) = &ctx.npc.riding {
@ -890,15 +943,19 @@ fn humanoid() -> impl Action {
} else { } else {
important(socialize()) important(socialize())
} }
} else if matches!(
ctx.npc.profession,
Some(Profession::Adventurer(_) | Profession::Merchant)
) {
casual(adventure())
} else if let Some(home) = ctx.npc.home {
casual(villager(home))
} else { } else {
casual(finish()) // Homeless let action = if matches!(
ctx.npc.profession,
Some(Profession::Adventurer(_) | Profession::Merchant)
) {
adventure().boxed()
} else if let Some(home) = ctx.npc.home {
villager(home).boxed()
} else {
idle().boxed() // Homeless
};
casual(action.interrupt_with(react_to_events))
} }
}) })
} }

43
rtsim/src/rule/report.rs Normal file
View File

@ -0,0 +1,43 @@
use crate::{
data::{report::ReportKind, Report},
event::{EventCtx, OnDeath},
RtState, Rule, RuleError,
};
pub struct ReportEvents;
impl Rule for ReportEvents {
fn start(rtstate: &mut RtState) -> Result<Self, RuleError> {
rtstate.bind::<Self, OnDeath>(on_death);
Ok(Self)
}
}
fn on_death(ctx: EventCtx<ReportEvents, OnDeath>) {
let data = &mut *ctx.state.data_mut();
if let Some(wpos) = ctx.event.wpos {
let nearby = data
.npcs
.nearby(None, wpos, 32.0)
.filter_map(|actor| actor.npc())
.collect::<Vec<_>>();
if !nearby.is_empty() {
let report = data.reports.create(Report {
kind: ReportKind::Death {
actor: ctx.event.actor,
killer: ctx.event.killer,
},
at: data.time_of_day,
});
for npc_id in nearby {
if let Some(npc) = data.npcs.get_mut(npc_id) {
npc.inbox.push_back(report);
}
}
}
}
}

View File

@ -246,6 +246,7 @@ fn on_tick(ctx: EventCtx<SimulateNpcs, OnTick>) {
for action in std::mem::take(&mut npc.controller.actions) { for action in std::mem::take(&mut npc.controller.actions) {
match action { match action {
NpcAction::Say(_, _) => {}, // Currently, just swallow interactions NpcAction::Say(_, _) => {}, // Currently, just swallow interactions
NpcAction::Attack(_) => {}, // TODO: Implement simulated combat
} }
} }

View File

@ -90,6 +90,23 @@ fn on_tick(ctx: EventCtx<SyncNpcs, OnTick>) {
.find_map(|site| data.sites.world_site_map.get(site).copied()) .find_map(|site| data.sites.world_site_map.get(site).copied())
}); });
// Share known reports with current site, if it's our home
// TODO: Only share new reports
if let Some(current_site) = npc.current_site
&& Some(current_site) == npc.home
{
if let Some(site) = data.sites.get_mut(current_site) {
// TODO: Sites should have an inbox and their own AI code
site.known_reports.extend(npc.known_reports
.iter()
.copied());
npc.inbox.extend(site.known_reports
.iter()
.copied()
.filter(|report| !npc.known_reports.contains(report)));
}
}
// Update the NPC's grid cell // Update the NPC's grid cell
let chunk_pos = npc let chunk_pos = npc
.wpos .wpos

View File

@ -2069,13 +2069,20 @@ fn handle_kill_npcs(
let to_kill = { let to_kill = {
let ecs = server.state.ecs(); let ecs = server.state.ecs();
let entities = ecs.entities(); let entities = ecs.entities();
let positions = ecs.write_storage::<comp::Pos>();
let healths = ecs.write_storage::<comp::Health>(); let healths = ecs.write_storage::<comp::Health>();
let players = ecs.read_storage::<comp::Player>(); let players = ecs.read_storage::<comp::Player>();
let alignments = ecs.read_storage::<Alignment>(); let alignments = ecs.read_storage::<Alignment>();
(&entities, &healths, !&players, alignments.maybe()) (
&entities,
&healths,
!&players,
alignments.maybe(),
&positions,
)
.join() .join()
.filter_map(|(entity, _health, (), alignment)| { .filter_map(|(entity, _health, (), alignment, pos)| {
let should_kill = kill_pets let should_kill = kill_pets
|| if let Some(Alignment::Owned(owned)) = alignment { || if let Some(Alignment::Owned(owned)) = alignment {
ecs.entity_from_uid(owned.0) ecs.entity_from_uid(owned.0)
@ -2095,6 +2102,7 @@ fn handle_kill_npcs(
&ecs.read_resource::<Arc<world::World>>(), &ecs.read_resource::<Arc<world::World>>(),
ecs.read_resource::<world::IndexOwned>().as_index_ref(), ecs.read_resource::<world::IndexOwned>().as_index_ref(),
Actor::Npc(rtsim_entity.0), Actor::Npc(rtsim_entity.0),
Some(pos.0),
None, None,
); );
} }

View File

@ -529,6 +529,7 @@ pub fn handle_destroy(server: &mut Server, entity: EcsEntity, last_change: Healt
.read_resource::<world::IndexOwned>() .read_resource::<world::IndexOwned>()
.as_index_ref(), .as_index_ref(),
actor, actor,
state.ecs().read_storage::<Pos>().get(entity).map(|p| p.0),
last_change last_change
.by .by
.as_ref() .as_ref()

View File

@ -62,13 +62,14 @@ impl RtSim {
}, },
Err(e) => { Err(e) => {
error!("Rtsim data failed to load: {}", e); error!("Rtsim data failed to load: {}", e);
info!("Old rtsim data will now be moved to a backup file");
let mut i = 0; let mut i = 0;
loop { loop {
let mut backup_path = file_path.clone(); let mut backup_path = file_path.clone();
backup_path.set_extension(if i == 0 { backup_path.set_extension(if i == 0 {
format!("backup_{}", i)
} else {
"ron_backup".to_string() "ron_backup".to_string()
} else {
format!("ron_backup_{}", i)
}); });
if !backup_path.exists() { if !backup_path.exists() {
fs::rename(&file_path, &backup_path)?; fs::rename(&file_path, &backup_path)?;
@ -78,6 +79,11 @@ impl RtSim {
); );
info!("A fresh rtsim data will now be generated."); info!("A fresh rtsim data will now be generated.");
break; break;
} else {
info!(
"Backup file {} already exists, trying another name...",
backup_path.display()
);
} }
i += 1; i += 1;
} }
@ -169,9 +175,18 @@ impl RtSim {
world: &World, world: &World,
index: IndexRef, index: IndexRef,
actor: Actor, actor: Actor,
wpos: Option<Vec3<f32>>,
killer: Option<Actor>, killer: Option<Actor>,
) { ) {
self.state.emit(OnDeath { actor, killer }, world, index); self.state.emit(
OnDeath {
wpos,
actor,
killer,
},
world,
index,
);
} }
pub fn save(&mut self, /* slowjob_pool: &SlowJobPool, */ wait_until_finished: bool) { pub fn save(&mut self, /* slowjob_pool: &SlowJobPool, */ wait_until_finished: bool) {

View File

@ -501,6 +501,18 @@ fn handle_rtsim_actions(bdata: &mut BehaviorData) -> bool {
bdata.agent_data.chat_npc(msg, bdata.event_emitter); bdata.agent_data.chat_npc(msg, bdata.event_emitter);
} }
}, },
NpcAction::Attack(target) => {
if let Some(target) = bdata.read_data.lookup_actor(target) {
bdata.agent.target = Some(Target::new(
target,
true,
bdata.read_data.time.0,
false,
bdata.read_data.positions.get(target).map(|p| p.0),
));
bdata.agent.awareness.set_maximally_aware();
}
},
} }
true true
} else { } else {