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, skip_serializing_if = "Option::is_none")]
90 #[ts(optional)]
91 pub checkpoints: Option<EngineCheckpointStats>,
92 #[serde(default)]
96 pub think_samples_hidden: u32,
97 #[serde(default)]
98 pub by_type: Vec<EngineTypeTurnaround>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
102#[serde(rename_all = "camelCase")]
103#[ts(export, export_to = "telemetry.ts")]
104pub struct EngineTurnaround {
105 pub n: u32,
106 pub p50: u32,
107 pub p90: u32,
108 pub max: u32,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
112#[serde(rename_all = "camelCase")]
113#[ts(export, export_to = "telemetry.ts")]
114pub struct EngineTypeTurnaround {
115 #[serde(rename = "type")]
116 pub prompt_type: String,
117 pub n: u32,
118 pub p50: u32,
119 pub max: u32,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
123#[serde(rename_all = "camelCase")]
124#[ts(export, export_to = "telemetry.ts")]
125pub struct EngineCheckpointTiming {
126 pub n: u32,
127 #[ts(type = "number")]
128 pub sum_us: u64,
129 #[ts(type = "number")]
130 pub p50_us: u64,
131 #[ts(type = "number")]
132 pub p95_us: u64,
133 #[ts(type = "number")]
134 pub p99_us: u64,
135 #[ts(type = "number")]
136 pub max_us: u64,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
140#[serde(rename_all = "camelCase")]
141#[ts(export, export_to = "telemetry.ts")]
142pub struct EngineCheckpointStats {
143 pub copy: EngineCheckpointTiming,
144 pub bookkeeping: EngineCheckpointTiming,
145 pub total: EngineCheckpointTiming,
146 pub decision: EngineCheckpointTiming,
147 pub hidden: u32,
148 pub dropped: u32,
149}
150
151impl EngineCheckpointTiming {
152 fn is_plausible(&self) -> bool {
153 self.n <= 4000
154 && self.p50_us <= self.p95_us
155 && self.p95_us <= self.p99_us
156 && self.p99_us <= self.max_us
157 && self.max_us <= self.sum_us
158 && self.sum_us <= 86_400_000_000
159 && (self.n > 0 || self.sum_us == 0)
160 }
161}
162
163impl EngineCheckpointStats {
164 fn is_plausible(&self) -> bool {
165 [&self.copy, &self.bookkeeping, &self.total, &self.decision]
166 .iter()
167 .all(|timing| timing.is_plausible())
168 && self.copy.n == self.bookkeeping.n
169 && self.copy.n == self.total.n
170 && self.total.sum_us == self.copy.sum_us + self.bookkeeping.sum_us
171 }
172}
173
174impl EnginePlayStats {
175 pub fn is_plausible(&self) -> bool {
179 let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
180 uuid_shaped(&self.report_id)
181 && text(&self.engine, 40)
182 && text(&self.client_version, 40)
183 && text(&self.platform, 20)
184 && text(&self.end_reason, 20)
185 && self.format.as_ref().is_none_or(|f| f.len() <= 40)
186 && (1..=8).contains(&self.seats)
187 && self.by_type.len() <= 32
188 && self.turnaround.n > 0
189 && self
190 .checkpoints
191 .as_ref()
192 .is_none_or(EngineCheckpointStats::is_plausible)
193 }
194
195 pub fn linked_game_id(&self) -> Option<&str> {
200 self.game_id.as_deref().filter(|id| uuid_shaped(id))
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
207#[serde(rename_all = "camelCase")]
208#[ts(export, export_to = "telemetry.ts")]
209pub struct OfflinePlayGame {
210 pub report_id: String,
213 pub started_at: String,
214 pub ended_at: String,
215 pub duration_s: u32,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 #[ts(optional)]
218 pub format: Option<String>,
219 pub engine: String,
220 pub starting_life: i32,
221 pub end_reason: String,
222 pub game_over: bool,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 #[ts(optional)]
225 pub engine_error: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 #[ts(optional)]
228 pub winner: Option<String>,
229 #[serde(default)]
230 pub conceded: Vec<String>,
231 pub client_version: String,
232 pub platform: String,
233 pub players: Vec<OfflinePlaySeat>,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
237#[serde(rename_all = "camelCase")]
238#[ts(export, export_to = "telemetry.ts")]
239pub struct OfflinePlaySeat {
240 pub username: String,
242 pub is_bot: bool,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 #[ts(optional)]
245 pub deck_name: Option<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 #[ts(optional)]
248 pub commander: Option<String>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 #[ts(optional)]
251 pub published_deck_id: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 #[ts(optional)]
254 pub deck_fingerprint: Option<String>,
255 #[serde(default)]
256 pub sideboard_count: u32,
257 #[serde(default)]
258 pub cards: Vec<OfflinePlayCard>,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
262#[serde(rename_all = "camelCase")]
263#[ts(export, export_to = "telemetry.ts")]
264pub struct OfflinePlayCard {
265 pub name: String,
266 pub set_code: String,
267 pub count: u32,
268}
269
270const MAX_CARDS_PER_SEAT: usize = 400;
271
272impl OfflinePlayGame {
273 pub fn is_plausible(&self) -> bool {
276 let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
277 uuid_shaped(&self.report_id)
278 && text(&self.started_at, 40)
279 && text(&self.ended_at, 40)
280 && text(&self.engine, 40)
281 && text(&self.client_version, 40)
282 && text(&self.platform, 20)
283 && text(&self.end_reason, 30)
284 && self.format.as_ref().is_none_or(|f| f.len() <= 40)
285 && self.winner.as_ref().is_none_or(|w| w.len() <= 80)
286 && self.conceded.len() <= 8
287 && self.conceded.iter().all(|name| text(name, 80))
288 && (1..=8).contains(&self.players.len())
289 && self.players.iter().all(OfflinePlaySeat::is_plausible)
290 }
291}
292
293impl OfflinePlaySeat {
294 fn is_plausible(&self) -> bool {
295 let opt =
296 |value: &Option<String>, max: usize| value.as_ref().is_none_or(|v| v.len() <= max);
297 !self.username.is_empty()
298 && self.username.len() <= 80
299 && opt(&self.deck_name, 120)
300 && opt(&self.commander, 120)
301 && opt(&self.published_deck_id, 200)
302 && self
304 .deck_fingerprint
305 .as_ref()
306 .is_none_or(|value| value.len() == 64 && value.bytes().all(is_lower_hex))
307 && self.cards.len() <= MAX_CARDS_PER_SEAT
308 && self.cards.iter().all(OfflinePlayCard::is_plausible)
309 }
310}
311
312impl OfflinePlayCard {
313 fn is_plausible(&self) -> bool {
314 !self.name.is_empty()
315 && self.name.len() <= 200
316 && self.set_code.len() <= 20
317 && (1..=1000).contains(&self.count)
318 }
319}
320
321fn is_lower_hex(byte: u8) -> bool {
322 byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
323}
324
325fn uuid_shaped(value: &str) -> bool {
326 value.len() == 36
327 && value.bytes().enumerate().all(|(index, byte)| match index {
328 8 | 13 | 18 | 23 => byte == b'-',
329 _ => byte.is_ascii_hexdigit(),
330 })
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 fn sample() -> EnginePlayStats {
338 EnginePlayStats {
339 report_id: "11111111-2222-3333-4444-555555555555".to_string(),
340 game_id: Some("66666666-7777-8888-9999-aaaaaaaaaaaa".to_string()),
341 engine: "forge-wasm".to_string(),
342 client_version: "3.18.5".to_string(),
343 platform: "web".to_string(),
344 format: Some("standard".to_string()),
345 seats: 2,
346 multiplayer: false,
347 duration_s: 400,
348 end_reason: "gameOver".to_string(),
349 turnaround: EngineTurnaround {
350 n: 180,
351 p50: 46,
352 p90: 78,
353 max: 320,
354 },
355 reply_wait: None,
356 client_work: None,
357 engine_think: None,
358 engine_think_same_turn: None,
359 engine_think_cross_turn: None,
360 engine_think_bot: None,
361 engine_think_rules: None,
362 checkpoints: None,
363 think_samples_hidden: 0,
364 by_type: vec![],
365 }
366 }
367
368 #[test]
369 fn accepts_a_real_report() {
370 assert!(sample().is_plausible());
371 assert_eq!(
372 sample().linked_game_id(),
373 Some("66666666-7777-8888-9999-aaaaaaaaaaaa")
374 );
375 }
376
377 #[test]
381 fn a_bad_game_id_costs_the_link_not_the_report() {
382 let mut report = sample();
383 report.game_id = Some("not-a-uuid".to_string());
384 assert!(report.is_plausible());
385 assert_eq!(report.linked_game_id(), None);
386
387 report.game_id = None;
388 assert!(report.is_plausible());
389 assert_eq!(report.linked_game_id(), None);
390 }
391
392 #[test]
393 fn rejects_the_shapes_a_hostile_client_sends() {
394 let cases: Vec<(&str, Box<dyn Fn(&mut EnginePlayStats)>)> = vec![
395 (
396 "not a uuid",
397 Box::new(|s: &mut EnginePlayStats| s.report_id = "nope".to_string()),
398 ),
399 (
400 "empty engine",
401 Box::new(|s: &mut EnginePlayStats| s.engine = String::new()),
402 ),
403 (
404 "huge engine",
405 Box::new(|s: &mut EnginePlayStats| s.engine = "x".repeat(41)),
406 ),
407 ("no seats", Box::new(|s: &mut EnginePlayStats| s.seats = 0)),
408 (
409 "too many seats",
410 Box::new(|s: &mut EnginePlayStats| s.seats = 9),
411 ),
412 (
413 "no decisions",
414 Box::new(|s: &mut EnginePlayStats| s.turnaround.n = 0),
415 ),
416 ];
417 for (name, break_it) in cases {
418 let mut report = sample();
419 break_it(&mut report);
420 assert!(!report.is_plausible(), "{name} should have been rejected");
421 }
422 }
423}