use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use kanade_shared::manifest::ExecMode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Completion {
pub pc_id: String,
pub finished_at: DateTime<Utc>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum FireAction {
Skip,
FireWholeTarget,
FirePcs(Vec<String>),
}
#[derive(Debug, PartialEq, Eq)]
pub struct Decision {
pub action: FireAction,
pub auto_disable: bool,
}
pub fn decide_fire(
mode: ExecMode,
cooldown: Option<ChronoDuration>,
auto_disable_when_done: bool,
expected_pcs: &[String],
completions: &[Completion],
now: DateTime<Utc>,
) -> Decision {
match mode {
ExecMode::EveryTick => Decision {
action: FireAction::FireWholeTarget,
auto_disable: false,
},
ExecMode::OncePerPc => decide_once_per_pc(
cooldown,
auto_disable_when_done,
expected_pcs,
completions,
now,
),
ExecMode::OncePerTarget => decide_once_per_target(
cooldown,
auto_disable_when_done,
expected_pcs,
completions,
now,
),
}
}
fn decide_once_per_pc(
cooldown: Option<ChronoDuration>,
auto_disable_when_done: bool,
expected_pcs: &[String],
completions: &[Completion],
now: DateTime<Utc>,
) -> Decision {
let mut last_success: HashMap<&str, DateTime<Utc>> = HashMap::new();
for c in completions {
last_success
.entry(c.pc_id.as_str())
.and_modify(|t| {
if c.finished_at > *t {
*t = c.finished_at;
}
})
.or_insert(c.finished_at);
}
let mut eligible: Vec<String> = Vec::new();
let mut all_done = !expected_pcs.is_empty();
for pc in expected_pcs {
let armed = match last_success.get(pc.as_str()) {
None => {
all_done = false;
true
}
Some(t) => match cooldown {
None => false, Some(cd) => {
let elapsed = now.signed_duration_since(*t);
if elapsed >= cd {
all_done = false;
true
} else {
false
}
}
},
};
if armed {
eligible.push(pc.clone());
}
}
if eligible.is_empty() {
let auto = auto_disable_when_done && cooldown.is_none() && all_done;
return Decision {
action: FireAction::Skip,
auto_disable: auto,
};
}
Decision {
action: FireAction::FirePcs(eligible),
auto_disable: false,
}
}
fn decide_once_per_target(
cooldown: Option<ChronoDuration>,
auto_disable_when_done: bool,
expected_pcs: &[String],
completions: &[Completion],
now: DateTime<Utc>,
) -> Decision {
let expected: HashSet<&str> = expected_pcs.iter().map(String::as_str).collect();
let latest = completions
.iter()
.filter(|c| expected.contains(c.pc_id.as_str()))
.map(|c| c.finished_at)
.max();
let armed = match (latest, cooldown) {
(None, _) => true,
(Some(_), None) => false,
(Some(t), Some(cd)) => now.signed_duration_since(t) >= cd,
};
if armed {
if expected_pcs.is_empty() {
return Decision {
action: FireAction::Skip,
auto_disable: false,
};
}
return Decision {
action: FireAction::FireWholeTarget,
auto_disable: false,
};
}
let auto = auto_disable_when_done && cooldown.is_none() && latest.is_some();
Decision {
action: FireAction::Skip,
auto_disable: auto,
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn t(secs: i64) -> DateTime<Utc> {
Utc.timestamp_opt(1_700_000_000 + secs, 0).single().unwrap()
}
fn pcs(ids: &[&str]) -> Vec<String> {
ids.iter().map(|s| (*s).into()).collect()
}
fn done(pc: &str, secs: i64) -> Completion {
Completion {
pc_id: pc.into(),
finished_at: t(secs),
}
}
#[test]
fn every_tick_always_fires_whole_target() {
let d = decide_fire(
ExecMode::EveryTick,
None,
true,
&pcs(&["a", "b"]),
&[done("a", 0), done("b", 1)],
t(100),
);
assert_eq!(d.action, FireAction::FireWholeTarget);
assert!(!d.auto_disable);
}
#[test]
fn every_tick_ignores_empty_expected() {
let d = decide_fire(ExecMode::EveryTick, None, false, &[], &[], t(0));
assert_eq!(d.action, FireAction::FireWholeTarget);
}
#[test]
fn once_per_pc_no_completions_fires_every_pc() {
let d = decide_fire(
ExecMode::OncePerPc,
None,
false,
&pcs(&["a", "b", "c"]),
&[],
t(0),
);
assert_eq!(d.action, FireAction::FirePcs(pcs(&["a", "b", "c"])));
assert!(!d.auto_disable);
}
#[test]
fn once_per_pc_partial_completion_fires_remainder() {
let d = decide_fire(
ExecMode::OncePerPc,
None,
false,
&pcs(&["a", "b", "c"]),
&[done("a", 0)],
t(100),
);
assert_eq!(d.action, FireAction::FirePcs(pcs(&["b", "c"])));
assert!(!d.auto_disable);
}
#[test]
fn once_per_pc_all_completed_skips() {
let d = decide_fire(
ExecMode::OncePerPc,
None,
false,
&pcs(&["a", "b"]),
&[done("a", 0), done("b", 1)],
t(100),
);
assert_eq!(d.action, FireAction::Skip);
assert!(!d.auto_disable);
}
#[test]
fn once_per_pc_all_completed_with_auto_disable() {
let d = decide_fire(
ExecMode::OncePerPc,
None,
true,
&pcs(&["a", "b"]),
&[done("a", 0), done("b", 1)],
t(100),
);
assert_eq!(d.action, FireAction::Skip);
assert!(d.auto_disable);
}
#[test]
fn once_per_pc_empty_expected_does_not_auto_disable() {
let d = decide_fire(ExecMode::OncePerPc, None, true, &[], &[], t(0));
assert_eq!(d.action, FireAction::Skip);
assert!(!d.auto_disable);
}
#[test]
fn once_per_pc_cooldown_recent_excluded() {
let d = decide_fire(
ExecMode::OncePerPc,
Some(ChronoDuration::seconds(60)),
false,
&pcs(&["a"]),
&[done("a", 100)],
t(110),
);
assert_eq!(d.action, FireAction::Skip);
assert!(!d.auto_disable, "cooldown'd schedules never auto-disable");
}
#[test]
fn once_per_pc_cooldown_exactly_at_boundary_rearmed() {
let d = decide_fire(
ExecMode::OncePerPc,
Some(ChronoDuration::seconds(60)),
false,
&pcs(&["a"]),
&[done("a", 100)],
t(160), );
assert_eq!(d.action, FireAction::FirePcs(pcs(&["a"])));
}
#[test]
fn once_per_pc_cooldown_one_second_before_boundary_excluded() {
let d = decide_fire(
ExecMode::OncePerPc,
Some(ChronoDuration::seconds(60)),
false,
&pcs(&["a"]),
&[done("a", 100)],
t(159),
);
assert_eq!(d.action, FireAction::Skip);
}
#[test]
fn once_per_pc_cooldown_past_boundary_rearmed() {
let d = decide_fire(
ExecMode::OncePerPc,
Some(ChronoDuration::seconds(60)),
false,
&pcs(&["a", "b"]),
&[done("a", 100), done("b", 200)],
t(500), );
assert_eq!(d.action, FireAction::FirePcs(pcs(&["a", "b"])));
}
#[test]
fn once_per_pc_cooldown_mix_one_armed_one_not() {
let d = decide_fire(
ExecMode::OncePerPc,
Some(ChronoDuration::seconds(60)),
false,
&pcs(&["a", "b"]),
&[done("a", 100), done("b", 150)],
t(170), );
assert_eq!(d.action, FireAction::FirePcs(pcs(&["a"])));
}
#[test]
fn once_per_pc_uses_latest_success_per_pc() {
let d = decide_fire(
ExecMode::OncePerPc,
Some(ChronoDuration::seconds(60)),
false,
&pcs(&["a"]),
&[done("a", 0), done("a", 150), done("a", 50)],
t(170), );
assert_eq!(d.action, FireAction::Skip);
}
#[test]
fn once_per_pc_cooldown_no_auto_disable_when_in_cooldown() {
let d = decide_fire(
ExecMode::OncePerPc,
Some(ChronoDuration::hours(1)),
true,
&pcs(&["a"]),
&[done("a", 0)],
t(100), );
assert!(!d.auto_disable);
}
#[test]
fn once_per_pc_decommissioned_pc_completion_ignored() {
let d = decide_fire(
ExecMode::OncePerPc,
None,
true,
&pcs(&["a", "b"]),
&[done("a", 0), done("b", 1), done("ghost", 2)],
t(100),
);
assert_eq!(d.action, FireAction::Skip);
assert!(d.auto_disable);
}
#[test]
fn once_per_target_no_success_fires_whole_target() {
let d = decide_fire(
ExecMode::OncePerTarget,
None,
false,
&pcs(&["a", "b"]),
&[],
t(0),
);
assert_eq!(d.action, FireAction::FireWholeTarget);
assert!(!d.auto_disable);
}
#[test]
fn once_per_target_any_in_scope_success_skips() {
let d = decide_fire(
ExecMode::OncePerTarget,
None,
false,
&pcs(&["a", "b"]),
&[done("a", 0)],
t(100),
);
assert_eq!(d.action, FireAction::Skip);
}
#[test]
fn once_per_target_any_success_with_auto_disable() {
let d = decide_fire(
ExecMode::OncePerTarget,
None,
true,
&pcs(&["a", "b"]),
&[done("a", 0)],
t(100),
);
assert_eq!(d.action, FireAction::Skip);
assert!(d.auto_disable);
}
#[test]
fn once_per_target_out_of_scope_success_does_not_count() {
let d = decide_fire(
ExecMode::OncePerTarget,
None,
true,
&pcs(&["a", "b"]),
&[done("ghost", 0)],
t(100),
);
assert_eq!(d.action, FireAction::FireWholeTarget);
assert!(
!d.auto_disable,
"no in-scope completion ⇒ schedule still hunting; never auto-disable on first tick"
);
}
#[test]
fn once_per_target_empty_expected_no_completion_skips() {
let d = decide_fire(ExecMode::OncePerTarget, None, true, &[], &[], t(0));
assert_eq!(d.action, FireAction::Skip);
assert!(!d.auto_disable);
}
#[test]
fn once_per_target_cooldown_recent_skipped() {
let d = decide_fire(
ExecMode::OncePerTarget,
Some(ChronoDuration::seconds(60)),
false,
&pcs(&["a", "b"]),
&[done("a", 100)],
t(110), );
assert_eq!(d.action, FireAction::Skip);
assert!(!d.auto_disable);
}
#[test]
fn once_per_target_cooldown_exactly_at_boundary_rearmed() {
let d = decide_fire(
ExecMode::OncePerTarget,
Some(ChronoDuration::seconds(60)),
false,
&pcs(&["a", "b"]),
&[done("a", 100)],
t(160), );
assert_eq!(d.action, FireAction::FireWholeTarget);
}
#[test]
fn once_per_target_cooldown_one_second_before_boundary_skipped() {
let d = decide_fire(
ExecMode::OncePerTarget,
Some(ChronoDuration::seconds(60)),
false,
&pcs(&["a", "b"]),
&[done("a", 100)],
t(159),
);
assert_eq!(d.action, FireAction::Skip);
}
#[test]
fn once_per_target_uses_most_recent_completion() {
let d = decide_fire(
ExecMode::OncePerTarget,
Some(ChronoDuration::seconds(60)),
false,
&pcs(&["a", "b"]),
&[done("a", 0), done("b", 200)], t(259), );
assert_eq!(d.action, FireAction::Skip);
}
}