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 #[serde(default, skip_serializing_if = "Option::is_none")]
36 #[ts(optional)]
37 pub game_id: Option<String>,
38 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 pub turnaround: EngineTurnaround,
53 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
67 #[ts(optional)]
68 pub engine_think: Option<EngineTurnaround>,
69 #[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 #[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 #[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 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 pub fn linked_game_id(&self) -> Option<&str> {
141 self.game_id.as_deref().filter(|id| uuid_shaped(id))
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
148#[serde(rename_all = "camelCase")]
149#[ts(export, export_to = "telemetry.ts")]
150pub struct OfflinePlayGame {
151 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 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 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 && 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 #[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}