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//! One report per game, aggregates only. No deck, no cards, no opponent,
9//! nothing that identifies a player. It travels two ways — over the relay for
10//! a game the relay already knows about, and to the hub for offline play,
11//! where no server is in the loop at all.
12
13use serde::{Deserialize, Serialize};
14use ts_rs::TS;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
17#[serde(rename_all = "camelCase")]
18#[ts(export, export_to = "telemetry.ts")]
19pub struct EnginePlayStats {
20    /// Generated by the client, so a retry cannot double-count the game.
21    pub report_id: String,
22    /// Which engine actually ran, which is not always the one the room asked
23    /// for: a browser with the Forge engine on hosts a "Manabrew" room on it.
24    pub engine: String,
25    pub client_version: String,
26    pub platform: String,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    #[ts(optional)]
29    pub format: Option<String>,
30    pub seats: u32,
31    pub multiplayer: bool,
32    pub duration_s: u32,
33    pub end_reason: String,
34    /// Client-side turnaround: answer sent to next prompt landing. This is the
35    /// interval the player feels, and the one comparable across engines.
36    pub turnaround: EngineTurnaround,
37    /// The engine's own think time, when it reports one.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    #[ts(optional)]
40    pub engine_think: Option<EngineTurnaround>,
41    #[serde(default)]
42    pub by_type: Vec<EngineTypeTurnaround>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
46#[serde(rename_all = "camelCase")]
47#[ts(export, export_to = "telemetry.ts")]
48pub struct EngineTurnaround {
49    pub n: u32,
50    pub p50: u32,
51    pub p90: u32,
52    pub max: u32,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
56#[serde(rename_all = "camelCase")]
57#[ts(export, export_to = "telemetry.ts")]
58pub struct EngineTypeTurnaround {
59    #[serde(rename = "type")]
60    pub prompt_type: String,
61    pub n: u32,
62    pub p50: u32,
63    pub max: u32,
64}
65
66impl EnginePlayStats {
67    /// Whether this is worth storing. Rejects the shapes a mistake or a hostile
68    /// client produces: an unparseable id, empty or oversized text, a table
69    /// with impossible seats, a report of no decisions at all.
70    pub fn is_plausible(&self) -> bool {
71        let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
72        uuid_shaped(&self.report_id)
73            && text(&self.engine, 40)
74            && text(&self.client_version, 40)
75            && text(&self.platform, 20)
76            && text(&self.end_reason, 20)
77            && self.format.as_ref().is_none_or(|f| f.len() <= 40)
78            && (1..=8).contains(&self.seats)
79            && self.by_type.len() <= 32
80            && self.turnaround.n > 0
81    }
82}
83
84fn uuid_shaped(value: &str) -> bool {
85    value.len() == 36
86        && value.bytes().enumerate().all(|(index, byte)| match index {
87            8 | 13 | 18 | 23 => byte == b'-',
88            _ => byte.is_ascii_hexdigit(),
89        })
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    fn sample() -> EnginePlayStats {
97        EnginePlayStats {
98            report_id: "11111111-2222-3333-4444-555555555555".to_string(),
99            engine: "forge-wasm".to_string(),
100            client_version: "3.18.5".to_string(),
101            platform: "web".to_string(),
102            format: Some("standard".to_string()),
103            seats: 2,
104            multiplayer: false,
105            duration_s: 400,
106            end_reason: "gameOver".to_string(),
107            turnaround: EngineTurnaround {
108                n: 180,
109                p50: 46,
110                p90: 78,
111                max: 320,
112            },
113            engine_think: None,
114            by_type: vec![],
115        }
116    }
117
118    #[test]
119    fn accepts_a_real_report() {
120        assert!(sample().is_plausible());
121    }
122
123    #[test]
124    fn rejects_the_shapes_a_hostile_client_sends() {
125        let cases: Vec<(&str, Box<dyn Fn(&mut EnginePlayStats)>)> = vec![
126            (
127                "not a uuid",
128                Box::new(|s: &mut EnginePlayStats| s.report_id = "nope".to_string()),
129            ),
130            (
131                "empty engine",
132                Box::new(|s: &mut EnginePlayStats| s.engine = String::new()),
133            ),
134            (
135                "huge engine",
136                Box::new(|s: &mut EnginePlayStats| s.engine = "x".repeat(41)),
137            ),
138            ("no seats", Box::new(|s: &mut EnginePlayStats| s.seats = 0)),
139            (
140                "too many seats",
141                Box::new(|s: &mut EnginePlayStats| s.seats = 9),
142            ),
143            (
144                "no decisions",
145                Box::new(|s: &mut EnginePlayStats| s.turnaround.n = 0),
146            ),
147        ];
148        for (name, break_it) in cases {
149            let mut report = sample();
150            break_it(&mut report);
151            assert!(!report.is_plausible(), "{name} should have been rejected");
152        }
153    }
154}