use crate::events::{EventMetadata, GameEvent, MatchConnectionStateEvent};
use crate::log::entry::{EntryHeader, LogEntry};
use crate::parsers::api_common;
const STATE_CHANGED_MARKER: &str = "STATE CHANGED ";
pub fn try_parse(
entry: &LogEntry,
timestamp: Option<chrono::DateTime<chrono::Utc>>,
) -> Option<GameEvent> {
if entry.header != EntryHeader::UnityCrossThreadLogger {
return None;
}
if !entry.body.contains(STATE_CHANGED_MARKER) {
return None;
}
let json_str = api_common::extract_json_from_body(&entry.body)?;
let parsed: serde_json::Value = match serde_json::from_str(json_str) {
Ok(v) => v,
Err(e) => {
::log::warn!("STATE CHANGED: malformed JSON payload: {e}");
return None;
}
};
let old_state = parsed.get("old")?.as_str()?.to_owned();
let new_state = parsed.get("new")?.as_str()?.to_owned();
let metadata = EventMetadata::new(timestamp, entry.body.as_bytes().to_vec());
Some(GameEvent::MatchConnectionState(
MatchConnectionStateEvent::new(
metadata,
serde_json::json!({ "old": old_state, "new": new_state }),
),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parsers::test_helpers::{
match_connection_state_payload, test_timestamp, unity_entry,
};
fn state_body(old: &str, new: &str) -> String {
format!("[UnityCrossThreadLogger]STATE CHANGED {{\"old\":\"{old}\",\"new\":\"{new}\"}}")
}
fn assert_transition(entry: &LogEntry, old: &str, new: &str) {
let result = try_parse(entry, Some(test_timestamp()));
assert!(
result.is_some(),
"expected Some(MatchConnectionState), got None for body {:?}",
entry.body
);
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert!(
matches!(event, GameEvent::MatchConnectionState(_)),
"expected GameEvent::MatchConnectionState, got {event:?}"
);
let payload = match_connection_state_payload(event);
assert_eq!(payload["old"], old, "old state mismatch");
assert_eq!(payload["new"], new, "new state mismatch");
}
mod transitions {
use super::*;
#[test]
fn test_parses_none_to_connected_to_match_door() {
let body = state_body("None", "ConnectedToMatchDoor");
let entry = unity_entry(&body);
assert_transition(&entry, "None", "ConnectedToMatchDoor");
}
#[test]
fn test_parses_match_door_to_connecting_to_gre() {
let body = state_body(
"ConnectedToMatchDoor",
"ConnectedToMatchDoor_ConnectingToGRE",
);
let entry = unity_entry(&body);
assert_transition(
&entry,
"ConnectedToMatchDoor",
"ConnectedToMatchDoor_ConnectingToGRE",
);
}
#[test]
fn test_parses_connecting_to_gre_to_waiting() {
let body = state_body(
"ConnectedToMatchDoor_ConnectingToGRE",
"ConnectedToMatchDoor_ConnectedToGRE_Waiting",
);
let entry = unity_entry(&body);
assert_transition(
&entry,
"ConnectedToMatchDoor_ConnectingToGRE",
"ConnectedToMatchDoor_ConnectedToGRE_Waiting",
);
}
#[test]
fn test_parses_fast_path_connecting_to_gre_to_playing() {
let body = state_body("ConnectedToMatchDoor_ConnectingToGRE", "Playing");
let entry = unity_entry(&body);
assert_transition(&entry, "ConnectedToMatchDoor_ConnectingToGRE", "Playing");
}
#[test]
fn test_parses_waiting_to_playing() {
let body = state_body("ConnectedToMatchDoor_ConnectedToGRE_Waiting", "Playing");
let entry = unity_entry(&body);
assert_transition(
&entry,
"ConnectedToMatchDoor_ConnectedToGRE_Waiting",
"Playing",
);
}
#[test]
fn test_parses_playing_to_match_completed() {
let body = state_body("Playing", "MatchCompleted");
let entry = unity_entry(&body);
assert_transition(&entry, "Playing", "MatchCompleted");
}
#[test]
fn test_parses_playing_to_disconnected() {
let body = state_body("Playing", "Disconnected");
let entry = unity_entry(&body);
assert_transition(&entry, "Playing", "Disconnected");
}
#[test]
fn test_parses_match_completed_to_disconnected() {
let body = state_body("MatchCompleted", "Disconnected");
let entry = unity_entry(&body);
assert_transition(&entry, "MatchCompleted", "Disconnected");
}
#[test]
fn test_parses_none_to_disconnected() {
let body = state_body("None", "Disconnected");
let entry = unity_entry(&body);
assert_transition(&entry, "None", "Disconnected");
}
}
mod macos_recovery {
use super::*;
#[test]
fn test_parses_macos_recovery_sequence() {
let disconnect_body = state_body("Playing", "Disconnected");
let disconnect_entry = unity_entry(&disconnect_body);
assert_transition(&disconnect_entry, "Playing", "Disconnected");
let recovery_body = state_body("None", "ConnectedToMatchDoor");
let recovery_entry = unity_entry(&recovery_body);
assert_transition(&recovery_entry, "None", "ConnectedToMatchDoor");
}
}
mod non_matching {
use super::*;
#[test]
fn test_non_state_changed_unity_body_returns_none() {
let entry =
unity_entry("[UnityCrossThreadLogger]FrontDoorConnection.Close some details");
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_empty_unity_body_returns_none() {
let entry = unity_entry("[UnityCrossThreadLogger]");
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_connection_manager_header_returns_none() {
let entry = LogEntry {
header: EntryHeader::ConnectionManager,
body: "[ConnectionManager]STATE CHANGED {\"old\":\"Playing\",\"new\":\"Disconnected\"}"
.to_owned(),
};
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_metadata_header_returns_none() {
let entry = LogEntry {
header: EntryHeader::Metadata,
body: "STATE CHANGED {\"old\":\"Playing\",\"new\":\"Disconnected\"}".to_owned(),
};
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_malformed_json_returns_none() {
let entry =
unity_entry("[UnityCrossThreadLogger]STATE CHANGED {\"old\":\"Playing\",new}");
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_missing_old_field_returns_none() {
let entry =
unity_entry("[UnityCrossThreadLogger]STATE CHANGED {\"new\":\"Disconnected\"}");
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_missing_new_field_returns_none() {
let entry = unity_entry("[UnityCrossThreadLogger]STATE CHANGED {\"old\":\"Playing\"}");
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
}
mod metadata {
use super::*;
#[test]
fn test_metadata_preserves_raw_bytes() {
let body = state_body("Playing", "Disconnected");
let entry = unity_entry(&body);
let result = try_parse(&entry, Some(test_timestamp()));
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert_eq!(event.metadata().raw_bytes(), body.as_bytes());
}
#[test]
fn test_metadata_preserves_timestamp() {
let body = state_body("Playing", "Disconnected");
let entry = unity_entry(&body);
let ts = Some(test_timestamp());
let result = try_parse(&entry, ts);
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert_eq!(event.metadata().timestamp(), ts);
}
#[test]
fn test_metadata_passes_through_none_timestamp() {
let body = state_body("Playing", "Disconnected");
let entry = unity_entry(&body);
let result = try_parse(&entry, None);
assert!(result.is_some());
let event = result.as_ref().unwrap_or_else(|| unreachable!());
assert!(event.metadata().timestamp().is_none());
}
}
}