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