use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "telemetry.ts")]
pub struct EnginePlayStats {
pub report_id: String,
pub engine: String,
pub client_version: String,
pub platform: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub format: Option<String>,
pub seats: u32,
pub multiplayer: bool,
pub duration_s: u32,
pub end_reason: String,
pub turnaround: EngineTurnaround,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub engine_think: Option<EngineTurnaround>,
#[serde(default)]
pub by_type: Vec<EngineTypeTurnaround>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "telemetry.ts")]
pub struct EngineTurnaround {
pub n: u32,
pub p50: u32,
pub p90: u32,
pub max: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "telemetry.ts")]
pub struct EngineTypeTurnaround {
#[serde(rename = "type")]
pub prompt_type: String,
pub n: u32,
pub p50: u32,
pub max: u32,
}
impl EnginePlayStats {
pub fn is_plausible(&self) -> bool {
let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
uuid_shaped(&self.report_id)
&& text(&self.engine, 40)
&& text(&self.client_version, 40)
&& text(&self.platform, 20)
&& text(&self.end_reason, 20)
&& self.format.as_ref().is_none_or(|f| f.len() <= 40)
&& (1..=8).contains(&self.seats)
&& self.by_type.len() <= 32
&& self.turnaround.n > 0
}
}
fn uuid_shaped(value: &str) -> bool {
value.len() == 36
&& value.bytes().enumerate().all(|(index, byte)| match index {
8 | 13 | 18 | 23 => byte == b'-',
_ => byte.is_ascii_hexdigit(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> EnginePlayStats {
EnginePlayStats {
report_id: "11111111-2222-3333-4444-555555555555".to_string(),
engine: "forge-wasm".to_string(),
client_version: "3.18.5".to_string(),
platform: "web".to_string(),
format: Some("standard".to_string()),
seats: 2,
multiplayer: false,
duration_s: 400,
end_reason: "gameOver".to_string(),
turnaround: EngineTurnaround {
n: 180,
p50: 46,
p90: 78,
max: 320,
},
engine_think: None,
by_type: vec![],
}
}
#[test]
fn accepts_a_real_report() {
assert!(sample().is_plausible());
}
#[test]
fn rejects_the_shapes_a_hostile_client_sends() {
let cases: Vec<(&str, Box<dyn Fn(&mut EnginePlayStats)>)> = vec![
(
"not a uuid",
Box::new(|s: &mut EnginePlayStats| s.report_id = "nope".to_string()),
),
(
"empty engine",
Box::new(|s: &mut EnginePlayStats| s.engine = String::new()),
),
(
"huge engine",
Box::new(|s: &mut EnginePlayStats| s.engine = "x".repeat(41)),
),
("no seats", Box::new(|s: &mut EnginePlayStats| s.seats = 0)),
(
"too many seats",
Box::new(|s: &mut EnginePlayStats| s.seats = 9),
),
(
"no decisions",
Box::new(|s: &mut EnginePlayStats| s.turnaround.n = 0),
),
];
for (name, break_it) in cases {
let mut report = sample();
break_it(&mut report);
assert!(!report.is_plausible(), "{name} should have been rejected");
}
}
}