//! # openfut-identity //! //! A **generic, game-scoped external-identity store**: a durable, reversible //! mapping between an opaque OpenFUT core id and a game's external wire id, //! keyed by `(game_id, entity_kind, core_id)`. //! //! It is game-independent infrastructure. It knows nothing about FIFA — a game //! adapter supplies the numeric **policy** (e.g. FIFA 17 owned-item ids start at //! `100_000_000`) by passing a `base_floor`; the store only guarantees the //! structural properties every adapter needs: //! //! * **stable** — the same `(game, kind, core_id)` always resolves to the same //! external id, including after restart; //! * **unique** — two distinct core ids in a scope never share an external id; //! * **reversible** — an external id resolves back to its exact core id; //! * **game-scoped** — `fifa17` and a future `fifa23` allocate in isolation; //! * **persistent** — not process memory; //! * **atomic** — allocation is serialized (one host process owns the file) and //! persisted with a temp+rename so a crash cannot leave a torn mapping; //! * **explicit** — an unknown core id or external id returns `None`, never a //! fabricated value. //! //! Core never learns the external (FIFA) integer: only the host/adapter that //! owns a game boundary uses this store. Persistence is a small JSON file (the //! smallest durable mechanism; the host uses no database today). The //! [`ExternalIdentityStore`] trait keeps that a swappable detail — a SQLite //! implementation can drop in later without touching callers. use std::collections::HashMap; use std::path::{Path, PathBuf}; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; /// Store errors. #[derive(Debug)] pub enum IdError { Io(std::io::Error), Corrupt(String), } impl std::fmt::Display for IdError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { IdError::Io(e) => write!(f, "identity store io: {e}"), IdError::Corrupt(e) => write!(f, "identity store corrupt: {e}"), } } } impl std::error::Error for IdError {} /// A generic, durable, reversible external-identity mapping. pub trait ExternalIdentityStore: Send + Sync { /// Return the external id for `(game, kind, core_id)`, allocating a new one /// if absent. New ids are monotonic within the `(game, kind)` scope, never /// below `base_floor` (the adapter's namespace policy). Idempotent: an /// existing mapping is returned unchanged (base_floor ignored then). fn resolve_or_allocate( &self, game: &str, kind: &str, core_id: &str, base_floor: i64, ) -> Result; /// Existing external id for a core id, or `None` (never allocates). fn external_for(&self, game: &str, kind: &str, core_id: &str) -> Result, IdError>; /// Reverse lookup: the core id that owns `external_id`, or `None`. fn core_for(&self, game: &str, kind: &str, external_id: i64) -> Result, IdError>; } /// One persisted mapping row. #[derive(Debug, Clone, Serialize, Deserialize)] struct Row { game_id: String, entity_kind: String, core_id: String, external_id: i64, } #[derive(Default)] struct Index { rows: Vec, fwd: HashMap<(String, String, String), i64>, rev: HashMap<(String, String, i64), String>, max: HashMap<(String, String), i64>, } impl Index { fn insert(&mut self, row: Row) { let scope = (row.game_id.clone(), row.entity_kind.clone()); self.fwd.insert( ( row.game_id.clone(), row.entity_kind.clone(), row.core_id.clone(), ), row.external_id, ); self.rev.insert( ( row.game_id.clone(), row.entity_kind.clone(), row.external_id, ), row.core_id.clone(), ); let m = self.max.entry(scope).or_insert(i64::MIN); if row.external_id > *m { *m = row.external_id; } self.rows.push(row); } } /// JSON-file backed [`ExternalIdentityStore`]. Single-writer (one host process). pub struct JsonIdentityStore { path: PathBuf, inner: Mutex, } impl JsonIdentityStore { /// Open (or create) the store at `path`, loading any existing mappings. pub fn open(path: impl AsRef) -> Result { let path = path.as_ref().to_path_buf(); let mut index = Index::default(); if path.exists() { let raw = std::fs::read_to_string(&path).map_err(IdError::Io)?; if !raw.trim().is_empty() { let rows: Vec = serde_json::from_str(&raw).map_err(|e| IdError::Corrupt(e.to_string()))?; for row in rows { // Reject a torn file that violates reverse-uniqueness rather // than silently serving an ambiguous reverse lookup. let rkey = ( row.game_id.clone(), row.entity_kind.clone(), row.external_id, ); if index.rev.contains_key(&rkey) { return Err(IdError::Corrupt(format!( "duplicate external id {} in scope ({}, {})", row.external_id, row.game_id, row.entity_kind ))); } index.insert(row); } } } Ok(JsonIdentityStore { path, inner: Mutex::new(index), }) } /// Atomically persist the full row set (temp + rename). fn persist(&self, index: &Index) -> Result<(), IdError> { let json = serde_json::to_vec_pretty(&index.rows).map_err(|e| IdError::Corrupt(e.to_string()))?; let tmp = self.path.with_extension("tmp"); std::fs::write(&tmp, &json).map_err(IdError::Io)?; std::fs::rename(&tmp, &self.path).map_err(IdError::Io)?; Ok(()) } } impl ExternalIdentityStore for JsonIdentityStore { fn resolve_or_allocate( &self, game: &str, kind: &str, core_id: &str, base_floor: i64, ) -> Result { let mut ix = self.inner.lock(); let fkey = (game.to_string(), kind.to_string(), core_id.to_string()); if let Some(&ext) = ix.fwd.get(&fkey) { return Ok(ext); } let scope = (game.to_string(), kind.to_string()); let next = match ix.max.get(&scope) { Some(&m) => (m + 1).max(base_floor), None => base_floor, }; let row = Row { game_id: game.to_string(), entity_kind: kind.to_string(), core_id: core_id.to_string(), external_id: next, }; ix.insert(row); // Persist before returning; on failure roll the in-memory insert back so // memory and disk never diverge. if let Err(e) = self.persist(&ix) { ix.rows.pop(); ix.fwd.remove(&fkey); ix.rev.remove(&(game.to_string(), kind.to_string(), next)); // `max` may now be stale-high; recompute for the scope. let new_max = ix .rows .iter() .filter(|r| r.game_id == game && r.entity_kind == kind) .map(|r| r.external_id) .max(); match new_max { Some(m) => { ix.max.insert(scope, m); } None => { ix.max.remove(&scope); } } return Err(e); } Ok(next) } fn external_for(&self, game: &str, kind: &str, core_id: &str) -> Result, IdError> { let ix = self.inner.lock(); Ok(ix .fwd .get(&(game.to_string(), kind.to_string(), core_id.to_string())) .copied()) } fn core_for( &self, game: &str, kind: &str, external_id: i64, ) -> Result, IdError> { let ix = self.inner.lock(); Ok(ix .rev .get(&(game.to_string(), kind.to_string(), external_id)) .cloned()) } } #[cfg(test)] mod tests { use super::*; fn store() -> (JsonIdentityStore, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); let s = JsonIdentityStore::open(dir.path().join("ids.json")).unwrap(); (s, dir) } const G: &str = "fifa17"; const K: &str = "owned-item"; const BASE: i64 = 100_000_001; #[test] fn allocation_is_stable_and_reversible() { let (s, _d) = store(); let a = s.resolve_or_allocate(G, K, "oc-A", BASE).unwrap(); // idempotent assert_eq!(s.resolve_or_allocate(G, K, "oc-A", BASE).unwrap(), a); assert_eq!(s.external_for(G, K, "oc-A").unwrap(), Some(a)); // reversible assert_eq!(s.core_for(G, K, a).unwrap().as_deref(), Some("oc-A")); } #[test] fn first_allocation_respects_base_floor_then_monotonic() { let (s, _d) = store(); let a = s.resolve_or_allocate(G, K, "oc-A", BASE).unwrap(); let b = s.resolve_or_allocate(G, K, "oc-B", BASE).unwrap(); assert_eq!(a, BASE); assert_eq!(b, BASE + 1); assert_ne!(a, b, "two owned items get distinct wire ids"); } #[test] fn unknown_ids_are_explicit_none() { let (s, _d) = store(); assert_eq!(s.external_for(G, K, "nope").unwrap(), None); assert_eq!(s.core_for(G, K, 999_999).unwrap(), None); } #[test] fn game_and_kind_scopes_are_isolated() { let (s, _d) = store(); let f = s.resolve_or_allocate("fifa17", K, "oc-A", BASE).unwrap(); let g = s.resolve_or_allocate("fifa23", K, "oc-A", BASE).unwrap(); // same core id, different games -> independent allocations, both from base assert_eq!(f, BASE); assert_eq!(g, BASE); // reverse lookup respects the game scope assert_eq!(s.core_for("fifa17", K, f).unwrap().as_deref(), Some("oc-A")); assert_eq!(s.core_for("fifa23", K, f).unwrap().as_deref(), Some("oc-A")); // a different entity kind is its own scope let k2 = s .resolve_or_allocate("fifa17", "card-def", "oc-A", 1) .unwrap(); assert_eq!(k2, 1); } #[test] fn mappings_persist_across_reopen() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("ids.json"); let (a, b); { let s = JsonIdentityStore::open(&path).unwrap(); a = s.resolve_or_allocate(G, K, "oc-A", BASE).unwrap(); b = s.resolve_or_allocate(G, K, "oc-B", BASE).unwrap(); } // reopen: same ids, reverse intact, next allocation continues monotonic let s = JsonIdentityStore::open(&path).unwrap(); assert_eq!(s.external_for(G, K, "oc-A").unwrap(), Some(a)); assert_eq!(s.external_for(G, K, "oc-B").unwrap(), Some(b)); assert_eq!(s.core_for(G, K, a).unwrap().as_deref(), Some("oc-A")); let c = s.resolve_or_allocate(G, K, "oc-C", BASE).unwrap(); assert_eq!(c, b + 1, "counter resumes after restart, no reuse"); } #[test] fn corrupt_reverse_duplicate_is_rejected_on_open() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("ids.json"); // hand-write a file with two core ids sharing one external id in a scope std::fs::write( &path, r#"[{"game_id":"fifa17","entity_kind":"owned-item","core_id":"a","external_id":5}, {"game_id":"fifa17","entity_kind":"owned-item","core_id":"b","external_id":5}]"#, ) .unwrap(); assert!( JsonIdentityStore::open(&path).is_err(), "ambiguous reverse must not load" ); } }