Skip to main content

steel_core/player/
session.rs

1use std::sync::{
2    Arc, Weak,
3    atomic::{AtomicU64, Ordering},
4};
5
6use steel_utils::locks::{SyncMutex, SyncRwLock};
7
8use super::{
9    DROP_SPAM_THROTTLER_INCREMENT_STEP, DROP_SPAM_THROTTLER_THRESHOLD, Player, chat::ChatState,
10    chunk_sender::ChunkSender, spam_throttler::TickThrottler,
11};
12
13static LAST_PLAYER_SESSION_ID: AtomicU64 = AtomicU64::new(0);
14
15/// Runtime identity for one connected client session.
16#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
17pub(crate) struct PlayerSessionId(u64);
18
19/// Connection-owned state that survives replacement of the active player entity.
20pub struct PlayerSession {
21    id: PlayerSessionId,
22    current_player: SyncRwLock<CurrentPlayerSlot>,
23    pub(crate) chunk_sender: SyncMutex<ChunkSender>,
24    pub(crate) chat: SyncMutex<ChatState>,
25    /// Vanilla keeps creative drop throttling on the connection across respawns.
26    pub(super) drop_spam_throttler: SyncMutex<TickThrottler>,
27}
28
29enum CurrentPlayerSlot {
30    Unbound,
31    Bound(Weak<Player>),
32    Closed,
33}
34
35impl PlayerSession {
36    /// Creates an unbound session for a newly accepted client connection.
37    ///
38    /// # Panics
39    ///
40    /// Panics if the process exhausts the player-session ID space.
41    #[must_use]
42    pub fn new(chat_spam_threshold_seconds: i32, command_spam_threshold_seconds: i32) -> Self {
43        let Ok(previous) =
44            LAST_PLAYER_SESSION_ID.try_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
45                current.checked_add(1)
46            })
47        else {
48            panic!("player session ID space exhausted");
49        };
50
51        Self {
52            id: PlayerSessionId(previous + 1),
53            current_player: SyncRwLock::new(CurrentPlayerSlot::Unbound),
54            chunk_sender: SyncMutex::new(ChunkSender::default()),
55            chat: SyncMutex::new(ChatState::new(
56                chat_spam_threshold_seconds,
57                command_spam_threshold_seconds,
58            )),
59            drop_spam_throttler: SyncMutex::new(TickThrottler::new(
60                DROP_SPAM_THROTTLER_INCREMENT_STEP,
61                DROP_SPAM_THROTTLER_THRESHOLD,
62            )),
63        }
64    }
65
66    #[must_use]
67    pub(crate) const fn id(&self) -> PlayerSessionId {
68        self.id
69    }
70
71    pub(crate) const fn chunk_sender(&self) -> &SyncMutex<ChunkSender> {
72        &self.chunk_sender
73    }
74
75    /// Returns the player entity currently controlled by this connection.
76    #[must_use]
77    pub(crate) fn current_player(&self) -> Option<Arc<Player>> {
78        match &*self.current_player.read() {
79            CurrentPlayerSlot::Bound(player) => player.upgrade(),
80            CurrentPlayerSlot::Unbound | CurrentPlayerSlot::Closed => None,
81        }
82    }
83
84    /// Returns whether `player` is the entity currently controlled by this session.
85    #[must_use]
86    pub(crate) fn is_current_player(&self, player: &Arc<Player>) -> bool {
87        self.owns(player)
88            && matches!(
89                &*self.current_player.read(),
90                CurrentPlayerSlot::Bound(current) if current.ptr_eq(&Arc::downgrade(player))
91            )
92    }
93
94    /// Binds the first player entity created for this connection.
95    pub fn bind_initial_player(&self, player: &Arc<Player>) -> bool {
96        if !self.owns(player) {
97            return false;
98        }
99
100        let mut current = self.current_player.write();
101        if !matches!(*current, CurrentPlayerSlot::Unbound) {
102            return false;
103        }
104        *current = CurrentPlayerSlot::Bound(Arc::downgrade(player));
105        true
106    }
107
108    /// Rebinds the connection only if `expected` is still its active player entity.
109    pub(crate) fn replace_player(&self, expected: &Arc<Player>, replacement: &Arc<Player>) -> bool {
110        if !self.owns(expected) || !self.owns(replacement) {
111            return false;
112        }
113
114        let mut current = self.current_player.write();
115        if !matches!(
116            &*current,
117            CurrentPlayerSlot::Bound(player) if player.ptr_eq(&Arc::downgrade(expected))
118        ) {
119            return false;
120        }
121        *current = CurrentPlayerSlot::Bound(Arc::downgrade(replacement));
122        true
123    }
124
125    /// Clears the active player only if `expected` still owns this connection.
126    pub(crate) fn clear_player(&self, expected: &Arc<Player>) -> bool {
127        if !self.owns(expected) {
128            return false;
129        }
130
131        let mut current = self.current_player.write();
132        if !matches!(
133            &*current,
134            CurrentPlayerSlot::Bound(player) if player.ptr_eq(&Arc::downgrade(expected))
135        ) {
136            return false;
137        }
138        *current = CurrentPlayerSlot::Closed;
139        true
140    }
141
142    fn owns(&self, player: &Player) -> bool {
143        self.id == player.session.id
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use std::sync::Arc;
150
151    use crate::{
152        entity::Entity as _,
153        player::{ClientInformation, Player},
154        test_support::{TestPlayerBuilder, fresh_test_world},
155    };
156
157    use super::PlayerSession;
158
159    fn replacement_for(player: &Arc<Player>, session: Arc<PlayerSession>) -> Arc<Player> {
160        Arc::new(Player::new(
161            player.gameprofile.clone(),
162            Arc::clone(&player.connection),
163            session,
164            player.get_world(),
165            player.server.clone(),
166            Arc::clone(&player.config),
167            player.id(),
168            ClientInformation::default(),
169        ))
170    }
171
172    #[test]
173    fn replacement_requires_session_ownership_and_exact_current_player() {
174        let world = fresh_test_world("player_session_exact_replacement");
175        let original = TestPlayerBuilder::new(Arc::clone(&world), "Original", 1).build();
176        let session = Arc::clone(&original.session);
177        let replacement = replacement_for(&original, Arc::clone(&session));
178        let stale_replacement = replacement_for(&original, Arc::clone(&session));
179        let foreign = TestPlayerBuilder::new(world, "Foreign", 2).build();
180        original.chat().lock().messages_sent = 7;
181
182        assert!(!session.replace_player(&original, &foreign));
183        assert!(session.replace_player(&original, &replacement));
184        assert!(session.is_current_player(&replacement));
185        assert!(!session.is_current_player(&original));
186        assert!(!session.replace_player(&original, &stale_replacement));
187        assert_eq!(replacement.chat().lock().messages_sent, 7);
188
189        let Some(current) = session.current_player() else {
190            panic!("replacement should remain bound to the session");
191        };
192        assert!(Arc::ptr_eq(&current, &replacement));
193    }
194
195    #[test]
196    fn closed_session_cannot_be_bound_again() {
197        let session = Arc::new(PlayerSession::new(10, 10));
198        let foreign = TestPlayerBuilder::new(
199            fresh_test_world("player_session_foreign_initial_bind"),
200            "Foreign",
201            3,
202        )
203        .build();
204        assert!(!session.bind_initial_player(&foreign));
205
206        let player = replacement_for(&foreign, Arc::clone(&session));
207        assert!(session.bind_initial_player(&player));
208        assert!(session.clear_player(&player));
209        assert!(session.current_player().is_none());
210
211        let replacement = replacement_for(&player, Arc::clone(&session));
212        assert!(!session.bind_initial_player(&replacement));
213        assert!(!session.replace_player(&player, &replacement));
214        assert!(!session.clear_player(&player));
215    }
216}