feat(import-fifa17): --apply — recoverable two-store real-profile import

FIFA17-specific orchestration that installs the real profile across BOTH durable
stores (openfut-identity + Core SQLite) recoverably and idempotently, handing
Core only a GENERIC request (all FIFA17 semantics stay in this adapter layer).

apply module:
- owned_item_id(persona, wire) = deterministic UUIDv5 from a private namespace;
  identical in BOTH stores (identity core_id AND Core owned_cards.id), so the
  running host's wire->owned reverse lookup resolves exactly what Core stored.
- plan_apply(report, raw_profile, fp): pure translation to a GenericImportRequest
  (card_id = fifa17_<resourceId>) + the preserved (owned_item_id <-> source wire)
  mappings + watermark. Canonical squad + Fifa17SquadExtensionV1 are built by the
  SAME adapter code (parse_squad_put + build_squad_write) the retail-validated
  live squad path uses. Refuses if the report has blockers.
- Two-store protocol (apply): staging gate -> local Core-preflight mirror ->
  identity dry-preflight -> idempotent seed (insert_existing_mapping per instance
  + set_watermark) -> ONE generic Core import transaction (spawned binary) ->
  cross-store post-validation -> completion record. A crash after identity
  seeding re-converges on re-run (idempotent mappings + Core already_imported):
  no cleanup, no reminting.
- gate_staging: deferred players are ABSENT from an import; allowed only for a
  staged run behind --allow-deferred-players-for-staging (never a silent default;
  prints an INCOMPLETE banner). Production requires zero deferred instances.

CLI: --apply (with --emit-content, --core-bin, --core-db, --core-data,
--identity-store, --allow-deferred-players-for-staging). Depends on
openfut-adapter-fifa17 + openfut-identity + uuid(v5).

Proven end-to-end on the real 33068179/CAGE profile (staged): first apply
imports 1949 supported instances (293 base + versioned), 11/11 f433 squad +
opaque extension, coins 28,112,944, fingerprint 8dc5582d2414af28; re-run is an
idempotent no-op (already_imported, DB unchanged); staging-not-default refuses
before any write; every OwnedItemId is an opaque UUID; identity wire-id set ==
source supported set exactly (0 minted, 0 dropped); 13 deferred instances leak 0.

