Skip to main content

turnbase_session/
file.rs

1//! [`FileSession`]: a stateless, file-backed adapter over [`LocalSession`].
2//!
3//! Not itself a [`Session`] implementation: it is a thin adapter that, per
4//! call, loads a [`LocalSession`] from a JSON save file, submits one typed
5//! request, and (only if an action was applied) saves it back. This is the
6//! shape a headless CLI drives: one process invocation per request, with the
7//! whole game resumed from a single self-describing file.
8//!
9//! Only the *state* (plus the version and chance sampler) is serialized into
10//! the save file; the typed request and response pass through unserialized.
11//! Actions and views only ever need serializing at a real wire boundary, which
12//! is a future transport's job.
13
14use std::fmt;
15use std::io;
16use std::path::{Path, PathBuf};
17
18use serde::de::DeserializeOwned;
19use serde::{Deserialize, Serialize};
20use turnbase::{Game, PlayerId, Prng};
21use turnbase_protocol::{PROTOCOL_VERSION, Request, Response};
22
23use crate::{LocalSession, Session};
24
25/// The on-disk envelope: everything needed to resume a game from one file.
26///
27/// It stores the [`Game`] value (its per-match configuration, e.g. player
28/// count) alongside the state, so a resumed session cannot drift from the
29/// config it was created with, and `query`/`act` need no configuration flags
30/// of their own. `chance` is the committed-chance sampler's position, so deck
31/// deals resolved across separate invocations do not resample. `protocol_version`
32/// is stamped so a stale save fails fast on an explicit check (see
33/// [`Error::ProtocolMismatch`]).
34#[derive(Serialize, Deserialize)]
35#[serde(bound(
36    serialize = "G: Serialize, G::State: Serialize",
37    deserialize = "G: Deserialize<'de>, G::State: Deserialize<'de>"
38))]
39struct SaveFile<G: Game> {
40    protocol_version: u32,
41    version: u64,
42    chance: Prng,
43    game: G,
44    state: G::State,
45}
46
47/// A stateless, file-backed adapter over [`LocalSession`]: create a save, then
48/// query or drive it across separate process invocations.
49pub struct FileSession;
50
51impl FileSession {
52    /// Creates a new save file holding `game` at `state`, its chance sampler
53    /// seeded from `seed`, and returns its resolved path. Any chance node the
54    /// opening position starts on is resolved before the save is written.
55    /// Never overwrites an existing file.
56    ///
57    /// `path`'s `None` case generates one under the system temp directory, so
58    /// a caller need not name a file up front.
59    ///
60    /// # Errors
61    /// Returns [`Error::AlreadyExists`] if `path` is `Some` and a file is
62    /// already there, or [`Error::Io`]/[`Error::Serde`] if writing fails.
63    pub fn create<G>(
64        game: G,
65        path: Option<PathBuf>,
66        state: G::State,
67        seed: u64,
68    ) -> Result<PathBuf, Error>
69    where
70        G: Game + Serialize,
71        G::State: Serialize,
72    {
73        let path = path.unwrap_or_else(generate_temp_path);
74        if path.exists() {
75            return Err(Error::AlreadyExists(path));
76        }
77        let (game, state, version, chance) = LocalSession::new(game, state, seed).into_parts();
78        write_save(
79            &path,
80            &SaveFile {
81                protocol_version: PROTOCOL_VERSION,
82                version,
83                chance,
84                game,
85                state,
86            },
87        )?;
88        Ok(path)
89    }
90
91    /// Loads the save at `path`, applies `request` for `player`, saves back if
92    /// an action was applied, and returns the response.
93    ///
94    /// A [`Request::Query`] and a rejected action leave the file untouched;
95    /// only a successful apply ([`Response::Ack`]) is persisted.
96    ///
97    /// # Errors
98    /// Returns [`Error::NotFound`] if `path` does not exist,
99    /// [`Error::ProtocolMismatch`] if the save was written by an incompatible
100    /// protocol version, [`Error::Serde`] if it does not parse, or
101    /// [`Error::Io`] if reading or writing fails.
102    pub fn handle<G>(
103        path: &Path,
104        player: PlayerId,
105        request: Request<G::Action>,
106    ) -> Result<Response<G::View>, Error>
107    where
108        G: Game + Serialize + DeserializeOwned,
109        G::State: Serialize + DeserializeOwned,
110    {
111        if !path.exists() {
112            return Err(Error::NotFound(path.to_path_buf()));
113        }
114        let save: SaveFile<G> = read_save(path)?;
115        let mut session = LocalSession::resume(save.game, save.state, save.version, save.chance);
116        let response = session.submit(player, request);
117
118        // Only an applied action changes state, version, or the chance sampler;
119        // skip the write (and its serialization cost) on queries and rejects.
120        if matches!(response, Response::Ack) {
121            let (game, state, version, chance) = session.into_parts();
122            write_save(
123                path,
124                &SaveFile {
125                    protocol_version: PROTOCOL_VERSION,
126                    version,
127                    chance,
128                    game,
129                    state,
130                },
131            )?;
132        }
133        Ok(response)
134    }
135}
136
137/// Errors from the file adapter itself.
138///
139/// Distinct from [`Response::Error`]: that is the *game* rejecting a request
140/// (illegal action); these are the *file* misbehaving (missing, already
141/// exists, unreadable, wrong protocol version).
142#[derive(Debug)]
143pub enum Error {
144    /// [`FileSession::create`] was asked to write where a file already exists.
145    AlreadyExists(PathBuf),
146    /// [`FileSession::handle`] was asked to load a path with no file.
147    NotFound(PathBuf),
148    /// The save was written by an incompatible [`PROTOCOL_VERSION`].
149    ProtocolMismatch {
150        /// The version stamped in the save file.
151        found: u32,
152        /// The version this build understands.
153        expected: u32,
154    },
155    /// Reading or writing the save file failed.
156    Io(io::Error),
157    /// The save file did not parse, or a state failed to serialize.
158    Serde(serde_json::Error),
159}
160
161impl fmt::Display for Error {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        match self {
164            Self::AlreadyExists(path) => write!(f, "session already exists at {}", path.display()),
165            Self::NotFound(path) => write!(f, "no session found at {}", path.display()),
166            Self::ProtocolMismatch { found, expected } => write!(
167                f,
168                "save file protocol version {found} is incompatible with {expected}"
169            ),
170            Self::Io(err) => write!(f, "session I/O error: {err}"),
171            Self::Serde(err) => write!(f, "session data error: {err}"),
172        }
173    }
174}
175
176impl std::error::Error for Error {}
177
178fn generate_temp_path() -> PathBuf {
179    std::env::temp_dir().join(format!(
180        "turnbase-{}-{}.json",
181        std::process::id(),
182        unique_nanos()
183    ))
184}
185
186fn unique_nanos() -> u128 {
187    std::time::SystemTime::now()
188        .duration_since(std::time::UNIX_EPOCH)
189        .unwrap_or_default()
190        .as_nanos()
191}
192
193fn read_save<G>(path: &Path) -> Result<SaveFile<G>, Error>
194where
195    G: Game + DeserializeOwned,
196    G::State: DeserializeOwned,
197{
198    let bytes = std::fs::read(path).map_err(Error::Io)?;
199    let save: SaveFile<G> = serde_json::from_slice(&bytes).map_err(Error::Serde)?;
200    if save.protocol_version != PROTOCOL_VERSION {
201        return Err(Error::ProtocolMismatch {
202            found: save.protocol_version,
203            expected: PROTOCOL_VERSION,
204        });
205    }
206    Ok(save)
207}
208
209fn write_save<G>(path: &Path, save: &SaveFile<G>) -> Result<(), Error>
210where
211    G: Game + Serialize,
212    G::State: Serialize,
213{
214    let bytes = serde_json::to_vec_pretty(save).map_err(Error::Serde)?;
215    // Write to a sibling temp file then rename over the target, so a crash or a
216    // concurrent reader never sees a half-written save (rename is atomic within
217    // one filesystem).
218    let tmp = path.with_extension(format!("tmp-{}-{}", std::process::id(), unique_nanos()));
219    std::fs::write(&tmp, &bytes).map_err(Error::Io)?;
220    std::fs::rename(&tmp, path).map_err(|err| {
221        let _ = std::fs::remove_file(&tmp);
222        Error::Io(err)
223    })
224}
225
226#[cfg(test)]
227mod tests {
228    use super::{Error, FileSession};
229    use serde::{Deserialize, Serialize};
230    use turnbase::{ActivePlayers, Game, PlayerId};
231    use turnbase_protocol::{Request, Response};
232
233    const P0: PlayerId = PlayerId::new(0);
234    const P1: PlayerId = PlayerId::new(1);
235
236    /// Two seats alternately add 1 to a shared total; whoever reaches 3 wins.
237    /// Only the game and its state need to serialize (for the save file); the
238    /// action is passed typed, so `Bump` needs no serde derive.
239    #[derive(Serialize, Deserialize)]
240    struct CountToThree;
241
242    #[derive(Clone, Copy, PartialEq, Eq, Debug)]
243    struct Bump;
244
245    impl Game for CountToThree {
246        type State = u32;
247        type Action = Bump;
248        type View = u32;
249
250        fn new_initial_state(&self, _seed: u64) -> Self::State {
251            0
252        }
253        fn num_players(&self) -> usize {
254            2
255        }
256        fn active_players(&self, state: &Self::State) -> ActivePlayers {
257            if self.is_terminal(state) {
258                ActivePlayers::none()
259            } else {
260                ActivePlayers::one(PlayerId::new(state % 2))
261            }
262        }
263        fn legal_actions(&self, state: &Self::State, _player: PlayerId) -> Vec<Self::Action> {
264            if self.is_terminal(state) {
265                Vec::new()
266            } else {
267                vec![Bump]
268            }
269        }
270        fn apply(&self, state: &mut Self::State, _player: PlayerId, _action: Self::Action) {
271            *state += 1;
272        }
273        fn is_terminal(&self, state: &Self::State) -> bool {
274            *state >= 3
275        }
276        fn reward(&self, state: &Self::State, player: PlayerId) -> f64 {
277            let winner = (state + 1) % 2;
278            if player.index() == winner { 1.0 } else { -1.0 }
279        }
280        fn view(&self, state: &Self::State, _viewer: Option<PlayerId>) -> Self::View {
281            *state
282        }
283    }
284
285    /// A pure-chance game: `CHANCE` reveals a value 0/1/2, then it is terminal.
286    /// Serializable end to end so it can drive the file adapter.
287    #[derive(Serialize, Deserialize)]
288    struct Reveal;
289
290    impl Game for Reveal {
291        type State = Option<u8>;
292        type Action = u8;
293        type View = Option<u8>;
294
295        fn new_initial_state(&self, _seed: u64) -> Self::State {
296            None
297        }
298        fn num_players(&self) -> usize {
299            0
300        }
301        fn active_players(&self, state: &Self::State) -> ActivePlayers {
302            if state.is_some() {
303                ActivePlayers::none()
304            } else {
305                ActivePlayers::one(PlayerId::CHANCE)
306            }
307        }
308        fn legal_actions(&self, state: &Self::State, player: PlayerId) -> Vec<Self::Action> {
309            if player.is_chance() && state.is_none() {
310                vec![0, 1, 2]
311            } else {
312                Vec::new()
313            }
314        }
315        fn apply(&self, state: &mut Self::State, _player: PlayerId, action: Self::Action) {
316            *state = Some(action);
317        }
318        fn is_terminal(&self, state: &Self::State) -> bool {
319            state.is_some()
320        }
321        fn reward(&self, _state: &Self::State, _player: PlayerId) -> f64 {
322            0.0
323        }
324        fn view(&self, state: &Self::State, _viewer: Option<PlayerId>) -> Self::View {
325            *state
326        }
327    }
328
329    fn bump() -> Request<Bump> {
330        Request::Act(Bump)
331    }
332
333    fn state_of(response: &Response<u32>) -> (u64, u32) {
334        let Response::State { version, view } = response else {
335            panic!("expected a State response, got {response:?}");
336        };
337        (*version, *view)
338    }
339
340    #[test]
341    fn create_refuses_to_overwrite_an_existing_file() {
342        let dir = tempfile::tempdir().unwrap();
343        let path = dir.path().join("game.json");
344        std::fs::write(&path, b"anything").unwrap();
345
346        let result = FileSession::create(CountToThree, Some(path), 0, 0);
347        assert!(matches!(result, Err(Error::AlreadyExists(_))));
348    }
349
350    #[test]
351    fn handle_round_trips_state_and_bumps_version_only_on_apply() {
352        let dir = tempfile::tempdir().unwrap();
353        let path = dir.path().join("game.json");
354        let path = FileSession::create(CountToThree, Some(path), 0, 0).unwrap();
355
356        // A query is a no-op: version 0, total 0, file unchanged.
357        let response = FileSession::handle::<CountToThree>(&path, P0, Request::Query).unwrap();
358        assert_eq!(state_of(&response), (0, 0));
359
360        // Applying an action acks and persists the bumped state.
361        assert!(matches!(
362            FileSession::handle::<CountToThree>(&path, P0, bump()).unwrap(),
363            Response::Ack
364        ));
365
366        // Loading fresh from disk sees version 1 and total 1.
367        let response = FileSession::handle::<CountToThree>(&path, P1, Request::Query).unwrap();
368        assert_eq!(state_of(&response), (1, 1));
369    }
370
371    #[test]
372    fn a_rejected_action_does_not_persist() {
373        let dir = tempfile::tempdir().unwrap();
374        let path = dir.path().join("game.json");
375        let path = FileSession::create(CountToThree, Some(path), 0, 0).unwrap();
376
377        // Seat 1 is not active first (total 0 -> seat 0), so this is rejected
378        // and nothing is written.
379        assert!(matches!(
380            FileSession::handle::<CountToThree>(&path, P1, bump()).unwrap(),
381            Response::Error(_)
382        ));
383        let response = FileSession::handle::<CountToThree>(&path, P0, Request::Query).unwrap();
384        assert_eq!(state_of(&response), (0, 0), "version stayed at 0");
385    }
386
387    #[test]
388    fn handle_errors_on_a_missing_file() {
389        let dir = tempfile::tempdir().unwrap();
390        let path = dir.path().join("missing.json");
391        let result = FileSession::handle::<CountToThree>(&path, P0, Request::Query);
392        assert!(matches!(result, Err(Error::NotFound(_))));
393    }
394
395    #[test]
396    fn a_full_game_reaches_a_terminal_state_through_the_file() {
397        let dir = tempfile::tempdir().unwrap();
398        let path = dir.path().join("game.json");
399        let path = FileSession::create(CountToThree, Some(path), 0, 0).unwrap();
400
401        for round in 0..3 {
402            let seat = PlayerId::new(round % 2);
403            FileSession::handle::<CountToThree>(&path, seat, bump()).unwrap();
404        }
405        let response = FileSession::handle::<CountToThree>(&path, P0, Request::Query).unwrap();
406        assert_eq!(
407            state_of(&response),
408            (3, 3),
409            "three bumps, version 3, total 3"
410        );
411    }
412
413    #[test]
414    fn create_resolves_an_opening_chance_node_and_persists_it() {
415        let dir = tempfile::tempdir().unwrap();
416        let path = dir.path().join("chance.json");
417        let path = FileSession::create(Reveal, Some(path), None, 5).unwrap();
418
419        // The opening chance deal was resolved before the save was written, so
420        // a fresh load already sees a terminal, revealed value at version 1.
421        let response = FileSession::handle::<Reveal>(&path, P0, Request::Query).unwrap();
422        let Response::State { version, view } = response else {
423            panic!("expected a state response");
424        };
425        assert_eq!(version, 1);
426        assert!(matches!(view, Some(0..=2)));
427    }
428}