//! Generic, game-agnostic transactional profile import. //! //! Core installs a profile + club + owned cards + canonical squad + one opaque //! game extension in a SINGLE all-or-nothing SQLite transaction, stamped with a //! generic `source_fingerprint` provenance token. Core NEVER interprets FIFA17 //! wire ids, resourceIds, `nextItemId`, or the extension payload — the //! `openfut-import-fifa17` adapter reads the Python profile, chooses every //! `CardDefinitionId` and every opaque `OwnedItemId`, builds the squad //! extension bytes, and hands Core this generic request. //! //! Invariants enforced here: //! - Definition preflight: every incoming `card_id` MUST already resolve in the //! loaded production content, so the transaction never creates ownership //! pointing at absent content. //! - Squad all-or-nothing: every active-squad `owned_item_id` MUST be among the //! imported ownership set before the transaction begins. //! - Rerun identity: identical `source_fingerprint` against an already-imported //! game is an idempotent no-op; a differing token fails; a pre-existing //! non-imported profile is never clobbered. //! - The whole thing commits together or not at all. use std::collections::BTreeMap; use crate::db::Pool; use crate::models::card::ContentKind; use crate::models::game_ext::{MAX_EXT_NAMESPACE_LEN, MAX_EXT_PAYLOAD_BYTES}; use crate::services::card_db::CardDb; use crate::services::squad::squad_fingerprint; use anyhow::{bail, Context, Result}; use chrono::Utc; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use uuid::Uuid; #[derive(Debug, Deserialize)] pub struct ImportProfile { pub username: String, pub game_id: String, } #[derive(Debug, Deserialize)] pub struct ImportClub { pub name: String, #[serde(default)] pub coins: i64, } #[derive(Debug, Deserialize)] pub struct ImportOwnedCard { /// Opaque, stable Core OwnedItemId chosen by the adapter. Core never parses /// why it is stable — it is a primary key, nothing more. pub owned_item_id: String, /// CardDefinitionId that MUST resolve in loaded production content. pub card_id: String, /// Generic content classification. Absent = `player`, which is what every /// pre-taxonomy import produced; the adapter maps its own taxonomy (FIFA 17 /// `cardsubtypeid`, resource ranges, …) onto this before calling Core. #[serde(default)] pub content_kind: ContentKind, /// Optional per-instance stack size. Absent / `null` means "not a stack"; it /// never collapses two instances into one row. /// /// NOT a consumable's wire `amount`: in FIFA 17 that field is the /// definition's effect magnitude (a "+15 training" card), and every copy is /// its own instance carrying the same value, so storing it here would claim /// the club owns fifteen of them. #[serde(default)] pub quantity: Option, } #[derive(Debug, Deserialize)] pub struct ImportEntitlement { /// Opaque definition reference for one unconsumed entitlement (e.g. a pack /// id as text). Core stores it verbatim; it never interprets the value. pub definition_id: String, } #[derive(Debug, Deserialize)] pub struct ImportSlot { pub owned_item_id: String, pub position_index: i64, #[serde(default)] pub is_captain: bool, #[serde(default)] pub is_on_bench: bool, } #[derive(Debug, Deserialize)] pub struct ImportExtension { /// Opaque adapter key, e.g. "fifa17.squad.v1". pub namespace: String, /// Adapter payload version (distinct from DB storage schema). pub schema_version: i64, /// Uninterpreted bytes-as-text. Core enforces only generic size bounds. pub payload: String, } #[derive(Debug, Deserialize)] pub struct ImportSquad { pub formation: String, #[serde(default = "default_squad_name")] pub name: String, pub slots: Vec, pub extension: ImportExtension, } fn default_squad_name() -> String { "My Squad".to_string() } #[derive(Debug, Deserialize)] pub struct ProfileImportRequest { /// Generic provenance/rerun-identity token. Core stores it verbatim. pub source_fingerprint: String, pub profile: ImportProfile, pub club: ImportClub, pub owned: Vec, #[serde(default)] pub squad: Option, /// Unconsumed entitlements to seed (e.g. from a source's unopened packs). #[serde(default)] pub entitlements: Vec, } #[derive(Debug, Serialize, PartialEq, Eq)] #[serde(tag = "outcome", rename_all = "snake_case")] pub enum ImportOutcome { /// A fresh import committed. Imported { owned: usize, squad_slots: usize }, /// The same fingerprint was already imported for this game — no-op. AlreadyImported, } /// Apply a generic transactional profile import. See module docs for invariants. pub async fn apply_profile_import( pool: &Pool, card_db: &CardDb, req: &ProfileImportRequest, ) -> Result { // ── 0. generic input validation (no writes) ── if req.source_fingerprint.trim().is_empty() { bail!("source_fingerprint must be non-empty"); } if req.owned.is_empty() { bail!("import request has zero owned cards; refusing to import an empty profile"); } // A stack size is either absent ("not a stack") or a real positive count. // Reject an explicit 0/negative up front rather than letting the column // CHECK surface it as an opaque constraint failure mid-transaction. for o in &req.owned { if let Some(q) = o.quantity { if q < 1 { bail!( "owned card {} has quantity {q}; a stack size must be omitted or >= 1", o.owned_item_id ); } } } // ── 1. rerun identity / single-profile-per-game ── let existing: Option<(String, Option)> = sqlx::query_as( "SELECT id, import_fingerprint FROM profiles \ WHERE game_id = ? ORDER BY created_at ASC LIMIT 1", ) .bind(&req.profile.game_id) .fetch_optional(pool) .await?; if let Some((_id, fp)) = existing { match fp { Some(fp) if fp == req.source_fingerprint => return Ok(ImportOutcome::AlreadyImported), Some(fp) => bail!( "game '{}' already imported from a different source (stored fingerprint {fp}, \ incoming {}); refusing to overwrite without an explicit update mode", req.profile.game_id, req.source_fingerprint ), None => bail!( "game '{}' already has a non-imported profile; refusing to clobber it", req.profile.game_id ), } } // ── 2. definition preflight: every card_id MUST resolve in loaded content ── let mut missing: Vec<&str> = req .owned .iter() .filter(|o| card_db.get(&o.card_id).is_none()) .map(|o| o.card_id.as_str()) .collect(); if !missing.is_empty() { missing.sort_unstable(); missing.dedup(); let sample = &missing[..missing.len().min(5)]; bail!( "definition preflight failed: {} owned card(s) reference CardDefinitionId(s) not in \ loaded content (e.g. {sample:?}); refusing to create ownership pointing at absent content", missing.len() ); } // ── 3. owned-item-id uniqueness ── let mut owned_ids: HashSet<&str> = HashSet::with_capacity(req.owned.len()); for o in &req.owned { if !owned_ids.insert(o.owned_item_id.as_str()) { bail!( "duplicate OwnedItemId in import request: {}", o.owned_item_id ); } } // ── 4. squad all-or-nothing + generic extension bounds (no writes) ── if let Some(sq) = &req.squad { let ns_len = sq.extension.namespace.len(); if ns_len == 0 || ns_len > MAX_EXT_NAMESPACE_LEN { bail!( "extension namespace length {ns_len} out of bounds (1..={MAX_EXT_NAMESPACE_LEN})" ); } if sq.extension.payload.len() > MAX_EXT_PAYLOAD_BYTES { bail!( "extension payload {} bytes exceeds MAX_EXT_PAYLOAD_BYTES ({MAX_EXT_PAYLOAD_BYTES})", sq.extension.payload.len() ); } for slot in &sq.slots { if !owned_ids.contains(slot.owned_item_id.as_str()) { bail!( "active squad references OwnedItemId {} not present in imported ownership set; \ squad import is all-or-nothing", slot.owned_item_id ); } } } // ── 5. single transaction: everything commits together or not at all ── let now = Utc::now().to_rfc3339(); let profile_id = Uuid::new_v4().to_string(); let club_id = Uuid::new_v4().to_string(); let mut tx = pool.begin().await?; sqlx::query( "INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at, import_fingerprint) \ VALUES (?, ?, 1, 0, ?, ?, ?, ?)", ) .bind(&profile_id) .bind(&req.profile.username) .bind(&req.profile.game_id) .bind(&now) .bind(&now) .bind(&req.source_fingerprint) .execute(&mut *tx) .await .context("insert profile")?; sqlx::query( "INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at) \ VALUES (?, ?, ?, ?, 1, ?, ?)", ) .bind(&club_id) .bind(&profile_id) .bind(&req.club.name) .bind(req.club.coins) .bind(&now) .bind(&now) .execute(&mut *tx) .await .context("insert club")?; for o in &req.owned { sqlx::query( "INSERT INTO owned_cards \ (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \ content_kind, quantity) \ VALUES (?, ?, ?, 0, NULL, ?, ?, ?)", ) .bind(&o.owned_item_id) .bind(&club_id) .bind(&o.card_id) .bind(&now) .bind(o.content_kind.as_str()) .bind(o.quantity) .execute(&mut *tx) .await .with_context(|| format!("insert owned_card {}", o.owned_item_id))?; } for e in &req.entitlements { sqlx::query( "INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, 0, ?)", ) .bind(Uuid::new_v4().to_string()) .bind(&club_id) .bind(&e.definition_id) .bind(&now) .execute(&mut *tx) .await .with_context(|| format!("insert entitlement {}", e.definition_id))?; } let mut squad_slots = 0usize; if let Some(sq) = &req.squad { let squad_id = Uuid::new_v4().to_string(); sqlx::query( "INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) \ VALUES (?, ?, ?, ?, ?, ?)", ) .bind(&squad_id) .bind(&club_id) .bind(&sq.name) .bind(&sq.formation) .bind(&now) .bind(&now) .execute(&mut *tx) .await .context("insert squad")?; for slot in &sq.slots { sqlx::query( "INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) \ VALUES (?, ?, ?, ?, ?, ?)", ) .bind(Uuid::new_v4().to_string()) .bind(&squad_id) .bind(&slot.owned_item_id) .bind(slot.position_index) .bind(slot.is_captain) .bind(slot.is_on_bench) .execute(&mut *tx) .await .context("insert squad_player")?; } squad_slots = sq.slots.len(); // Core computes the canonical fingerprint over the COMMITTED squad — never // an adapter-supplied value — and persists the opaque extension atomically // in the same tx, exactly as the live squad-write path does. let canonical_fingerprint = squad_fingerprint( &squad_id, &sq.formation, sq.slots.iter().map(|s| { ( s.position_index, s.owned_item_id.as_str(), s.is_captain, s.is_on_bench, ) }), ); 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(&req.profile.game_id) .bind(&squad_id) .bind(&sq.extension.namespace) .bind(sq.extension.schema_version) .bind(&canonical_fingerprint) .bind(&sq.extension.payload) .bind(&now) .execute(&mut *tx) .await .context("insert game_entity_ext")?; } tx.commit().await?; Ok(ImportOutcome::Imported { owned: req.owned.len(), squad_slots, }) } /// One adapter-supplied classification: "every owned row of this definition is /// really this kind of content". #[derive(Debug, Clone, Deserialize)] pub struct ContentKindAssignment { pub card_id: String, pub content_kind: ContentKind, } /// Request for [`reclassify_owned_content`]. #[derive(Debug, Clone, Deserialize)] pub struct ReclassifyRequest { pub game_id: String, pub assignments: Vec, /// Compute the outcome and roll back instead of committing. Lets an operator /// see exactly what a production run would touch before it touches it. #[serde(default)] pub dry_run: bool, } #[derive(Debug, Serialize, PartialEq, Eq)] pub struct ReclassifyOutcome { /// Rows whose `content_kind` actually changed. On a dry run, the rows that /// WOULD change; nothing is committed. pub updated: usize, /// Rows already carrying the requested kind (a rerun updates nothing). pub unchanged: usize, /// Assignments naming a definition this game owns no copy of. pub unmatched_definitions: Vec, /// True when the transaction was rolled back rather than committed. pub dry_run: bool, /// Per-kind tally of the rows that changed, so an operator can sanity-check /// the shape of the change ("3 staff, 17 consumable") before committing. pub updated_by_kind: BTreeMap, } /// Correct the `content_kind` of ALREADY-IMPORTED owned rows, in one transaction. /// /// A profile import is once-only (same fingerprint no-ops, a different one is /// refused), so a taxonomy fix cannot arrive by re-importing. Core defaults an /// unstated row to `player`, which means every pre-taxonomy import durably /// recorded coaches, kits and consumables as players — wrong in the ownership /// authority even where a catalog-driven wire looked right. /// /// Core stays generic: the caller supplies `card_id -> kind`, because only the /// game adapter can map its own taxonomy. Idempotent, and scoped to one game's /// clubs so a shared database cannot be reclassified across games. pub async fn reclassify_owned_content( pool: &Pool, req: &ReclassifyRequest, ) -> Result { if req.assignments.is_empty() { bail!("reclassify request has zero assignments"); } let mut tx = pool.begin().await?; let mut updated = 0usize; let mut unchanged = 0usize; let mut unmatched = Vec::new(); let mut by_kind: BTreeMap = BTreeMap::new(); for a in &req.assignments { // Scope by game through the owning club, so the same definition id in // another game is never touched. let present: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM owned_cards o JOIN clubs c ON c.id = o.club_id \ JOIN profiles p ON p.id = c.profile_id \ WHERE p.game_id = ? AND o.card_id = ?", ) .bind(&req.game_id) .bind(&a.card_id) .fetch_one(&mut *tx) .await .context("count owned rows for definition")?; if present == 0 { unmatched.push(a.card_id.clone()); continue; } let changed = sqlx::query( "UPDATE owned_cards SET content_kind = ? \ WHERE card_id = ? AND content_kind != ? AND club_id IN \ (SELECT c.id FROM clubs c JOIN profiles p ON p.id = c.profile_id \ WHERE p.game_id = ?)", ) .bind(a.content_kind.as_str()) .bind(&a.card_id) .bind(a.content_kind.as_str()) .bind(&req.game_id) .execute(&mut *tx) .await .context("update owned content_kind")? .rows_affected() as usize; updated += changed; unchanged += present as usize - changed; if changed > 0 { *by_kind .entry(a.content_kind.as_str().to_string()) .or_default() += changed; } } // A dry run does the real UPDATEs and then throws them away, so the counts // it reports are measured rather than predicted — the same statements, the // same WHERE clauses, just no commit. if req.dry_run { tx.rollback().await?; } else { tx.commit().await?; } Ok(ReclassifyOutcome { updated, unchanged, unmatched_definitions: unmatched, dry_run: req.dry_run, updated_by_kind: by_kind, }) }