steel_core/player/profile/
known_players.rs1use chrono::{Months, Utc};
4use uuid::Uuid;
5
6pub(crate) const GAME_PROFILE_CACHE_LIMIT: usize = 1_000;
7
8#[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 #[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 #[must_use]
48 pub const fn uuid(&self) -> Uuid {
49 self.uuid
50 }
51
52 #[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#[derive(Clone, Debug, Default, PartialEq, Eq)]
65pub struct KnownPlayers {
66 entries: Vec<KnownPlayer>,
67}
68
69impl KnownPlayers {
70 #[must_use]
72 pub const fn new() -> Self {
73 Self {
74 entries: Vec::new(),
75 }
76 }
77
78 #[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 #[must_use]
90 pub fn entries(&self) -> &[KnownPlayer] {
91 &self.entries
92 }
93
94 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 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 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 #[must_use]
142 pub fn by_uuid(&self, uuid: Uuid) -> Option<&KnownPlayer> {
143 self.entries.iter().find(|entry| entry.uuid == uuid)
144 }
145
146 #[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}