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