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
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "telemetry.ts")]
pub struct OfflinePlayGame {
pub report_id: String,
pub started_at: String,
pub ended_at: String,
pub duration_s: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub format: Option<String>,
pub engine: String,
pub starting_life: i32,
pub end_reason: String,
pub game_over: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub winner: Option<String>,
#[serde(default)]
pub conceded: Vec<String>,
pub client_version: String,
pub platform: String,
pub players: Vec<OfflinePlaySeat>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "telemetry.ts")]
pub struct OfflinePlaySeat {
pub username: String,
pub is_bot: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub deck_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub commander: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub published_deck_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub deck_fingerprint: Option<String>,
#[serde(default)]
pub sideboard_count: u32,
#[serde(default)]
pub cards: Vec<OfflinePlayCard>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "telemetry.ts")]
pub struct OfflinePlayCard {
pub name: String,
pub set_code: String,
pub count: u32,
}
const MAX_CARDS_PER_SEAT: usize = 400;
impl OfflinePlayGame {
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.started_at, 40)
&& text(&self.ended_at, 40)
&& text(&self.engine, 40)
&& text(&self.client_version, 40)
&& text(&self.platform, 20)
&& text(&self.end_reason, 30)
&& self.format.as_ref().is_none_or(|f| f.len() <= 40)
&& self.winner.as_ref().is_none_or(|w| w.len() <= 80)
&& self.conceded.len() <= 8
&& self.conceded.iter().all(|name| text(name, 80))
&& (1..=8).contains(&self.players.len())
&& self.players.iter().all(OfflinePlaySeat::is_plausible)
}
}
impl OfflinePlaySeat {
fn is_plausible(&self) -> bool {
let opt =
|value: &Option<String>, max: usize| value.as_ref().is_none_or(|v| v.len() <= max);
!self.username.is_empty()
&& self.username.len() <= 80
&& opt(&self.deck_name, 120)
&& opt(&self.commander, 120)
&& opt(&self.published_deck_id, 200)
&& self
.deck_fingerprint
.as_ref()
.is_none_or(|value| value.len() == 64 && value.bytes().all(is_lower_hex))
&& self.cards.len() <= MAX_CARDS_PER_SEAT
&& self.cards.iter().all(OfflinePlayCard::is_plausible)
}
}
impl OfflinePlayCard {
fn is_plausible(&self) -> bool {
!self.name.is_empty()
&& self.name.len() <= 200
&& self.set_code.len() <= 20
&& (1..=1000).contains(&self.count)
}
}
fn is_lower_hex(byte: u8) -> bool {
byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
}
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");
}
}
}