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