1use 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 pub report_id: String,
27 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 pub turnaround: EngineTurnaround,
42 #[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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
92#[serde(rename_all = "camelCase")]
93#[ts(export, export_to = "telemetry.ts")]
94pub struct OfflinePlayGame {
95 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 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 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 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 && 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}