manasight_parser/parsers/
session.rs1use crate::events::{EventMetadata, GameEvent, SessionEvent};
15use crate::log::entry::LogEntry;
16use crate::parsers::api_common;
17
18const AUTHENTICATE_RESPONSE_MARKER: &str = "authenticateResponse";
20
21const FRONT_DOOR_CLOSE_MARKER: &str = "FrontDoorConnection.Close";
23
24pub fn try_parse(
34 entry: &LogEntry,
35 timestamp: Option<chrono::DateTime<chrono::Utc>>,
36) -> Option<GameEvent> {
37 let body = &entry.body;
38
39 let content = strip_header_prefix(body);
42
43 if let Some(payload) = try_parse_authenticate_response(body) {
44 let metadata = EventMetadata::new(timestamp, body.as_bytes().to_vec());
45 return Some(GameEvent::Session(SessionEvent::new(metadata, payload)));
46 }
47
48 if try_match_front_door_close(content) {
49 let metadata = EventMetadata::new(timestamp, body.as_bytes().to_vec());
50 let payload = serde_json::json!({
51 "type": "session_disconnect",
52 });
53 return Some(GameEvent::Session(SessionEvent::new(metadata, payload)));
54 }
55
56 None
57}
58
59fn strip_header_prefix(body: &str) -> &str {
65 let first_line = body.lines().next().unwrap_or(body);
67 if let Some(pos) = first_line.find(']') {
68 first_line[pos + 1..].trim_start()
69 } else {
70 first_line
71 }
72}
73
74fn try_parse_authenticate_response(full_body: &str) -> Option<serde_json::Value> {
85 if !full_body.contains(AUTHENTICATE_RESPONSE_MARKER) {
87 return None;
88 }
89
90 let json_body = api_common::extract_json_from_body(full_body);
92
93 if let Some(json_str) = json_body {
94 match serde_json::from_str::<serde_json::Value>(json_str) {
95 Ok(parsed) => {
96 let screen_name = find_screen_name(&parsed);
98 return Some(serde_json::json!({
99 "type": "session_authenticate",
100 "screen_name": screen_name.unwrap_or_default(),
101 "raw_response": parsed,
102 }));
103 }
104 Err(e) => {
105 ::log::warn!(
106 "authenticateResponse: malformed JSON body, falling back to empty screen_name: {e}"
107 );
108 }
109 }
110 }
111
112 Some(serde_json::json!({
114 "type": "session_authenticate",
115 "screen_name": "",
116 }))
117}
118
119fn try_match_front_door_close(content: &str) -> bool {
121 content.contains(FRONT_DOOR_CLOSE_MARKER)
122}
123
124fn find_screen_name(value: &serde_json::Value) -> Option<String> {
129 if let Some(name) = value.get("screenName").and_then(|v| v.as_str()) {
131 return Some(name.to_owned());
132 }
133
134 if let Some(obj) = value.as_object() {
136 for (_key, nested) in obj {
137 if let Some(name) = nested.get("screenName").and_then(|v| v.as_str()) {
138 return Some(name.to_owned());
139 }
140 }
141 }
142
143 None
144}
145
146#[cfg(test)]
151#[allow(deprecated)]
152mod tests {
153 use super::*;
154 use crate::parsers::test_helpers::{session_payload, test_timestamp, unity_entry, EntryHeader};
155
156 mod authenticate_response {
159 use super::*;
160
161 #[test]
162 fn test_try_parse_authenticate_response_with_json_body() {
163 let body = "[UnityCrossThreadLogger]authenticateResponse\n\
164 {\n\
165 \"screenName\": \"TestPlayer#12345\"\n\
166 }";
167 let entry = unity_entry(body);
168 let result = try_parse(&entry, Some(test_timestamp()));
169
170 assert!(result.is_some());
171 let event = result.as_ref().unwrap_or_else(|| unreachable!());
172 let payload = session_payload(event);
173
174 assert_eq!(payload["type"], "session_authenticate");
175 assert_eq!(payload["screen_name"], "TestPlayer#12345");
176 }
177
178 #[test]
179 fn test_try_parse_authenticate_response_nested_screen_name() {
180 let body = "[UnityCrossThreadLogger]authenticateResponse\n\
181 {\n\
182 \"authenticateResponse\": {\n\
183 \"screenName\": \"Nested#99999\"\n\
184 }\n\
185 }";
186 let entry = unity_entry(body);
187 let result = try_parse(&entry, Some(test_timestamp()));
188
189 assert!(result.is_some());
190 let event = result.as_ref().unwrap_or_else(|| unreachable!());
191 let payload = session_payload(event);
192
193 assert_eq!(payload["screen_name"], "Nested#99999");
194 }
195
196 #[test]
197 fn test_try_parse_authenticate_response_no_json() {
198 let body = "[UnityCrossThreadLogger]authenticateResponse";
199 let entry = unity_entry(body);
200 let result = try_parse(&entry, Some(test_timestamp()));
201
202 assert!(result.is_some());
203 let event = result.as_ref().unwrap_or_else(|| unreachable!());
204 let payload = session_payload(event);
205
206 assert_eq!(payload["type"], "session_authenticate");
207 assert_eq!(payload["screen_name"], "");
208 }
209
210 #[test]
211 fn test_try_parse_authenticate_response_no_screen_name_in_json() {
212 let body = "[UnityCrossThreadLogger]authenticateResponse\n\
213 {\"otherField\": \"value\"}";
214 let entry = unity_entry(body);
215 let result = try_parse(&entry, Some(test_timestamp()));
216
217 assert!(result.is_some());
218 let event = result.as_ref().unwrap_or_else(|| unreachable!());
219 let payload = session_payload(event);
220
221 assert_eq!(payload["type"], "session_authenticate");
222 assert_eq!(payload["screen_name"], "");
223 }
224
225 #[test]
226 fn test_try_parse_authenticate_response_preserves_raw_response() {
227 let body = "[UnityCrossThreadLogger]authenticateResponse\n\
228 {\"screenName\": \"Player#1\", \"token\": \"abc\"}";
229 let entry = unity_entry(body);
230 let result = try_parse(&entry, Some(test_timestamp()));
231
232 assert!(result.is_some());
233 let event = result.as_ref().unwrap_or_else(|| unreachable!());
234 let payload = session_payload(event);
235
236 assert!(payload.get("raw_response").is_some());
237 assert_eq!(payload["raw_response"]["token"], "abc");
238 }
239
240 #[test]
241 fn test_try_parse_authenticate_response_with_timestamp() {
242 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM \
243 authenticateResponse\n\
244 {\"screenName\": \"TsPlayer#555\"}";
245 let entry = unity_entry(body);
246 let result = try_parse(&entry, Some(test_timestamp()));
247
248 assert!(result.is_some());
249 let event = result.as_ref().unwrap_or_else(|| unreachable!());
250 let payload = session_payload(event);
251
252 assert_eq!(payload["screen_name"], "TsPlayer#555");
253 }
254 }
255
256 mod front_door_close {
259 use super::*;
260
261 #[test]
262 fn test_try_parse_front_door_close_basic() {
263 let body = "[UnityCrossThreadLogger]FrontDoorConnection.Close";
264 let entry = unity_entry(body);
265 let result = try_parse(&entry, Some(test_timestamp()));
266
267 assert!(result.is_some());
268 let event = result.as_ref().unwrap_or_else(|| unreachable!());
269 let payload = session_payload(event);
270
271 assert_eq!(payload["type"], "session_disconnect");
272 }
273
274 #[test]
275 fn test_try_parse_front_door_close_with_details() {
276 let body = "[UnityCrossThreadLogger]FrontDoorConnection.Close \
277 reason: server shutdown";
278 let entry = unity_entry(body);
279 let result = try_parse(&entry, Some(test_timestamp()));
280
281 assert!(result.is_some());
282 let event = result.as_ref().unwrap_or_else(|| unreachable!());
283 let payload = session_payload(event);
284
285 assert_eq!(payload["type"], "session_disconnect");
286 }
287
288 #[test]
289 fn test_try_parse_front_door_close_with_timestamp() {
290 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM \
291 FrontDoorConnection.Close";
292 let entry = unity_entry(body);
293 let result = try_parse(&entry, Some(test_timestamp()));
294
295 assert!(result.is_some());
296 let event = result.as_ref().unwrap_or_else(|| unreachable!());
297 let payload = session_payload(event);
298
299 assert_eq!(payload["type"], "session_disconnect");
300 }
301
302 #[test]
303 fn test_try_parse_front_door_close_preserves_metadata() {
304 let body = "[UnityCrossThreadLogger]FrontDoorConnection.Close";
305 let entry = unity_entry(body);
306 let ts = Some(test_timestamp());
307 let result = try_parse(&entry, ts);
308
309 assert!(result.is_some());
310 let event = result.as_ref().unwrap_or_else(|| unreachable!());
311 assert_eq!(event.metadata().timestamp(), ts);
312 assert_eq!(event.metadata().raw_bytes(), body.as_bytes());
313 }
314 }
315
316 mod non_session {
319 use super::*;
320
321 #[test]
322 fn test_try_parse_unrelated_entry_returns_none() {
323 let body = "[UnityCrossThreadLogger]greToClientEvent\n{\"data\": 1}";
324 let entry = unity_entry(body);
325 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
326 }
327
328 #[test]
329 fn test_try_parse_empty_body_returns_none() {
330 let body = "[UnityCrossThreadLogger]";
331 let entry = unity_entry(body);
332 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
333 }
334
335 #[test]
336 fn test_try_parse_connection_manager_entry_returns_none() {
337 let entry = LogEntry {
338 header: EntryHeader::ConnectionManager,
339 body: "[ConnectionManager]some connection message".to_owned(),
340 };
341 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
342 }
343
344 #[test]
345 fn test_try_parse_similar_but_different_marker_returns_none() {
346 let body = "[UnityCrossThreadLogger]FrontDoorConnection.Open";
347 let entry = unity_entry(body);
348 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
349 }
350 }
351
352 mod performance_class {
355 use super::*;
356 use crate::events::PerformanceClass;
357
358 #[test]
359 fn test_session_event_is_durable_per_event() {
360 let body = "[UnityCrossThreadLogger]authenticateResponse\n\
361 {\"screenName\":\"ClassTest\"}";
362 let entry = unity_entry(body);
363 let result = try_parse(&entry, Some(test_timestamp()));
364
365 assert!(result.is_some());
366 let event = result.as_ref().unwrap_or_else(|| unreachable!());
367 assert_eq!(event.performance_class(), PerformanceClass::DurablePerEvent);
368 }
369 }
370
371 mod helpers {
374 use super::*;
375
376 #[test]
377 fn test_strip_header_prefix_unity() {
378 let result = strip_header_prefix("[UnityCrossThreadLogger]some content");
379 assert_eq!(result, "some content");
380 }
381
382 #[test]
383 fn test_strip_header_prefix_with_space() {
384 let result = strip_header_prefix("[UnityCrossThreadLogger] spaced content");
385 assert_eq!(result, "spaced content");
386 }
387
388 #[test]
389 fn test_strip_header_prefix_connection_manager() {
390 let result = strip_header_prefix("[ConnectionManager]connection content");
391 assert_eq!(result, "connection content");
392 }
393
394 #[test]
395 fn test_strip_header_prefix_no_bracket() {
396 let result = strip_header_prefix("no bracket here");
397 assert_eq!(result, "no bracket here");
398 }
399
400 #[test]
401 fn test_find_screen_name_top_level() {
402 let value = serde_json::json!({"screenName": "Player#123"});
403 assert_eq!(find_screen_name(&value), Some("Player#123".to_owned()));
404 }
405
406 #[test]
407 fn test_find_screen_name_nested() {
408 let value = serde_json::json!({
409 "authenticateResponse": {"screenName": "Nested#456"}
410 });
411 assert_eq!(find_screen_name(&value), Some("Nested#456".to_owned()));
412 }
413
414 #[test]
415 fn test_find_screen_name_not_present() {
416 let value = serde_json::json!({"other": "data"});
417 assert!(find_screen_name(&value).is_none());
418 }
419 }
420}