manabrew-protocol 5.4.1

Engine-free game DTOs shared by manabrew clients
Documentation
//! How fast an engine answered, for one finished game.
//!
//! The hosted node publishes its own decision timings to Prometheus. Nothing
//! measured the engines that run on a player's machine, and the browser Forge
//! build made that gap matter: the whole point of it is latency, and the only
//! numbers we had came from a laptop under a test harness.
//!
//! One report per game, aggregates only. No deck, no cards, no opponent,
//! nothing that identifies a player. It travels two ways — over the relay for
//! a game the relay already knows about, and to the hub for offline play,
//! where no server is in the loop at all.

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 {
    /// Generated by the client, so a retry cannot double-count the game.
    pub report_id: String,
    /// Which engine actually ran, which is not always the one the room asked
    /// for: a browser with the Forge engine on hosts a "Manabrew" room on it.
    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,
    /// Client-side turnaround: answer sent to next prompt landing. This is the
    /// interval the player feels, and the one comparable across engines.
    pub turnaround: EngineTurnaround,
    /// The engine's own think time, when it reports one.
    #[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 {
    /// Whether this is worth storing. Rejects the shapes a mistake or a hostile
    /// client produces: an unparseable id, empty or oversized text, a table
    /// with impossible seats, a report of no decisions at all.
    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");
        }
    }
}