feat(fifa17): import consumable + staff content as first-class Core content

Close 20 of the 33-record content gap (17 consumables + 3 staff; 13 Legends are
unrecoverable from PC data). Verdict A (no Core change): consumables/staff become
ordinary Core CardDefinitions (neutral player fields + honest family/role names)
and owned instances via the SAME generic import path; a catalog kind lets the
adapter exclude them from the player-only /club projection.

- adapter fut::content_taxonomy: evidence-based cardsubtypeid->family/label
  (Ghidra-derived ranges) + staff role map; unknown subtype => defer, never fabricate.
- adapter catalog: Fifa17CardIdentity/RawCard gain optional kind+subtype
  (backward-compat: legacy catalogs load as player); kind_of/subtype_of lookups.
- adapter item/club_response: shape_club_response excludes non-player kinds
  (ShapeStats.excluded_non_player); ItemIdentityResolver::kind_of default=Player.
- host Fifa17IdentityResolver overrides kind_of to delegate to the catalog so
  /club excludes consumables/staff in production.
- import: Item gains cardsubtypeid/cardassetid/amount/contract; plan_non_player_definitions
  (resourceId-grouped, subtype-consistency gated); emit_content writes non-player
  defs + catalog kind + manifest; apply mints owned instances via owned_item_id.

