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,
164 pub game_over: bool,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 #[ts(optional)]
167 pub winner: Option<String>,
168 #[serde(default)]
169 pub conceded: Vec<String>,
170 pub client_version: String,
171 pub platform: String,
172 pub players: Vec<OfflinePlaySeat>,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
176#[serde(rename_all = "camelCase")]
177#[ts(export, export_to = "telemetry.ts")]
178pub struct OfflinePlaySeat {
179 pub username: String,
181 pub is_bot: bool,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 #[ts(optional)]
184 pub deck_name: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 #[ts(optional)]
187 pub commander: Option<String>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 #[ts(optional)]
190 pub published_deck_id: Option<String>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
192 #[ts(optional)]
193 pub deck_fingerprint: Option<String>,
194 #[serde(default)]
195 pub sideboard_count: u32,
196 #[serde(default)]
197 pub cards: Vec<OfflinePlayCard>,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
201#[serde(rename_all = "camelCase")]
202#[ts(export, export_to = "telemetry.ts")]
203pub struct OfflinePlayCard {
204 pub name: String,
205 pub set_code: String,
206 pub count: u32,
207}
208
209const MAX_CARDS_PER_SEAT: usize = 400;
210
211impl OfflinePlayGame {
212 pub fn is_plausible(&self) -> bool {
215 let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
216 uuid_shaped(&self.report_id)
217 && text(&self.started_at, 40)
218 && text(&self.ended_at, 40)
219 && text(&self.engine, 40)
220 && text(&self.client_version, 40)
221 && text(&self.platform, 20)
222 && text(&self.end_reason, 30)
223 && self.format.as_ref().is_none_or(|f| f.len() <= 40)
224 && self.winner.as_ref().is_none_or(|w| w.len() <= 80)
225 && self.conceded.len() <= 8
226 && self.conceded.iter().all(|name| text(name, 80))
227 && (1..=8).contains(&self.players.len())
228 && self.players.iter().all(OfflinePlaySeat::is_plausible)
229 }
230}
231
232impl OfflinePlaySeat {
233 fn is_plausible(&self) -> bool {
234 let opt =
235 |value: &Option<String>, max: usize| value.as_ref().is_none_or(|v| v.len() <= max);
236 !self.username.is_empty()
237 && self.username.len() <= 80
238 && opt(&self.deck_name, 120)
239 && opt(&self.commander, 120)
240 && opt(&self.published_deck_id, 200)
241 && self
243 .deck_fingerprint
244 .as_ref()
245 .is_none_or(|value| value.len() == 64 && value.bytes().all(is_lower_hex))
246 && self.cards.len() <= MAX_CARDS_PER_SEAT
247 && self.cards.iter().all(OfflinePlayCard::is_plausible)
248 }
249}
250
251impl OfflinePlayCard {
252 fn is_plausible(&self) -> bool {
253 !self.name.is_empty()
254 && self.name.len() <= 200
255 && self.set_code.len() <= 20
256 && (1..=1000).contains(&self.count)
257 }
258}
259
260fn is_lower_hex(byte: u8) -> bool {
261 byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
262}
263
264fn uuid_shaped(value: &str) -> bool {
265 value.len() == 36
266 && value.bytes().enumerate().all(|(index, byte)| match index {
267 8 | 13 | 18 | 23 => byte == b'-',
268 _ => byte.is_ascii_hexdigit(),
269 })
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 fn sample() -> EnginePlayStats {
277 EnginePlayStats {
278 report_id: "11111111-2222-3333-4444-555555555555".to_string(),
279 game_id: Some("66666666-7777-8888-9999-aaaaaaaaaaaa".to_string()),
280 engine: "forge-wasm".to_string(),
281 client_version: "3.18.5".to_string(),
282 platform: "web".to_string(),
283 format: Some("standard".to_string()),
284 seats: 2,
285 multiplayer: false,
286 duration_s: 400,
287 end_reason: "gameOver".to_string(),
288 turnaround: EngineTurnaround {
289 n: 180,
290 p50: 46,
291 p90: 78,
292 max: 320,
293 },
294 reply_wait: None,
295 client_work: None,
296 engine_think: None,
297 engine_think_same_turn: None,
298 engine_think_cross_turn: None,
299 engine_think_bot: None,
300 engine_think_rules: None,
301 think_samples_hidden: 0,
302 by_type: vec![],
303 }
304 }
305
306 #[test]
307 fn accepts_a_real_report() {
308 assert!(sample().is_plausible());
309 assert_eq!(
310 sample().linked_game_id(),
311 Some("66666666-7777-8888-9999-aaaaaaaaaaaa")
312 );
313 }
314
315 #[test]
319 fn a_bad_game_id_costs_the_link_not_the_report() {
320 let mut report = sample();
321 report.game_id = Some("not-a-uuid".to_string());
322 assert!(report.is_plausible());
323 assert_eq!(report.linked_game_id(), None);
324
325 report.game_id = None;
326 assert!(report.is_plausible());
327 assert_eq!(report.linked_game_id(), None);
328 }
329
330 #[test]
331 fn rejects_the_shapes_a_hostile_client_sends() {
332 let cases: Vec<(&str, Box<dyn Fn(&mut EnginePlayStats)>)> = vec![
333 (
334 "not a uuid",
335 Box::new(|s: &mut EnginePlayStats| s.report_id = "nope".to_string()),
336 ),
337 (
338 "empty engine",
339 Box::new(|s: &mut EnginePlayStats| s.engine = String::new()),
340 ),
341 (
342 "huge engine",
343 Box::new(|s: &mut EnginePlayStats| s.engine = "x".repeat(41)),
344 ),
345 ("no seats", Box::new(|s: &mut EnginePlayStats| s.seats = 0)),
346 (
347 "too many seats",
348 Box::new(|s: &mut EnginePlayStats| s.seats = 9),
349 ),
350 (
351 "no decisions",
352 Box::new(|s: &mut EnginePlayStats| s.turnaround.n = 0),
353 ),
354 ];
355 for (name, break_it) in cases {
356 let mut report = sample();
357 break_it(&mut report);
358 assert!(!report.is_plausible(), "{name} should have been rejected");
359 }
360 }
361}