1use std::collections::VecDeque;
8use std::time::{Duration, Instant};
9use crate::networking::http::{HttpClient, HttpRequest};
10
11#[derive(Debug, Clone)]
14pub enum AnalyticsEvent {
15 SessionStart { build: String, platform: String },
16 SessionEnd { duration_secs: f32, outcome: String },
17 Death { floor: u32, cause: String, score: i64, time_secs: f32 },
18 BossEncounter { name: String, floor: u32, defeated: bool, attempts: u32 },
19 Achievement { id: String, name: String },
20 ItemPickup { item: String, rarity: String, floor: u32 },
21 FloorComplete { floor: u32, time_secs: f32, score: i64 },
22 FrameTime { avg_ms: f32, p99_ms: f32, min_ms: f32, max_ms: f32 },
23 MemoryUsage { bytes: u64, peak_bytes: u64 },
24 Custom { name: String, value: f64, tags: Vec<String> },
25}
26
27impl AnalyticsEvent {
28 pub fn name(&self) -> &str {
29 match self {
30 Self::SessionStart { .. } => "session_start",
31 Self::SessionEnd { .. } => "session_end",
32 Self::Death { .. } => "death",
33 Self::BossEncounter { .. } => "boss_encounter",
34 Self::Achievement { .. } => "achievement",
35 Self::ItemPickup { .. } => "item_pickup",
36 Self::FloorComplete { .. } => "floor_complete",
37 Self::FrameTime { .. } => "frame_time",
38 Self::MemoryUsage { .. } => "memory_usage",
39 Self::Custom { name, .. } => name.as_str(),
40 }
41 }
42
43 pub fn to_json(&self) -> String {
44 match self {
45 Self::SessionStart { build, platform } =>
46 format!(r#"{{"event":"session_start","build":"{}","platform":"{}"}}"#, build, platform),
47 Self::SessionEnd { duration_secs, outcome } =>
48 format!(r#"{{"event":"session_end","duration":{:.2},"outcome":"{}"}}"#, duration_secs, outcome),
49 Self::Death { floor, cause, score, time_secs } =>
50 format!(r#"{{"event":"death","floor":{},"cause":"{}","score":{},"time":{:.2}}}"#,
51 floor, cause, score, time_secs),
52 Self::BossEncounter { name, floor, defeated, attempts } =>
53 format!(r#"{{"event":"boss","name":"{}","floor":{},"defeated":{},"attempts":{}}}"#,
54 name, floor, defeated, attempts),
55 Self::FloorComplete { floor, time_secs, score } =>
56 format!(r#"{{"event":"floor_complete","floor":{},"time":{:.2},"score":{}}}"#,
57 floor, time_secs, score),
58 Self::FrameTime { avg_ms, p99_ms, min_ms, max_ms } =>
59 format!(r#"{{"event":"frame_time","avg":{:.2},"p99":{:.2},"min":{:.2},"max":{:.2}}}"#,
60 avg_ms, p99_ms, min_ms, max_ms),
61 Self::Custom { name, value, tags } =>
62 format!(r#"{{"event":"{}","value":{:.6},"tags":[{}]}}"#,
63 name, value,
64 tags.iter().map(|t| format!("\"{}\"", t)).collect::<Vec<_>>().join(",")),
65 _ => format!(r#"{{"event":"{}"}}"#, self.name()),
66 }
67 }
68}
69
70#[derive(Debug, Clone, Default)]
74pub struct SessionStats {
75 pub deaths: u32,
76 pub kills: u32,
77 pub floors: u32,
78 pub items: u32,
79 pub score: i64,
80 pub play_time: f32,
81 pub bosses_seen: u32,
82 pub bosses_killed: u32,
83 pub max_floor: u32,
84 pub total_damage_dealt: f64,
85 pub total_damage_taken: f64,
86 pub frame_times: Vec<f32>,
87}
88
89impl SessionStats {
90 pub fn avg_frame_ms(&self) -> f32 {
91 if self.frame_times.is_empty() { return 0.0; }
92 self.frame_times.iter().sum::<f32>() / self.frame_times.len() as f32
93 }
94
95 pub fn p99_frame_ms(&self) -> f32 {
96 if self.frame_times.is_empty() { return 0.0; }
97 let mut sorted = self.frame_times.clone();
98 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
99 let idx = ((sorted.len() as f32 * 0.99) as usize).min(sorted.len()-1);
100 sorted[idx]
101 }
102
103 pub fn to_csv_row(&self) -> String {
104 format!("{},{},{},{},{},{:.2},{},{},{:.2},{:.2},{:.2},{:.2}",
105 self.deaths, self.kills, self.floors, self.items, self.score,
106 self.play_time, self.bosses_seen, self.bosses_killed,
107 self.total_damage_dealt, self.total_damage_taken,
108 self.avg_frame_ms(), self.p99_frame_ms())
109 }
110
111 pub fn csv_header() -> &'static str {
112 "deaths,kills,floors,items,score,play_time,bosses_seen,bosses_killed,damage_dealt,damage_taken,avg_frame_ms,p99_frame_ms"
113 }
114}
115
116pub struct Analytics {
121 pub enabled: bool,
123 pub endpoint: Option<String>,
124 pub build_version: String,
125 pub platform: String,
126 pub session_id: String,
127 pub stats: SessionStats,
128
129 pending: Vec<AnalyticsEvent>,
130 local_log: VecDeque<(String, String)>, http: Option<HttpClient>,
132 session_start: Instant,
133 flush_interval: f32,
134 flush_timer: f32,
135 batch_size: usize,
136 max_local_log: usize,
137 uploads_sent: u32,
138}
139
140impl Analytics {
141 pub fn new(build: impl Into<String>, platform: impl Into<String>) -> Self {
142 Self {
143 enabled: false, endpoint: None,
145 build_version: build.into(),
146 platform: platform.into(),
147 session_id: Self::generate_session_id(),
148 stats: SessionStats::default(),
149 pending: Vec::new(),
150 local_log: VecDeque::new(),
151 http: None,
152 session_start: Instant::now(),
153 flush_interval: 60.0, flush_timer: 0.0,
155 batch_size: 100,
156 max_local_log: 10_000,
157 uploads_sent: 0,
158 }
159 }
160
161 pub fn enable(&mut self, endpoint: impl Into<String>) {
162 self.enabled = true;
163 self.endpoint = Some(endpoint.into());
164 self.http = Some(HttpClient::new());
165 self.record(AnalyticsEvent::SessionStart {
166 build: self.build_version.clone(),
167 platform: self.platform.clone(),
168 });
169 }
170
171 pub fn disable(&mut self) {
172 self.enabled = false;
173 }
174
175 pub fn record(&mut self, event: AnalyticsEvent) {
177 if !self.enabled { return; }
178
179 match &event {
181 AnalyticsEvent::Death { .. } => self.stats.deaths += 1,
182 AnalyticsEvent::BossEncounter { defeated, .. } => {
183 self.stats.bosses_seen += 1;
184 if *defeated { self.stats.bosses_killed += 1; }
185 }
186 AnalyticsEvent::ItemPickup { .. } => self.stats.items += 1,
187 AnalyticsEvent::FloorComplete { floor, .. } => {
188 self.stats.floors += 1;
189 self.stats.max_floor = self.stats.max_floor.max(*floor);
190 }
191 AnalyticsEvent::FrameTime { avg_ms, .. } => {
192 self.stats.frame_times.push(*avg_ms);
193 if self.stats.frame_times.len() > 1000 {
195 self.stats.frame_times.remove(0);
196 }
197 }
198 _ => {}
199 }
200
201 let ts = format!("{}", self.session_start.elapsed().as_secs());
202 let json = event.to_json();
203
204 let full_json = format!(r#"{{"session":"{}","t":{},"data":{}}}"#,
206 self.session_id, ts, json);
207
208 if self.local_log.len() < self.max_local_log {
209 self.local_log.push_back((ts, full_json.clone()));
210 }
211 self.pending.push(event);
212 }
213
214 pub fn tick(&mut self, dt: f32) {
216 if !self.enabled { return; }
217
218 self.stats.play_time += dt;
219 self.flush_timer += dt;
220
221 let should_flush = self.flush_timer >= self.flush_interval
222 || self.pending.len() >= self.batch_size;
223
224 if should_flush && !self.pending.is_empty() {
225 self.flush();
226 }
227
228 if let Some(ref mut http) = self.http {
229 http.tick(dt);
230 let _: Vec<_> = http.drain_events().collect();
232 }
233 }
234
235 pub fn flush(&mut self) {
237 if self.pending.is_empty() { return; }
238
239 let batch: Vec<String> = self.local_log.iter()
240 .rev()
241 .take(self.pending.len())
242 .map(|(_, j)| j.clone())
243 .collect();
244
245 if let (Some(ref endpoint), Some(ref mut http)) = (self.endpoint.clone(), self.http.as_mut()) {
246 let url = format!("{}/events", endpoint);
247 let body = format!("[{}]", batch.join(","));
248 let req = HttpRequest::post_json(url, body)
249 .with_header("X-Session-Id", self.session_id.clone());
250 http.send(req);
251 self.uploads_sent += 1;
252 }
253
254 self.pending.clear();
255 self.flush_timer = 0.0;
256 }
257
258 pub fn export_csv(&self) -> String {
260 let mut csv = String::from("timestamp,event_json\n");
261 for (ts, json) in &self.local_log {
262 csv.push_str(&format!("{},{}\n", ts, json.replace(',', ";")));
263 }
264 csv
265 }
266
267 pub fn local_log_entries(&self) -> impl Iterator<Item = &(String, String)> {
269 self.local_log.iter()
270 }
271
272 pub fn clear_local_log(&mut self) { self.local_log.clear(); }
274
275 pub fn session_duration(&self) -> Duration { self.session_start.elapsed() }
276 pub fn uploads_sent(&self) -> u32 { self.uploads_sent }
277
278 fn generate_session_id() -> String {
279 use std::sync::atomic::{AtomicU64, Ordering};
281 static COUNTER: AtomicU64 = AtomicU64::new(1);
282 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
283 format!("{:016x}{:016x}", n, n.wrapping_mul(0xdeadbeef_cafebabe))
285 }
286}