diff --git a/migrations/0017_game_entity_ext.sql b/migrations/0017_game_entity_ext.sql new file mode 100644 index 0000000..a794b50 --- /dev/null +++ b/migrations/0017_game_entity_ext.sql @@ -0,0 +1,23 @@ +-- Generic, game-scoped OPAQUE extension storage. +-- +-- Core persists, versions, associates (to a canonical entity + a server-computed +-- fingerprint), and enforces generic safety bounds on these bytes — but NEVER +-- interprets them. A game adapter owns the payload's schema and meaning. This is +-- how a game keeps wire-only round-trip state (e.g. FIFA 17 squad custom[]/ +-- kicktakers/kitNumber) durable and atomic with its canonical entity without +-- leaking game-specific columns into generic Core. +-- +-- Scope key: (game_id, entity_kind, entity_id, namespace). `namespace` is an +-- opaque adapter key (e.g. "fifa17.squad.v1"); `schema_version` is the adapter's +-- payload version (distinct from this table's storage schema). +CREATE TABLE IF NOT EXISTS game_entity_ext ( + game_id TEXT NOT NULL, + entity_kind TEXT NOT NULL, + entity_id TEXT NOT NULL, + namespace TEXT NOT NULL, + schema_version INTEGER NOT NULL, + canonical_fingerprint TEXT NOT NULL, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (game_id, entity_kind, entity_id, namespace) +); diff --git a/src/models/game_ext.rs b/src/models/game_ext.rs new file mode 100644 index 0000000..670bdc8 --- /dev/null +++ b/src/models/game_ext.rs @@ -0,0 +1,60 @@ +//! Generic, game-scoped **opaque** extension state. +//! +//! Core stores and versions these bytes and associates them with a canonical +//! entity + a server-computed fingerprint, but never interprets them. A game +//! adapter owns the payload schema/meaning. This keeps game-only wire round-trip +//! state (e.g. a FIFA 17 squad's `custom[]`/`kicktakers`/`kitNumber`) durable and +//! atomic with its canonical entity without leaking game concepts into Core. + +use serde::{Deserialize, Serialize}; + +/// Generic safety bounds Core enforces without interpreting the payload. +pub const MAX_EXT_PAYLOAD_BYTES: usize = 64 * 1024; +pub const MAX_EXT_NAMESPACE_LEN: usize = 64; + +/// An opaque extension payload a game adapter asks Core to persist atomically +/// alongside a canonical entity. `payload` is uninterpreted bytes-as-text. +#[derive(Debug, Clone, Deserialize)] +pub struct OpaqueExtensionWrite { + /// Opaque adapter key, e.g. `"fifa17.squad.v1"`. Core treats it as a string. + pub namespace: String, + /// Adapter's payload schema version (distinct from the DB storage schema). + pub schema_version: i64, + /// Uninterpreted payload (the adapter's serialized game-only state). + pub payload: String, +} + +impl OpaqueExtensionWrite { + /// Generic bounds check — namespace non-empty/length, payload size. Semantic + /// validation of the payload is the adapter's job; Core only guards size. + pub fn validate(&self) -> Result<(), String> { + if self.namespace.is_empty() || self.namespace.len() > MAX_EXT_NAMESPACE_LEN { + return Err(format!( + "namespace length {} out of bounds (1..={MAX_EXT_NAMESPACE_LEN})", + self.namespace.len() + )); + } + if self.payload.len() > MAX_EXT_PAYLOAD_BYTES { + return Err(format!( + "extension payload {} bytes exceeds max {MAX_EXT_PAYLOAD_BYTES}", + self.payload.len() + )); + } + Ok(()) + } +} + +/// A stored opaque extension row (read side). `canonical_fingerprint` is the +/// server-computed fingerprint of the canonical entity at write time; a reader +/// compares it against the entity's *current* fingerprint to detect staleness. +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct GameEntityExt { + pub game_id: String, + pub entity_kind: String, + pub entity_id: String, + pub namespace: String, + pub schema_version: i64, + pub canonical_fingerprint: String, + pub payload: String, + pub updated_at: String, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 45069a7..f3c6981 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -5,6 +5,7 @@ pub mod notification; pub mod club; pub mod draft; pub mod fut_champs; +pub mod game_ext; pub mod event; pub mod season; pub mod market; diff --git a/src/models/squad.rs b/src/models/squad.rs index d6ec109..1b343ea 100644 --- a/src/models/squad.rs +++ b/src/models/squad.rs @@ -96,4 +96,7 @@ pub struct SquadReplaced { pub slots_written: usize, pub evaluation: crate::services::squad_rules::SquadEvaluation, pub client_disagreements: Vec, + /// Server-computed deterministic fingerprint of the committed canonical squad + /// (anchors any opaque game extension against stale projection). + pub canonical_fingerprint: String, } diff --git a/src/services/game_ext.rs b/src/services/game_ext.rs new file mode 100644 index 0000000..e78e996 --- /dev/null +++ b/src/services/game_ext.rs @@ -0,0 +1,34 @@ +//! Generic read/write for [`crate::models::game_ext`] opaque state. +//! +//! Core never interprets the payload. Writes happen INSIDE the owning entity's +//! transaction (see `squad::replace_squad_with_extension`) so the canonical +//! entity and its opaque extension commit atomically — there is deliberately no +//! standalone "write extension" entry point that could desync the two. + +use crate::db::Pool; +use crate::error::AppResult; +use crate::models::game_ext::GameEntityExt; + +/// Fetch the stored opaque extension for a scoped entity, or `None`. The caller +/// compares `canonical_fingerprint` against the entity's *current* fingerprint to +/// decide freshness — this layer does not know how to fingerprint any entity. +pub async fn get_ext( + pool: &Pool, + game_id: &str, + entity_kind: &str, + entity_id: &str, + namespace: &str, +) -> AppResult> { + let row = sqlx::query_as::<_, GameEntityExt>( + "SELECT game_id, entity_kind, entity_id, namespace, schema_version, \ + canonical_fingerprint, payload, updated_at FROM game_entity_ext \ + WHERE game_id = ? AND entity_kind = ? AND entity_id = ? AND namespace = ?", + ) + .bind(game_id) + .bind(entity_kind) + .bind(entity_id) + .bind(namespace) + .fetch_optional(pool) + .await?; + Ok(row) +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 03a2a82..fd7cd0c 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -6,6 +6,7 @@ pub mod notification; pub mod draft; pub mod event; pub mod fut_champs; +pub mod game_ext; pub mod inventory; pub mod season; pub mod market; diff --git a/src/services/squad.rs b/src/services/squad.rs index b9e9f55..e467e31 100644 --- a/src/services/squad.rs +++ b/src/services/squad.rs @@ -3,6 +3,7 @@ use crate::{ error::{AppError, AppResult}, models::{ card::{CardDefinition, OwnedCard}, + game_ext::{GameEntityExt, OpaqueExtensionWrite}, squad::{ SaveSquadRequest, SlotAssignment, Squad, SquadPlayer, SquadPlayerInput, SquadReplaced, SquadReplacement, @@ -10,6 +11,7 @@ use crate::{ }, services::{ card_db::CardDb, + game_ext, squad_rules::{ ClientReportedEvaluation, DefaultSquadRules, SquadPlayerCard, SquadRules, SquadSnapshot, }, @@ -222,15 +224,23 @@ pub async fn calculate_chemistry( /// Validation happens BEFORE any write, so a rejected replacement leaves the /// existing squad exactly as it was. Everything that does write happens inside /// one transaction. -pub async fn replace_squad( +#[allow(clippy::too_many_arguments)] +async fn replace_squad_inner( pool: &Pool, card_db: &CardDb, rules: &dyn SquadRules, + game_id: Option<&str>, club_id: &str, squad_id: Option<&str>, replacement: &SquadReplacement, client_reported: &ClientReportedEvaluation, + ext: Option<&OpaqueExtensionWrite>, ) -> AppResult { + // Generic bounds on the opaque extension, before any write (fail fast, no + // partial state). Core guards size only — the adapter owns payload meaning. + if let Some(ext) = ext { + ext.validate().map_err(AppError::BadRequest)?; + } // ── validate before touching anything ──────────────────────────────── let mut seen: HashSet<&str> = HashSet::new(); let mut slots_seen: HashSet = HashSet::new(); @@ -359,6 +369,35 @@ pub async fn replace_squad( .fetch_one(&mut *tx) .await?; + // Fingerprint the COMMITTED canonical state (server-computed; never a + // client/adapter value) and, atomically in this same tx, persist the opaque + // game extension anchored to it. Canonical squad + extension commit together + // or not at all — no split-brain, no distributed protocol. + let canonical_fingerprint = squad_fingerprint( + &squad_id, + &squad.formation, + resolved + .iter() + .map(|(s, o)| (s.slot, o.id.as_str(), s.is_captain, s.is_on_bench)), + ); + if let Some(ext) = ext { + let gid = game_id.expect("game_id is required whenever an extension is written"); + sqlx::query( + "INSERT OR REPLACE INTO game_entity_ext \ + (game_id, entity_kind, entity_id, namespace, schema_version, canonical_fingerprint, payload, updated_at) \ + VALUES (?, 'squad', ?, ?, ?, ?, ?, ?)", + ) + .bind(gid) + .bind(&squad_id) + .bind(&ext.namespace) + .bind(ext.schema_version) + .bind(&canonical_fingerprint) + .bind(&ext.payload) + .bind(&now) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; // ── evaluate with the game's rules, never with the client's numbers ── @@ -390,6 +429,7 @@ pub async fn replace_squad( slots_written: resolved.len(), evaluation, client_disagreements, + canonical_fingerprint, }) } @@ -399,6 +439,125 @@ struct SlotAssignmentRef { is_on_bench: bool, } +/// Replace a squad's slots atomically (no game extension). +pub async fn replace_squad( + pool: &Pool, + card_db: &CardDb, + rules: &dyn SquadRules, + club_id: &str, + squad_id: Option<&str>, + replacement: &SquadReplacement, + client_reported: &ClientReportedEvaluation, +) -> AppResult { + replace_squad_inner( + pool, + card_db, + rules, + None, + club_id, + squad_id, + replacement, + client_reported, + None, + ) + .await +} + +/// Replace a squad AND persist an opaque game extension in ONE transaction, so +/// the canonical squad and its game-only round-trip state can never split-brain. +/// The extension is anchored to the committed squad by a server-computed +/// fingerprint; Core never interprets the payload. +#[allow(clippy::too_many_arguments)] +pub async fn replace_squad_with_extension( + pool: &Pool, + card_db: &CardDb, + rules: &dyn SquadRules, + game_id: &str, + club_id: &str, + squad_id: Option<&str>, + replacement: &SquadReplacement, + client_reported: &ClientReportedEvaluation, + ext: &OpaqueExtensionWrite, +) -> AppResult { + replace_squad_inner( + pool, + card_db, + rules, + Some(game_id), + club_id, + squad_id, + replacement, + client_reported, + Some(ext), + ) + .await +} + +/// Deterministic, order-stable fingerprint of a squad's canonical state. Server- +/// computed; non-cryptographic (FNV-1a-64) — a stale-extension guard, not a +/// security boundary. The encoding is sorted + delimited so it never depends on +/// row/iteration order. +fn squad_fingerprint<'a>( + squad_id: &str, + formation: &str, + slots: impl Iterator, +) -> String { + let mut items: Vec = slots + .map(|(slot, owned, cap, bench)| format!("{slot}:{owned}:{}:{}", cap as u8, bench as u8)) + .collect(); + items.sort(); + let canon = format!("v1|{squad_id}|{formation}|{}", items.join(";")); + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in canon.as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{h:016x}") +} + +/// Freshness of a squad's opaque extension vs the current canonical squad. +pub enum SquadExtState { + Fresh(GameEntityExt), + Stale { + stored: GameEntityExt, + current_fingerprint: String, + }, + Missing, +} + +/// Read a club's active squad, its players, and its opaque game extension for +/// `namespace`, with an explicit freshness verdict. NEVER silently projects a +/// stale blob — the caller decides policy on `Stale`/`Missing`. +pub async fn read_squad_with_ext( + pool: &Pool, + game_id: &str, + club_id: &str, + namespace: &str, +) -> AppResult<(Squad, Vec, SquadExtState)> { + let (squad, players) = get_squad(pool, club_id).await?; + let current = squad_fingerprint( + &squad.id, + &squad.formation, + players.iter().map(|p| { + ( + p.position_index, + p.owned_card_id.as_str(), + p.is_captain, + p.is_on_bench, + ) + }), + ); + let state = match game_ext::get_ext(pool, game_id, "squad", &squad.id, namespace).await? { + None => SquadExtState::Missing, + Some(row) if row.canonical_fingerprint == current => SquadExtState::Fresh(row), + Some(row) => SquadExtState::Stale { + stored: row, + current_fingerprint: current, + }, + }; + Ok((squad, players, state)) +} + /// Compatibility wrapper over [`replace_squad`]. /// /// Kept so the existing Core REST route keeps working, but it no longer has its @@ -729,4 +888,253 @@ mod tests { ); assert_eq!(out.evaluation.rules, "openfut-default-v2"); } + + // ── opaque game-extension (co-located, single-transaction) ────────────── + + const NS: &str = "fifa17.squad.v1"; + + fn ext(payload: &str) -> OpaqueExtensionWrite { + OpaqueExtensionWrite { + namespace: NS.into(), + schema_version: 1, + payload: payload.into(), + } + } + + async fn replace_ext( + pool: &Pool, + db: &CardDb, + game: &str, + club: &str, + id: Option<&str>, + slots: Vec, + payload: &str, + ) -> AppResult { + replace_squad_with_extension( + pool, + db, + &DefaultSquadRules, + game, + club, + id, + &SquadReplacement { + name: Some("S".into()), + formation: Some("4-4-2".into()), + slots, + }, + &ClientReportedEvaluation::default(), + &ext(payload), + ) + .await + } + + #[tokio::test] + async fn squad_and_extension_commit_atomically_and_read_fresh() { + let (pool, db) = fixture().await; + let out = replace_ext( + &pool, + &db, + "fifa17", + "club-a", + None, + vec![slot("card-1", 0)], + "{\"custom\":[1,2,3]}", + ) + .await + .unwrap(); + assert!(!out.canonical_fingerprint.is_empty()); + + let (_s, _p, state) = read_squad_with_ext(&pool, "fifa17", "club-a", NS) + .await + .unwrap(); + match state { + SquadExtState::Fresh(row) => { + assert_eq!(row.payload, "{\"custom\":[1,2,3]}"); + assert_eq!(row.schema_version, 1); + assert_eq!(row.canonical_fingerprint, out.canonical_fingerprint); + } + _ => panic!("expected Fresh extension"), + } + } + + #[tokio::test] + async fn oversized_extension_rejected_with_no_partial_write() { + let (pool, db) = fixture().await; + let big = "x".repeat(crate::models::game_ext::MAX_EXT_PAYLOAD_BYTES + 1); + let err = replace_ext( + &pool, + &db, + "fifa17", + "club-a", + None, + vec![slot("card-1", 0)], + &big, + ) + .await; + assert!( + matches!(err, Err(AppError::BadRequest(_))), + "oversized payload must be rejected" + ); + // Fail-fast before the tx: no squad was created. + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM squads WHERE club_id = 'club-a'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(n, 0, "rejected replacement leaves no partial squad"); + let e: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM game_entity_ext") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(e, 0, "no extension row written"); + } + + #[tokio::test] + async fn fingerprint_is_deterministic_and_placement_sensitive() { + let (pool, db) = fixture().await; + let a = replace_ext( + &pool, + &db, + "fifa17", + "club-a", + None, + vec![slot("card-1", 0), slot("card-2", 1)], + "p", + ) + .await + .unwrap(); + // Same placement again → identical fingerprint (idempotent, deterministic). + let b = replace_ext( + &pool, + &db, + "fifa17", + "club-a", + Some(&a.squad.id), + vec![slot("card-1", 0), slot("card-2", 1)], + "p", + ) + .await + .unwrap(); + assert_eq!(a.canonical_fingerprint, b.canonical_fingerprint); + // Different placement (swap the two slots) → different fingerprint. + let c = replace_ext( + &pool, + &db, + "fifa17", + "club-a", + Some(&a.squad.id), + vec![slot("card-1", 1), slot("card-2", 0)], + "p", + ) + .await + .unwrap(); + assert_ne!(a.canonical_fingerprint, c.canonical_fingerprint); + } + + #[tokio::test] + async fn stale_extension_is_detected_never_silently_fresh() { + let (pool, db) = fixture().await; + let first = replace_ext( + &pool, + &db, + "fifa17", + "club-a", + None, + vec![slot("card-1", 0)], + "p1", + ) + .await + .unwrap(); + // A later plain replace (no extension) changes the canonical squad. + replace( + &pool, + &db, + "club-a", + Some(&first.squad.id), + vec![slot("card-2", 0)], + ) + .await + .unwrap(); + let (_s, _p, state) = read_squad_with_ext(&pool, "fifa17", "club-a", NS) + .await + .unwrap(); + match state { + SquadExtState::Stale { + stored, + current_fingerprint, + } => { + assert_eq!(stored.canonical_fingerprint, first.canonical_fingerprint); + assert_ne!(current_fingerprint, first.canonical_fingerprint); + } + _ => panic!("expected Stale extension after canonical squad changed"), + } + } + + #[tokio::test] + async fn idempotent_repeat_does_not_duplicate_extension() { + let (pool, db) = fixture().await; + let a = replace_ext( + &pool, + &db, + "fifa17", + "club-a", + None, + vec![slot("card-1", 0)], + "same", + ) + .await + .unwrap(); + replace_ext( + &pool, + &db, + "fifa17", + "club-a", + Some(&a.squad.id), + vec![slot("card-1", 0)], + "same", + ) + .await + .unwrap(); + let rows: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM game_entity_ext WHERE entity_id = ?") + .bind(&a.squad.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(rows, 1, "identical repeat converges on one extension row"); + } + + #[tokio::test] + async fn extension_is_scoped_by_game_and_namespace() { + let (pool, db) = fixture().await; + let out = replace_ext( + &pool, + &db, + "fifa17", + "club-a", + None, + vec![slot("card-1", 0)], + "p", + ) + .await + .unwrap(); + let sid = &out.squad.id; + assert!(game_ext::get_ext(&pool, "fifa17", "squad", sid, NS) + .await + .unwrap() + .is_some()); + assert!( + game_ext::get_ext(&pool, "fifa17", "squad", sid, "other.ns") + .await + .unwrap() + .is_none(), + "wrong namespace" + ); + assert!( + game_ext::get_ext(&pool, "fifa23", "squad", sid, NS) + .await + .unwrap() + .is_none(), + "wrong game" + ); + } }