Skip to main content

turnbase_session/
local.rs

1//! [`LocalSession`]: an in-memory host that applies requests directly.
2
3use turnbase::{Game, PlayerId, Prng, sample_chance};
4use turnbase_protocol::{Request, Response};
5
6use crate::Session;
7
8/// Offset mixed into the match seed for the chance sampler, so committed
9/// chance outcomes do not correlate with a game's own in-state generator.
10/// Deliberately the same value `turnbase-match` uses, so a game resolved
11/// headlessly and one resolved by the interactive loop agree for a given seed.
12const CHANCE_SEED_OFFSET: u64 = 0x00C0_FFEE;
13
14/// An in-memory game: owns a [`Game`] and its state, and applies requests to
15/// them directly. No file, socket, or other I/O, and no serialization: the
16/// typed [`Request`]/[`Response`] pass straight through.
17///
18/// This is the authority. A [`crate::FileSession`] is a thin wrapper that
19/// loads one of these, submits a single request, and saves it back; a
20/// long-lived server would hold one across many requests.
21///
22/// A committed chance node ([`PlayerId::CHANCE`] active) is resolved
23/// automatically from `chance`, so `submit` only ever returns control at a
24/// player decision or a terminal state, never stuck on a deck deal a client
25/// cannot make. `chance` is seeded from the match seed but offset, so it does
26/// not share a stream with the game's own in-state generator.
27pub struct LocalSession<G: Game> {
28    game: G,
29    state: G::State,
30    version: u64,
31    chance: Prng,
32}
33
34impl<G: Game> LocalSession<G> {
35    /// Starts a session over `game` at `state`, at version 0, with the chance
36    /// sampler seeded from `seed`. Any chance node the opening position starts
37    /// on (e.g. an initial deal) is resolved immediately.
38    #[must_use]
39    pub fn new(game: G, state: G::State, seed: u64) -> Self {
40        let mut session = Self {
41            game,
42            state,
43            version: 0,
44            chance: Prng::new(seed ^ CHANCE_SEED_OFFSET),
45        };
46        session.resolve_chance();
47        session
48    }
49
50    /// Resumes a session at a previously saved `state`, `version`, and chance
51    /// sampler, e.g. from a [`crate::FileSession`] save file. Does not
52    /// re-resolve chance: a saved position is always left at a player decision
53    /// or terminal, never mid-chance.
54    #[must_use]
55    pub const fn resume(game: G, state: G::State, version: u64, chance: Prng) -> Self {
56        Self {
57            game,
58            state,
59            version,
60            chance,
61        }
62    }
63
64    /// Returns the rules governing this session.
65    #[must_use]
66    pub const fn game(&self) -> &G {
67        &self.game
68    }
69
70    /// Returns the current authoritative state, e.g. to persist or inspect it.
71    #[must_use]
72    pub const fn state(&self) -> &G::State {
73        &self.state
74    }
75
76    /// Returns the current version: the number of actions (player and chance)
77    /// applied so far.
78    #[must_use]
79    pub const fn version(&self) -> u64 {
80        self.version
81    }
82
83    /// Consumes the session, returning its parts for persistence.
84    #[must_use]
85    pub fn into_parts(self) -> (G, G::State, u64, Prng) {
86        (self.game, self.state, self.version, self.chance)
87    }
88
89    /// Applies committed chance outcomes until a player is owed a decision or
90    /// the match ends, mirroring `turnbase-match`'s loop.
91    fn resolve_chance(&mut self) {
92        while !self.game.is_terminal(&self.state) {
93            let Some(player) = self.game.active_players(&self.state).iter().next() else {
94                break;
95            };
96            if !player.is_chance() {
97                break;
98            }
99            let Some(action) = sample_chance(&self.game, &self.state, &mut self.chance) else {
100                break;
101            };
102            self.game.apply(&mut self.state, PlayerId::CHANCE, action);
103            self.version += 1;
104        }
105    }
106
107    /// Returns the requesting seat's view. Never exposes other seats' private
108    /// data: [`Game::view`] does the redaction.
109    fn query(&self, player: PlayerId) -> Response<G::View> {
110        Response::State {
111            version: self.version,
112            view: self.game.view(&self.state, Some(player)),
113        }
114    }
115
116    /// Applies one action, bumping the version and resolving any chance node it
117    /// exposes.
118    ///
119    /// Runs the same guards as [`Game::apply_cloned`] (active seat, legal
120    /// action) but in place, turning a rejection into a [`Response::Error`]
121    /// rather than a panic.
122    fn act(&mut self, player: PlayerId, action: G::Action) -> Response<G::View> {
123        if !self.game.active_players(&self.state).contains(player) {
124            return Response::Error(format!("{player} is not active"));
125        }
126        if !self.game.is_legal(&self.state, player, &action) {
127            return Response::Error(format!("illegal action for {player}"));
128        }
129        self.game.apply(&mut self.state, player, action);
130        self.version += 1;
131        self.resolve_chance();
132        Response::Ack
133    }
134}
135
136impl<G: Game> Session<G> for LocalSession<G> {
137    fn submit(&mut self, player: PlayerId, request: Request<G::Action>) -> Response<G::View> {
138        match request {
139            Request::Query => self.query(player),
140            Request::Act(action) => self.act(player, action),
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use turnbase::{ActivePlayers, Game, PlayerId, PlayerView, State};
148    use turnbase_protocol::{Request, Response};
149
150    use super::LocalSession;
151    use crate::Session;
152
153    const P0: PlayerId = PlayerId::new(0);
154    const P1: PlayerId = PlayerId::new(1);
155
156    /// A two-seat game with a per-seat secret, to exercise the port's redaction
157    /// promise: a `Query` returns only the requesting seat's private data.
158    struct Secret;
159
160    impl Game for Secret {
161        type State = State<u32, u32>;
162        type Action = u32;
163        type View = PlayerView<u32, u32>;
164
165        fn new_initial_state(&self, seed: u64) -> Self::State {
166            let mut state = State::new(0, seed);
167            state.insert_private(P0, 111);
168            state.insert_private(P1, 222);
169            state
170        }
171        fn num_players(&self) -> usize {
172            2
173        }
174        fn active_players(&self, _state: &Self::State) -> ActivePlayers {
175            ActivePlayers::one(P0)
176        }
177        fn legal_actions(&self, _state: &Self::State, _player: PlayerId) -> Vec<Self::Action> {
178            vec![0]
179        }
180        fn apply(&self, _state: &mut Self::State, _player: PlayerId, _action: Self::Action) {}
181        fn is_terminal(&self, _state: &Self::State) -> bool {
182            false
183        }
184        fn reward(&self, _state: &Self::State, _player: PlayerId) -> f64 {
185            0.0
186        }
187        fn view(&self, state: &Self::State, viewer: Option<PlayerId>) -> Self::View {
188            state.view_for(viewer)
189        }
190    }
191
192    #[test]
193    fn query_redacts_to_the_requesting_seat() {
194        let mut session = LocalSession::new(Secret, Secret.new_initial_state(1), 1);
195
196        let Response::State { view, .. } = session.submit(P0, Request::Query) else {
197            panic!("expected a state response");
198        };
199        assert_eq!(view.own_private, Some(111), "seat 0 sees its own secret");
200
201        let Response::State { view, .. } = session.submit(P1, Request::Query) else {
202            panic!("expected a state response");
203        };
204        assert_eq!(
205            view.own_private,
206            Some(222),
207            "seat 1 sees its own, never seat 0's"
208        );
209    }
210
211    /// A pure-chance game: `CHANCE` reveals a value 0/1/2, then it is terminal.
212    struct RevealOnce;
213
214    impl Game for RevealOnce {
215        type State = Option<u8>;
216        type Action = u8;
217        type View = Option<u8>;
218
219        fn new_initial_state(&self, _seed: u64) -> Self::State {
220            None
221        }
222        fn num_players(&self) -> usize {
223            0
224        }
225        fn active_players(&self, state: &Self::State) -> ActivePlayers {
226            if state.is_some() {
227                ActivePlayers::none()
228            } else {
229                ActivePlayers::one(PlayerId::CHANCE)
230            }
231        }
232        fn legal_actions(&self, state: &Self::State, player: PlayerId) -> Vec<Self::Action> {
233            if player.is_chance() && state.is_none() {
234                vec![0, 1, 2]
235            } else {
236                Vec::new()
237            }
238        }
239        fn apply(&self, state: &mut Self::State, _player: PlayerId, action: Self::Action) {
240            *state = Some(action);
241        }
242        fn is_terminal(&self, state: &Self::State) -> bool {
243            state.is_some()
244        }
245        fn reward(&self, _state: &Self::State, _player: PlayerId) -> f64 {
246            0.0
247        }
248        fn view(&self, state: &Self::State, _viewer: Option<PlayerId>) -> Self::View {
249            *state
250        }
251    }
252
253    #[test]
254    fn new_resolves_an_opening_chance_node() {
255        // The opening position is a chance node; `new` resolves it, so the
256        // session lands on a terminal state with a revealed value at version 1.
257        let session = LocalSession::new(RevealOnce, RevealOnce.new_initial_state(0), 5);
258        assert!(matches!(session.state(), Some(0..=2)));
259        assert_eq!(session.version(), 1, "one chance outcome was applied");
260    }
261}