Files
OpenFUT/openfut-import-fifa17/src/apply.rs
T
funman300 770029f207 fix(import-fifa17): carry the fields a consumable needs to exist
Two defects that together made the club's 17 owned consumables invisible while
club/stats still counted them — the count gate promised 17, the item route
served 0.

1. The catalog omitted `card_asset_id`, `amount`, `contract` and `rating` for
   non-player definitions. Without an art id the adapter refuses to emit the
   card (it would draw the notfound box), and the families that read
   `amount`/`contract` would render "-1" or grant nothing. All four values are
   present in the source wire and were simply dropped on the way out.

2. Owned rows were imported without a `content_kind`, and Core defaults an
   unstated row to `player` — durably recording a fitness coach and a contract
   card as players in the ownership authority, even though the catalog-driven
   wire looked right.

These are definition-level fields, so every owned copy must agree; a group that
disagrees is deferred rather than resolved by taking the first copy's value.
Measured on the real profile: observed `amount` equals the `fcc_*` table row for
every consumable that carries one (1, 2, 4, 5, 10, 15), and a wire omission
corresponds to a table amount of 0. It is therefore NOT a stack count — two
copies of 5003068 are two instances — so the import states no `quantity` at all.
2026-08-21 19:50:13 +00:00

549 lines
21 KiB
Rust

//! `--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_<resourceId>`, 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<GenericSlot>,
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<GenericOwned>,
#[serde(skip_serializing_if = "Option::is_none")]
pub squad: Option<GenericSquad>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub entitlements: Vec<GenericEntitlement>,
}
/// 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<IdentityMapping>,
pub watermark: i64,
pub supported_instances: usize,
pub deferred_instances: usize,
pub source_fingerprint: String,
}
struct MapResolver<'a>(&'a HashMap<i64, String>);
impl SquadWireResolver for MapResolver<'_> {
fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
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<ApplyPlan> {
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<i64, String> = 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_<resourceId>.
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::<usize>(),
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<bool> {
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<String>) -> 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<BTreeSet<String>> {
let raw =
std::fs::read(pack).with_context(|| format!("read content pack {}", pack.display()))?;
let defs: Vec<serde_json::Value> =
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<ApplyOutcome> {
// 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)
}