10 new apply tests (determinism, request/mapping/squad translation, blocker
refusal, staging gate both ways, local preflight, identity seed/dry/postvalidate/
idempotency, conflict detection, graceful spawn failure). clippy -D warnings
clean; crate suite 23 tests green.
This commit is contained in:
funman300
2026-08-12 20:36:34 +00:00
parent c71c2a8d33
commit 1631d3b1a2
6 changed files with 811 additions and 4 deletions
+240
View File
@@ -318,3 +318,243 @@ fn emit_refuses_when_blocked() {
let dir = tempfile::tempdir().unwrap();
assert!(emit_content(&rep, dir.path(), "x").is_err());
}
// ------------------------------------------------------------------ apply
use crate::apply::{
apply as apply_import, content_card_ids, gate_staging, identity_dry_preflight,
local_core_preflight, owned_item_id, plan_apply, post_validate_identity, seed_identity,
};
use openfut_identity::{ExternalIdentityStore, JsonIdentityStore};
const SQUAD_F433: &str = r#"[{"formation":"f433","squadName":"OpenFUT","captain":100000001,
"squadType":"REGULAR_SQUAD","custom":"[0,0,0]",
"players":[{"index":0,"itemData":{"id":100000001},"kitNumber":7},
{"index":1,"itemData":{"id":100000002},"kitNumber":9}],
"kicktakers":[{"index":0,"id":100000001}],"manager":[{"id":100000427}]}]"#;
/// Build a report + raw profile Value from the same JSON (typed profile for
/// analysis, raw Value for the squad wire).
fn report_and_raw(
items: &[String],
squads: &str,
next_item_id: i64,
) -> (Report, serde_json::Value) {
let json = format!(
r#"{{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC",
"coins":1000,"nextItemId":{next_item_id},"items":[{}],"squads":{}}}"#,
items.join(","),
squads
);
let prof = Profile::from_json_str(&json).unwrap();
let report = analyze(&prof, &roster(), &entities(), &none());
let raw: serde_json::Value = serde_json::from_str(&json).unwrap();
(report, raw)
}
#[test]
fn owned_item_id_is_deterministic_and_distinct() {
let a = owned_item_id(33068179, 100000001);
assert_eq!(a, owned_item_id(33068179, 100000001), "stable across calls");
assert_ne!(
a,
owned_item_id(33068179, 100000002),
"distinct per wire id"
);
assert_ne!(
a,
owned_item_id(90909090, 100000001),
"distinct per account"
);
}
#[test]
fn plan_apply_builds_generic_request_mappings_and_squad() {
let items = vec![
player(100000001, 20801, 20801, 94),
player(100000002, VER5_176580, 176580, 92),
];
let (report, raw) = report_and_raw(&items, SQUAD_F433, 100000500);
let plan = plan_apply(&report, &raw, "fp-test").unwrap();
assert_eq!(plan.request.owned.len(), 2);
assert_eq!(plan.mappings.len(), 2);
assert_eq!(plan.watermark, 100000500);
assert_eq!(plan.supported_instances, 2);
assert_eq!(plan.deferred_instances, 0);
assert_eq!(plan.source_fingerprint, "fp-test");
// card ids are fifa17_<resourceId>, base and versioned distinct.
let mut cards: Vec<&str> = plan
.request
.owned
.iter()
.map(|o| o.card_id.as_str())
.collect();
cards.sort_unstable();
assert_eq!(
cards,
vec!["fifa17_20801", &format!("fifa17_{VER5_176580}")[..]]
);
// mapping core_id == the owned_item_id for that wire (both stores agree).
for m in &plan.mappings {
assert_eq!(m.core_id, owned_item_id(33068179, m.wire_id));
}
// squad + opaque extension.
let sq = plan.request.squad.as_ref().expect("squad present");
assert_eq!(sq.formation, "f433");
assert_eq!(sq.slots.len(), 2);
assert_eq!(sq.extension.namespace, "fifa17.squad");
assert_eq!(sq.extension.schema_version, 1);
assert!(
sq.extension.payload.contains("[0,0,0]"),
"custom carried verbatim"
);
// captain flag follows wire 100000001, keyed by its OwnedItemId.
let cap_owned = owned_item_id(33068179, 100000001);
assert!(sq
.slots
.iter()
.any(|s| s.owned_item_id == cap_owned && s.is_captain));
}
#[test]
fn plan_apply_refuses_when_blockers_present() {
// unapproved same-resourceId conflict -> blocker.
let items = vec![
player(100000003, 20801, 20801, 94),
player(100000004, 20801, 20801, 90),
];
let (report, raw) = report_and_raw(&items, "[]", 100000500);
assert!(plan_apply(&report, &raw, "fp").is_err());
}
#[test]
fn gate_staging_requires_explicit_optin_for_deferred() {
// one supported + one NoName (unresolvable asset) deferred instance.
let items = vec![
player(100000001, 20801, 20801, 94),
format!(
r#"{{"id":100000009,"resourceId":999999,"assetId":999999,"itemType":"player","rating":80,
"preferredPosition":"ST","nation":38,"teamid":243,"leagueId":53,"attributeList":{}}}"#,
attrs()
),
];
let (report, raw) = report_and_raw(&items, "[]", 100000500);
let plan = plan_apply(&report, &raw, "fp").unwrap();
assert_eq!(plan.deferred_instances, 1);
assert!(gate_staging(&plan, false).is_err(), "deferred needs opt-in");
assert!(gate_staging(&plan, true).unwrap(), "staging with opt-in");
}
#[test]
fn gate_staging_production_complete_needs_no_flag() {
let items = vec![player(100000001, 20801, 20801, 94)];
let (report, raw) = report_and_raw(&items, "[]", 100000500);
let plan = plan_apply(&report, &raw, "fp").unwrap();
assert_eq!(plan.deferred_instances, 0);
assert!(!gate_staging(&plan, false).unwrap());
}
#[test]
fn local_preflight_rejects_owned_card_absent_from_content() {
let items = vec![player(100000001, 20801, 20801, 94)];
let (report, raw) = report_and_raw(&items, "[]", 100000500);
let plan = plan_apply(&report, &raw, "fp").unwrap();
let mut present = BTreeSet::new();
present.insert("fifa17_20801".to_string());
assert!(local_core_preflight(&plan, &present).is_ok());
assert!(local_core_preflight(&plan, &BTreeSet::new()).is_err());
}
#[test]
fn identity_seed_dry_postvalidate_and_idempotent_rerun() {
let items = vec![
player(100000001, 20801, 20801, 94),
player(100000002, VER5_176580, 176580, 92),
];
let (report, raw) = report_and_raw(&items, SQUAD_F433, 100004617);
let plan = plan_apply(&report, &raw, "fp").unwrap();
let dir = tempfile::tempdir().unwrap();
let store = JsonIdentityStore::open(dir.path().join("ids.json")).unwrap();
identity_dry_preflight(&store, &plan).unwrap();
seed_identity(&store, &plan).unwrap();
// every preserved wire id resolves exactly; watermark set.
for m in &plan.mappings {
assert_eq!(
store
.external_for("fifa17", "owned-item", &m.core_id)
.unwrap(),
Some(m.wire_id)
);
}
assert_eq!(store.watermark_for("fifa17", "owned-item"), Some(100004617));
post_validate_identity(&store, &plan).unwrap();
// re-seeding the SAME plan is an idempotent no-op (crash recovery).
seed_identity(&store, &plan).unwrap();
post_validate_identity(&store, &plan).unwrap();
}
#[test]
fn identity_dry_preflight_detects_conflicting_existing_mapping() {
let items = vec![player(100000001, 20801, 20801, 94)];
let (report, raw) = report_and_raw(&items, "[]", 100000500);
let plan = plan_apply(&report, &raw, "fp").unwrap();
let dir = tempfile::tempdir().unwrap();
let store = JsonIdentityStore::open(dir.path().join("ids.json")).unwrap();
// wire 100000001 already owned by a DIFFERENT core id.
store
.insert_existing_mapping("fifa17", "owned-item", "someone-else", 100000001)
.unwrap();
assert!(identity_dry_preflight(&store, &plan).is_err());
}
#[test]
fn content_card_ids_reads_emitted_pack() {
let items = vec![player(100000001, 20801, 20801, 94)];
let rep = analyze(
&profile(&items, "[]", 100000500),
&roster(),
&entities(),
&none(),
);
let dir = tempfile::tempdir().unwrap();
let sum = emit_content(&rep, dir.path(), "fp").unwrap();
let ids = content_card_ids(&sum.content_pack).unwrap();
assert!(ids.contains("fifa17_20801"));
}
/// The full apply spawns the Core binary; that end-to-end path is exercised by
/// the staged runtime gate, not here. This asserts the orchestration refuses
/// cleanly (no panic, no partial identity seed) when the Core binary is absent
/// AFTER the two local gates pass — proving ordering: gates first, spawn last.
#[test]
fn apply_fails_gracefully_when_core_binary_missing() {
use crate::apply::ApplyPaths;
let items = vec![player(100000001, 20801, 20801, 94)];
let (report, raw) = report_and_raw(&items, "[]", 100000500);
let plan = plan_apply(&report, &raw, "fp").unwrap();
let dir = tempfile::tempdir().unwrap();
// emit a real content pack so local preflight passes.
let rep2 = analyze(
&profile(&items, "[]", 100000500),
&roster(),
&entities(),
&none(),
);
let sum = emit_content(&rep2, dir.path(), "fp").unwrap();
let paths = ApplyPaths {
core_bin: dir.path().join("no-such-core-binary"),
core_db_url: "sqlite::memory:".to_string(),
content_pack: sum.content_pack,
data_dir: "data".to_string(),
identity_store: dir.path().join("ids.json"),
request_out: dir.path().join("req.json"),
completion_out: dir.path().join("done.json"),
};
let err = apply_import(&plan, &paths, false).unwrap_err();
assert!(format!("{err:#}").contains("spawn core import"), "{err:#}");
}