#![allow(clippy::missing_errors_doc)] #![allow(clippy::doc_markdown)] use std::{collections::HashMap, fmt, path::Path}; use serde::{Deserialize, Serialize}; /// Read version from version.ini file /// # Errors /// Returns error if file cannot be read or parsed pub fn read_version_from_ini(game_dir: &Path) -> eyre::Result> { let version_file = game_dir.join("version.ini"); if !version_file.exists() { return Ok(None); } let content = std::fs::read_to_string(&version_file)?; let version = content.trim().to_string(); // Validate format (YYYYMMDD) if version.len() == 8 && version.chars().all(|c| c.is_ascii_digit()) { Ok(Some(version)) } else { tracing::warn!( "Invalid version format in {}: {}", version_file.display(), version ); Ok(None) } } #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] pub enum Availability { Ready, #[default] LocalOnly, } impl Availability { #[must_use] pub fn from_downloaded(downloaded: bool) -> Self { if downloaded { Self::Ready } else { Self::LocalOnly } } } /// A game #[derive(Clone, Serialize, Deserialize)] pub struct Game { /// example: aoe2 pub id: String, /// example: Age of Empires 2 pub name: String, /// example: Dieses Paket enthält die original AoE 2 Version,... pub description: String, /// example: 1999 pub release_year: String, /// Microsoft pub publisher: String, /// example: 8 pub max_players: u32, /// example: 3.5 pub version: String, /// example: Echtzeit-Strategie pub genre: String, /// size in bytes: example: 3455063152 pub size: u64, /// indicates that the ETI bundle exists locally #[serde(default)] pub downloaded: bool, /// only relevant for client (yeah... I know) #[serde(default)] pub installed: bool, /// Backend-reported availability state for this game's local or peer summary. #[serde(default)] pub availability: Availability, /// Authoritative ETI game version from the bundled game.db (YYYYMMDD format). pub eti_game_version: Option, /// Local game version from version.ini (YYYYMMDD format) pub local_version: Option, /// Number of peers that have this game available pub peer_count: u32, } impl Game { /// Sets sentinel-derived download state and matching availability. pub fn set_downloaded(&mut self, downloaded: bool) { self.downloaded = downloaded; self.availability = Availability::from_downloaded(downloaded); } #[must_use] pub fn normalized_availability(&self) -> Availability { if self.downloaded { Availability::Ready } else { Availability::LocalOnly } } } impl fmt::Debug for Game { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "{}: {} ({} MB)", self.id, self.name, self.size / 1024 / 1024, ) } } impl fmt::Display for Game { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.name) } } impl PartialEq for Game { fn eq(&self, other: &Self) -> bool { self.id == other.id } } impl Eq for Game {} impl PartialOrd for Game { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for Game { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.name.cmp(&other.name) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GameDB { pub games: HashMap, } impl GameDB { #[must_use] pub fn empty() -> Self { GameDB { games: HashMap::new(), } } #[must_use] pub fn from(games: Vec) -> Self { let mut db = GameDB::empty(); for game in games { db.games.insert(game.id.clone(), game); } db } #[must_use] pub fn get_game_by_id(&self, id: S) -> Option<&Game> where S: AsRef, { self.games.get(id.as_ref()) } #[must_use] pub fn get_mut_game_by_id(&mut self, id: S) -> Option<&mut Game> where S: AsRef, { self.games.get_mut(id.as_ref()) } #[must_use] pub fn get_game_by_name(&self, name: &str) -> Option<&Game> { self.games.values().find(|game| game.name == name) } #[must_use] pub fn all_games(&self) -> Vec<&Game> { let mut games: Vec<_> = self.games.values().collect(); games.sort_by(|a, b| a.name.cmp(&b.name)); games } } impl Default for GameDB { fn default() -> Self { Self::empty() } } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct GameCatalog { expected_versions: HashMap>, } impl GameCatalog { #[must_use] pub fn empty() -> Self { Self { expected_versions: HashMap::new(), } } #[must_use] pub fn from_game_db(game_db: &GameDB) -> Self { Self { expected_versions: game_db .games .values() .map(|game| (game.id.clone(), game.eti_game_version.clone())) .collect(), } } #[must_use] pub fn from_ids(ids: impl IntoIterator) -> Self { Self { expected_versions: ids.into_iter().map(|id| (id, None)).collect(), } } pub fn insert(&mut self, id: String, expected_version: Option) { self.expected_versions.insert(id, expected_version); } #[must_use] pub fn contains(&self, id: S) -> bool where S: AsRef, { self.expected_versions.contains_key(id.as_ref()) } #[must_use] pub fn expected_version(&self, id: S) -> Option<&str> where S: AsRef, { self.expected_versions .get(id.as_ref()) .and_then(Option::as_deref) } } #[derive(Clone, Serialize, Deserialize)] pub struct GameFileDescription { pub game_id: String, pub relative_path: String, pub is_dir: bool, pub size: u64, } impl GameFileDescription { #[must_use] pub fn is_version_ini(&self) -> bool { let expected = format!("{}/version.ini", self.game_id); self.relative_path.replace('\\', "/") == expected } #[must_use] pub fn file_size(&self) -> u64 { if self.is_dir { 0 } else { self.size } } } impl fmt::Debug for GameFileDescription { #[allow(clippy::cast_precision_loss)] fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "{}: [{}] path:{} size:{}", self.game_id, if self.is_dir { 'D' } else { 'F' }, self.relative_path, self.size, ) } } #[cfg(test)] mod tests { use serde_json::json; use super::{Availability, Game, GameFileDescription}; fn game_fixture() -> Game { Game { id: "aoe2".to_string(), name: "Age of Empires II".to_string(), description: "desc".to_string(), release_year: "1999".to_string(), publisher: "Microsoft".to_string(), max_players: 8, version: "1.0".to_string(), genre: "RTS".to_string(), size: 123_456, downloaded: false, installed: false, availability: Availability::LocalOnly, eti_game_version: None, local_version: None, peer_count: 0, } } #[test] fn missing_client_state_defaults_to_false_and_local_only() { let raw = json!({ "id": "aoe2", "name": "Age of Empires II", "description": "desc", "release_year": "1999", "publisher": "Microsoft", "max_players": 8, "version": "1.0", "genre": "RTS", "size": 123_456, "thumbnail": null, "downloaded": true, "eti_game_version": "20240101", "local_version": null, "peer_count": 2 }) .to_string(); let game: Game = serde_json::from_str(&raw).expect("game should deserialize"); assert!( !game.installed, "missing installed flag should default to false" ); assert_eq!(game.availability, Availability::LocalOnly); } #[test] fn download_state_helpers_keep_ready_in_lockstep() { assert_eq!(Availability::from_downloaded(true), Availability::Ready); assert_eq!( Availability::from_downloaded(false), Availability::LocalOnly ); let mut game = game_fixture(); game.set_downloaded(true); assert!(game.downloaded); assert_eq!(game.availability, Availability::Ready); game.set_downloaded(false); assert!(!game.downloaded); assert_eq!(game.availability, Availability::LocalOnly); game.availability = Availability::Ready; assert_eq!(game.normalized_availability(), Availability::LocalOnly); game.downloaded = true; assert_eq!(game.normalized_availability(), Availability::Ready); } #[test] fn version_ini_predicate_matches_only_game_root_sentinel() { let root = GameFileDescription { game_id: "aoe2".to_string(), relative_path: "aoe2/version.ini".to_string(), is_dir: false, size: 8, }; assert!(root.is_version_ini()); let nested = GameFileDescription { game_id: "aoe2".to_string(), relative_path: "aoe2/local/version.ini".to_string(), is_dir: false, size: 8, }; assert!(!nested.is_version_ini()); let other_game = GameFileDescription { game_id: "aoe2".to_string(), relative_path: "other/version.ini".to_string(), is_dir: false, size: 8, }; assert!(!other_game.is_version_ini()); } #[test] fn version_ini_predicate_accepts_windows_separators() { let root = GameFileDescription { game_id: "aoe2".to_string(), relative_path: r"aoe2\version.ini".to_string(), is_dir: false, size: 8, }; assert!(root.is_version_ini()); } }