Skip to main content

heatsync_core/
heat.rs

1//! the heat ramp — one decaying scalar → a stepped temperature color.
2//! terminal-native: a tier is an ANSI color name plus vt320 attributes, so it
3//! follows the user's terminal palette. each frontend maps `Look` to its toolkit.
4
5/// the 8 ansi colors, by name.
6#[derive(Clone, Copy, PartialEq, Eq, Debug)]
7pub enum Hue {
8    Black,
9    Red,
10    Green,
11    Yellow,
12    Blue,
13    Magenta,
14    Cyan,
15    White,
16}
17
18/// a hue plus emphasis attributes.
19#[derive(Clone, Copy, PartialEq, Eq, Debug)]
20pub struct Look {
21    pub hue: Hue,
22    pub bold: bool,
23    pub reversed: bool,
24    pub blink: bool,
25}
26
27impl Look {
28    pub const fn new(hue: Hue) -> Look {
29        Look {
30            hue,
31            bold: false,
32            reversed: false,
33            blink: false,
34        }
35    }
36    pub const fn bold(mut self) -> Look {
37        self.bold = true;
38        self
39    }
40    pub const fn reversed(mut self) -> Look {
41        self.reversed = true;
42        self
43    }
44    pub const fn blink(mut self) -> Look {
45        self.blink = true;
46        self
47    }
48}
49
50/// tier thresholds, ascending. matches HEAT_THRESHOLDS in colors.js.
51pub const SPARK: f64 = 10.0;
52pub const WARM: f64 = 50.0;
53pub const HOT: f64 = 250.0;
54pub const ERUPTING: f64 = 1000.0;
55pub const MYTHIC: f64 = 5000.0;
56
57/// live-chat heat = a decaying counter (the same model as post heat). each
58/// message adds `INCREMENT`; heat halves every `HALFLIFE_MS`. tuned so steady
59/// chat rates land on the ramp: ~0.5 msg/s → cold/spark, ~5 msg/s → hot,
60/// a raid (~50 msg/s) → erupting. no server value needed — it's local velocity.
61pub const HALFLIFE_MS: f64 = 20_000.0;
62pub const INCREMENT: f64 = 2.0;
63
64/// decay `heat` forward by `dt_ms` milliseconds. exact exponential half-life.
65pub fn decay(heat: f64, dt_ms: f64) -> f64 {
66    if dt_ms <= 0.0 {
67        return heat;
68    }
69    heat * 0.5_f64.powf(dt_ms / HALFLIFE_MS)
70}
71
72#[derive(Clone, Copy, PartialEq, Eq, Debug)]
73pub enum Tier {
74    Zero,
75    Cold,
76    Spark,
77    Warm,
78    Hot,
79    Erupting,
80    Mythic,
81}
82
83impl Tier {
84    pub fn of(heat: f64) -> Tier {
85        match heat {
86            h if h >= MYTHIC => Tier::Mythic,
87            h if h >= ERUPTING => Tier::Erupting,
88            h if h >= HOT => Tier::Hot,
89            h if h >= WARM => Tier::Warm,
90            h if h >= SPARK => Tier::Spark,
91            h if h >= 1.0 => Tier::Cold,
92            _ => Tier::Zero,
93        }
94    }
95
96    /// how this tier reads on the 8-color palette: cold = plain white, then
97    /// white bold → yellow → yellow bold → red bold → red reversed + blink.
98    pub fn look(self) -> Look {
99        match self {
100            Tier::Zero | Tier::Cold => Look::new(Hue::White),
101            Tier::Spark => Look::new(Hue::White).bold(),
102            Tier::Warm => Look::new(Hue::Yellow),
103            Tier::Hot => Look::new(Hue::Yellow).bold(),
104            Tier::Erupting => Look::new(Hue::Red).bold(),
105            Tier::Mythic => Look::new(Hue::Red).reversed().blink(),
106        }
107    }
108
109    /// the ??? capstone marker — the only non-color glyph, mythic only.
110    pub fn marker(self) -> &'static str {
111        match self {
112            Tier::Mythic => "???",
113            _ => "",
114        }
115    }
116}
117
118/// convenience: heat → look.
119pub fn look(heat: f64) -> Look {
120    Tier::of(heat).look()
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn boundaries_are_inclusive_lower() {
129        assert_eq!(Tier::of(0.0), Tier::Zero);
130        assert_eq!(Tier::of(0.9), Tier::Zero);
131        assert_eq!(Tier::of(1.0), Tier::Cold);
132        assert_eq!(Tier::of(9.9), Tier::Cold);
133        assert_eq!(Tier::of(10.0), Tier::Spark);
134        assert_eq!(Tier::of(50.0), Tier::Warm);
135        assert_eq!(Tier::of(250.0), Tier::Hot);
136        assert_eq!(Tier::of(1000.0), Tier::Erupting);
137        assert_eq!(Tier::of(5000.0), Tier::Mythic);
138        assert_eq!(Tier::of(999999.0), Tier::Mythic);
139    }
140
141    #[test]
142    fn ladder_escalates_and_blinks_only_at_the_top() {
143        assert_eq!(Tier::Cold.look(), Look::new(Hue::White));
144        assert_eq!(Tier::Erupting.look(), Look::new(Hue::Red).bold());
145        for t in [
146            Tier::Zero,
147            Tier::Cold,
148            Tier::Spark,
149            Tier::Warm,
150            Tier::Hot,
151            Tier::Erupting,
152        ] {
153            assert!(!t.look().blink && !t.look().reversed);
154        }
155        assert!(Tier::Mythic.look().blink && Tier::Mythic.look().reversed);
156    }
157
158    #[test]
159    fn only_mythic_has_a_marker() {
160        assert_eq!(Tier::Mythic.marker(), "???");
161        assert_eq!(Tier::Warm.marker(), "");
162    }
163}