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