diff --git a/koucha/src/score.rs b/koucha/src/score.rs index 7c54b63..d8ff046 100644 --- a/koucha/src/score.rs +++ b/koucha/src/score.rs @@ -1,10 +1,15 @@ use chrono::{DateTime, Utc, TimeDelta}; +use crate::{Result}; mod default { + use crate::score::SECONDS_IN_A_DAY; + pub const INITIAL_SCORE: i64 = 70; pub const GRAVITY: i64 = -10; pub const BOOST: i64 = 12; + pub const BOOST_FREEZE_IN_SECONDS: i64 = SECONDS_IN_A_DAY; } +const SECONDS_IN_A_DAY: i64 = 60 * 60 * 24; macro_rules! rich_i64 { ($name:ident, $default:expr) => { @@ -21,69 +26,144 @@ macro_rules! rich_i64 { rich_i64!(Gravity, default::GRAVITY); rich_i64!(Boost, default::BOOST); -impl Boost { - fn should_apply( - old_boost_time: Option>, new_boost_time: DateTime - ) -> bool { - old_boost_time.map_or(true, |obs| { - let time_delta = obs - &new_boost_time; - time_delta.num_days() >= 1 - }) +pub struct UnparsedScore { + pub value: i64, + pub last_updated: DateTime, + pub last_boosted: Option>, +} + +impl UnparsedScore { + pub fn parse(self) -> Score { + match self.last_boosted { + None => Score::Decaying(DecayingScore { + value: self.value, + last_updated: self.last_updated, + }), + Some(last_boosted) => Score::Boosted(BoostedScore { + value: self.value, + boosted_at: last_boosted, + }), + } } } -pub struct Score { - value: i64, - last_updated: DateTime, - last_boosted: Option>, + +pub enum Score { + Decaying(DecayingScore), + Boosted(BoostedScore), } impl Score { - pub fn new() -> Self { - Self::new_at_time(Utc::now()) - } - - pub fn new_at_time(creation_time: DateTime) -> Self { - Score { + pub fn new() -> DecayingScore { + DecayingScore { value: default::INITIAL_SCORE, - last_updated: creation_time, - last_boosted: Some(creation_time), + last_updated: Utc::now(), } } - pub fn update(self) -> Self { - self.update_at_time(Utc::now()) + pub fn get_score(&self) -> i64 { + match self { + Self::Decaying(s) => s.get_score(), + Self::Boosted(b) => b.get_score(), + } } - pub fn update_at_time(self, update_time: DateTime) -> Self { - let to_elapse_from = self.last_boosted.map_or(self.last_updated, |lb| { - std::cmp::max(self.last_updated, lb + TimeDelta::days(1)) - }); - let elapsed_time = update_time.signed_duration_since(to_elapse_from); - - self - } - - pub fn boost(self, boost: Boost) -> Self { - self.boost_at_time(boost, Utc::now()) - } - - // This function does not "fast-forward" the score's value to the time of - // boost, but still updates `last_updated`. Meaning that hours of gravity may - // not be applied. - // The expectation is that a boost is a user interaction, and that the user - // should be seeing a "fresh" score. We wouldn't want a boost to end up - // applying lots of gravity. But care should be taken to ensure that only - // "fresh" scores are boosted. Or that boost_time reflects the state of the - // user experience. - pub fn boost_at_time(self, boost: Boost, boost_time: DateTime) -> Self { - if Boost::should_apply(self.last_boosted, boost_time) { - Self { - value: self.value + i64::from(boost), - last_updated: boost_time, - last_boosted: Some(boost_time), + pub fn update_score(self, gravity: Gravity) -> Self { + match self { + Self::Decaying(d) => Score::Decaying(d.update_score(gravity)), + Self::Boosted(b) => { + let try_unfrozen = b.try_unfreeze(); + match try_unfrozen { + Self::Decaying(s) => Score::Decaying(s.update_score(gravity)), + Self::Boosted(b) => Score::Boosted(b), + } } - } else { - self + } + } + + pub fn get_decaying(self) -> Result { + match self { + Self::Decaying(s) => Ok(s), + Self::Boosted(_) => Err("Attempted to get_decaying() of a boosted score".into()), + } + } + + pub fn get_boosted(self) -> Result { + match self { + Self::Decaying(_) => Err("Attempted to get_boosted() of a decaying score".into()), + Self::Boosted(b) => Ok(b), + } + } +} + +pub struct BoostedScore { + value: i64, + boosted_at: DateTime, +} + +pub struct DecayingScore { + value: i64, + last_updated: DateTime, +} + +impl DecayingScore { + fn get_score(&self) -> i64 { + self.value + } + + fn update_score(self, gravity: Gravity) -> Self { + self.update_score_at_time(gravity, Utc::now()) + } + + fn update_score_at_time( + self, gravity: Gravity, update_time: DateTime + ) -> Self { + let elapsed_time: TimeDelta = update_time.signed_duration_since(self.last_updated); + let new_value = if elapsed_time <= TimeDelta::zero() { self.value } else { + i64::from(gravity) * (elapsed_time.num_seconds() / SECONDS_IN_A_DAY) + }; + Self { + last_updated: update_time, + value: new_value, + } + } + + pub fn boost(self, boost: Boost) -> BoostedScore { + self.boost_at_time(boost, Utc::now()) + } + + fn boost_at_time(self, boost: Boost, boost_time: DateTime) -> BoostedScore { + BoostedScore { + value: self.value + i64::from(boost), + boosted_at: boost_time, + } + } +} + +impl BoostedScore { + fn get_score(&self) -> i64 { + self.value + } + + pub fn unboost(self, boost: Boost) -> DecayingScore { + DecayingScore { + value: self.value - i64::from(boost), + last_updated: self.boosted_at, + } + } + + fn try_unfreeze(self) -> Score { + self.try_unfreeze_at_time(Utc::now()) + } + + fn try_unfreeze_at_time(self, update_time: DateTime) -> Score { + let boost_end = self.boosted_at + TimeDelta::seconds(default::BOOST_FREEZE_IN_SECONDS); + if boost_end > update_time { + Score::Decaying(DecayingScore { + value: self.value, + last_updated: boost_end, + }) + } else { + Score::Boosted(self) } } }