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 game these timings belong to: the relay's `game_id` for a relay
28    /// game, and [`OfflinePlayGame::report_id`] for an offline one. Without it
29    /// a report is an orphan: the timings are there, but nothing says what was
30    /// played, who won, or how it ended.
31    ///
32    /// It points at a record that names players. The report itself stays
33    /// anonymous, and erasure reaches the record it points at by handle, so
34    /// the id survives a scrub while what it leads to no longer names anyone.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    #[ts(optional)]
37    pub game_id: Option<String>,
38    /// Which engine actually ran, which is not always the one the room asked
39    /// for: a browser with the Forge engine on hosts a "Manabrew" room on it.
40    pub engine: String,
41    pub client_version: String,
42    pub platform: String,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    #[ts(optional)]
45    pub format: Option<String>,
46    pub seats: u32,
47    pub multiplayer: bool,
48    pub duration_s: u32,
49    pub end_reason: String,
50    /// Client-side turnaround: answer sent to next prompt landing. This is the
51    /// interval the player feels, and the one comparable across engines.
52    pub turnaround: EngineTurnaround,
53    /// The engine's own think time, when it reports one.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    #[ts(optional)]
56    pub engine_think: Option<EngineTurnaround>,
57    /// `engine_think` split by whether an opponent turn happened inside the
58    /// window. A window is answer-received to next-prompt-ready, so in a game
59    /// against the AI the cross-turn half carries whole opponent turns and is
60    /// not a measure of one decision.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    #[ts(optional)]
63    pub engine_think_same_turn: Option<EngineTurnaround>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    #[ts(optional)]
66    pub engine_think_cross_turn: Option<EngineTurnaround>,
67    /// Windows dropped because the tab was backgrounded for part of them: the
68    /// engine times itself in wall clock, which keeps running while the worker
69    /// is descheduled.
70    #[serde(default)]
71    pub think_samples_hidden: u32,
72    #[serde(default)]
73    pub by_type: Vec<EngineTypeTurnaround>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
77#[serde(rename_all = "camelCase")]
78#[ts(export, export_to = "telemetry.ts")]
79pub struct EngineTurnaround {
80    pub n: u32,
81    pub p50: u32,
82    pub p90: u32,
83    pub max: u32,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
87#[serde(rename_all = "camelCase")]
88#[ts(export, export_to = "telemetry.ts")]
89pub struct EngineTypeTurnaround {
90    #[serde(rename = "type")]
91    pub prompt_type: String,
92    pub n: u32,
93    pub p50: u32,
94    pub max: u32,
95}
96
97impl EnginePlayStats {
98    /// Whether this is worth storing. Rejects the shapes a mistake or a hostile
99    /// client produces: an unparseable id, empty or oversized text, a table
100    /// with impossible seats, a report of no decisions at all.
101    pub fn is_plausible(&self) -> bool {
102        let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
103        uuid_shaped(&self.report_id)
104            && text(&self.engine, 40)
105            && text(&self.client_version, 40)
106            && text(&self.platform, 20)
107            && text(&self.end_reason, 20)
108            && self.format.as_ref().is_none_or(|f| f.len() <= 40)
109            && (1..=8).contains(&self.seats)
110            && self.by_type.len() <= 32
111            && self.turnaround.n > 0
112    }
113
114    /// The game this report belongs to, when the id is one a store can key on.
115    /// A bad id costs the link, never the report: the timings are the point,
116    /// and dropping a whole game over a malformed field is how telemetry ends
117    /// up measuring nothing.
118    pub fn linked_game_id(&self) -> Option<&str> {
119        self.game_id.as_deref().filter(|id| uuid_shaped(id))
120    }
121}
122
123/// The relay's `game_started` + `game_ended` + `deck_selected` collapsed into
124/// one record: offline play has no connection to stream them over.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
126#[serde(rename_all = "camelCase")]
127#[ts(export, export_to = "telemetry.ts")]
128pub struct OfflinePlayGame {
129    /// Client-generated, so a retry cannot double-count the game. Stands in for
130    /// the relay's `game_id` downstream.
131    pub report_id: String,
132    pub started_at: String,
133    pub ended_at: String,
134    pub duration_s: u32,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    #[ts(optional)]
137    pub format: Option<String>,
138    pub engine: String,
139    pub starting_life: i32,
140    /// The relay's vocabulary, not the client's: `game_over`, `abandoned`.
141    pub end_reason: String,
142    pub game_over: bool,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    #[ts(optional)]
145    pub winner: Option<String>,
146    #[serde(default)]
147    pub conceded: Vec<String>,
148    pub client_version: String,
149    pub platform: String,
150    pub players: Vec<OfflinePlaySeat>,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
154#[serde(rename_all = "camelCase")]
155#[ts(export, export_to = "telemetry.ts")]
156pub struct OfflinePlaySeat {
157    /// The account handle when there is one, the local display name otherwise.
158    pub username: String,
159    pub is_bot: bool,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    #[ts(optional)]
162    pub deck_name: Option<String>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    #[ts(optional)]
165    pub commander: Option<String>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    #[ts(optional)]
168    pub published_deck_id: Option<String>,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    #[ts(optional)]
171    pub deck_fingerprint: Option<String>,
172    #[serde(default)]
173    pub sideboard_count: u32,
174    #[serde(default)]
175    pub cards: Vec<OfflinePlayCard>,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
179#[serde(rename_all = "camelCase")]
180#[ts(export, export_to = "telemetry.ts")]
181pub struct OfflinePlayCard {
182    pub name: String,
183    pub set_code: String,
184    pub count: u32,
185}
186
187const MAX_CARDS_PER_SEAT: usize = 400;
188
189impl OfflinePlayGame {
190    /// Whether this is worth storing. Same job as
191    /// [`EnginePlayStats::is_plausible`], over a wider record.
192    pub fn is_plausible(&self) -> bool {
193        let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
194        uuid_shaped(&self.report_id)
195            && text(&self.started_at, 40)
196            && text(&self.ended_at, 40)
197            && text(&self.engine, 40)
198            && text(&self.client_version, 40)
199            && text(&self.platform, 20)
200            && text(&self.end_reason, 30)
201            && self.format.as_ref().is_none_or(|f| f.len() <= 40)
202            && self.winner.as_ref().is_none_or(|w| w.len() <= 80)
203            && self.conceded.len() <= 8
204            && self.conceded.iter().all(|name| text(name, 80))
205            && (1..=8).contains(&self.players.len())
206            && self.players.iter().all(OfflinePlaySeat::is_plausible)
207    }
208}
209
210impl OfflinePlaySeat {
211    fn is_plausible(&self) -> bool {
212        let opt =
213            |value: &Option<String>, max: usize| value.as_ref().is_none_or(|v| v.len() <= max);
214        !self.username.is_empty()
215            && self.username.len() <= 80
216            && opt(&self.deck_name, 120)
217            && opt(&self.commander, 120)
218            && opt(&self.published_deck_id, 200)
219            // The same 64 lowercase hex the deck-play endpoint takes.
220            && self
221                .deck_fingerprint
222                .as_ref()
223                .is_none_or(|value| value.len() == 64 && value.bytes().all(is_lower_hex))
224            && self.cards.len() <= MAX_CARDS_PER_SEAT
225            && self.cards.iter().all(OfflinePlayCard::is_plausible)
226    }
227}
228
229impl OfflinePlayCard {
230    fn is_plausible(&self) -> bool {
231        !self.name.is_empty()
232            && self.name.len() <= 200
233            && self.set_code.len() <= 20
234            && (1..=1000).contains(&self.count)
235    }
236}
237
238fn is_lower_hex(byte: u8) -> bool {
239    byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
240}
241
242fn uuid_shaped(value: &str) -> bool {
243    value.len() == 36
244        && value.bytes().enumerate().all(|(index, byte)| match index {
245            8 | 13 | 18 | 23 => byte == b'-',
246            _ => byte.is_ascii_hexdigit(),
247        })
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    fn sample() -> EnginePlayStats {
255        EnginePlayStats {
256            report_id: "11111111-2222-3333-4444-555555555555".to_string(),
257            game_id: Some("66666666-7777-8888-9999-aaaaaaaaaaaa".to_string()),
258            engine: "forge-wasm".to_string(),
259            client_version: "3.18.5".to_string(),
260            platform: "web".to_string(),
261            format: Some("standard".to_string()),
262            seats: 2,
263            multiplayer: false,
264            duration_s: 400,
265            end_reason: "gameOver".to_string(),
266            turnaround: EngineTurnaround {
267                n: 180,
268                p50: 46,
269                p90: 78,
270                max: 320,
271            },
272            engine_think: None,
273            engine_think_same_turn: None,
274            engine_think_cross_turn: None,
275            think_samples_hidden: 0,
276            by_type: vec![],
277        }
278    }
279
280    #[test]
281    fn accepts_a_real_report() {
282        assert!(sample().is_plausible());
283        assert_eq!(
284            sample().linked_game_id(),
285            Some("66666666-7777-8888-9999-aaaaaaaaaaaa")
286        );
287    }
288
289    /// The timings are the point. A game id that cannot be keyed on costs the
290    /// link to the game and nothing else, because dropping the report over it
291    /// would lose the measurement this whole path exists to take.
292    #[test]
293    fn a_bad_game_id_costs_the_link_not_the_report() {
294        let mut report = sample();
295        report.game_id = Some("not-a-uuid".to_string());
296        assert!(report.is_plausible());
297        assert_eq!(report.linked_game_id(), None);
298
299        report.game_id = None;
300        assert!(report.is_plausible());
301        assert_eq!(report.linked_game_id(), None);
302    }
303
304    #[test]
305    fn rejects_the_shapes_a_hostile_client_sends() {
306        let cases: Vec<(&str, Box<dyn Fn(&mut EnginePlayStats)>)> = vec![
307            (
308                "not a uuid",
309                Box::new(|s: &mut EnginePlayStats| s.report_id = "nope".to_string()),
310            ),
311            (
312                "empty engine",
313                Box::new(|s: &mut EnginePlayStats| s.engine = String::new()),
314            ),
315            (
316                "huge engine",
317                Box::new(|s: &mut EnginePlayStats| s.engine = "x".repeat(41)),
318            ),
319            ("no seats", Box::new(|s: &mut EnginePlayStats| s.seats = 0)),
320            (
321                "too many seats",
322                Box::new(|s: &mut EnginePlayStats| s.seats = 9),
323            ),
324            (
325                "no decisions",
326                Box::new(|s: &mut EnginePlayStats| s.turnaround.n = 0),
327            ),
328        ];
329        for (name, break_it) in cases {
330            let mut report = sample();
331            break_it(&mut report);
332            assert!(!report.is_plausible(), "{name} should have been rejected");
333        }
334    }
335}