use crate::events::{CollectionEvent, EventMetadata, GameEvent};
use crate::log::entry::LogEntry;
use crate::parsers::api_common;
const START_HOOK_METHOD: &str = "StartHook";
const PLAYER_CARDS_FIELD: &str = "PlayerCards";
pub fn try_parse(
entry: &LogEntry,
timestamp: Option<chrono::DateTime<chrono::Utc>>,
) -> Option<GameEvent> {
let body = &entry.body;
if !api_common::is_api_response(body, START_HOOK_METHOD) {
return None;
}
let parsed = api_common::parse_json_from_body(body, "StartHook collection")?;
let player_cards = parsed.get(PLAYER_CARDS_FIELD)?;
let payload = serde_json::json!({
"type": "collection_snapshot",
"cards": player_cards.clone(),
"raw_start_hook": parsed,
});
let metadata = EventMetadata::new(timestamp, body.as_bytes().to_vec());
Some(GameEvent::Collection(CollectionEvent::new(
metadata, payload,
)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::PerformanceClass;
use crate::parsers::test_helpers::{collection_payload, test_timestamp, unity_entry};
mod matching {
use super::*;
#[test]
fn test_try_parse_start_hook_with_player_cards() {
let body = "[UnityCrossThreadLogger]2/22/2026 11:59:51 AM\n\
<== StartHook(e3f1a2b4-5678-9abc-def0-123456789abc)\n\
{\n\
\"InventoryInfo\": {\"Gems\": 1234},\n\
\"PlayerCards\": {\"98535\": 4, \"12345\": 2}\n\
}";
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!());
let payload = collection_payload(event);
assert_eq!(payload["type"], "collection_snapshot");
assert_eq!(payload["cards"]["98535"], 4);
assert_eq!(payload["cards"]["12345"], 2);
}
#[test]
fn test_try_parse_large_collection() {
let body = "[UnityCrossThreadLogger]2/22/2026 12:00:00 PM\n\
<== StartHook(large-uuid)\n\
{\"PlayerCards\": {\
\"10001\": 4, \"10002\": 3, \"10003\": 2, \"10004\": 1\
}}";
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!());
let payload = collection_payload(event);
assert_eq!(payload["cards"]["10001"], 4);
assert_eq!(payload["cards"]["10004"], 1);
}
#[test]
fn test_try_parse_preserves_raw_start_hook() {
let body = "[UnityCrossThreadLogger]2/22/2026 12:00:00 PM\n\
<== StartHook(raw-uuid)\n\
{\"PlayerCards\": {\"1\": 1}, \"ExtraField\": true}";
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!());
let payload = collection_payload(event);
assert_eq!(payload["raw_start_hook"]["ExtraField"], true);
}
#[test]
fn test_try_parse_empty_collection() {
let body = "[UnityCrossThreadLogger]2/22/2026 12:00:00 PM\n\
<== StartHook(empty-uuid)\n\
{\"PlayerCards\": {}}";
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!());
let payload = collection_payload(event);
assert!(payload["cards"].is_object());
}
}
mod metadata {
use super::*;
#[test]
fn test_try_parse_preserves_raw_bytes() {
let body = "[UnityCrossThreadLogger]2/22/2026 12:00:00 PM\n\
<== StartHook(meta-uuid)\n\
{\"PlayerCards\": {\"1\": 1}}";
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_try_parse_stores_timestamp() {
let body = "[UnityCrossThreadLogger]2/22/2026 12:00:00 PM\n\
<== StartHook(ts-uuid)\n\
{\"PlayerCards\": {\"1\": 1}}";
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);
}
}
mod non_matching {
use super::*;
#[test]
fn test_try_parse_start_hook_without_player_cards_returns_none() {
let body = "[UnityCrossThreadLogger]2/22/2026 12:00:00 PM\n\
<== StartHook(no-cards-uuid)\n\
{\"InventoryInfo\": {\"Gems\": 1234}}";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_try_parse_different_api_response_returns_none() {
let body = "[UnityCrossThreadLogger]2/22/2026 12:00:00 PM\n\
<== RankGetCombinedRankInfo(uuid)\n\
{\"constructedClass\": \"Gold\"}";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_try_parse_api_request_returns_none() {
let body = "[UnityCrossThreadLogger]==> StartHook {\"data\": 1}";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_try_parse_unrelated_entry_returns_none() {
let body = "[UnityCrossThreadLogger]greToClientEvent\n{\"data\": 1}";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_try_parse_old_marker_returns_none() {
let body = "[UnityCrossThreadLogger]PlayerInventory.GetPlayerCardsV3\n{\"98535\": 4}";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
#[test]
fn test_try_parse_malformed_json_returns_none() {
let body = "[UnityCrossThreadLogger]2/22/2026 12:00:00 PM\n\
<== StartHook(uuid)\n\
{broken json!!!}";
let entry = unity_entry(body);
assert!(try_parse(&entry, Some(test_timestamp())).is_none());
}
}
mod performance_class {
use super::*;
#[test]
fn test_collection_event_is_durable_per_event() {
let body = "[UnityCrossThreadLogger]2/22/2026 12:00:00 PM\n\
<== StartHook(perf-uuid)\n\
{\"PlayerCards\": {\"1\": 1}}";
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.performance_class(), PerformanceClass::DurablePerEvent);
}
}
}