use serde::{Deserialize, Serialize};
use super::conditions::{Condition, ConditionValue};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Milestone {
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub condition: Condition,
pub weight: f64,
#[serde(default)]
pub partial: bool,
#[serde(default)]
pub partial_config: Option<PartialConfig>,
}
impl Milestone {
pub fn new(name: impl Into<String>, condition: Condition, weight: f64) -> Self {
Self {
name: name.into(),
description: None,
condition,
weight,
partial: false,
partial_config: None,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_partial(mut self, config: PartialConfig) -> Self {
self.partial = true;
self.partial_config = Some(config);
self
}
pub fn evaluate(&self, actual: &ConditionValue) -> f64 {
if self.condition.evaluate(actual) {
return 1.0;
}
if !self.partial {
return 0.0;
}
self.calculate_partial_score(actual)
}
fn calculate_partial_score(&self, actual: &ConditionValue) -> f64 {
let config = match &self.partial_config {
Some(c) => c,
None => return 0.0,
};
let actual_f64 = match actual {
ConditionValue::Integer(v) => *v as f64,
ConditionValue::Float(v) => *v,
_ => return 0.0,
};
let target_f64 = match &self.condition.value {
ConditionValue::Integer(v) => *v as f64,
ConditionValue::Float(v) => *v,
_ => return 0.0,
};
match config {
PartialConfig::Linear {
min,
max,
descending,
} => {
let min_val = min.unwrap_or(0.0);
let max_val = max.unwrap_or(target_f64);
if *descending {
if actual_f64 <= min_val {
1.0
} else if actual_f64 >= max_val {
0.0
} else {
(max_val - actual_f64) / (max_val - min_val)
}
} else {
if actual_f64 <= min_val {
0.0
} else if actual_f64 >= max_val {
1.0
} else {
(actual_f64 - min_val) / (max_val - min_val)
}
}
}
PartialConfig::Threshold { thresholds } => {
let mut score = 0.0;
for (threshold, threshold_score) in thresholds {
if actual_f64 >= *threshold {
score = *threshold_score;
}
}
score
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PartialConfig {
Linear {
min: Option<f64>,
max: Option<f64>,
#[serde(default)]
descending: bool,
},
Threshold {
thresholds: Vec<(f64, f64)>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MilestoneResult {
pub name: String,
pub achievement: f64,
pub weight: f64,
pub weighted_score: f64,
pub completed: bool,
}
impl MilestoneResult {
pub fn new(milestone: &Milestone, achievement: f64) -> Self {
Self {
name: milestone.name.clone(),
achievement,
weight: milestone.weight,
weighted_score: achievement * milestone.weight,
completed: achievement >= 1.0,
}
}
}
#[derive(Debug, Clone)]
pub struct KpiCalculator {
milestones: Vec<Milestone>,
}
impl KpiCalculator {
pub fn new(milestones: Vec<Milestone>) -> Self {
Self { milestones }
}
pub fn calculate<F>(&self, metric_getter: F) -> KpiScore
where
F: Fn(&str) -> Option<ConditionValue>,
{
let mut results = Vec::new();
let mut total_score = 0.0;
let mut total_weight = 0.0;
for milestone in &self.milestones {
let achievement = match metric_getter(&milestone.condition.metric) {
Some(value) => milestone.evaluate(&value),
None => 0.0,
};
let result = MilestoneResult::new(milestone, achievement);
total_score += result.weighted_score;
total_weight += milestone.weight;
results.push(result);
}
let normalized_score = if total_weight > 0.0 {
total_score / total_weight
} else {
0.0
};
KpiScore {
score: normalized_score,
raw_score: total_score,
total_weight,
results,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KpiScore {
pub score: f64,
pub raw_score: f64,
pub total_weight: f64,
pub results: Vec<MilestoneResult>,
}
impl KpiScore {
pub fn completed_count(&self) -> usize {
self.results.iter().filter(|r| r.completed).count()
}
pub fn total_count(&self) -> usize {
self.results.len()
}
}
#[cfg(test)]
mod tests {
use super::super::conditions::CompareOp;
use super::*;
fn create_test_milestone(
name: &str,
metric: &str,
op: CompareOp,
value: i64,
weight: f64,
) -> Milestone {
Milestone::new(name, Condition::new(name, metric, op, value), weight)
}
#[test]
fn test_milestone_evaluate_complete() {
let milestone = create_test_milestone(
"first_collection",
"resources_collected",
CompareOp::Gte,
1,
0.2,
);
assert_eq!(milestone.evaluate(&ConditionValue::Integer(1)), 1.0);
assert_eq!(milestone.evaluate(&ConditionValue::Integer(5)), 1.0);
assert_eq!(milestone.evaluate(&ConditionValue::Integer(0)), 0.0);
}
#[test]
fn test_milestone_evaluate_partial_linear() {
let mut milestone = create_test_milestone("efficiency", "tick", CompareOp::Lte, 300, 0.3);
milestone = milestone.with_partial(PartialConfig::Linear {
min: Some(300.0),
max: Some(400.0),
descending: true, });
assert_eq!(milestone.evaluate(&ConditionValue::Integer(250)), 1.0);
assert_eq!(milestone.evaluate(&ConditionValue::Integer(300)), 1.0);
assert!((milestone.evaluate(&ConditionValue::Integer(350)) - 0.5).abs() < 0.01);
assert_eq!(milestone.evaluate(&ConditionValue::Integer(400)), 0.0);
assert_eq!(milestone.evaluate(&ConditionValue::Integer(500)), 0.0);
}
#[test]
fn test_kpi_calculator() {
let milestones = vec![
create_test_milestone("first", "collected", CompareOp::Gte, 1, 0.2),
create_test_milestone("half", "collected", CompareOp::Gte, 3, 0.3),
create_test_milestone("all", "collected", CompareOp::Gte, 5, 0.5),
];
let calculator = KpiCalculator::new(milestones);
let score = calculator.calculate(|_| Some(ConditionValue::Integer(5)));
assert_eq!(score.score, 1.0);
assert_eq!(score.completed_count(), 3);
let score = calculator.calculate(|_| Some(ConditionValue::Integer(3)));
assert!((score.score - 0.5).abs() < 0.01);
assert_eq!(score.completed_count(), 2);
let score = calculator.calculate(|_| Some(ConditionValue::Integer(1)));
assert!((score.score - 0.2).abs() < 0.01);
assert_eq!(score.completed_count(), 1);
}
#[test]
fn test_milestone_deserialize() {
let json = r#"{
"name": "efficiency_bonus",
"description": "Complete within 300 ticks",
"condition": {
"name": "efficiency",
"metric": "tick",
"op": "lte",
"value": 300
},
"weight": 0.2,
"partial": true,
"partial_config": {
"type": "linear",
"min": 300.0,
"max": 400.0
}
}"#;
let milestone: Milestone = serde_json::from_str(json).unwrap();
assert_eq!(milestone.name, "efficiency_bonus");
assert!(milestone.partial);
}
}