use cardpack::prelude::BasicPile;
use serde::{Deserialize, Serialize};
#[cfg(not(target_arch = "wasm32"))]
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
use crate::error::GfError;
use crate::game::{GameEvent, PlayerAction};
pub const FORMAT_VERSION: u32 = 1;
fn default_gfcore_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
fn default_format_version() -> u32 {
FORMAT_VERSION
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TurnRecord {
pub player: usize,
pub events: Vec<GameEvent>,
pub books_after_turn: Vec<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actions: Option<Vec<PlayerAction>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GameRecord {
pub id: String,
pub variant: String,
pub timestamp: String,
pub players: Vec<String>,
pub turns: Vec<TurnRecord>,
pub winner: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_draw_pile: Option<BasicPile>,
}
impl GameRecord {
#[must_use]
pub fn new(variant: impl Into<String>, players: Vec<String>) -> Self {
#[cfg(not(target_arch = "wasm32"))]
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.to_string();
#[cfg(target_arch = "wasm32")]
let ts = "0".to_string();
Self {
id: Uuid::new_v4().to_string(),
variant: variant.into(),
timestamp: ts,
players,
turns: Vec::new(),
winner: None,
initial_draw_pile: None,
}
}
pub fn to_yaml(&self) -> Result<String, GfError> {
serde_norway::to_string(self).map_err(GfError::from)
}
pub fn from_yaml(s: &str) -> Result<Self, GfError> {
serde_norway::from_str(s).map_err(GfError::from)
}
pub fn to_json(&self) -> Result<String, GfError> {
serde_json::to_string(self).map_err(GfError::from)
}
pub fn from_json(s: &str) -> Result<Self, GfError> {
serde_json::from_str(s).map_err(GfError::from)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GameCollection {
#[serde(default = "default_gfcore_version")]
pub gfcore_version: String,
#[serde(default = "default_format_version")]
pub format_version: u32,
pub games: Vec<GameRecord>,
}
impl GameCollection {
#[must_use]
pub fn new() -> Self {
Self {
gfcore_version: env!("CARGO_PKG_VERSION").to_string(),
format_version: FORMAT_VERSION,
games: Vec::new(),
}
}
pub fn push(&mut self, record: GameRecord) {
self.games.push(record);
}
#[must_use]
pub fn len(&self) -> usize {
self.games.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.games.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &GameRecord> {
self.games.iter()
}
pub fn to_yaml(&self) -> Result<String, GfError> {
serde_norway::to_string(self).map_err(GfError::from)
}
pub fn from_yaml(s: &str) -> Result<Self, GfError> {
serde_norway::from_str(s).map_err(GfError::from)
}
pub fn to_json(&self) -> Result<String, GfError> {
serde_json::to_string(self).map_err(GfError::from)
}
pub fn from_json(s: &str) -> Result<Self, GfError> {
serde_json::from_str(s).map_err(GfError::from)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn save(&self, run_name: &str) -> Result<String, GfError> {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let path = format!("generated/{run_name}_{ts}.yaml");
self.save_to(&path)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn save_to(&self, path: &str) -> Result<String, GfError> {
let yaml = self.to_yaml()?;
if let Some(parent) = std::path::Path::new(path).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|e| GfError::IoError(e.to_string()))?;
}
}
std::fs::write(path, &yaml).map_err(|e| GfError::IoError(e.to_string()))?;
Ok(path.to_string())
}
}
impl Default for GameCollection {
fn default() -> Self {
Self::new()
}
}
impl std::ops::Index<usize> for GameCollection {
type Output = GameRecord;
fn index(&self, idx: usize) -> &Self::Output {
&self.games[idx]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::GameEvent;
fn make_record() -> GameRecord {
GameRecord::new("Standard", vec!["Alice".to_string(), "Bob".to_string()])
}
#[test]
fn test_game_record_new_has_uuid() {
let r = make_record();
assert_eq!(r.id.len(), 36);
}
#[test]
fn test_game_record_new_has_timestamp() {
let r = make_record();
let ts: u64 = r.timestamp.parse().unwrap();
assert!(ts > 1_600_000_000);
}
#[test]
fn test_game_record_new_players_and_variant() {
let r = make_record();
assert_eq!(r.variant, "Standard");
assert_eq!(r.players, ["Alice", "Bob"]);
assert!(r.turns.is_empty());
assert!(r.winner.is_none());
}
#[test]
fn test_game_record_yaml_round_trip() {
let r = make_record();
let yaml = r.to_yaml().unwrap();
let back = GameRecord::from_yaml(&yaml).unwrap();
assert_eq!(r, back);
}
#[test]
fn test_game_record_json_round_trip() {
let r = make_record();
let json = r.to_json().unwrap();
let back = GameRecord::from_json(&json).unwrap();
assert_eq!(r, back);
}
#[test]
fn test_game_record_with_turns_round_trip() {
let mut r = make_record();
let turn = TurnRecord {
player: 0,
events: vec![
GameEvent::Asked {
asker: 0,
target: 1,
rank: "A".to_string(),
},
GameEvent::GoFish { player: 0 },
GameEvent::Drew {
player: 0,
matched: false,
},
],
books_after_turn: vec![0, 0],
actions: None,
};
r.turns.push(turn);
r.winner = Some(0);
let yaml = r.to_yaml().unwrap();
let back = GameRecord::from_yaml(&yaml).unwrap();
assert_eq!(r, back);
let json = r.to_json().unwrap();
let back_json = GameRecord::from_json(&json).unwrap();
assert_eq!(r, back_json);
}
#[test]
fn test_game_record_from_yaml_bad_input_returns_error() {
let result = GameRecord::from_yaml("not: valid: yaml: [[[");
assert!(result.is_err());
}
#[test]
fn test_game_record_from_json_bad_input_returns_error() {
let result = GameRecord::from_json("{not json}");
assert!(result.is_err());
}
#[test]
fn test_game_collection_new_is_empty() {
let col = GameCollection::new();
assert!(col.is_empty());
assert_eq!(col.len(), 0);
}
#[test]
fn test_game_collection_push_and_len() {
let mut col = GameCollection::new();
col.push(make_record());
assert_eq!(col.len(), 1);
assert!(!col.is_empty());
col.push(make_record());
assert_eq!(col.len(), 2);
}
#[test]
fn test_game_collection_yaml_round_trip() {
let mut col = GameCollection::new();
col.push(make_record());
col.push(make_record());
let yaml = col.to_yaml().unwrap();
let back = GameCollection::from_yaml(&yaml).unwrap();
assert_eq!(col, back);
}
#[test]
fn test_game_collection_json_round_trip() {
let mut col = GameCollection::new();
col.push(make_record());
let json = col.to_json().unwrap();
let back = GameCollection::from_json(&json).unwrap();
assert_eq!(col, back);
}
#[test]
fn test_game_collection_empty_round_trip() {
let col = GameCollection::new();
let yaml = col.to_yaml().unwrap();
let back = GameCollection::from_yaml(&yaml).unwrap();
assert_eq!(col, back);
}
#[test]
fn test_turn_record_actions_default_is_none() {
let turn = TurnRecord {
player: 0,
events: vec![],
books_after_turn: vec![0, 0],
actions: None,
};
assert!(turn.actions.is_none());
}
#[test]
fn test_turn_record_with_actions_yaml_round_trip() {
use crate::game::PlayerAction;
use cardpack::prelude::{DeckedBase, Standard52};
let rank = Standard52::basic_pile().v()[0].rank;
let turn = TurnRecord {
player: 0,
events: vec![],
books_after_turn: vec![0, 0],
actions: Some(vec![
PlayerAction::Ask { target: 1, rank },
PlayerAction::Draw,
]),
};
let yaml = serde_norway::to_string(&turn).unwrap();
let back: TurnRecord = serde_norway::from_str(&yaml).unwrap();
assert_eq!(turn, back);
}
#[test]
fn test_turn_record_none_actions_omitted_from_yaml() {
let turn = TurnRecord {
player: 0,
events: vec![],
books_after_turn: vec![0, 0],
actions: None,
};
let yaml = serde_norway::to_string(&turn).unwrap();
assert!(!yaml.contains("actions"));
}
#[test]
fn test_game_collection_has_format_version() {
let col = GameCollection::new();
assert_eq!(col.format_version, FORMAT_VERSION);
}
#[test]
fn test_game_collection_has_gfcore_version() {
let col = GameCollection::new();
assert!(!col.gfcore_version.is_empty());
}
#[test]
fn test_game_collection_yaml_contains_version_fields() {
let col = GameCollection::new();
let yaml = col.to_yaml().unwrap();
assert!(yaml.contains("format_version"));
assert!(yaml.contains("gfcore_version"));
assert!(yaml.contains("games"));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_game_collection_save_to_temp_dir() {
let mut col = GameCollection::new();
col.push(make_record());
let path = std::env::temp_dir()
.join("gfcore_test_save_to.yaml")
.to_string_lossy()
.to_string();
let result = col.save_to(&path);
assert!(result.is_ok(), "save_to failed: {:?}", result);
assert!(std::path::Path::new(&path).exists());
let yaml = std::fs::read_to_string(&path).unwrap();
let loaded = GameCollection::from_yaml(&yaml).unwrap();
assert_eq!(col, loaded);
let _ = std::fs::remove_file(&path);
}
}