Skip to main content

heatsync_core/
mock.rs

1//! deterministic mock feed — proves the aesthetic before the relay WS exists.
2//! no rand crate, no real clock: a seeded xorshift + a synthetic ms clock
3//! advanced each tick. heat emerges from the same decaying-counter model the
4//! live feed uses, so the mock and the real thing behave identically.
5
6use crate::{Channel, Message, Platform};
7
8struct Rng(u64);
9impl Rng {
10    fn next(&mut self) -> u64 {
11        let mut x = self.0;
12        x ^= x << 13;
13        x ^= x >> 7;
14        x ^= x << 17;
15        self.0 = x;
16        x
17    }
18    fn below(&mut self, n: u64) -> u64 {
19        self.next() % n.max(1)
20    }
21    fn pick<'a, T>(&mut self, xs: &'a [T]) -> &'a T {
22        &xs[self.below(xs.len() as u64) as usize]
23    }
24    fn unit(&mut self) -> f64 {
25        (self.next() >> 11) as f64 / (1u64 << 53) as f64
26    }
27}
28
29const USERS: &[&str] = &[
30    "forsen",
31    "nymn",
32    "pajlada",
33    "supibot",
34    "mel",
35    "kkona",
36    "wide",
37    "peeposit",
38    "gigachad",
39    "monkas",
40    "widepeepo",
41    "hasanabi",
42    "vedal",
43    "neuro",
44    "anon",
45];
46
47const WORDS: &[&str] = &[
48    "GAMBA",
49    "OMEGALUL",
50    "Sadge",
51    "PepeLaugh",
52    "gigachad moment",
53    "actual clip",
54    "no way",
55    "chat is this real",
56    "COPIUM",
57    "based",
58    "ratio",
59    "Nerd",
60    "KEKW",
61    "this is peak",
62    "mods asleep",
63    "PogU",
64    "sptvJAM",
65    "Aware",
66    // modifier / overlay grammar, for any demo set that carries these names
67    "Kappa RainTime w!",
68    "KEKW ffzW ffzRainbow",
69    "w!h!ffzX KEKW",
70    "GAMBA notL ffzSpin",
71    "c!#0f0 PogU ffzBounce",
72];
73
74/// per-channel baseline messages/tick — spreads channels across the ramp.
75struct Seed {
76    name: &'static str,
77    platform: Platform,
78    activity: f64,
79}
80
81const SEEDS: &[Seed] = &[
82    Seed {
83        name: "xqc",
84        platform: Platform::Twitch,
85        activity: 7.0,
86    },
87    Seed {
88        name: "forsen",
89        platform: Platform::Twitch,
90        activity: 2.5,
91    },
92    Seed {
93        name: "hasanabi",
94        platform: Platform::Twitch,
95        activity: 1.0,
96    },
97    Seed {
98        name: "trainwreckstv",
99        platform: Platform::Kick,
100        activity: 11.0,
101    },
102    Seed {
103        name: "adin",
104        platform: Platform::Kick,
105        activity: 0.4,
106    },
107];
108
109/// the demo channel set — same shape the live feed produces.
110pub fn channels() -> Vec<Channel> {
111    SEEDS
112        .iter()
113        .map(|s| Channel::new(s.name, s.platform, 200))
114        .collect()
115}
116
117/// drives the demo channels with a synthetic clock — no real time, no rand crate.
118pub struct Driver {
119    rng: Rng,
120    clock_ms: u64,
121    activity: Vec<f64>,
122}
123
124impl Default for Driver {
125    fn default() -> Driver {
126        Driver::new()
127    }
128}
129
130impl Driver {
131    pub fn new() -> Driver {
132        Driver {
133            rng: Rng(0x9E3779B97F4A7C15),
134            clock_ms: 1,
135            activity: SEEDS.iter().map(|s| s.activity).collect(),
136        }
137    }
138
139    /// one 350ms tick: advance the clock, cool every channel, then emit a
140    /// poisson-ish burst per channel scaled by its activity (rare 6× spikes).
141    pub fn tick(&mut self, channels: &mut [Channel]) {
142        self.clock_ms += 350;
143        let now = self.clock_ms;
144        for (i, ch) in channels.iter_mut().enumerate() {
145            ch.cool(now);
146            let spike = self.rng.unit() < 0.03;
147            let mult = if spike { 6.0 } else { 1.0 };
148            let mut budget = self.activity.get(i).copied().unwrap_or(1.0) * mult;
149            // rare inline events so the offline feed exercises note rendering.
150            if self.rng.unit() < 0.008 {
151                let user = self.rng.pick(USERS).to_string();
152                let (kind, what, text) = match (self.rng.unit() * 8.0) as u32 {
153                    0 => (
154                        crate::NoteKind::Sub,
155                        "subscribed at Tier 1. They've subscribed for 14 months!",
156                        "still here Pog",
157                    ),
158                    1 => (crate::NoteKind::Gift, "is gifting 5 Tier 1 Subs!", ""),
159                    2 => (crate::NoteKind::Raid, "12 raiders have joined!", ""),
160                    3 => (crate::NoteKind::Redeem, "redeemed Hydrate (500)", ""),
161                    4 => (crate::NoteKind::Live, "went live — Just Chatting", ""),
162                    5 => (crate::NoteKind::Category, "switched to Elden Ring", ""),
163                    6 => (crate::NoteKind::Offline, "went offline", ""),
164                    _ => (crate::NoteKind::Cheer, "cheered 100 bits", "gg"),
165                };
166                ch.record(
167                    Message {
168                        platform: ch.platform,
169                        user: match kind {
170                            crate::NoteKind::Raid => String::new(),
171                            // stream rows name the streamer, like the live feed's
172                            crate::NoteKind::Live
173                            | crate::NoteKind::Category
174                            | crate::NoteKind::Offline => ch.name.to_lowercase(),
175                            _ => user,
176                        },
177                        text: text.to_string(),
178                        color: None,
179                        badges: Vec::new(),
180                        id: None,
181                        gone: None,
182                        reply_to: None,
183                        note: Some(crate::Note {
184                            kind,
185                            what: what.to_string(),
186                        }),
187                        extra: Default::default(),
188                        emotes: None,
189                        tags: Vec::new(),
190                        heat: 0.0,
191                        uid: None,
192                    },
193                    now,
194                );
195                if matches!(
196                    kind,
197                    crate::NoteKind::Live | crate::NoteKind::Category | crate::NoteKind::Offline
198                ) {
199                    ch.flag_event();
200                }
201            }
202            while budget > 0.0 {
203                if self.rng.unit() < budget.min(1.0) {
204                    let user = self.rng.pick(USERS).to_string();
205                    let text = self.rng.pick(WORDS).to_string();
206                    // sprinkle role badges so the offline feed exercises the
207                    // same chrome the live one does.
208                    let badges = match self.rng.unit() {
209                        r if r < 0.03 => vec![crate::Badge::new(crate::BadgeKind::Moderator)],
210                        r if r < 0.05 => vec![crate::Badge::new(crate::BadgeKind::Vip)],
211                        r if r < 0.12 => vec![crate::Badge::new(crate::BadgeKind::Subscriber)],
212                        _ => Vec::new(),
213                    };
214                    // a few replies, /me lines and first messages so the demo
215                    // shows the richer chrome too.
216                    let roll = self.rng.unit();
217                    let reply_to = (roll < 0.04).then(|| self.rng.pick(USERS).to_string());
218                    let extra = crate::Extra {
219                        reply_text: reply_to
220                            .as_ref()
221                            .map(|_| "wait is that actually true".to_string())
222                            .unwrap_or_default(),
223                        action: (0.04..0.06).contains(&roll),
224                        first: (0.06..0.07).contains(&roll),
225                        ..Default::default()
226                    };
227                    ch.record(
228                        Message {
229                            platform: ch.platform,
230                            user,
231                            text,
232                            color: None,
233                            badges,
234                            id: None,
235                            gone: None,
236                            reply_to,
237                            note: None,
238                            extra,
239                            emotes: None,
240                            tags: Vec::new(),
241                            heat: 0.0,
242                            uid: None,
243                        },
244                        now,
245                    );
246                }
247                budget -= 1.0;
248            }
249        }
250    }
251}