//! `--apply`: the FIFA17-specific orchestration that installs a real profile //! across the TWO durable stores — the `openfut-identity` external-id store and //! OpenFUT Core's SQLite DB — recoverably and idempotently. //! //! Boundary: everything FIFA17-specific stays here. This module chooses every //! `CardDefinitionId` (`fifa17_`, from the report) and every //! deterministic opaque Core `OwnedItemId`, preserves each owned instance's //! existing Python wire id, builds the canonical squad + `Fifa17SquadExtensionV1` //! with the SAME adapter code the retail-validated live squad path uses, then //! hands Core a GENERIC [`GenericImportRequest`]. Core never sees a resourceId, //! a wire id, or the extension's meaning. //! //! Recoverable two-store protocol (see [`apply`]): //! validate → local Core-preflight mirror → identity dry-preflight → //! idempotently install existing wire-id mappings + watermark → //! ONE generic Core import transaction → cross-store post-validation → //! completion record. //! If Core fails after identity seeding, rerunning the same manifest re-installs //! identical identity mappings (idempotent) and Core converges (same //! `source_fingerprint` → `already_imported`). No cleanup, no reminting. use std::collections::{BTreeSet, HashMap}; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{bail, Context, Result}; use serde::Serialize; use uuid::Uuid; use openfut_adapter_fifa17::fut::catalog::Fifa17WireItemIdPolicy; use openfut_adapter_fifa17::fut::squad::{parse_squad_put, SquadWireResolver}; use openfut_adapter_fifa17::fut::squad_ext::{ build_squad_write, EXT_NAMESPACE, EXT_SCHEMA_VERSION, }; use openfut_identity::{ExternalIdentityStore, JsonIdentityStore}; use crate::Report; /// Private, project-specific UUIDv5 namespace for deriving Core OwnedItemIds from /// a source account + source owned-item identity. Fixed forever so a re-import of /// the same account yields the SAME opaque ids (idempotent across both stores). /// Core never parses why these ids are stable — they are opaque primary keys. const OWNED_ITEM_NAMESPACE: Uuid = Uuid::from_bytes([ 0x0f, 0x17, 0x0f, 0x17, 0x9a, 0x11, 0x5e, 0xd0, 0xb0, 0x0f, 0x0e, 0x11, 0x0c, 0x0a, 0x11, 0x17, ]); /// Deterministic opaque Core OwnedItemId for one source owned instance. Stable /// across runs and identical in BOTH stores (identity mapping core_id AND Core /// `owned_cards.id`), so the running host's wire→owned reverse lookup resolves /// exactly what Core stored. pub fn owned_item_id(persona_id: i64, source_wire_id: i64) -> String { let name = format!("fifa17:{persona_id}:{source_wire_id}"); Uuid::new_v5(&OWNED_ITEM_NAMESPACE, name.as_bytes()).to_string() } // ----------------------------------------------------- generic Core request #[derive(Debug, Serialize)] pub struct GenericProfile { pub username: String, pub game_id: String, } #[derive(Debug, Serialize)] pub struct GenericClub { pub name: String, pub coins: i64, } #[derive(Debug, Serialize)] pub struct GenericOwned { pub owned_item_id: String, pub card_id: String, /// Generic classification for Core's ownership row. FIFA17 is the only side /// that can map `cardsubtypeid` onto this, and Core defaults to `player`, so /// leaving it off would durably record a coach or a contract card as a /// player — wrong in the ownership authority even when the wire looks right. pub content_kind: &'static str, } #[derive(Debug, Serialize)] pub struct GenericSlot { pub owned_item_id: String, pub position_index: i64, pub is_captain: bool, pub is_on_bench: bool, } #[derive(Debug, Serialize)] pub struct GenericExtension { pub namespace: String, pub schema_version: i64, pub payload: String, } #[derive(Debug, Serialize)] pub struct GenericSquad { pub formation: String, pub name: String, pub slots: Vec, pub extension: GenericExtension, } /// The game-agnostic request Core consumes. Field names match /// `openfut_core::services::import::ProfileImportRequest` exactly. #[derive(Debug, Serialize)] pub struct GenericImportRequest { pub source_fingerprint: String, pub profile: GenericProfile, pub club: GenericClub, pub owned: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub squad: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub entitlements: Vec, } /// One unconsumed entitlement to seed into Core (a source `unopenedPackIds` /// entry). `definition_id` is the pack id as text; Core stores it verbatim. #[derive(Debug, Serialize)] pub struct GenericEntitlement { pub definition_id: String, } /// One preserved (opaque OwnedItemId ↔ source wire id) mapping to install into /// the identity store under `(fifa17, owned-item)`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct IdentityMapping { pub core_id: String, pub wire_id: i64, } /// The complete, side-effect-free plan for an apply: the generic Core request, /// the identity mappings to install, and the watermark to set. #[derive(Debug)] pub struct ApplyPlan { pub request: GenericImportRequest, pub mappings: Vec, pub watermark: i64, pub supported_instances: usize, pub deferred_instances: usize, pub source_fingerprint: String, } struct MapResolver<'a>(&'a HashMap); impl SquadWireResolver for MapResolver<'_> { fn owned_id_for_wire(&self, wire: i64) -> Option { self.0.get(&wire).cloned() } } /// Build the full apply plan from an already-analysed [`Report`] + the raw /// profile JSON (needed verbatim for the squad wire: `custom`, `kicktakers`, /// `kitNumber`, manager, squadType). Pure: no store or Core writes. /// /// Refuses if the report has blockers — never plans an unsafe or lossy import. pub fn plan_apply( report: &Report, raw_profile: &serde_json::Value, snapshot_fingerprint: &str, ) -> Result { if report.has_blockers() { bail!( "refusing to plan apply while blockers are present: {}", report.blockers().join("; ") ); } let persona = report.persona_id; // Owned cards + identity mappings: one per SUPPORTED owned instance. Each // instance keeps its existing Python wire id; its Core OwnedItemId is derived // deterministically from (persona, wire). let mut owned = Vec::with_capacity(report.identity.import_wire_ids.len()); let mut mappings = Vec::with_capacity(report.identity.import_wire_ids.len()); let mut wire_to_owned: HashMap = HashMap::new(); for def in &report.definitions.supported { for &wire in &def.wire_ids { let core_id = owned_item_id(persona, wire); wire_to_owned.insert(wire, core_id.clone()); owned.push(GenericOwned { owned_item_id: core_id.clone(), card_id: def.card_id.clone(), content_kind: "player", }); mappings.push(IdentityMapping { core_id, wire_id: wire, }); } } // Non-player (consumable/staff) owned instances mint via the IDENTICAL // generic path: deterministic OwnedItemId per (persona, wire), an identity // mapping, and a GenericOwned with card_id = fifa17_. for def in &report.non_player.supported { for &wire in &def.wire_ids { let core_id = owned_item_id(persona, wire); wire_to_owned.insert(wire, core_id.clone()); owned.push(GenericOwned { owned_item_id: core_id.clone(), card_id: def.card_id.clone(), content_kind: def.kind.as_str(), }); mappings.push(IdentityMapping { core_id, wire_id: wire, }); } } // Canonical squad + opaque extension, built by the SAME adapter code the live // squad-write path uses, over the raw source squad. The resolver maps every // occupied wire id to its deterministic OwnedItemId. let squad = if report.squad.present { let raw_squad = raw_profile .get("squads") .and_then(|s| s.get(0)) .context("report says a squad is present but profile has no squads[0]")?; let body = serde_json::to_vec(raw_squad).context("re-serialize source squad")?; let mut put = parse_squad_put(&body).map_err(|e| anyhow::anyhow!("parse source squad: {e}"))?; // A HISTORICAL profile may reference a manager whose owned instance is // not imported (unsupported/deferred, or a dangling id with no owned // item at all). Drop such a manager ref here rather than failing the // whole import — the manager assignment is only imported when its owned // instance is. (A LIVE squad PUT still refuses an unresolved manager, // because the client is actively assigning one it must own.) put.manager.retain(|m| wire_to_owned.contains_key(&m.id)); let build = build_squad_write(&put, &MapResolver(&wire_to_owned)) .map_err(|e| anyhow::anyhow!("build squad write: {e}"))?; let formation = build .canonical .formation .clone() .context("source squad has no formation token")?; let slots = build .canonical .slots .iter() .map(|s| GenericSlot { owned_item_id: s.owned_card_id.clone(), position_index: s.index, is_captain: s.is_captain, is_on_bench: s.is_on_bench, }) .collect(); Some(GenericSquad { formation, name: build .canonical .name .clone() .unwrap_or_else(|| report.club_name.clone()), slots, extension: GenericExtension { namespace: EXT_NAMESPACE.to_string(), schema_version: EXT_SCHEMA_VERSION, payload: build.extension.to_payload(), }, }) } else { None }; let request = GenericImportRequest { source_fingerprint: snapshot_fingerprint.to_string(), profile: GenericProfile { username: report.persona_name.clone(), game_id: report.game.clone(), }, club: GenericClub { name: report.club_name.clone(), coins: report.coins, }, owned, squad, entitlements: report .unopened_pack_ids .iter() .map(|id| GenericEntitlement { definition_id: id.to_string(), }) .collect(), }; Ok(ApplyPlan { request, mappings, watermark: report.identity.source_watermark, supported_instances: report.identity.import_wire_ids.len() + report .non_player .supported .iter() .map(|d| d.wire_ids.len()) .sum::(), deferred_instances: report.deferred_instances() + report.non_player.deferred_instances(), source_fingerprint: snapshot_fingerprint.to_string(), }) } // -------------------------------------------------------------- side effects /// Where the apply reads/writes the two stores and Core. pub struct ApplyPaths { /// The `openfut-core` binary to invoke for the generic import transaction. pub core_bin: PathBuf, /// `DATABASE_URL` passed to that Core invocation (the target profile DB). pub core_db_url: String, /// The emitted production content pack (`OPENFUT_CONTENT_PACKS`). pub content_pack: PathBuf, /// `DATA_DIR` for the Core invocation (base catalog directory). pub data_dir: String, /// The identity store JSON file. pub identity_store: PathBuf, /// Where to write the generic request JSON handed to Core. pub request_out: PathBuf, /// Where to write the completion record. pub completion_out: PathBuf, } #[derive(Debug, Serialize)] pub struct ApplyOutcome { pub core_outcome: String, pub mappings_installed: usize, pub watermark: i64, pub supported_instances: usize, pub deferred_instances: usize, pub staging: bool, pub source_fingerprint: String, } /// The staging/production gate. Deferred players are absent from an import; that /// is acceptable ONLY for a staged run, and ONLY with the explicit opt-in — never /// a silent default. Production requires zero deferred instances. pub fn gate_staging(plan: &ApplyPlan, allow_deferred_staging: bool) -> Result { if plan.deferred_instances == 0 { return Ok(false); // production-complete } if !allow_deferred_staging { bail!( "{} player instance(s) are DEFERRED (unsupported/conflicted) and would be ABSENT from \ this import. Production import requires zero deferred instances. To import anyway for \ a STAGED test, pass --allow-deferred-players-for-staging (this is never the default).", plan.deferred_instances ); } eprintln!( "\n================= STAGING IMPORT (INCOMPLETE) =================\n\ {} player instance(s) are DEFERRED and WILL BE ABSENT from this import.\n\ This profile is NOT production-complete. Resolve the deferred definitions\n\ before a production cutover.\n\ ==============================================================\n", plan.deferred_instances ); Ok(true) } /// Local mirror of Core's pre-transaction preflight, run BEFORE touching either /// store so a doomed import never seeds identity. `content_card_ids` is the set /// of CardDefinitionIds in the emitted production pack. pub fn local_core_preflight(plan: &ApplyPlan, content_card_ids: &BTreeSet) -> Result<()> { let mut owned_ids: BTreeSet<&str> = BTreeSet::new(); for o in &plan.request.owned { if !content_card_ids.contains(&o.card_id) { bail!( "local preflight: owned card references CardDefinitionId {} absent from the \ production content pack", o.card_id ); } if !owned_ids.insert(o.owned_item_id.as_str()) { bail!("local preflight: duplicate OwnedItemId {}", o.owned_item_id); } } if let Some(sq) = &plan.request.squad { for slot in &sq.slots { if !owned_ids.contains(slot.owned_item_id.as_str()) { bail!( "local preflight: squad slot OwnedItemId {} not in imported ownership set", slot.owned_item_id ); } } } Ok(()) } /// Dry-check that installing the identity mappings would not conflict with any /// mapping already present (a re-run's identical mappings are fine). No writes. pub fn identity_dry_preflight(store: &JsonIdentityStore, plan: &ApplyPlan) -> Result<()> { let (g, k) = ( Fifa17WireItemIdPolicy::GAME, Fifa17WireItemIdPolicy::OWNED_ITEM_KIND, ); let mut conflicts = Vec::new(); for m in &plan.mappings { if let Some(existing) = store.external_for(g, k, &m.core_id)? { if existing != m.wire_id { conflicts.push(format!( "core {} already maps to wire {existing} (want {})", m.core_id, m.wire_id )); } } if let Some(existing) = store.core_for(g, k, m.wire_id)? { if existing != m.core_id { conflicts.push(format!( "wire {} already maps to core {existing} (want {})", m.wire_id, m.core_id )); } } } if !conflicts.is_empty() { bail!( "identity dry-preflight found {} conflict(s): {}", conflicts.len(), conflicts.join("; ") ); } Ok(()) } /// Idempotently install every preserved wire-id mapping + the allocation /// watermark. Re-running re-inserts identical mappings (no-op) and re-sets the /// same watermark — safe after a mid-apply crash. pub fn seed_identity(store: &JsonIdentityStore, plan: &ApplyPlan) -> Result<()> { let (g, k) = ( Fifa17WireItemIdPolicy::GAME, Fifa17WireItemIdPolicy::OWNED_ITEM_KIND, ); for m in &plan.mappings { store .insert_existing_mapping(g, k, &m.core_id, m.wire_id) .with_context(|| format!("install mapping {} -> {}", m.core_id, m.wire_id))?; } store .set_watermark(g, k, plan.watermark) .context("set allocation watermark")?; Ok(()) } /// After a successful Core import, re-open the identity store and assert every /// preserved wire id resolves exactly, and the watermark is set. Core's own /// return (`imported`/`already_imported`) is the Core-side proof. pub fn post_validate_identity(store: &JsonIdentityStore, plan: &ApplyPlan) -> Result<()> { let (g, k) = ( Fifa17WireItemIdPolicy::GAME, Fifa17WireItemIdPolicy::OWNED_ITEM_KIND, ); for m in &plan.mappings { let got = store.external_for(g, k, &m.core_id)?; if got != Some(m.wire_id) { bail!( "post-validate: core {} resolves to {:?}, expected wire {}", m.core_id, got, m.wire_id ); } } match store.watermark_for(g, k) { Some(w) if w == plan.watermark => Ok(()), other => bail!( "post-validate: watermark is {:?}, expected {}", other, plan.watermark ), } } /// Load the CardDefinitionIds from an emitted production content pack. pub fn content_card_ids(pack: &Path) -> Result> { let raw = std::fs::read(pack).with_context(|| format!("read content pack {}", pack.display()))?; let defs: Vec = serde_json::from_slice(&raw).context("parse content pack as CardDefinition[]")?; Ok(defs .iter() .filter_map(|d| d.get("id").and_then(|v| v.as_str()).map(String::from)) .collect()) } /// Full recoverable apply across both stores. See module docs for the protocol. pub fn apply( plan: &ApplyPlan, paths: &ApplyPaths, allow_deferred_staging: bool, ) -> Result { // 1. staging/production gate (explicit, never silent). let staging = gate_staging(plan, allow_deferred_staging)?; // 2. local Core-preflight mirror BEFORE any store write. let card_ids = content_card_ids(&paths.content_pack)?; local_core_preflight(plan, &card_ids)?; // 3. identity dry-preflight, then idempotent seed + watermark. let store = JsonIdentityStore::open(&paths.identity_store) .with_context(|| format!("open identity store {}", paths.identity_store.display()))?; identity_dry_preflight(&store, plan)?; seed_identity(&store, plan)?; // 4. one generic Core import transaction (Core owns atomicity). let req_json = serde_json::to_vec_pretty(&plan.request)?; std::fs::write(&paths.request_out, &req_json) .with_context(|| format!("write request {}", paths.request_out.display()))?; let out = Command::new(&paths.core_bin) .arg("import") .arg(&paths.request_out) .env("DATABASE_URL", &paths.core_db_url) .env("DATA_DIR", &paths.data_dir) .env("OPENFUT_CONTENT_PACKS", paths.content_pack.as_os_str()) .output() .with_context(|| format!("spawn core import: {}", paths.core_bin.display()))?; if !out.status.success() { bail!( "core import failed ({}): {}\n(identity already seeded idempotently; re-run the same \ manifest to converge — no cleanup needed)", out.status, String::from_utf8_lossy(&out.stderr) ); } let stdout = String::from_utf8_lossy(&out.stdout); let core_result: serde_json::Value = serde_json::from_str(stdout.trim()).context("parse core import outcome JSON")?; let core_outcome = core_result .get("outcome") .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); // 5. cross-store post-validation. post_validate_identity(&store, plan)?; // 6. completion record. let outcome = ApplyOutcome { core_outcome, mappings_installed: plan.mappings.len(), watermark: plan.watermark, supported_instances: plan.supported_instances, deferred_instances: plan.deferred_instances, staging, source_fingerprint: plan.source_fingerprint.clone(), }; let rec = serde_json::to_vec_pretty(&outcome)?; std::fs::write(&paths.completion_out, &rec) .with_context(|| format!("write completion {}", paths.completion_out.display()))?; Ok(outcome) }