use serde::{Deserialize, Serialize};
use crate::mood::MoodTrigger;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Falloff {
Step,
Linear,
Exponential,
}
impl Falloff {
#[must_use]
#[inline]
pub fn intensity(&self, distance: f32, radius: f32) -> f32 {
if radius <= 0.0 || distance >= radius {
return 0.0;
}
if distance <= 0.0 {
return 1.0;
}
match self {
Self::Step => 1.0,
Self::Linear => 1.0 - distance / radius,
Self::Exponential => {
(-3.0 * distance / radius).exp()
}
}
}
}
impl_display!(Falloff {
Step => "step",
Linear => "linear",
Exponential => "exponential",
});
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProximityRule {
pub location_tag: String,
pub radius: f32,
pub trigger: MoodTrigger,
pub falloff: Falloff,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProximityHit {
pub location_tag: String,
pub trigger: MoodTrigger,
pub intensity: f32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProximitySystem {
rules: Vec<ProximityRule>,
}
impl ProximitySystem {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_rule(&mut self, rule: ProximityRule) {
self.rules.push(rule);
}
#[must_use]
pub fn rule_count(&self) -> usize {
self.rules.len()
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
#[must_use]
pub fn evaluate(&self, location_tag: &str, distance: f32) -> Vec<ProximityHit> {
self.rules
.iter()
.filter(|r| r.location_tag == location_tag)
.filter_map(|r| {
let intensity = r.falloff.intensity(distance, r.radius);
if intensity < f32::EPSILON {
return None;
}
Some(ProximityHit {
location_tag: r.location_tag.clone(),
trigger: r.trigger.clone(),
intensity,
})
})
.collect()
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
#[must_use]
pub fn evaluate_many(&self, locations: &[(&str, f32)]) -> Vec<ProximityHit> {
locations
.iter()
.flat_map(|&(tag, dist)| self.evaluate(tag, dist))
.collect()
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
pub fn remove_location(&mut self, location_tag: &str) -> usize {
let before = self.rules.len();
self.rules.retain(|r| r.location_tag != location_tag);
before - self.rules.len()
}
}
#[must_use]
pub fn rule(location_tag: impl Into<String>, radius: f32, trigger: MoodTrigger) -> ProximityRule {
ProximityRule {
location_tag: location_tag.into(),
radius,
trigger,
falloff: Falloff::Linear,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mood::{Emotion, MoodTrigger};
fn tavern_trigger() -> MoodTrigger {
MoodTrigger::new("tavern_warmth")
.respond(Emotion::Joy, 0.2)
.respond(Emotion::Trust, 0.15)
}
fn graveyard_trigger() -> MoodTrigger {
MoodTrigger::new("graveyard_dread")
.respond(Emotion::Joy, -0.3)
.respond(Emotion::Arousal, 0.1)
}
#[test]
fn test_step_inside() {
assert!((Falloff::Step.intensity(5.0, 10.0) - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_step_outside() {
assert!(Falloff::Step.intensity(15.0, 10.0).abs() < f32::EPSILON);
}
#[test]
fn test_linear_center() {
assert!((Falloff::Linear.intensity(0.0, 10.0) - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_linear_half() {
assert!((Falloff::Linear.intensity(5.0, 10.0) - 0.5).abs() < f32::EPSILON);
}
#[test]
fn test_linear_edge() {
assert!(Falloff::Linear.intensity(10.0, 10.0).abs() < f32::EPSILON);
}
#[test]
fn test_exponential_center() {
assert!((Falloff::Exponential.intensity(0.0, 10.0) - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_exponential_decays() {
let mid = Falloff::Exponential.intensity(5.0, 10.0);
assert!(mid > 0.0 && mid < 1.0, "mid-distance: {mid}");
}
#[test]
fn test_exponential_edge_near_zero() {
let edge = Falloff::Exponential.intensity(10.0, 10.0);
assert!(edge.abs() < f32::EPSILON);
}
#[test]
fn test_falloff_negative_distance() {
assert!((Falloff::Linear.intensity(-5.0, 10.0) - 1.0).abs() < f32::EPSILON);
assert!((Falloff::Step.intensity(-1.0, 10.0) - 1.0).abs() < f32::EPSILON);
assert!((Falloff::Exponential.intensity(-1.0, 10.0) - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_falloff_negative_radius() {
assert!(Falloff::Linear.intensity(0.0, -10.0).abs() < f32::EPSILON);
}
#[test]
fn test_serde_proximity_hit() {
let hit = ProximityHit {
location_tag: "tavern".into(),
trigger: tavern_trigger(),
intensity: 0.75,
};
let json = serde_json::to_string(&hit).unwrap();
let hit2: ProximityHit = serde_json::from_str(&json).unwrap();
assert_eq!(hit2.location_tag, "tavern");
assert!((hit2.intensity - 0.75).abs() < f32::EPSILON);
}
#[test]
fn test_falloff_zero_radius() {
assert!(Falloff::Linear.intensity(0.0, 0.0).abs() < f32::EPSILON);
}
#[test]
fn test_falloff_display() {
assert_eq!(Falloff::Step.to_string(), "step");
assert_eq!(Falloff::Linear.to_string(), "linear");
assert_eq!(Falloff::Exponential.to_string(), "exponential");
}
#[test]
fn test_empty_system() {
let sys = ProximitySystem::new();
assert_eq!(sys.rule_count(), 0);
let hits = sys.evaluate("tavern", 0.0);
assert!(hits.is_empty());
}
#[test]
fn test_hit_within_radius() {
let mut sys = ProximitySystem::new();
sys.add_rule(ProximityRule {
location_tag: "tavern".into(),
radius: 20.0,
trigger: tavern_trigger(),
falloff: Falloff::Linear,
});
let hits = sys.evaluate("tavern", 5.0);
assert_eq!(hits.len(), 1);
assert!((hits[0].intensity - 0.75).abs() < f32::EPSILON);
}
#[test]
fn test_miss_outside_radius() {
let mut sys = ProximitySystem::new();
sys.add_rule(ProximityRule {
location_tag: "tavern".into(),
radius: 20.0,
trigger: tavern_trigger(),
falloff: Falloff::Linear,
});
let hits = sys.evaluate("tavern", 25.0);
assert!(hits.is_empty());
}
#[test]
fn test_wrong_location_no_hit() {
let mut sys = ProximitySystem::new();
sys.add_rule(ProximityRule {
location_tag: "tavern".into(),
radius: 20.0,
trigger: tavern_trigger(),
falloff: Falloff::Linear,
});
let hits = sys.evaluate("graveyard", 5.0);
assert!(hits.is_empty());
}
#[test]
fn test_evaluate_many() {
let mut sys = ProximitySystem::new();
sys.add_rule(ProximityRule {
location_tag: "tavern".into(),
radius: 20.0,
trigger: tavern_trigger(),
falloff: Falloff::Linear,
});
sys.add_rule(ProximityRule {
location_tag: "graveyard".into(),
radius: 30.0,
trigger: graveyard_trigger(),
falloff: Falloff::Step,
});
let hits = sys.evaluate_many(&[("tavern", 10.0), ("graveyard", 15.0)]);
assert_eq!(hits.len(), 2);
}
#[test]
fn test_remove_location() {
let mut sys = ProximitySystem::new();
sys.add_rule(rule("tavern", 20.0, tavern_trigger()));
sys.add_rule(rule("graveyard", 30.0, graveyard_trigger()));
assert_eq!(sys.rule_count(), 2);
let removed = sys.remove_location("tavern");
assert_eq!(removed, 1);
assert_eq!(sys.rule_count(), 1);
}
#[test]
fn test_rule_builder() {
let r = rule("market", 15.0, tavern_trigger());
assert_eq!(r.location_tag, "market");
assert!((r.radius - 15.0).abs() < f32::EPSILON);
assert!(matches!(r.falloff, Falloff::Linear));
}
#[test]
fn test_serde_system() {
let mut sys = ProximitySystem::new();
sys.add_rule(rule("tavern", 20.0, tavern_trigger()));
let json = serde_json::to_string(&sys).unwrap();
let sys2: ProximitySystem = serde_json::from_str(&json).unwrap();
assert_eq!(sys2.rule_count(), 1);
}
#[test]
fn test_serde_falloff() {
let f = Falloff::Exponential;
let json = serde_json::to_string(&f).unwrap();
let f2: Falloff = serde_json::from_str(&json).unwrap();
assert_eq!(f2, f);
}
}