Real profile 33068179: 1962 players + 20 non-player = 1982 owned; 18 non-player
defs (16 consumable + 2 staff, dup resourceIds shared); 0 deferred non-player; 0 blockers.
This commit is contained in:
funman300
2026-08-14 05:26:02 +00:00
parent f5a33eb58c
commit abe9e663c1
11 changed files with 913 additions and 8 deletions
+273 -6
View File
@@ -34,6 +34,7 @@ pub mod apply;
pub mod model;
use model::{Item, Profile};
use openfut_adapter_fifa17::fut::content_taxonomy::{consumable_family, staff_role, ContentKind};
// ----------------------------------------------------------------- roster
@@ -521,6 +522,157 @@ pub fn plan_definitions(
plan
}
// ------------------------------------------------------- non-player content
/// An honest, profile-derived NON-player CardDefinition proposal (consumable or
/// staff), keyed by `fifa17_<resourceId>`. Neutral player fields are supplied at
/// emit time; this carries only the identity + honest functional `name` (the
/// taxonomy label, never a marketing name).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NonPlayerDefinition {
pub card_id: String,
pub resource_id: i64,
/// Base asset id when the source carries one (consumables: `== resource_id`);
/// staff carry no `assetId`, so this is `None`.
pub asset_id: Option<i64>,
pub kind: ContentKind,
/// FIFA `cardsubtypeid` (consumable family / staff role selector).
pub subtype: i64,
/// Honest functional label (e.g. "Player Contract", "GK Coach").
pub name: String,
/// Wire ids of every owned copy of this resourceId (preserved).
pub wire_ids: Vec<i64>,
}
/// A non-player group that cannot be honestly classified (DEFERRED, never
/// fabricated). Mirrors the player NoName gate.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeferredNonPlayer {
pub resource_id: i64,
/// The agreed subtype when present; `None` when absent or in conflict.
pub subtype: Option<i64>,
pub wire_ids: Vec<i64>,
pub reason: String,
}
#[derive(Debug, Default)]
pub struct NonPlayerPlan {
pub supported: Vec<NonPlayerDefinition>,
pub deferred: Vec<DeferredNonPlayer>,
/// Count of SUPPORTED consumable definitions.
pub consumables: usize,
/// Count of SUPPORTED staff definitions.
pub staff: usize,
}
impl NonPlayerPlan {
/// Deferred non-player INSTANCES (owned copies) across all deferred groups.
pub fn deferred_instances(&self) -> usize {
self.deferred.iter().map(|d| d.wire_ids.len()).sum()
}
}
/// Plan the non-player (consumable + staff) CardDefinitions. Groups Consumable
/// and Staff items by `resourceId`; each group must agree on `cardsubtypeid`
/// across copies (a disagreement DEFERS with `subtype_conflict`), then resolves
/// the family (consumable) or role (staff) via the adapter's evidence-based
/// taxonomy. A missing or unknown `cardsubtypeid` DEFERS — never a placeholder.
pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
let mut groups: BTreeMap<i64, Vec<&Item>> = BTreeMap::new();
for it in &profile.items {
if matches!(classify(it), ItemClass::Consumable | ItemClass::Staff) {
groups.entry(it.resource_id).or_default().push(it);
}
}
let mut plan = NonPlayerPlan::default();
for (resource_id, items) in groups {
let wire_ids: Vec<i64> = items.iter().map(|i| i.id).collect();
// Class agreement (a resourceId is either all-consumable or all-staff).
let class = classify(items[0]);
if items.iter().any(|i| classify(i) != class) {
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: None,
wire_ids,
reason: "class_conflict".to_string(),
});
continue;
}
// Subtype must agree across every owned copy (identity invariant).
let first_subtype = items[0].cardsubtypeid;
if items.iter().any(|i| i.cardsubtypeid != first_subtype) {
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: None,
wire_ids,
reason: "subtype_conflict".to_string(),
});
continue;
}
let Some(subtype) = first_subtype else {
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: None,
wire_ids,
reason: "missing_cardsubtypeid".to_string(),
});
continue;
};
let (kind, label) = match class {
ItemClass::Consumable => match consumable_family(subtype) {
Some((_family, label)) => (ContentKind::Consumable, label),
None => {
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: Some(subtype),
wire_ids,
reason: "unknown_subtype".to_string(),
});
continue;
}
},
ItemClass::Staff => match staff_role(subtype) {
Some((_role, label)) => (ContentKind::Staff, label),
None => {
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: Some(subtype),
wire_ids,
reason: "unknown_subtype".to_string(),
});
continue;
}
},
_ => unreachable!("only Consumable/Staff were grouped"),
};
plan.supported.push(NonPlayerDefinition {
card_id: format!("fifa17_{resource_id}"),
resource_id,
asset_id: items[0].asset_id,
kind,
subtype,
name: label.to_string(),
wire_ids,
});
}
plan.consumables = plan
.supported
.iter()
.filter(|d| d.kind == ContentKind::Consumable)
.count();
plan.staff = plan
.supported
.iter()
.filter(|d| d.kind == ContentKind::Staff)
.count();
plan
}
// --------------------------------------------------------------- identity
#[derive(Debug, Default)]
@@ -632,6 +784,8 @@ pub struct Report {
pub definitions: DefinitionPlan,
pub identity: IdentityPlan,
pub squad: SquadCoverage,
/// Consumable + staff content (supported definitions + deferred groups).
pub non_player: NonPlayerPlan,
/// Unconsumed pack entitlements to seed (from `unopenedPackIds`).
pub unopened_pack_ids: Vec<i64>,
}
@@ -720,6 +874,7 @@ pub fn analyze(
let identity = plan_identity(profile, &supported_rids);
let supported_wire: BTreeSet<i64> = identity.import_wire_ids.iter().copied().collect();
let squad = plan_squad(profile, &supported_wire);
let non_player = plan_non_player_definitions(profile);
Report {
game: "fifa17".to_string(),
persona_id: profile.persona_id,
@@ -731,6 +886,7 @@ pub fn analyze(
definitions,
identity,
squad,
non_player,
unopened_pack_ids: profile.unopened_pack_ids.clone(),
}
}
@@ -740,6 +896,7 @@ impl std::fmt::Display for Report {
let d = &self.definitions;
let id = &self.identity;
let sq = &self.squad;
let np = &self.non_player;
writeln!(f, "OpenFUT FIFA17 real-profile import — analysis")?;
writeln!(f, "=============================================")?;
writeln!(
@@ -819,11 +976,30 @@ impl std::fmt::Display for Report {
}
writeln!(
f,
"\nRESULT would_import_players={} deferred_player_instances={} deferred_consumables={} deferred_staff={}",
"\nNON-PLAYER CONTENT (consumable/staff) supported={} (consumables={} staff={}) deferred_groups={} deferred_instances={}",
np.supported.len(),
np.consumables,
np.staff,
np.deferred.len(),
np.deferred_instances()
)?;
for nd in &np.deferred {
writeln!(
f,
" DEFER resourceId={} subtype={:?} copies={} reason={}",
nd.resource_id,
nd.subtype,
nd.wire_ids.len(),
nd.reason
)?;
}
writeln!(
f,
"\nRESULT would_import_players={} would_import_non_players={} deferred_player_instances={} deferred_non_player_instances={}",
id.import_wire_ids.len(),
np.supported.iter().map(|d| d.wire_ids.len()).sum::<usize>(),
self.deferred_instances(),
self.counts.consumables,
self.counts.staff
np.deferred_instances()
)?;
let blockers = self.blockers();
if blockers.is_empty() {
@@ -861,6 +1037,10 @@ pub struct EmitSummary {
pub catalog_entries: usize,
pub supported_instances: usize,
pub deferred_instances: usize,
/// Non-player (consumable/staff) supported definitions written.
pub non_player_definitions: usize,
/// Non-player supported owned INSTANCES (owned copies across those defs).
pub non_player_instances: usize,
}
/// Emit the PUBLIC content pack + host catalog for supported definitions, and a
@@ -880,7 +1060,7 @@ pub fn emit_content(
std::fs::create_dir_all(&manifest_dir)?;
// ---- PUBLIC: Core CardDefinition[] (matches openfut-core models::card) ----
let defs: Vec<serde_json::Value> = report
let mut defs: Vec<serde_json::Value> = report
.definitions
.supported
.iter()
@@ -904,15 +1084,58 @@ pub fn emit_content(
})
})
.collect();
// Non-player CardDefinitions use NEUTRAL player fields + the honest family/
// role name; Core stores them like any other definition (no FIFA concept).
for d in &report.non_player.supported {
defs.push(serde_json::json!({
"id": d.card_id,
"name": d.name,
"overall": 0,
"position": "",
"nation": "",
"league": "",
"club": "",
"pace": 0,
"shooting": 0,
"passing": 0,
"dribbling": 0,
"defending": 0,
"physical": 0,
"rarity": "bronze",
"image_path": serde_json::Value::Null,
}));
}
let content_pack = content_dir.join("fifa17-production-cards.json");
write_json_pretty(&content_pack, &defs)?;
// ---- PUBLIC: host identity catalog {card_id: {asset_id, version, rareflag}} ----
let mut cards = serde_json::Map::new();
for d in &report.definitions.supported {
// Players carry an explicit kind:"player" + subtype:0 so the adapter can
// classify EVERY catalogued card (not just non-players).
cards.insert(
d.card_id.clone(),
serde_json::json!({ "asset_id": d.asset_id, "version": d.version, "rareflag": d.rareflag }),
serde_json::json!({
"asset_id": d.asset_id,
"version": d.version,
"rareflag": d.rareflag,
"kind": "player",
"subtype": 0,
}),
);
}
for d in &report.non_player.supported {
// asset_id falls back to resource_id (staff carry no assetId); version 0,
// rareflag 0 — a consumable/staff never renders as a special card.
cards.insert(
d.card_id.clone(),
serde_json::json!({
"asset_id": d.asset_id.unwrap_or(d.resource_id),
"version": 0,
"rareflag": 0,
"kind": d.kind.as_str(),
"subtype": d.subtype,
}),
);
}
let catalog = serde_json::json!({
@@ -966,8 +1189,44 @@ pub fn emit_content(
"distinct_variants": c.distinct.len(),
}));
}
let non_player_supported: Vec<serde_json::Value> = report
.non_player
.supported
.iter()
.map(|d| {
serde_json::json!({
"card_id": d.card_id,
"resource_id": d.resource_id,
"asset_id": d.asset_id,
"kind": d.kind.as_str(),
"subtype": d.subtype,
"name": d.name,
"wire_ids": d.wire_ids,
})
})
.collect();
let non_player_deferred: Vec<serde_json::Value> = report
.non_player
.deferred
.iter()
.map(|dd| {
serde_json::json!({
"resource_id": dd.resource_id,
"subtype": dd.subtype,
"wire_ids": dd.wire_ids,
"reason": dd.reason,
})
})
.collect();
let supported_instances = report.identity.import_wire_ids.len();
let deferred_instances = report.deferred_instances();
let non_player_definitions = report.non_player.supported.len();
let non_player_instances: usize = report
.non_player
.supported
.iter()
.map(|d| d.wire_ids.len())
.sum();
let manifest = serde_json::json!({
"generator": "openfut-import-fifa17",
"source_kind": "python-profile-observation",
@@ -987,6 +1246,12 @@ pub fn emit_content(
},
"supported_definitions": supported,
"deferred": deferred,
"non_player": {
"supported_definitions": non_player_supported,
"supported_instances": non_player_instances,
"deferred": non_player_deferred,
"deferred_instances": report.non_player.deferred_instances(),
},
});
let manifest_path = manifest_dir.join("fifa17-import-manifest.json");
write_json_pretty(&manifest_path, &manifest)?;
@@ -996,9 +1261,11 @@ pub fn emit_content(
host_catalog,
manifest: manifest_path,
definitions: report.definitions.supported.len(),
catalog_entries: report.definitions.supported.len(),
catalog_entries: report.definitions.supported.len() + non_player_definitions,
supported_instances,
deferred_instances,
non_player_definitions,
non_player_instances,
})
}