//! Durable FIFA 17 **client-data blob** store (`clientdata` / `userHubData`). //! //! FIFA persists opaque per-user client blobs via `PUT/POST …/clientdata/` //! and reads them back via `GET …/clientdata/`. The blobs are entirely //! client-defined (UI/hub state) — the server only round-trips them and never //! interprets their contents. This store keeps them in memory keyed by //! `:` and persists the whole map to a JSON file on every write, so //! the client's saved state survives a host restart. //! //! The blobs are non-authoritative client presentation state (NOT economy or //! ownership), so a missing/unreadable backing file starts empty rather than //! being a hard failure. use std::collections::HashMap; use std::path::PathBuf; use parking_lot::Mutex; use serde_json::Value; /// In-memory client-data blobs, persisted to a JSON file on write. pub struct ClientDataStore { path: PathBuf, map: Mutex>, } impl ClientDataStore { /// Open the store, loading any previously-persisted blobs. A missing or /// unreadable file starts empty. pub fn open(path: impl Into) -> Self { let path = path.into(); let map = std::fs::read(&path) .ok() .and_then(|b| serde_json::from_slice::>(&b).ok()) .unwrap_or_default(); ClientDataStore { path, map: Mutex::new(map), } } fn compound_key(persona: i64, key: &str) -> String { format!("{persona}:{key}") } /// The stored blob for `:`, or `None` if never written. pub fn get(&self, persona: i64, key: &str) -> Option { self.map .lock() .get(&Self::compound_key(persona, key)) .cloned() } /// Store `value` under `:` and persist the whole map to disk. /// The serialized snapshot is taken under the lock; the file write happens /// after the lock is released. pub fn put(&self, persona: i64, key: &str, value: Value) { let snapshot = { let mut map = self.map.lock(); map.insert(Self::compound_key(persona, key), value); serde_json::to_vec(&*map).unwrap_or_default() }; if let Some(parent) = self.path.parent() { if !parent.as_os_str().is_empty() { let _ = std::fs::create_dir_all(parent); } } let _ = std::fs::write(&self.path, snapshot); } } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn temp_path(tag: &str) -> PathBuf { std::env::temp_dir().join(format!( "openfut-clientdata-test-{}-{}.json", std::process::id(), tag )) } #[test] fn round_trips_and_persists_across_reopen() { let path = temp_path("roundtrip"); let _ = std::fs::remove_file(&path); let store = ClientDataStore::open(&path); assert_eq!(store.get(33_068_179, "userHubData"), None); store.put(33_068_179, "userHubData", json!({"tiles": [1, 2, 3]})); assert_eq!( store.get(33_068_179, "userHubData"), Some(json!({"tiles": [1, 2, 3]})) ); // A different persona under the same key is isolated. assert_eq!(store.get(1, "userHubData"), None); // Reopening reads the persisted blob back. let reopened = ClientDataStore::open(&path); assert_eq!( reopened.get(33_068_179, "userHubData"), Some(json!({"tiles": [1, 2, 3]})) ); let _ = std::fs::remove_file(&path); } }