Skip to main content

heatsync_core/
streamev.rs

1//! stream-event anti-wall state machine — port of the extension's go-live
2//! pairing + guards (stream-event-text.js `createGoLivePairing`, main.js
3//! `sessionWentLiveSeen`, the 60s text dedupe). pure: the clock is passed in
4//! as `now_ms`, nothing here sleeps or reads a timer. feed it every parsed
5//! `ChatLine`; lines without stream facts pass straight through.
6//!
7//! twitch announces one go-live as two events: a first-game `stream:update`
8//! ("switched to X") and `stream:online`. an update with no previous game is
9//! held `HOLD_MS`; a went-live for the same game swallows it.
10
11use std::collections::{HashMap, HashSet};
12
13use crate::proto::{ChatLine, StreamKind};
14use crate::Platform;
15
16/// how long a first "switched to" waits for the "went live" riding with it.
17pub const HOLD_MS: u64 = 2_000;
18/// a "switched to" for the game a channel just went live with, inside this, is the same news.
19pub const ECHO_MS: u64 = 30_000;
20/// identical channel+text inside this is a re-broadcast.
21pub const DEDUPE_MS: u64 = 60_000;
22/// a reload only brings back stored events this young (`replayKeeps`).
23pub const REPLAY_WINDOW_MS: u64 = 15 * 60 * 1000;
24
25type Key = (Platform, String);
26
27fn key(l: &ChatLine) -> Key {
28    (l.platform, l.channel.to_lowercase())
29}
30
31#[derive(Default)]
32pub struct StreamEvents {
33    /// first-game updates waiting for their go-live: key → (line, due_ms).
34    held: HashMap<Key, (ChatLine, u64)>,
35    /// channels' last go-live: key → (game, at_ms).
36    online: HashMap<Key, (String, u64)>,
37    /// channels that already said "went live" this session (offline clears).
38    seen: HashSet<Key>,
39    /// channel+text → last shown, for the 60s dedupe.
40    recent: HashMap<(Key, String), u64>,
41}
42
43impl StreamEvents {
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// `follow:live:snapshot`: these were live before we connected, so their
49    /// next went-live (a server re-broadcast) is not news.
50    pub fn seed_live(&mut self, live: impl IntoIterator<Item = (Platform, String)>) {
51        for (p, c) in live {
52            self.seen.insert((p, c.to_lowercase()));
53        }
54    }
55
56    /// one live line. returns what to show now (zero or one line).
57    pub fn push(&mut self, line: ChatLine, now_ms: u64) -> Vec<ChatLine> {
58        let Some(ev) = line.stream.clone() else {
59            return vec![line];
60        };
61        let k = key(&line);
62        match ev.kind {
63            StreamKind::Update => {
64                if !ev.prev_game.is_empty() {
65                    return self.show(line, now_ms);
66                }
67                if let Some((g, at)) = self.online.get(&k) {
68                    if *g == ev.game && now_ms.saturating_sub(*at) <= ECHO_MS {
69                        return Vec::new();
70                    }
71                }
72                // a newer held update for the channel replaces the older one.
73                self.held.insert(k, (line, now_ms + HOLD_MS));
74                Vec::new()
75            }
76            StreamKind::Online => {
77                if self
78                    .held
79                    .get(&k)
80                    .is_some_and(|(h, _)| h.stream.as_ref().is_some_and(|s| s.game == ev.game))
81                {
82                    self.held.remove(&k);
83                }
84                if !self.seen.insert(k.clone()) {
85                    return Vec::new();
86                }
87                self.online.insert(k, (ev.game, now_ms));
88                self.show(line, now_ms)
89            }
90            StreamKind::Offline => {
91                self.held.remove(&k);
92                self.online.remove(&k);
93                self.seen.remove(&k);
94                self.show(line, now_ms)
95            }
96        }
97    }
98
99    /// backfilled / stored lines: a went-live never replays (it was shown when
100    /// it happened); anything else stays only inside the replay window.
101    /// `age_ms` = how old the event is, when known.
102    pub fn replay_keeps(line: &ChatLine, age_ms: Option<u64>) -> bool {
103        match line.stream.as_ref().map(|s| s.kind) {
104            Some(StreamKind::Online) => false,
105            Some(_) => age_ms.is_none_or(|a| a <= REPLAY_WINDOW_MS),
106            None => true,
107        }
108    }
109
110    /// release held first-game updates whose wait is over. call every frame.
111    pub fn tick(&mut self, now_ms: u64) -> Vec<ChatLine> {
112        let due: Vec<Key> = self
113            .held
114            .iter()
115            .filter(|(_, (_, at))| *at <= now_ms)
116            .map(|(k, _)| k.clone())
117            .collect();
118        let mut out = Vec::new();
119        for k in due {
120            if let Some((line, _)) = self.held.remove(&k) {
121                out.extend(self.show(line, now_ms));
122            }
123        }
124        out
125    }
126
127    /// the 60s identical-text dedupe, then emit.
128    fn show(&mut self, line: ChatLine, now_ms: u64) -> Vec<ChatLine> {
129        let text = line
130            .note
131            .as_ref()
132            .map(|n| n.what.clone())
133            .unwrap_or_default();
134        let id = (key(&line), text);
135        if self
136            .recent
137            .get(&id)
138            .is_some_and(|at| now_ms.saturating_sub(*at) < DEDUPE_MS)
139        {
140            return Vec::new();
141        }
142        if self.recent.len() >= 256 {
143            self.recent
144                .retain(|_, at| now_ms.saturating_sub(*at) < DEDUPE_MS);
145        }
146        self.recent.insert(id, now_ms);
147        vec![line]
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::proto::{parse, Event};
155
156    fn line(json: &str) -> ChatLine {
157        match parse(json) {
158            Event::Chat(l) => l,
159            e => panic!("not a line: {e:?}"),
160        }
161    }
162    fn online(c: &str, g: &str) -> ChatLine {
163        line(&format!(
164            r#"{{"type":"stream:online","platform":"twitch","channel":"{c}","game":"{g}"}}"#
165        ))
166    }
167    fn update(c: &str, g: &str, prev: &str) -> ChatLine {
168        line(&format!(
169            r#"{{"type":"stream:update","platform":"twitch","channel":"{c}","game":"{g}","prevGame":"{prev}"}}"#
170        ))
171    }
172    fn offline(c: &str) -> ChatLine {
173        line(&format!(
174            r#"{{"type":"stream:offline","platform":"twitch","channel":"{c}"}}"#
175        ))
176    }
177    fn whats(v: &[ChatLine]) -> Vec<String> {
178        v.iter()
179            .map(|l| l.note.as_ref().unwrap().what.clone())
180            .collect()
181    }
182
183    #[test]
184    fn plain_chat_passes_through() {
185        let mut s = StreamEvents::new();
186        let l = line(
187            r#"{"type":"stream:redeem","platform":"twitch","channel":"c","user":"u","title":"t","cost":1}"#,
188        );
189        assert_eq!(s.push(l.clone(), 0), vec![l]);
190    }
191
192    #[test]
193    fn real_switch_is_shown_at_once() {
194        let mut s = StreamEvents::new();
195        assert_eq!(whats(&s.push(update("c", "B", "A"), 0)), ["switched to B"]);
196    }
197
198    #[test]
199    fn first_game_update_waits_then_releases() {
200        let mut s = StreamEvents::new();
201        assert!(s.push(update("c", "X", ""), 1_000).is_empty());
202        assert!(s.tick(2_999).is_empty());
203        assert_eq!(whats(&s.tick(3_000)), ["switched to X"]);
204        assert!(s.tick(9_000).is_empty(), "released once");
205    }
206
207    #[test]
208    fn went_live_for_same_game_swallows_held_update() {
209        let mut s = StreamEvents::new();
210        assert!(s.push(update("c", "X", ""), 0).is_empty());
211        assert_eq!(whats(&s.push(online("c", "X"), 500)), ["went live — X"]);
212        assert!(s.tick(5_000).is_empty());
213    }
214
215    #[test]
216    fn went_live_for_other_game_keeps_held_update() {
217        let mut s = StreamEvents::new();
218        s.push(update("c", "X", ""), 0);
219        assert_eq!(whats(&s.push(online("c", "Y"), 500)), ["went live — Y"]);
220        assert_eq!(whats(&s.tick(2_000)), ["switched to X"]);
221    }
222
223    #[test]
224    fn same_game_update_just_after_go_live_is_dropped() {
225        let mut s = StreamEvents::new();
226        s.push(online("c", "X"), 0);
227        assert!(s.push(update("c", "X", ""), 30_000).is_empty());
228        assert!(s.tick(40_000).is_empty());
229        // past the echo window it is held like any first-game update
230        assert!(s.push(update("c", "X", ""), 30_001).is_empty());
231        assert_eq!(whats(&s.tick(32_001)), ["switched to X"]);
232    }
233
234    #[test]
235    fn one_went_live_per_session_until_offline() {
236        let mut s = StreamEvents::new();
237        assert_eq!(s.push(online("c", "X"), 0).len(), 1);
238        assert!(s.push(online("c", "X"), 100_000).is_empty());
239        assert!(s.push(online("c", "Y"), 200_000).is_empty());
240        assert_eq!(whats(&s.push(offline("c"), 300_000)), ["went offline"]);
241        assert_eq!(s.push(online("c", "X"), 400_000).len(), 1);
242    }
243
244    #[test]
245    fn channels_are_independent_and_case_blind() {
246        let mut s = StreamEvents::new();
247        assert_eq!(s.push(online("Cyr", "X"), 0).len(), 1);
248        assert_eq!(s.push(online("other", "X"), 0).len(), 1);
249        assert!(s.push(online("cyr", "X"), 1).is_empty());
250    }
251
252    #[test]
253    fn identical_text_inside_a_minute_is_dropped() {
254        let mut s = StreamEvents::new();
255        assert_eq!(s.push(update("c", "B", "A"), 0).len(), 1);
256        assert!(s.push(update("c", "B", "A"), 59_999).is_empty());
257        assert_eq!(s.push(update("c", "B", "A"), 60_000).len(), 1);
258        // a different channel with the same words still shows
259        assert_eq!(s.push(update("d", "B", "A"), 1).len(), 1);
260    }
261
262    #[test]
263    fn offline_drops_a_held_update_and_resets_echo() {
264        let mut s = StreamEvents::new();
265        s.push(online("c", "X"), 0);
266        s.push(offline("c"), 1_000);
267        s.push(update("c", "X", ""), 2_000);
268        s.push(offline("c"), 2_500);
269        assert!(s.tick(10_000).is_empty());
270        // echo window cleared: next first-game update is held, not dropped
271        s.push(update("c", "X", ""), 11_000);
272        assert_eq!(s.tick(13_000).len(), 1);
273    }
274
275    #[test]
276    fn newer_held_update_replaces_older() {
277        let mut s = StreamEvents::new();
278        s.push(update("c", "X", ""), 0);
279        s.push(update("c", "Y", ""), 1_000);
280        assert_eq!(whats(&s.tick(3_000)), ["switched to Y"]);
281    }
282
283    #[test]
284    fn snapshot_seeds_already_live_so_rebroadcast_is_silent() {
285        let mut s = StreamEvents::new();
286        s.seed_live([(Platform::Twitch, "Cyr".to_string())]);
287        assert!(s.push(online("cyr", "X"), 0).is_empty());
288        // but it can go offline and come back for real
289        s.push(offline("cyr"), 1);
290        assert_eq!(s.push(online("cyr", "X"), 2).len(), 1);
291    }
292
293    #[test]
294    fn follow_snapshot_event_feeds_seed() {
295        let mut s = StreamEvents::new();
296        let raw =
297            r#"{"type":"follow:live:snapshot","streams":[{"username":"a","platform":"twitch"}]}"#;
298        match parse(raw) {
299            Event::FollowLive(v) => s.seed_live(v),
300            _ => panic!(),
301        }
302        assert!(s.push(online("a", ""), 0).is_empty());
303    }
304
305    #[test]
306    fn replay_never_brings_back_went_live() {
307        let fresh = Some(1_000);
308        let old = Some(REPLAY_WINDOW_MS + 1);
309        let on = online("c", "X");
310        let up = update("c", "B", "A");
311        let off = offline("c");
312        let chat = line(
313            r#"{"type":"stream:redeem","platform":"twitch","channel":"c","user":"u","title":"t"}"#,
314        );
315        for (l, age, want) in [
316            (&on, fresh, false),
317            (&on, None, false),
318            (&up, fresh, true),
319            (&up, old, false),
320            (&off, fresh, true),
321            (&off, old, false),
322            (&up, Some(REPLAY_WINDOW_MS), true),
323            (&chat, old, true),
324        ] {
325            assert_eq!(
326                StreamEvents::replay_keeps(l, age),
327                want,
328                "{:?} {age:?}",
329                l.note
330            );
331        }
332    }
333}