Skip to main content

steel_core/player/profile/
known_players.rs

1//! Player profiles known to this server.
2
3use chrono::{Months, Utc};
4use uuid::Uuid;
5
6pub(crate) const GAME_PROFILE_CACHE_LIMIT: usize = 1_000;
7
8/// One cached player identity.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct KnownPlayer {
11    uuid: Uuid,
12    last_known_name: String,
13    expires_at_millis: i64,
14}
15
16impl KnownPlayer {
17    /// Creates a cached player identity.
18    ///
19    /// Steel persists the expiration instant directly in UTC instead of
20    /// vanilla's locale-formatted date, while retaining its one-month lifetime.
21    #[must_use]
22    pub fn new(uuid: Uuid, last_known_name: impl Into<String>) -> Self {
23        let now = Utc::now();
24        let Some(expiration) = now.checked_add_months(Months::new(1)) else {
25            unreachable!("current UTC time plus one month must fit chrono's date range");
26        };
27        Self {
28            uuid,
29            last_known_name: last_known_name.into(),
30            expires_at_millis: expiration.timestamp_millis(),
31        }
32    }
33
34    pub(crate) fn with_expiration(
35        uuid: Uuid,
36        last_known_name: impl Into<String>,
37        expires_at_millis: i64,
38    ) -> Self {
39        Self {
40            uuid,
41            last_known_name: last_known_name.into(),
42            expires_at_millis,
43        }
44    }
45
46    /// Returns the player's UUID.
47    #[must_use]
48    pub const fn uuid(&self) -> Uuid {
49        self.uuid
50    }
51
52    /// Returns the player's last observed profile name.
53    #[must_use]
54    pub fn last_known_name(&self) -> &str {
55        &self.last_known_name
56    }
57
58    pub(crate) const fn expires_at_millis(&self) -> i64 {
59        self.expires_at_millis
60    }
61}
62
63/// In-memory player identity cache.
64#[derive(Clone, Debug, Default, PartialEq, Eq)]
65pub struct KnownPlayers {
66    entries: Vec<KnownPlayer>,
67}
68
69impl KnownPlayers {
70    /// Creates an empty cache.
71    #[must_use]
72    pub const fn new() -> Self {
73        Self {
74            entries: Vec::new(),
75        }
76    }
77
78    /// Creates a normalized cache from entries.
79    #[must_use]
80    pub fn from_entries(entries: impl IntoIterator<Item = KnownPlayer>) -> Self {
81        let mut players = Self::new();
82        for entry in entries {
83            players.insert_loaded(entry);
84        }
85        players
86    }
87
88    /// Returns entries in stable cache order.
89    #[must_use]
90    pub fn entries(&self) -> &[KnownPlayer] {
91        &self.entries
92    }
93
94    /// Records the latest identity for a UUID and case-insensitive name.
95    ///
96    /// A name can belong to only one UUID. Returns whether the cache changed.
97    pub fn record(&mut self, uuid: Uuid, last_known_name: impl Into<String>) -> bool {
98        let last_known_name = last_known_name.into();
99        if let Some(index) = self.entries.iter().position(|entry| entry.uuid == uuid) {
100            self.entries.remove(index);
101        }
102        let duplicate_name = self
103            .entries
104            .iter()
105            .position(|entry| entry.last_known_name.eq_ignore_ascii_case(&last_known_name));
106        if let Some(index) = duplicate_name {
107            self.entries.remove(index);
108        }
109
110        self.entries
111            .insert(0, KnownPlayer::new(uuid, last_known_name));
112        true
113    }
114
115    /// Resolves and touches a cached name, removing it when its vanilla expiry elapsed.
116    pub(crate) fn resolve_name(&mut self, name: &str, now_millis: i64) -> KnownPlayerNameLookup {
117        let Some(index) = self
118            .entries
119            .iter()
120            .position(|entry| entry.last_known_name.eq_ignore_ascii_case(name))
121        else {
122            return KnownPlayerNameLookup::Missing;
123        };
124        let entry = self.entries.remove(index);
125        if now_millis >= entry.expires_at_millis {
126            return KnownPlayerNameLookup::Expired;
127        }
128        self.entries.insert(0, entry.clone());
129        KnownPlayerNameLookup::Found(entry)
130    }
131
132    /// Resolves and touches a cached UUID. Vanilla does not expiry-check UUID lookups.
133    pub(crate) fn resolve_uuid(&mut self, uuid: Uuid) -> Option<KnownPlayer> {
134        let index = self.entries.iter().position(|entry| entry.uuid == uuid)?;
135        let entry = self.entries.remove(index);
136        self.entries.insert(0, entry.clone());
137        Some(entry)
138    }
139
140    /// Looks up a profile by UUID.
141    #[must_use]
142    pub fn by_uuid(&self, uuid: Uuid) -> Option<&KnownPlayer> {
143        self.entries.iter().find(|entry| entry.uuid == uuid)
144    }
145
146    /// Looks up a profile by case-insensitive name.
147    #[must_use]
148    pub fn by_name(&self, name: &str) -> Option<&KnownPlayer> {
149        self.entries
150            .iter()
151            .find(|entry| entry.last_known_name.eq_ignore_ascii_case(name))
152    }
153
154    fn insert_loaded(&mut self, entry: KnownPlayer) {
155        if self.entries.iter().any(|current| {
156            current.uuid == entry.uuid
157                || current
158                    .last_known_name
159                    .eq_ignore_ascii_case(&entry.last_known_name)
160        }) {
161            return;
162        }
163        self.entries.push(entry);
164    }
165}
166
167pub(crate) enum KnownPlayerNameLookup {
168    Found(KnownPlayer),
169    Expired,
170    Missing,
171}
172
173#[cfg(test)]
174mod tests {
175    use super::{KnownPlayer, KnownPlayers};
176    use chrono::{Months, Utc};
177    use uuid::Uuid;
178
179    #[test]
180    fn record_updates_an_existing_uuid_name() {
181        let uuid = Uuid::from_u128(1);
182        let mut players = KnownPlayers::new();
183
184        assert!(players.record(uuid, "Steve"));
185        assert!(players.record(uuid, "Alex"));
186        assert!(players.record(uuid, "Alex"));
187        assert_eq!(players.entries().len(), 1);
188        assert_eq!(
189            players.by_uuid(uuid).map(KnownPlayer::last_known_name),
190            Some("Alex")
191        );
192        assert!(players.by_name("alex").is_some());
193    }
194
195    #[test]
196    fn record_reassigns_a_duplicate_name_to_the_latest_uuid() {
197        let old_uuid = Uuid::from_u128(1);
198        let new_uuid = Uuid::from_u128(2);
199        let mut players = KnownPlayers::from_entries([
200            KnownPlayer::new(old_uuid, "Steve"),
201            KnownPlayer::new(Uuid::from_u128(3), "Alex"),
202        ]);
203
204        assert!(players.record(new_uuid, "steve"));
205        assert!(players.by_uuid(old_uuid).is_none());
206        assert_eq!(
207            players.by_name("STEVE").map(KnownPlayer::uuid),
208            Some(new_uuid)
209        );
210        assert_eq!(players.entries().len(), 2);
211    }
212
213    #[test]
214    fn record_renews_an_unchanged_identity() {
215        let uuid = Uuid::from_u128(1);
216        let old_expiration = Utc::now()
217            .checked_add_months(Months::new(1))
218            .expect("test timestamp plus one month should fit")
219            .timestamp_millis()
220            - 1;
221        let mut players = KnownPlayers::from_entries([KnownPlayer::with_expiration(
222            uuid,
223            "Steve",
224            old_expiration,
225        )]);
226
227        assert!(players.record(uuid, "Steve"));
228        assert!(
229            players
230                .by_uuid(uuid)
231                .is_some_and(|player| player.expires_at_millis() > old_expiration)
232        );
233    }
234
235    #[test]
236    fn name_resolution_expires_and_removes_stale_entries() {
237        let uuid = Uuid::from_u128(1);
238        let mut players =
239            KnownPlayers::from_entries([KnownPlayer::with_expiration(uuid, "Steve", 10)]);
240
241        assert!(matches!(
242            players.resolve_name("Steve", 9),
243            super::KnownPlayerNameLookup::Found(profile) if profile.uuid() == uuid
244        ));
245        assert!(matches!(
246            players.resolve_name("Steve", 10),
247            super::KnownPlayerNameLookup::Expired
248        ));
249        assert!(players.entries().is_empty());
250    }
251}