Skip to main content

manabrew_server/analytics/
event.rs

1use serde::Serialize;
2use serde_json::Value;
3
4use crate::protocol::{EngineKind, GameFormat, SeatTransportReport};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
7#[serde(rename_all = "snake_case")]
8pub enum GameEndReason {
9    GameOver,
10    EngineFatal,
11    HostEnded,
12    ReconnectTimeout,
13    HostLost,
14    StaleExpired,
15    Abandoned,
16}
17
18impl GameEndReason {
19    pub fn as_str(self) -> &'static str {
20        match self {
21            Self::GameOver => "game_over",
22            Self::EngineFatal => "engine_fatal",
23            Self::HostEnded => "host_ended",
24            Self::ReconnectTimeout => "reconnect_timeout",
25            Self::HostLost => "host_lost",
26            Self::StaleExpired => "stale_expired",
27            Self::Abandoned => "abandoned",
28        }
29    }
30}
31
32#[derive(Debug, Serialize)]
33pub struct SeatInfo {
34    pub username: String,
35    pub is_bot: bool,
36    pub deck_name: Option<String>,
37    pub commander: Option<String>,
38    pub published_deck_id: Option<String>,
39    pub deck_fingerprint: Option<String>,
40}
41
42#[derive(Debug, Serialize)]
43pub struct CardEntry {
44    pub name: String,
45    pub set_code: String,
46    pub count: u32,
47}
48
49#[derive(Debug, Serialize)]
50#[serde(tag = "event", rename_all = "snake_case")]
51pub enum AnalyticsEvent {
52    ClientConnected {
53        ts: String,
54        username: String,
55        platform: String,
56        /// The client's reported app version. `None` means a build from before
57        /// clients reported one, which is the only handle we have on how much
58        /// of the player base is running a stale install.
59        #[serde(skip_serializing_if = "Option::is_none")]
60        version: Option<String>,
61        reconnected: bool,
62    },
63    GameStarted {
64        ts: String,
65        game_id: String,
66        room_id: String,
67        format: GameFormat,
68        engine: EngineKind,
69        hosted: bool,
70        official: bool,
71        #[serde(skip_serializing_if = "Option::is_none")]
72        table_style: Option<String>,
73        starting_life: i32,
74        players: Vec<SeatInfo>,
75    },
76    GameEnded {
77        ts: String,
78        game_id: String,
79        room_id: String,
80        reason: GameEndReason,
81        duration_s: f64,
82        game_over: bool,
83        winner: Option<String>,
84        conceded: Vec<String>,
85        fatal_message: Option<String>,
86        /// Whether the host filed the outcome; false means the reason is the
87        /// room lifecycle's fallback and `game_over` says nothing.
88        reported: bool,
89        #[serde(skip_serializing_if = "Option::is_none")]
90        turns: Option<u32>,
91    },
92    /// Which seats left the relay's data plane, as reported by the host.
93    TransportUsed {
94        ts: String,
95        room_id: String,
96        game_id: String,
97        host: String,
98        seats: Vec<SeatTransportReport>,
99    },
100    /// One end's account of one direct-plane attempt, whether or not it worked.
101    PlaneQuality {
102        ts: String,
103        #[serde(skip_serializing_if = "Option::is_none")]
104        room_id: Option<String>,
105        /// Relay-attested; never client supplied.
106        username: String,
107        /// The other end, as the reporter named it.
108        peer: String,
109        plane: String,
110        outcome: String,
111        /// `settled` or `measured`; a connected peer reports both.
112        phase: String,
113        #[serde(skip_serializing_if = "Option::is_none")]
114        connect_ms: Option<u32>,
115        #[serde(skip_serializing_if = "Option::is_none")]
116        rtt_ms: Option<u32>,
117        #[serde(skip_serializing_if = "Option::is_none")]
118        relay_rtt_ms: Option<u32>,
119        #[serde(skip_serializing_if = "Option::is_none")]
120        candidate_pair: Option<String>,
121    },
122    EngineStats {
123        ts: String,
124        /// The room the seat was in when the report landed, which at game over
125        /// is often none: the report outlives the seat's membership. Nothing
126        /// downstream reads it, so it stays a breadcrumb rather than a key.
127        #[serde(skip_serializing_if = "Option::is_none")]
128        room_id: Option<String>,
129        username: String,
130        report_id: String,
131        #[serde(skip_serializing_if = "Option::is_none")]
132        game_id: Option<String>,
133        engine: String,
134        client_version: String,
135        platform: String,
136        #[serde(skip_serializing_if = "Option::is_none")]
137        format: Option<String>,
138        seats: u32,
139        multiplayer: bool,
140        duration_s: u32,
141        end_reason: String,
142        decisions: u32,
143        turnaround_p50: u32,
144        turnaround_p90: u32,
145        turnaround_max: u32,
146        #[serde(skip_serializing_if = "Option::is_none")]
147        engine_p50: Option<u32>,
148        #[serde(skip_serializing_if = "Option::is_none")]
149        engine_p90: Option<u32>,
150        #[serde(skip_serializing_if = "Option::is_none")]
151        engine_max: Option<u32>,
152        #[serde(skip_serializing_if = "Option::is_none")]
153        engine_same_p50: Option<u32>,
154        #[serde(skip_serializing_if = "Option::is_none")]
155        engine_same_p90: Option<u32>,
156        #[serde(skip_serializing_if = "Option::is_none")]
157        engine_same_max: Option<u32>,
158        #[serde(skip_serializing_if = "Option::is_none")]
159        engine_cross_p50: Option<u32>,
160        #[serde(skip_serializing_if = "Option::is_none")]
161        engine_cross_p90: Option<u32>,
162        #[serde(skip_serializing_if = "Option::is_none")]
163        engine_cross_max: Option<u32>,
164        think_hidden: u32,
165    },
166
167    DeckSelected {
168        ts: String,
169        room_id: String,
170        username: String,
171        is_bot: bool,
172        deck_name: String,
173        commander: Option<String>,
174        cards: Vec<CardEntry>,
175        sideboard_count: usize,
176    },
177    SeatJoined {
178        ts: String,
179        room_id: String,
180        username: String,
181        is_bot: bool,
182        observer: bool,
183    },
184    SeatLeft {
185        ts: String,
186        room_id: String,
187        username: String,
188    },
189}
190
191#[derive(Serialize)]
192pub struct CaptureLine<'a> {
193    pub ts: String,
194    pub from: &'a str,
195    pub envelope: &'a Value,
196    /// Set only on envelopes a player sent, where `from` is that player and the
197    /// figure is theirs. Lets a decision be read against the link it crossed
198    /// rather than against a fleet-wide average.
199    #[serde(rename = "clientRttMs", skip_serializing_if = "Option::is_none")]
200    pub client_rtt_ms: Option<u32>,
201}