1use 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#[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
47pub struct FileSession;
50
51impl FileSession {
52 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 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 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#[derive(Debug)]
143pub enum Error {
144 AlreadyExists(PathBuf),
146 NotFound(PathBuf),
148 ProtocolMismatch {
150 found: u32,
152 expected: u32,
154 },
155 Io(io::Error),
157 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 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 #[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 #[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 let response = FileSession::handle::<CountToThree>(&path, P0, Request::Query).unwrap();
358 assert_eq!(state_of(&response), (0, 0));
359
360 assert!(matches!(
362 FileSession::handle::<CountToThree>(&path, P0, bump()).unwrap(),
363 Response::Ack
364 ));
365
366 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 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 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}