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