Skip to main content

manabrew_protocol/
telemetry.rs

1//! How fast an engine answered, for one finished game.
2//!
3//! The hosted node publishes its own decision timings to Prometheus. Nothing
4//! measured the engines that run on a player's machine, and the browser Forge
5//! build made that gap matter: the whole point of it is latency, and the only
6//! numbers we had came from a laptop under a test harness.
7//!
8//! [`EnginePlayStats`] is one report per game, aggregates only: no deck, no
9//! cards, no opponent, nothing that identifies a player. It travels two ways —
10//! over the relay for a game the relay already knows about, and to the hub for
11//! offline play, where no server is in the loop at all.
12//!
13//! [`OfflinePlayGame`] is the other half, and it is not anonymous: it names
14//! players, because the hosted node recorded exactly these fields for exactly
15//! these games until Play vs AI moved into the browser. Account erasure has to
16//! reach it, which `Storage::delete_account` does by handle.
17
18use serde::{Deserialize, Serialize};
19use ts_rs::TS;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
22#[serde(rename_all = "camelCase")]
23#[ts(export, export_to = "telemetry.ts")]
24pub struct EnginePlayStats {
25    /// Generated by the client, so a retry cannot double-count the game.
26    pub report_id: String,
27    /// Which engine actually ran, which is not always the one the room asked
28    /// for: a browser with the Forge engine on hosts a "Manabrew" room on it.
29    pub engine: String,
30    pub client_version: String,
31    pub platform: String,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    #[ts(optional)]
34    pub format: Option<String>,
35    pub seats: u32,
36    pub multiplayer: bool,
37    pub duration_s: u32,
38    pub end_reason: String,
39    /// Client-side turnaround: answer sent to next prompt landing. This is the
40    /// interval the player feels, and the one comparable across engines.
41    pub turnaround: EngineTurnaround,
42    /// The engine's own think time, when it reports one.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    #[ts(optional)]
45    pub engine_think: Option<EngineTurnaround>,
46    #[serde(default)]
47    pub by_type: Vec<EngineTypeTurnaround>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
51#[serde(rename_all = "camelCase")]
52#[ts(export, export_to = "telemetry.ts")]
53pub struct EngineTurnaround {
54    pub n: u32,
55    pub p50: u32,
56    pub p90: u32,
57    pub max: u32,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
61#[serde(rename_all = "camelCase")]
62#[ts(export, export_to = "telemetry.ts")]
63pub struct EngineTypeTurnaround {
64    #[serde(rename = "type")]
65    pub prompt_type: String,
66    pub n: u32,
67    pub p50: u32,
68    pub max: u32,
69}
70
71impl EnginePlayStats {
72    /// Whether this is worth storing. Rejects the shapes a mistake or a hostile
73    /// client produces: an unparseable id, empty or oversized text, a table
74    /// with impossible seats, a report of no decisions at all.
75    pub fn is_plausible(&self) -> bool {
76        let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
77        uuid_shaped(&self.report_id)
78            && text(&self.engine, 40)
79            && text(&self.client_version, 40)
80            && text(&self.platform, 20)
81            && text(&self.end_reason, 20)
82            && self.format.as_ref().is_none_or(|f| f.len() <= 40)
83            && (1..=8).contains(&self.seats)
84            && self.by_type.len() <= 32
85            && self.turnaround.n > 0
86    }
87}
88
89/// The relay's `game_started` + `game_ended` + `deck_selected` collapsed into
90/// one record: offline play has no connection to stream them over.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
92#[serde(rename_all = "camelCase")]
93#[ts(export, export_to = "telemetry.ts")]
94pub struct OfflinePlayGame {
95    /// Client-generated, so a retry cannot double-count the game. Stands in for
96    /// the relay's `game_id` downstream.
97    pub report_id: String,
98    pub started_at: String,
99    pub ended_at: String,
100    pub duration_s: u32,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    #[ts(optional)]
103    pub format: Option<String>,
104    pub engine: String,
105    pub starting_life: i32,
106    /// The relay's vocabulary, not the client's: `game_over`, `abandoned`.
107    pub end_reason: String,
108    pub game_over: bool,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    #[ts(optional)]
111    pub winner: Option<String>,
112    #[serde(default)]
113    pub conceded: Vec<String>,
114    pub client_version: String,
115    pub platform: String,
116    pub players: Vec<OfflinePlaySeat>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
120#[serde(rename_all = "camelCase")]
121#[ts(export, export_to = "telemetry.ts")]
122pub struct OfflinePlaySeat {
123    /// The account handle when there is one, the local display name otherwise.
124    pub username: String,
125    pub is_bot: bool,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    #[ts(optional)]
128    pub deck_name: Option<String>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    #[ts(optional)]
131    pub commander: Option<String>,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    #[ts(optional)]
134    pub published_deck_id: Option<String>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    #[ts(optional)]
137    pub deck_fingerprint: Option<String>,
138    #[serde(default)]
139    pub sideboard_count: u32,
140    #[serde(default)]
141    pub cards: Vec<OfflinePlayCard>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
145#[serde(rename_all = "camelCase")]
146#[ts(export, export_to = "telemetry.ts")]
147pub struct OfflinePlayCard {
148    pub name: String,
149    pub set_code: String,
150    pub count: u32,
151}
152
153const MAX_CARDS_PER_SEAT: usize = 400;
154
155impl OfflinePlayGame {
156    /// Whether this is worth storing. Same job as
157    /// [`EnginePlayStats::is_plausible`], over a wider record.
158    pub fn is_plausible(&self) -> bool {
159        let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
160        uuid_shaped(&self.report_id)
161            && text(&self.started_at, 40)
162            && text(&self.ended_at, 40)
163            && text(&self.engine, 40)
164            && text(&self.client_version, 40)
165            && text(&self.platform, 20)
166            && text(&self.end_reason, 30)
167            && self.format.as_ref().is_none_or(|f| f.len() <= 40)
168            && self.winner.as_ref().is_none_or(|w| w.len() <= 80)
169            && self.conceded.len() <= 8
170            && self.conceded.iter().all(|name| text(name, 80))
171            && (1..=8).contains(&self.players.len())
172            && self.players.iter().all(OfflinePlaySeat::is_plausible)
173    }
174}
175
176impl OfflinePlaySeat {
177    fn is_plausible(&self) -> bool {
178        let opt =
179            |value: &Option<String>, max: usize| value.as_ref().is_none_or(|v| v.len() <= max);
180        !self.username.is_empty()
181            && self.username.len() <= 80
182            && opt(&self.deck_name, 120)
183            && opt(&self.commander, 120)
184            && opt(&self.published_deck_id, 200)
185            // The same 64 lowercase hex the deck-play endpoint takes.
186            && self
187                .deck_fingerprint
188                .as_ref()
189                .is_none_or(|value| value.len() == 64 && value.bytes().all(is_lower_hex))
190            && self.cards.len() <= MAX_CARDS_PER_SEAT
191            && self.cards.iter().all(OfflinePlayCard::is_plausible)
192    }
193}
194
195impl OfflinePlayCard {
196    fn is_plausible(&self) -> bool {
197        !self.name.is_empty()
198            && self.name.len() <= 200
199            && self.set_code.len() <= 20
200            && (1..=1000).contains(&self.count)
201    }
202}
203
204fn is_lower_hex(byte: u8) -> bool {
205    byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
206}
207
208fn uuid_shaped(value: &str) -> bool {
209    value.len() == 36
210        && value.bytes().enumerate().all(|(index, byte)| match index {
211            8 | 13 | 18 | 23 => byte == b'-',
212            _ => byte.is_ascii_hexdigit(),
213        })
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn sample() -> EnginePlayStats {
221        EnginePlayStats {
222            report_id: "11111111-2222-3333-4444-555555555555".to_string(),
223            engine: "forge-wasm".to_string(),
224            client_version: "3.18.5".to_string(),
225            platform: "web".to_string(),
226            format: Some("standard".to_string()),
227            seats: 2,
228            multiplayer: false,
229            duration_s: 400,
230            end_reason: "gameOver".to_string(),
231            turnaround: EngineTurnaround {
232                n: 180,
233                p50: 46,
234                p90: 78,
235                max: 320,
236            },
237            engine_think: None,
238            by_type: vec![],
239        }
240    }
241
242    #[test]
243    fn accepts_a_real_report() {
244        assert!(sample().is_plausible());
245    }
246
247    #[test]
248    fn rejects_the_shapes_a_hostile_client_sends() {
249        let cases: Vec<(&str, Box<dyn Fn(&mut EnginePlayStats)>)> = vec![
250            (
251                "not a uuid",
252                Box::new(|s: &mut EnginePlayStats| s.report_id = "nope".to_string()),
253            ),
254            (
255                "empty engine",
256                Box::new(|s: &mut EnginePlayStats| s.engine = String::new()),
257            ),
258            (
259                "huge engine",
260                Box::new(|s: &mut EnginePlayStats| s.engine = "x".repeat(41)),
261            ),
262            ("no seats", Box::new(|s: &mut EnginePlayStats| s.seats = 0)),
263            (
264                "too many seats",
265                Box::new(|s: &mut EnginePlayStats| s.seats = 9),
266            ),
267            (
268                "no decisions",
269                Box::new(|s: &mut EnginePlayStats| s.turnaround.n = 0),
270            ),
271        ];
272        for (name, break_it) in cases {
273            let mut report = sample();
274            break_it(&mut report);
275            assert!(!report.is_plausible(), "{name} should have been rejected");
276        }
277    }
278}