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)]
83 pub think_samples_hidden: u32,
84 #[serde(default)]
85 pub by_type: Vec<EngineTypeTurnaround>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
89#[serde(rename_all = "camelCase")]
90#[ts(export, export_to = "telemetry.ts")]
91pub struct EngineTurnaround {
92 pub n: u32,
93 pub p50: u32,
94 pub p90: u32,
95 pub max: u32,
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 EngineTypeTurnaround {
102 #[serde(rename = "type")]
103 pub prompt_type: String,
104 pub n: u32,
105 pub p50: u32,
106 pub max: u32,
107}
108
109impl EnginePlayStats {
110 pub fn is_plausible(&self) -> bool {
114 let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
115 uuid_shaped(&self.report_id)
116 && text(&self.engine, 40)
117 && text(&self.client_version, 40)
118 && text(&self.platform, 20)
119 && text(&self.end_reason, 20)
120 && self.format.as_ref().is_none_or(|f| f.len() <= 40)
121 && (1..=8).contains(&self.seats)
122 && self.by_type.len() <= 32
123 && self.turnaround.n > 0
124 }
125
126 pub fn linked_game_id(&self) -> Option<&str> {
131 self.game_id.as_deref().filter(|id| uuid_shaped(id))
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
138#[serde(rename_all = "camelCase")]
139#[ts(export, export_to = "telemetry.ts")]
140pub struct OfflinePlayGame {
141 pub report_id: String,
144 pub started_at: String,
145 pub ended_at: String,
146 pub duration_s: u32,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 #[ts(optional)]
149 pub format: Option<String>,
150 pub engine: String,
151 pub starting_life: i32,
152 pub end_reason: String,
154 pub game_over: bool,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 #[ts(optional)]
157 pub winner: Option<String>,
158 #[serde(default)]
159 pub conceded: Vec<String>,
160 pub client_version: String,
161 pub platform: String,
162 pub players: Vec<OfflinePlaySeat>,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
166#[serde(rename_all = "camelCase")]
167#[ts(export, export_to = "telemetry.ts")]
168pub struct OfflinePlaySeat {
169 pub username: String,
171 pub is_bot: bool,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 #[ts(optional)]
174 pub deck_name: Option<String>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 #[ts(optional)]
177 pub commander: Option<String>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 #[ts(optional)]
180 pub published_deck_id: Option<String>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 #[ts(optional)]
183 pub deck_fingerprint: Option<String>,
184 #[serde(default)]
185 pub sideboard_count: u32,
186 #[serde(default)]
187 pub cards: Vec<OfflinePlayCard>,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
191#[serde(rename_all = "camelCase")]
192#[ts(export, export_to = "telemetry.ts")]
193pub struct OfflinePlayCard {
194 pub name: String,
195 pub set_code: String,
196 pub count: u32,
197}
198
199const MAX_CARDS_PER_SEAT: usize = 400;
200
201impl OfflinePlayGame {
202 pub fn is_plausible(&self) -> bool {
205 let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
206 uuid_shaped(&self.report_id)
207 && text(&self.started_at, 40)
208 && text(&self.ended_at, 40)
209 && text(&self.engine, 40)
210 && text(&self.client_version, 40)
211 && text(&self.platform, 20)
212 && text(&self.end_reason, 30)
213 && self.format.as_ref().is_none_or(|f| f.len() <= 40)
214 && self.winner.as_ref().is_none_or(|w| w.len() <= 80)
215 && self.conceded.len() <= 8
216 && self.conceded.iter().all(|name| text(name, 80))
217 && (1..=8).contains(&self.players.len())
218 && self.players.iter().all(OfflinePlaySeat::is_plausible)
219 }
220}
221
222impl OfflinePlaySeat {
223 fn is_plausible(&self) -> bool {
224 let opt =
225 |value: &Option<String>, max: usize| value.as_ref().is_none_or(|v| v.len() <= max);
226 !self.username.is_empty()
227 && self.username.len() <= 80
228 && opt(&self.deck_name, 120)
229 && opt(&self.commander, 120)
230 && opt(&self.published_deck_id, 200)
231 && self
233 .deck_fingerprint
234 .as_ref()
235 .is_none_or(|value| value.len() == 64 && value.bytes().all(is_lower_hex))
236 && self.cards.len() <= MAX_CARDS_PER_SEAT
237 && self.cards.iter().all(OfflinePlayCard::is_plausible)
238 }
239}
240
241impl OfflinePlayCard {
242 fn is_plausible(&self) -> bool {
243 !self.name.is_empty()
244 && self.name.len() <= 200
245 && self.set_code.len() <= 20
246 && (1..=1000).contains(&self.count)
247 }
248}
249
250fn is_lower_hex(byte: u8) -> bool {
251 byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
252}
253
254fn uuid_shaped(value: &str) -> bool {
255 value.len() == 36
256 && value.bytes().enumerate().all(|(index, byte)| match index {
257 8 | 13 | 18 | 23 => byte == b'-',
258 _ => byte.is_ascii_hexdigit(),
259 })
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 fn sample() -> EnginePlayStats {
267 EnginePlayStats {
268 report_id: "11111111-2222-3333-4444-555555555555".to_string(),
269 game_id: Some("66666666-7777-8888-9999-aaaaaaaaaaaa".to_string()),
270 engine: "forge-wasm".to_string(),
271 client_version: "3.18.5".to_string(),
272 platform: "web".to_string(),
273 format: Some("standard".to_string()),
274 seats: 2,
275 multiplayer: false,
276 duration_s: 400,
277 end_reason: "gameOver".to_string(),
278 turnaround: EngineTurnaround {
279 n: 180,
280 p50: 46,
281 p90: 78,
282 max: 320,
283 },
284 reply_wait: None,
285 client_work: None,
286 engine_think: None,
287 engine_think_same_turn: None,
288 engine_think_cross_turn: None,
289 think_samples_hidden: 0,
290 by_type: vec![],
291 }
292 }
293
294 #[test]
295 fn accepts_a_real_report() {
296 assert!(sample().is_plausible());
297 assert_eq!(
298 sample().linked_game_id(),
299 Some("66666666-7777-8888-9999-aaaaaaaaaaaa")
300 );
301 }
302
303 #[test]
307 fn a_bad_game_id_costs_the_link_not_the_report() {
308 let mut report = sample();
309 report.game_id = Some("not-a-uuid".to_string());
310 assert!(report.is_plausible());
311 assert_eq!(report.linked_game_id(), None);
312
313 report.game_id = None;
314 assert!(report.is_plausible());
315 assert_eq!(report.linked_game_id(), None);
316 }
317
318 #[test]
319 fn rejects_the_shapes_a_hostile_client_sends() {
320 let cases: Vec<(&str, Box<dyn Fn(&mut EnginePlayStats)>)> = vec![
321 (
322 "not a uuid",
323 Box::new(|s: &mut EnginePlayStats| s.report_id = "nope".to_string()),
324 ),
325 (
326 "empty engine",
327 Box::new(|s: &mut EnginePlayStats| s.engine = String::new()),
328 ),
329 (
330 "huge engine",
331 Box::new(|s: &mut EnginePlayStats| s.engine = "x".repeat(41)),
332 ),
333 ("no seats", Box::new(|s: &mut EnginePlayStats| s.seats = 0)),
334 (
335 "too many seats",
336 Box::new(|s: &mut EnginePlayStats| s.seats = 9),
337 ),
338 (
339 "no decisions",
340 Box::new(|s: &mut EnginePlayStats| s.turnaround.n = 0),
341 ),
342 ];
343 for (name, break_it) in cases {
344 let mut report = sample();
345 break_it(&mut report);
346 assert!(!report.is_plausible(), "{name} should have been rejected");
347 }
348 }
349}