1use serde::{Deserialize, Serialize};
14use ts_rs::TS;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
17#[serde(rename_all = "camelCase")]
18#[ts(export, export_to = "telemetry.ts")]
19pub struct EnginePlayStats {
20 pub report_id: String,
22 pub engine: String,
25 pub client_version: String,
26 pub platform: String,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 #[ts(optional)]
29 pub format: Option<String>,
30 pub seats: u32,
31 pub multiplayer: bool,
32 pub duration_s: u32,
33 pub end_reason: String,
34 pub turnaround: EngineTurnaround,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 #[ts(optional)]
40 pub engine_think: Option<EngineTurnaround>,
41 #[serde(default)]
42 pub by_type: Vec<EngineTypeTurnaround>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
46#[serde(rename_all = "camelCase")]
47#[ts(export, export_to = "telemetry.ts")]
48pub struct EngineTurnaround {
49 pub n: u32,
50 pub p50: u32,
51 pub p90: u32,
52 pub max: u32,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
56#[serde(rename_all = "camelCase")]
57#[ts(export, export_to = "telemetry.ts")]
58pub struct EngineTypeTurnaround {
59 #[serde(rename = "type")]
60 pub prompt_type: String,
61 pub n: u32,
62 pub p50: u32,
63 pub max: u32,
64}
65
66impl EnginePlayStats {
67 pub fn is_plausible(&self) -> bool {
71 let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
72 uuid_shaped(&self.report_id)
73 && text(&self.engine, 40)
74 && text(&self.client_version, 40)
75 && text(&self.platform, 20)
76 && text(&self.end_reason, 20)
77 && self.format.as_ref().is_none_or(|f| f.len() <= 40)
78 && (1..=8).contains(&self.seats)
79 && self.by_type.len() <= 32
80 && self.turnaround.n > 0
81 }
82}
83
84fn uuid_shaped(value: &str) -> bool {
85 value.len() == 36
86 && value.bytes().enumerate().all(|(index, byte)| match index {
87 8 | 13 | 18 | 23 => byte == b'-',
88 _ => byte.is_ascii_hexdigit(),
89 })
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 fn sample() -> EnginePlayStats {
97 EnginePlayStats {
98 report_id: "11111111-2222-3333-4444-555555555555".to_string(),
99 engine: "forge-wasm".to_string(),
100 client_version: "3.18.5".to_string(),
101 platform: "web".to_string(),
102 format: Some("standard".to_string()),
103 seats: 2,
104 multiplayer: false,
105 duration_s: 400,
106 end_reason: "gameOver".to_string(),
107 turnaround: EngineTurnaround {
108 n: 180,
109 p50: 46,
110 p90: 78,
111 max: 320,
112 },
113 engine_think: None,
114 by_type: vec![],
115 }
116 }
117
118 #[test]
119 fn accepts_a_real_report() {
120 assert!(sample().is_plausible());
121 }
122
123 #[test]
124 fn rejects_the_shapes_a_hostile_client_sends() {
125 let cases: Vec<(&str, Box<dyn Fn(&mut EnginePlayStats)>)> = vec![
126 (
127 "not a uuid",
128 Box::new(|s: &mut EnginePlayStats| s.report_id = "nope".to_string()),
129 ),
130 (
131 "empty engine",
132 Box::new(|s: &mut EnginePlayStats| s.engine = String::new()),
133 ),
134 (
135 "huge engine",
136 Box::new(|s: &mut EnginePlayStats| s.engine = "x".repeat(41)),
137 ),
138 ("no seats", Box::new(|s: &mut EnginePlayStats| s.seats = 0)),
139 (
140 "too many seats",
141 Box::new(|s: &mut EnginePlayStats| s.seats = 9),
142 ),
143 (
144 "no decisions",
145 Box::new(|s: &mut EnginePlayStats| s.turnaround.n = 0),
146 ),
147 ];
148 for (name, break_it) in cases {
149 let mut report = sample();
150 break_it(&mut report);
151 assert!(!report.is_plausible(), "{name} should have been rejected");
152 }
153 }
154}