Skip to main content

manabrew_server/
client_build.rs

1//! What the client on the far end of a session can be trusted to understand.
2//!
3//! The relay learns three things at `Authenticate` time: the platform the
4//! client runs on, the app version it was built from, and whether it is
5//! running the engine itself. Platform and engine gate are for analytics.
6//! Version is load-bearing: it decides which wire features the relay may use
7//! against that seat, because installed desktop builds lag the server by
8//! however long it takes the player to relaunch the app.
9
10use crate::protocol::{ClientPlatform, EngineGate};
11
12/// Cap on the version string a client may report. Anything can open a socket
13/// and send anything, and this string reaches the analytics log.
14const MAX_VERSION_CHARS: usize = 32;
15
16/// First release whose client can apply a `stateDelta` envelope. The applier is
17/// `src/lib/stateDelta.ts`, wired into `platform/web.ts`; before it, that switch
18/// had no default arm, so a patch was silently dropped and the board stopped
19/// updating for the rest of the game.
20const MIN_STATE_PATCH_VERSION: Version = Version(3, 17, 0);
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23struct Version(u32, u32, u32);
24
25impl Version {
26    /// Parses `X.Y.Z`, ignoring any pre-release or build suffix. Anything that
27    /// does not fit is not a version we know how to reason about.
28    fn parse(raw: &str) -> Option<Self> {
29        let core = raw.split(['-', '+']).next()?;
30        let mut parts = core.split('.');
31        let mut next = || parts.next()?.parse::<u32>().ok();
32        let version = Self(next()?, next()?, next()?);
33        parts.next().is_none().then_some(version)
34    }
35}
36
37/// The client identity carried on a session, as reported by the client itself.
38#[derive(Debug, Clone, Default)]
39pub struct ClientBuild {
40    pub platform: ClientPlatform,
41    pub engine_gate: EngineGate,
42    version: Option<Version>,
43    raw_version: Option<String>,
44}
45
46impl ClientBuild {
47    pub fn new(
48        platform: ClientPlatform,
49        raw_version: Option<String>,
50        engine_gate: EngineGate,
51    ) -> Self {
52        let raw_version = raw_version.map(|raw| {
53            if raw.chars().count() > MAX_VERSION_CHARS {
54                raw.chars().take(MAX_VERSION_CHARS).collect()
55            } else {
56                raw
57            }
58        });
59        Self {
60            platform,
61            engine_gate,
62            version: raw_version.as_deref().and_then(Version::parse),
63            raw_version,
64        }
65    }
66
67    /// What goes in the analytics event. `None` for a client that reported
68    /// nothing, which is itself the signal: it predates version reporting.
69    pub fn version(&self) -> Option<&str> {
70        self.raw_version.as_deref()
71    }
72
73    /// Whether this seat can apply `stateDelta` patches. Unreported and
74    /// unparseable versions answer no, so an unknown client gets full states.
75    pub fn applies_state_patches(&self) -> bool {
76        self.version
77            .is_some_and(|version| version >= MIN_STATE_PATCH_VERSION)
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    fn build(version: &str) -> ClientBuild {
86        ClientBuild::new(
87            ClientPlatform::Desktop,
88            Some(version.to_string()),
89            EngineGate::Unknown,
90        )
91    }
92
93    #[test]
94    fn parses_plain_releases() {
95        assert_eq!(Version::parse("3.17.0"), Some(Version(3, 17, 0)));
96        assert_eq!(Version::parse("3.17.3-rc.1"), Some(Version(3, 17, 3)));
97        assert_eq!(Version::parse("3.17.3+build.9"), Some(Version(3, 17, 3)));
98    }
99
100    #[test]
101    fn rejects_anything_else() {
102        for raw in ["3.17", "3.17.0.1", "v3.17.0", "", "latest", "3.x.0"] {
103            assert_eq!(Version::parse(raw), None, "{raw}");
104        }
105    }
106
107    #[test]
108    fn orders_by_component_not_lexically() {
109        assert!(Version(3, 9, 6) < Version(3, 17, 0));
110        assert!(Version(3, 17, 0) < Version(3, 17, 3));
111        assert!(Version(4, 0, 0) > Version(3, 17, 3));
112    }
113
114    #[test]
115    fn state_patches_need_the_applier_release() {
116        assert!(!build("3.16.0").applies_state_patches());
117        assert!(!build("3.9.6").applies_state_patches());
118        assert!(build("3.17.0").applies_state_patches());
119        assert!(build("3.17.3").applies_state_patches());
120        assert!(build("4.0.0").applies_state_patches());
121    }
122
123    #[test]
124    fn unknown_clients_get_full_states() {
125        assert!(!ClientBuild::default().applies_state_patches());
126        assert!(!build("who knows").applies_state_patches());
127        assert!(
128            !ClientBuild::new(ClientPlatform::Web, None, EngineGate::Unknown)
129                .applies_state_patches()
130        );
131    }
132
133    #[test]
134    fn caps_the_reported_string() {
135        let build = ClientBuild::new(
136            ClientPlatform::Web,
137            Some("9".repeat(500)),
138            EngineGate::Unknown,
139        );
140        assert_eq!(build.version().map(str::len), Some(MAX_VERSION_CHARS));
141        assert!(!build.applies_state_patches());
142    }
143}