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:
@@ -20,6 +20,8 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::fut::content_taxonomy::ContentKind;
|
||||||
|
|
||||||
/// The FIFA 17 render identity of a card definition. `version` is the high byte
|
/// The FIFA 17 render identity of a card definition. `version` is the high byte
|
||||||
/// of `resource_id`; `asset_id` (the low 24 bits) is the real FIFA player id.
|
/// of `resource_id`; `asset_id` (the low 24 bits) is the real FIFA player id.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -30,6 +32,12 @@ pub struct Fifa17CardIdentity {
|
|||||||
/// FIFA wire `rareflag` (rare/special card TYPE). Carried so specials render
|
/// FIFA wire `rareflag` (rare/special card TYPE). Carried so specials render
|
||||||
/// as specials; observed metadata, not a guessed label.
|
/// as specials; observed metadata, not a guessed label.
|
||||||
pub rareflag: i64,
|
pub rareflag: i64,
|
||||||
|
/// Content class of this definition. A catalog authored before this field
|
||||||
|
/// existed defaults to [`ContentKind::Player`] (backward compatible).
|
||||||
|
pub kind: ContentKind,
|
||||||
|
/// FIFA `cardsubtypeid` for a non-player definition (consumable family /
|
||||||
|
/// staff role), `0` for a player or when absent.
|
||||||
|
pub subtype: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The FIFA 17 numeric namespace policy for owned-item wire ids.
|
/// The FIFA 17 numeric namespace policy for owned-item wire ids.
|
||||||
@@ -115,6 +123,14 @@ struct RawCard {
|
|||||||
/// behaviour; the production catalog carries the observed value.
|
/// behaviour; the production catalog carries the observed value.
|
||||||
#[serde(default = "default_rareflag")]
|
#[serde(default = "default_rareflag")]
|
||||||
rareflag: i64,
|
rareflag: i64,
|
||||||
|
/// Content class token ("player"|"consumable"|"staff"). Absent → default
|
||||||
|
/// (empty) → [`ContentKind::Player`], so existing player-only catalogs load
|
||||||
|
/// unchanged.
|
||||||
|
#[serde(default)]
|
||||||
|
kind: String,
|
||||||
|
/// FIFA `cardsubtypeid` for a non-player entry; absent → `0`.
|
||||||
|
#[serde(default)]
|
||||||
|
subtype: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_rareflag() -> i64 {
|
fn default_rareflag() -> i64 {
|
||||||
@@ -169,6 +185,8 @@ impl Fifa17CardCatalog {
|
|||||||
version: rc.version,
|
version: rc.version,
|
||||||
resource_id,
|
resource_id,
|
||||||
rareflag: rc.rareflag,
|
rareflag: rc.rareflag,
|
||||||
|
kind: ContentKind::from_str(&rc.kind),
|
||||||
|
subtype: rc.subtype,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -197,6 +215,21 @@ impl Fifa17CardCatalog {
|
|||||||
self.by_resource.get(&resource_id).map(String::as_str)
|
self.by_resource.get(&resource_id).map(String::as_str)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Classify a `card_id` as player/consumable/staff. An unknown definition is
|
||||||
|
/// [`ContentKind::Player`] — the neutral, backward-compatible default (an
|
||||||
|
/// un-catalogued id was always treated as a player-shaped card).
|
||||||
|
pub fn kind_of(&self, card_id: &str) -> ContentKind {
|
||||||
|
self.by_card
|
||||||
|
.get(card_id)
|
||||||
|
.map(|c| c.kind)
|
||||||
|
.unwrap_or(ContentKind::Player)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The FIFA `cardsubtypeid` for a definition, or `0` if unknown / a player.
|
||||||
|
pub fn subtype_of(&self, card_id: &str) -> i64 {
|
||||||
|
self.by_card.get(card_id).map(|c| c.subtype).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.by_card.len()
|
self.by_card.len()
|
||||||
}
|
}
|
||||||
@@ -335,4 +368,44 @@ mod tests {
|
|||||||
assert_eq!(ron.version, 0);
|
assert_eq!(ron.version, 0);
|
||||||
assert_eq!(ron.resource_id, 20801);
|
assert_eq!(ron.resource_id, 20801);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_catalog_without_kind_loads_as_player() {
|
||||||
|
// A pre-taxonomy catalog (no `kind`/`subtype`) must load unchanged and
|
||||||
|
// classify every entry as a player, with subtype 0.
|
||||||
|
let cat = Fifa17CardCatalog::from_json_str(
|
||||||
|
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||||
|
"fifa17_20801":{"asset_id":20801},
|
||||||
|
"fifa17_176580":{"asset_id":176580,"version":5,"rareflag":3}
|
||||||
|
}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let base = cat.lookup("fifa17_20801").unwrap();
|
||||||
|
assert_eq!(base.kind, ContentKind::Player);
|
||||||
|
assert_eq!(base.subtype, 0);
|
||||||
|
assert_eq!(base.rareflag, 1, "absent rareflag still defaults to 1");
|
||||||
|
assert_eq!(cat.kind_of("fifa17_20801"), ContentKind::Player);
|
||||||
|
assert_eq!(cat.kind_of("fifa17_176580"), ContentKind::Player);
|
||||||
|
// Unknown id -> neutral Player default.
|
||||||
|
assert_eq!(cat.kind_of("fifa17_missing"), ContentKind::Player);
|
||||||
|
assert_eq!(cat.subtype_of("fifa17_missing"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kind_and_subtype_are_parsed_for_non_player_entries() {
|
||||||
|
let cat = Fifa17CardCatalog::from_json_str(
|
||||||
|
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||||
|
"fifa17_20801":{"asset_id":20801,"kind":"player","subtype":0},
|
||||||
|
"fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54,"rareflag":0},
|
||||||
|
"fifa17_3000083":{"asset_id":3000083,"kind":"staff","subtype":8,"rareflag":0}
|
||||||
|
}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cat.kind_of("fifa17_20801"), ContentKind::Player);
|
||||||
|
assert_eq!(cat.kind_of("fifa17_5003012"), ContentKind::Consumable);
|
||||||
|
assert_eq!(cat.subtype_of("fifa17_5003012"), 54);
|
||||||
|
assert_eq!(cat.kind_of("fifa17_3000083"), ContentKind::Staff);
|
||||||
|
assert_eq!(cat.subtype_of("fifa17_3000083"), 8);
|
||||||
|
assert_eq!(cat.lookup("fifa17_5003012").unwrap().rareflag, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::fut::content_taxonomy::ContentKind;
|
||||||
use crate::fut::entities::ReverseEntityResolver;
|
use crate::fut::entities::ReverseEntityResolver;
|
||||||
use crate::fut::item::shape_item;
|
use crate::fut::item::shape_item;
|
||||||
// Re-exported so existing `club_response::{…}` callers keep working; the types
|
// Re-exported so existing `club_response::{…}` callers keep working; the types
|
||||||
@@ -25,6 +26,12 @@ pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
|
|||||||
let mut out = Vec::with_capacity(items.len());
|
let mut out = Vec::with_capacity(items.len());
|
||||||
let mut stats = ShapeStats::default();
|
let mut stats = ShapeStats::default();
|
||||||
for item in items {
|
for item in items {
|
||||||
|
// Exclude non-player content (consumables/staff): a `/club` player list
|
||||||
|
// must never render them as 0-rated players. Counted, never emitted.
|
||||||
|
if ident.kind_of(item) != ContentKind::Player {
|
||||||
|
stats.excluded_non_player += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match ident.resolve(item) {
|
match ident.resolve(item) {
|
||||||
Some(id) => {
|
Some(id) => {
|
||||||
out.push(shape_item(item, id, ent));
|
out.push(shape_item(item, id, ent));
|
||||||
@@ -193,4 +200,66 @@ mod tests {
|
|||||||
"only itemData at top level"
|
"only itemData at top level"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A resolver that resolves an asset id for EVERY item (so exclusion is not
|
||||||
|
/// an artifact of a missing asset) but classifies some card_ids as non-player
|
||||||
|
/// via an explicit kind table.
|
||||||
|
struct KindMapIdentity {
|
||||||
|
ids: HashMap<String, Fifa17Identity>,
|
||||||
|
kinds: HashMap<String, ContentKind>,
|
||||||
|
}
|
||||||
|
impl ItemIdentityResolver for KindMapIdentity {
|
||||||
|
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||||
|
self.ids.get(&it.card_id).copied()
|
||||||
|
}
|
||||||
|
fn kind_of(&self, it: &CoreOwnedItem) -> ContentKind {
|
||||||
|
self.kinds
|
||||||
|
.get(&it.card_id)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(ContentKind::Player)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consumable_and_staff_are_excluded_from_club_players() {
|
||||||
|
let ent = entities();
|
||||||
|
let id = |item_id: u32, asset: u32| Fifa17Identity {
|
||||||
|
item_id,
|
||||||
|
asset_id: asset,
|
||||||
|
resource_id: asset,
|
||||||
|
rareflag: 1,
|
||||||
|
};
|
||||||
|
let ident = KindMapIdentity {
|
||||||
|
ids: HashMap::from([
|
||||||
|
("card_player".to_string(), id(100000001, 20801)),
|
||||||
|
("card_consumable".to_string(), id(100000002, 5003012)),
|
||||||
|
("card_staff".to_string(), id(100000003, 3000083)),
|
||||||
|
]),
|
||||||
|
kinds: HashMap::from([
|
||||||
|
("card_consumable".to_string(), ContentKind::Consumable),
|
||||||
|
("card_staff".to_string(), ContentKind::Staff),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
let items = vec![
|
||||||
|
item(
|
||||||
|
"oc1",
|
||||||
|
"card_player",
|
||||||
|
86,
|
||||||
|
"ST",
|
||||||
|
"Argentina",
|
||||||
|
"Premier League",
|
||||||
|
"Chelsea",
|
||||||
|
),
|
||||||
|
item("oc2", "card_consumable", 0, "", "", "", ""),
|
||||||
|
item("oc3", "card_staff", 0, "", "", "", ""),
|
||||||
|
];
|
||||||
|
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
||||||
|
assert_eq!(stats.emitted, 1, "only the player is emitted");
|
||||||
|
assert_eq!(stats.excluded_non_player, 2, "consumable + staff excluded");
|
||||||
|
assert_eq!(stats.dropped_no_asset, 0);
|
||||||
|
let arr = body["itemData"].as_array().unwrap();
|
||||||
|
assert_eq!(arr.len(), 1);
|
||||||
|
assert_eq!(arr[0]["id"], 100000001, "the player survives");
|
||||||
|
assert_eq!(arr[0]["itemType"], "player");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
//! FIFA 17 **non-player content taxonomy** — the evidence-based map from a card
|
||||||
|
//! `cardsubtypeid` to its functional family (consumables) or role (staff).
|
||||||
|
//!
|
||||||
|
//! This is the ONLY place the FIFA-specific `cardsubtypeid` vocabulary lives; it
|
||||||
|
//! keeps that game concept out of generic Core, exactly as the player-side
|
||||||
|
//! catalog keeps `resourceId`/`rareflag` out of Core. Nothing here is guessed:
|
||||||
|
//!
|
||||||
|
//! * Consumable families and their contiguous `cardsubtypeid` ranges are taken
|
||||||
|
//! verbatim from `fifa17-recon/tools/fut_consumables.py`
|
||||||
|
//! (`BY_SUBTYPE`/`CORE_KINDS`, Ghidra-derived from `FUN_18013f4d0` /
|
||||||
|
//! `FUN_1801bfac0`) and `docs/CARD_TAXONOMY.md` (verified against the `.105`
|
||||||
|
//! `fcc_*.json` tables).
|
||||||
|
//! * Staff roles are the `FUN_1800d8330` family selector: 4=manager, 5=headcoach,
|
||||||
|
//! 6=gkcoach, 7=physio, 8=fitnesscoach.
|
||||||
|
//!
|
||||||
|
//! Display **labels are functional, never marketing** (e.g. "Player Chemistry
|
||||||
|
//! Style", not a promo name). A `cardsubtypeid` outside every documented range
|
||||||
|
//! resolves to `None` — the caller DEFERS it (mirroring the player NoName gate),
|
||||||
|
//! never fabricating a family.
|
||||||
|
|
||||||
|
/// The disjoint content classes a FIFA 17 owned card can belong to. Player is
|
||||||
|
/// the default so a catalog authored before this taxonomy existed (no `kind`
|
||||||
|
/// field) still classifies every entry as a player, unchanged.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum ContentKind {
|
||||||
|
#[default]
|
||||||
|
Player,
|
||||||
|
Consumable,
|
||||||
|
Staff,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContentKind {
|
||||||
|
/// The stable wire/catalog token for this kind.
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ContentKind::Player => "player",
|
||||||
|
ContentKind::Consumable => "consumable",
|
||||||
|
ContentKind::Staff => "staff",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a catalog `kind` token. Unknown or "player" (or an absent field that
|
||||||
|
/// deserializes to the default) is `Player` — backward compatible.
|
||||||
|
// Intentionally infallible (every input maps to a kind, unknown → Player), so
|
||||||
|
// it is NOT `std::str::FromStr` (which is fallible); the name mirrors the
|
||||||
|
// catalog token vocabulary.
|
||||||
|
#[allow(clippy::should_implement_trait)]
|
||||||
|
pub fn from_str(s: &str) -> ContentKind {
|
||||||
|
match s {
|
||||||
|
"consumable" => ContentKind::Consumable,
|
||||||
|
"staff" => ContentKind::Staff,
|
||||||
|
_ => ContentKind::Player,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The functional family + honest display label for a consumable `cardsubtypeid`,
|
||||||
|
/// or `None` if the subtype is outside every documented range (→ DEFER).
|
||||||
|
///
|
||||||
|
/// Returns `(family, label)`, both `'static`. `family` is the neutral machine
|
||||||
|
/// name stored as the CardDefinition family; `label` is the functional
|
||||||
|
/// human-readable name.
|
||||||
|
pub fn consumable_family(subtype: i64) -> Option<(&'static str, &'static str)> {
|
||||||
|
let pair = match subtype {
|
||||||
|
51..=57 => ("gk_training", "GK Training"),
|
||||||
|
61..=67 => ("player_training", "Player Training"),
|
||||||
|
71..=86 => ("manager_formation_mod", "Manager Formation"),
|
||||||
|
91..=110 => ("position_mod", "Position Modifier"),
|
||||||
|
121..=136 => ("formation_mod", "Formation Modifier"),
|
||||||
|
201 => ("player_contract", "Player Contract"),
|
||||||
|
202 => ("manager_contract", "Manager Contract"),
|
||||||
|
211..=218 => ("healing", "Healing"),
|
||||||
|
219 => ("player_fitness", "Player Fitness"),
|
||||||
|
220 => ("squad_fitness", "Squad Fitness"),
|
||||||
|
250..=268 => ("player_playstyle", "Player Chemistry Style"),
|
||||||
|
269..=273 => ("gk_playstyle", "GK Chemistry Style"),
|
||||||
|
300..=341 => ("manager_league", "Manager League Modifier"),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(pair)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The staff role + honest display label for a staff `cardsubtypeid` (4..=8), or
|
||||||
|
/// `None` for any other subtype (→ DEFER). Grounded in the `FUN_1800d8330`
|
||||||
|
/// family selector.
|
||||||
|
pub fn staff_role(subtype: i64) -> Option<(&'static str, &'static str)> {
|
||||||
|
let pair = match subtype {
|
||||||
|
4 => ("manager", "Manager"),
|
||||||
|
5 => ("headcoach", "Head Coach"),
|
||||||
|
6 => ("gkcoach", "GK Coach"),
|
||||||
|
7 => ("physio", "Physio"),
|
||||||
|
8 => ("fitnesscoach", "Fitness Coach"),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(pair)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_kind_round_trips_and_defaults_to_player() {
|
||||||
|
assert_eq!(ContentKind::default(), ContentKind::Player);
|
||||||
|
for k in [
|
||||||
|
ContentKind::Player,
|
||||||
|
ContentKind::Consumable,
|
||||||
|
ContentKind::Staff,
|
||||||
|
] {
|
||||||
|
assert_eq!(ContentKind::from_str(k.as_str()), k);
|
||||||
|
}
|
||||||
|
// Unknown / absent tokens fall back to Player (backward compatible).
|
||||||
|
assert_eq!(ContentKind::from_str(""), ContentKind::Player);
|
||||||
|
assert_eq!(ContentKind::from_str("nonsense"), ContentKind::Player);
|
||||||
|
assert_eq!(ContentKind::from_str("player"), ContentKind::Player);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consumable_family_range_boundaries() {
|
||||||
|
// Each contiguous range: lower boundary, upper boundary, family + label.
|
||||||
|
let cases: &[(i64, i64, &str, &str)] = &[
|
||||||
|
(51, 57, "gk_training", "GK Training"),
|
||||||
|
(61, 67, "player_training", "Player Training"),
|
||||||
|
(71, 86, "manager_formation_mod", "Manager Formation"),
|
||||||
|
(91, 110, "position_mod", "Position Modifier"),
|
||||||
|
(121, 136, "formation_mod", "Formation Modifier"),
|
||||||
|
(211, 218, "healing", "Healing"),
|
||||||
|
(250, 268, "player_playstyle", "Player Chemistry Style"),
|
||||||
|
(269, 273, "gk_playstyle", "GK Chemistry Style"),
|
||||||
|
(300, 341, "manager_league", "Manager League Modifier"),
|
||||||
|
];
|
||||||
|
for &(lo, hi, family, label) in cases {
|
||||||
|
assert_eq!(consumable_family(lo), Some((family, label)), "lo {lo}");
|
||||||
|
assert_eq!(consumable_family(hi), Some((family, label)), "hi {hi}");
|
||||||
|
}
|
||||||
|
// Singleton subtypes.
|
||||||
|
assert_eq!(
|
||||||
|
consumable_family(201),
|
||||||
|
Some(("player_contract", "Player Contract"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
consumable_family(202),
|
||||||
|
Some(("manager_contract", "Manager Contract"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
consumable_family(219),
|
||||||
|
Some(("player_fitness", "Player Fitness"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
consumable_family(220),
|
||||||
|
Some(("squad_fitness", "Squad Fitness"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consumable_family_gaps_and_out_of_range_are_none() {
|
||||||
|
// Just outside range edges, and in documented gaps between ranges.
|
||||||
|
for s in [
|
||||||
|
0, 50, 58, 60, 68, 70, 87, 90, 111, 120, 137, 200, 203, 210, 221, 249, 274, 299, 342,
|
||||||
|
999,
|
||||||
|
] {
|
||||||
|
assert_eq!(consumable_family(s), None, "subtype {s} must be unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn staff_role_each_role_and_unknown_is_none() {
|
||||||
|
assert_eq!(staff_role(4), Some(("manager", "Manager")));
|
||||||
|
assert_eq!(staff_role(5), Some(("headcoach", "Head Coach")));
|
||||||
|
assert_eq!(staff_role(6), Some(("gkcoach", "GK Coach")));
|
||||||
|
assert_eq!(staff_role(7), Some(("physio", "Physio")));
|
||||||
|
assert_eq!(staff_role(8), Some(("fitnesscoach", "Fitness Coach")));
|
||||||
|
for s in [0, 1, 2, 3, 9, 10, 201, 300] {
|
||||||
|
assert_eq!(staff_role(s), None, "staff subtype {s} must be unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::fut::content_taxonomy::ContentKind;
|
||||||
use crate::fut::entities::ReverseEntityResolver;
|
use crate::fut::entities::ReverseEntityResolver;
|
||||||
|
|
||||||
/// One owned item in game-independent terms, as read from Core's inventory.
|
/// One owned item in game-independent terms, as read from Core's inventory.
|
||||||
@@ -69,6 +70,15 @@ pub struct Fifa17Identity {
|
|||||||
/// "no real FIFA asset id known" → the caller must not fabricate one.
|
/// "no real FIFA asset id known" → the caller must not fabricate one.
|
||||||
pub trait ItemIdentityResolver {
|
pub trait ItemIdentityResolver {
|
||||||
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
|
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
|
||||||
|
|
||||||
|
/// Classify a Core item's definition as player/consumable/staff. Defaults to
|
||||||
|
/// [`ContentKind::Player`] so existing resolvers keep their behaviour; a
|
||||||
|
/// catalog-backed resolver overrides this to consult its `kind_of`, letting
|
||||||
|
/// `/club` exclude non-player content (which must never render as a
|
||||||
|
/// 0-rated player).
|
||||||
|
fn kind_of(&self, _item: &CoreOwnedItem) -> ContentKind {
|
||||||
|
ContentKind::Player
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Diagnostics from shaping (safe to log — counts only).
|
/// Diagnostics from shaping (safe to log — counts only).
|
||||||
@@ -76,6 +86,9 @@ pub trait ItemIdentityResolver {
|
|||||||
pub struct ShapeStats {
|
pub struct ShapeStats {
|
||||||
pub emitted: usize,
|
pub emitted: usize,
|
||||||
pub dropped_no_asset: usize,
|
pub dropped_no_asset: usize,
|
||||||
|
/// Consumable/staff items excluded from a player projection (they must never
|
||||||
|
/// render as a 0-rated player). Counted, never emitted.
|
||||||
|
pub excluded_non_player: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table;
|
/// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! socket — a Rust UTAS host wires it to Core later.
|
//! socket — a Rust UTAS host wires it to Core later.
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod club_response;
|
pub mod club_response;
|
||||||
|
pub mod content_taxonomy;
|
||||||
pub mod economy;
|
pub mod economy;
|
||||||
pub mod economy_policy;
|
pub mod economy_policy;
|
||||||
pub mod entities;
|
pub mod entities;
|
||||||
|
|||||||
@@ -181,6 +181,23 @@ pub fn plan_apply(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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(),
|
||||||
|
});
|
||||||
|
mappings.push(IdentityMapping {
|
||||||
|
core_id,
|
||||||
|
wire_id: wire,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Canonical squad + opaque extension, built by the SAME adapter code the live
|
// 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
|
// squad-write path uses, over the raw source squad. The resolver maps every
|
||||||
@@ -253,8 +270,14 @@ pub fn plan_apply(
|
|||||||
request,
|
request,
|
||||||
mappings,
|
mappings,
|
||||||
watermark: report.identity.source_watermark,
|
watermark: report.identity.source_watermark,
|
||||||
supported_instances: report.identity.import_wire_ids.len(),
|
supported_instances: report.identity.import_wire_ids.len()
|
||||||
deferred_instances: report.deferred_instances(),
|
+ 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(),
|
source_fingerprint: snapshot_fingerprint.to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub mod apply;
|
|||||||
pub mod model;
|
pub mod model;
|
||||||
|
|
||||||
use model::{Item, Profile};
|
use model::{Item, Profile};
|
||||||
|
use openfut_adapter_fifa17::fut::content_taxonomy::{consumable_family, staff_role, ContentKind};
|
||||||
|
|
||||||
// ----------------------------------------------------------------- roster
|
// ----------------------------------------------------------------- roster
|
||||||
|
|
||||||
@@ -521,6 +522,157 @@ pub fn plan_definitions(
|
|||||||
plan
|
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
|
// --------------------------------------------------------------- identity
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -632,6 +784,8 @@ pub struct Report {
|
|||||||
pub definitions: DefinitionPlan,
|
pub definitions: DefinitionPlan,
|
||||||
pub identity: IdentityPlan,
|
pub identity: IdentityPlan,
|
||||||
pub squad: SquadCoverage,
|
pub squad: SquadCoverage,
|
||||||
|
/// Consumable + staff content (supported definitions + deferred groups).
|
||||||
|
pub non_player: NonPlayerPlan,
|
||||||
/// Unconsumed pack entitlements to seed (from `unopenedPackIds`).
|
/// Unconsumed pack entitlements to seed (from `unopenedPackIds`).
|
||||||
pub unopened_pack_ids: Vec<i64>,
|
pub unopened_pack_ids: Vec<i64>,
|
||||||
}
|
}
|
||||||
@@ -720,6 +874,7 @@ pub fn analyze(
|
|||||||
let identity = plan_identity(profile, &supported_rids);
|
let identity = plan_identity(profile, &supported_rids);
|
||||||
let supported_wire: BTreeSet<i64> = identity.import_wire_ids.iter().copied().collect();
|
let supported_wire: BTreeSet<i64> = identity.import_wire_ids.iter().copied().collect();
|
||||||
let squad = plan_squad(profile, &supported_wire);
|
let squad = plan_squad(profile, &supported_wire);
|
||||||
|
let non_player = plan_non_player_definitions(profile);
|
||||||
Report {
|
Report {
|
||||||
game: "fifa17".to_string(),
|
game: "fifa17".to_string(),
|
||||||
persona_id: profile.persona_id,
|
persona_id: profile.persona_id,
|
||||||
@@ -731,6 +886,7 @@ pub fn analyze(
|
|||||||
definitions,
|
definitions,
|
||||||
identity,
|
identity,
|
||||||
squad,
|
squad,
|
||||||
|
non_player,
|
||||||
unopened_pack_ids: profile.unopened_pack_ids.clone(),
|
unopened_pack_ids: profile.unopened_pack_ids.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -740,6 +896,7 @@ impl std::fmt::Display for Report {
|
|||||||
let d = &self.definitions;
|
let d = &self.definitions;
|
||||||
let id = &self.identity;
|
let id = &self.identity;
|
||||||
let sq = &self.squad;
|
let sq = &self.squad;
|
||||||
|
let np = &self.non_player;
|
||||||
writeln!(f, "OpenFUT FIFA17 real-profile import — analysis")?;
|
writeln!(f, "OpenFUT FIFA17 real-profile import — analysis")?;
|
||||||
writeln!(f, "=============================================")?;
|
writeln!(f, "=============================================")?;
|
||||||
writeln!(
|
writeln!(
|
||||||
@@ -819,11 +976,30 @@ impl std::fmt::Display for Report {
|
|||||||
}
|
}
|
||||||
writeln!(
|
writeln!(
|
||||||
f,
|
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(),
|
id.import_wire_ids.len(),
|
||||||
|
np.supported.iter().map(|d| d.wire_ids.len()).sum::<usize>(),
|
||||||
self.deferred_instances(),
|
self.deferred_instances(),
|
||||||
self.counts.consumables,
|
np.deferred_instances()
|
||||||
self.counts.staff
|
|
||||||
)?;
|
)?;
|
||||||
let blockers = self.blockers();
|
let blockers = self.blockers();
|
||||||
if blockers.is_empty() {
|
if blockers.is_empty() {
|
||||||
@@ -861,6 +1037,10 @@ pub struct EmitSummary {
|
|||||||
pub catalog_entries: usize,
|
pub catalog_entries: usize,
|
||||||
pub supported_instances: usize,
|
pub supported_instances: usize,
|
||||||
pub deferred_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
|
/// 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)?;
|
std::fs::create_dir_all(&manifest_dir)?;
|
||||||
|
|
||||||
// ---- PUBLIC: Core CardDefinition[] (matches openfut-core models::card) ----
|
// ---- PUBLIC: Core CardDefinition[] (matches openfut-core models::card) ----
|
||||||
let defs: Vec<serde_json::Value> = report
|
let mut defs: Vec<serde_json::Value> = report
|
||||||
.definitions
|
.definitions
|
||||||
.supported
|
.supported
|
||||||
.iter()
|
.iter()
|
||||||
@@ -904,15 +1084,58 @@ pub fn emit_content(
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.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");
|
let content_pack = content_dir.join("fifa17-production-cards.json");
|
||||||
write_json_pretty(&content_pack, &defs)?;
|
write_json_pretty(&content_pack, &defs)?;
|
||||||
|
|
||||||
// ---- PUBLIC: host identity catalog {card_id: {asset_id, version, rareflag}} ----
|
// ---- PUBLIC: host identity catalog {card_id: {asset_id, version, rareflag}} ----
|
||||||
let mut cards = serde_json::Map::new();
|
let mut cards = serde_json::Map::new();
|
||||||
for d in &report.definitions.supported {
|
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(
|
cards.insert(
|
||||||
d.card_id.clone(),
|
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!({
|
let catalog = serde_json::json!({
|
||||||
@@ -966,8 +1189,44 @@ pub fn emit_content(
|
|||||||
"distinct_variants": c.distinct.len(),
|
"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 supported_instances = report.identity.import_wire_ids.len();
|
||||||
let deferred_instances = report.deferred_instances();
|
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!({
|
let manifest = serde_json::json!({
|
||||||
"generator": "openfut-import-fifa17",
|
"generator": "openfut-import-fifa17",
|
||||||
"source_kind": "python-profile-observation",
|
"source_kind": "python-profile-observation",
|
||||||
@@ -987,6 +1246,12 @@ pub fn emit_content(
|
|||||||
},
|
},
|
||||||
"supported_definitions": supported,
|
"supported_definitions": supported,
|
||||||
"deferred": deferred,
|
"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");
|
let manifest_path = manifest_dir.join("fifa17-import-manifest.json");
|
||||||
write_json_pretty(&manifest_path, &manifest)?;
|
write_json_pretty(&manifest_path, &manifest)?;
|
||||||
@@ -996,9 +1261,11 @@ pub fn emit_content(
|
|||||||
host_catalog,
|
host_catalog,
|
||||||
manifest: manifest_path,
|
manifest: manifest_path,
|
||||||
definitions: report.definitions.supported.len(),
|
definitions: report.definitions.supported.len(),
|
||||||
catalog_entries: report.definitions.supported.len(),
|
catalog_entries: report.definitions.supported.len() + non_player_definitions,
|
||||||
supported_instances,
|
supported_instances,
|
||||||
deferred_instances,
|
deferred_instances,
|
||||||
|
non_player_definitions,
|
||||||
|
non_player_instances,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ fn run() -> Result<ExitCode> {
|
|||||||
sum.supported_instances,
|
sum.supported_instances,
|
||||||
sum.deferred_instances
|
sum.deferred_instances
|
||||||
);
|
);
|
||||||
|
println!(
|
||||||
|
" non-player : {} definition(s), {} instance(s) (consumable/staff)",
|
||||||
|
sum.non_player_definitions, sum.non_player_instances
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if do_apply {
|
if do_apply {
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ pub struct Item {
|
|||||||
pub league_id: Option<i64>,
|
pub league_id: Option<i64>,
|
||||||
#[serde(rename = "attributeList", default)]
|
#[serde(rename = "attributeList", default)]
|
||||||
pub attribute_list: Option<Vec<Attr>>,
|
pub attribute_list: Option<Vec<Attr>>,
|
||||||
|
/// FIFA `cardsubtypeid` — the consumable family / staff role selector. Absent
|
||||||
|
/// for player cards; present for consumables and staff.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cardsubtypeid: Option<i64>,
|
||||||
|
/// Consumable ART id (small id), distinct from `resourceId`. Permissive.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cardassetid: Option<i64>,
|
||||||
|
/// Consumable stack size (`amount`). Permissive.
|
||||||
|
#[serde(default)]
|
||||||
|
pub amount: Option<i64>,
|
||||||
|
/// Staff/contract `contract` count. Permissive.
|
||||||
|
#[serde(default)]
|
||||||
|
pub contract: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
|||||||
@@ -626,3 +626,259 @@ fn apply_fails_gracefully_when_core_binary_missing() {
|
|||||||
let err = apply_import(&plan, &paths, false).unwrap_err();
|
let err = apply_import(&plan, &paths, false).unwrap_err();
|
||||||
assert!(format!("{err:#}").contains("spawn core import"), "{err:#}");
|
assert!(format!("{err:#}").contains("spawn core import"), "{err:#}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------- non-player content
|
||||||
|
|
||||||
|
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
|
||||||
|
use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind;
|
||||||
|
|
||||||
|
/// A consumable owned item: itemType="player" but NO attributeList; identity is
|
||||||
|
/// carried entirely by resourceId (== assetId == carddbid) + cardsubtypeid.
|
||||||
|
fn consumable(id: i64, resource: i64, subtype: i64) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"id":{id},"resourceId":{resource},"assetId":{resource},"itemType":"player",
|
||||||
|
"cardsubtypeid":{subtype},"cardassetid":3,"amount":1,"rating":0,"rareflag":0}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A staff owned item: itemType="staff", resourceId only (NO assetId), keyed by
|
||||||
|
/// cardsubtypeid.
|
||||||
|
fn staff(id: i64, resource: i64, subtype: i64) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"id":{id},"resourceId":{resource},"itemType":"staff","cardsubtypeid":{subtype},"contract":10}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
|
||||||
|
// The exact record set from the ticket: distinct resourceIds, so each is its
|
||||||
|
// own definition even where two copies share a subtype (54,54 / 100,100 /
|
||||||
|
// 202,202 / staff 8,8) — subtype duplication across DISTINCT definitions is
|
||||||
|
// not a conflict.
|
||||||
|
let consumable_subtypes = [
|
||||||
|
54, 54, 52, 91, 92, 97, 98, 100, 100, 258, 267, 271, 201, 202, 202, 217, 213,
|
||||||
|
];
|
||||||
|
let staff_subtypes = [8i64, 8, 6];
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for (i, &st) in consumable_subtypes.iter().enumerate() {
|
||||||
|
let i = i as i64;
|
||||||
|
items.push(consumable(100_000_200 + i, 5_003_001 + i, st));
|
||||||
|
}
|
||||||
|
for (i, &st) in staff_subtypes.iter().enumerate() {
|
||||||
|
let i = i as i64;
|
||||||
|
items.push(staff(100_000_300 + i, 3_000_001 + i, st));
|
||||||
|
}
|
||||||
|
let plan = plan_non_player_definitions(&profile(&items, "[]", 100000500));
|
||||||
|
assert_eq!(
|
||||||
|
plan.supported.len(),
|
||||||
|
20,
|
||||||
|
"17 consumable + 3 staff definitions"
|
||||||
|
);
|
||||||
|
assert_eq!(plan.consumables, 17);
|
||||||
|
assert_eq!(plan.staff, 3);
|
||||||
|
assert!(plan.deferred.is_empty(), "0 deferred: {:?}", plan.deferred);
|
||||||
|
|
||||||
|
// Honest labels + kinds resolve from the taxonomy (spot checks).
|
||||||
|
let by_id = |cid: &str| plan.supported.iter().find(|d| d.card_id == cid).unwrap();
|
||||||
|
// subtype 201 -> Player Contract (13th consumable, resource 5003013)
|
||||||
|
let contract = by_id("fifa17_5003013");
|
||||||
|
assert_eq!(contract.name, "Player Contract");
|
||||||
|
assert_eq!(contract.kind, ContentKind::Consumable);
|
||||||
|
assert_eq!(contract.subtype, 201);
|
||||||
|
// subtype 258 -> Player Chemistry Style (10th consumable, resource 5003010)
|
||||||
|
assert_eq!(by_id("fifa17_5003010").name, "Player Chemistry Style");
|
||||||
|
// staff subtype 8 -> Fitness Coach; subtype 6 -> GK Coach
|
||||||
|
let fitness = by_id("fifa17_3000001");
|
||||||
|
assert_eq!(fitness.name, "Fitness Coach");
|
||||||
|
assert_eq!(fitness.kind, ContentKind::Staff);
|
||||||
|
assert_eq!(fitness.asset_id, None, "staff carry no assetId");
|
||||||
|
assert_eq!(by_id("fifa17_3000003").name, "GK Coach");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_subtype_consumable_defers_never_fabricated() {
|
||||||
|
let plan = plan_non_player_definitions(&profile(
|
||||||
|
&[consumable(100000300, 5009999, 999)],
|
||||||
|
"[]",
|
||||||
|
100000500,
|
||||||
|
));
|
||||||
|
assert!(plan.supported.is_empty());
|
||||||
|
assert_eq!(plan.deferred.len(), 1);
|
||||||
|
assert_eq!(plan.deferred[0].reason, "unknown_subtype");
|
||||||
|
assert_eq!(plan.deferred[0].subtype, Some(999));
|
||||||
|
assert_eq!(plan.deferred[0].wire_ids, vec![100000300]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_cardsubtypeid_defers() {
|
||||||
|
// itemType player, no attributeList, no cardsubtypeid -> consumable w/o a
|
||||||
|
// resolvable family -> DEFER (never a placeholder).
|
||||||
|
let item =
|
||||||
|
r#"{"id":100000301,"resourceId":5003050,"assetId":5003050,"itemType":"player","rating":0}"#
|
||||||
|
.to_string();
|
||||||
|
let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000500));
|
||||||
|
assert!(plan.supported.is_empty());
|
||||||
|
assert_eq!(plan.deferred.len(), 1);
|
||||||
|
assert_eq!(plan.deferred[0].reason, "missing_cardsubtypeid");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conflicting_subtype_across_copies_defers() {
|
||||||
|
// Two copies of one resourceId that disagree on subtype -> defer, never a
|
||||||
|
// silent winner.
|
||||||
|
let plan = plan_non_player_definitions(&profile(
|
||||||
|
&[
|
||||||
|
consumable(100000302, 5003060, 201),
|
||||||
|
consumable(100000303, 5003060, 202),
|
||||||
|
],
|
||||||
|
"[]",
|
||||||
|
100000500,
|
||||||
|
));
|
||||||
|
assert!(plan.supported.is_empty());
|
||||||
|
assert_eq!(plan.deferred.len(), 1);
|
||||||
|
assert_eq!(plan.deferred[0].reason, "subtype_conflict");
|
||||||
|
assert_eq!(plan.deferred[0].wire_ids, vec![100000302, 100000303]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_player_deferral_is_not_a_blocker() {
|
||||||
|
// A non-player deferral (like a player NoName deferral) must NOT block emit.
|
||||||
|
let items = vec![
|
||||||
|
player(100000001, 20801, 20801, 94),
|
||||||
|
consumable(100000300, 5009999, 999), // unknown subtype -> deferred
|
||||||
|
];
|
||||||
|
let rep = analyze(
|
||||||
|
&profile(&items, "[]", 100000500),
|
||||||
|
&roster(),
|
||||||
|
&entities(),
|
||||||
|
&none(),
|
||||||
|
);
|
||||||
|
assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
|
||||||
|
assert_eq!(rep.non_player.deferred.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
|
||||||
|
let items = vec![
|
||||||
|
player(100000001, 20801, 20801, 94),
|
||||||
|
consumable(100000201, 5003012, 201), // Player Contract
|
||||||
|
staff(100000427, 3000083, 8), // Fitness Coach
|
||||||
|
];
|
||||||
|
let rep = analyze(
|
||||||
|
&profile(&items, "[]", 100000500),
|
||||||
|
&roster(),
|
||||||
|
&entities(),
|
||||||
|
&none(),
|
||||||
|
);
|
||||||
|
assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
|
||||||
|
assert_eq!(rep.non_player.supported.len(), 2);
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let sum = emit_content(&rep, dir.path(), "fp").unwrap();
|
||||||
|
assert_eq!(sum.definitions, 1, "one player definition");
|
||||||
|
assert_eq!(sum.non_player_definitions, 2);
|
||||||
|
assert_eq!(sum.non_player_instances, 2);
|
||||||
|
assert_eq!(
|
||||||
|
sum.catalog_entries, 3,
|
||||||
|
"player + 2 non-player catalog entries"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Content pack: neutral non-player CardDefinition with honest name.
|
||||||
|
let pack: serde_json::Value =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(&sum.content_pack).unwrap()).unwrap();
|
||||||
|
let arr = pack.as_array().unwrap();
|
||||||
|
let cons = arr.iter().find(|c| c["id"] == "fifa17_5003012").unwrap();
|
||||||
|
assert_eq!(cons["name"], "Player Contract");
|
||||||
|
assert_eq!(cons["overall"], 0);
|
||||||
|
assert_eq!(cons["position"], "");
|
||||||
|
assert_eq!(cons["nation"], "");
|
||||||
|
assert_eq!(cons["rarity"], "bronze");
|
||||||
|
assert!(cons["image_path"].is_null());
|
||||||
|
|
||||||
|
// Catalog: kind+subtype on player AND non-player; staff asset falls back to
|
||||||
|
// resourceId; and the emitted catalog LOADS in the adapter with kind_of.
|
||||||
|
let cat: serde_json::Value =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(&sum.host_catalog).unwrap()).unwrap();
|
||||||
|
assert_eq!(cat["cards"]["fifa17_20801"]["kind"], "player");
|
||||||
|
assert_eq!(cat["cards"]["fifa17_20801"]["subtype"], 0);
|
||||||
|
assert_eq!(cat["cards"]["fifa17_5003012"]["kind"], "consumable");
|
||||||
|
assert_eq!(cat["cards"]["fifa17_5003012"]["subtype"], 201);
|
||||||
|
assert_eq!(cat["cards"]["fifa17_5003012"]["rareflag"], 0);
|
||||||
|
assert_eq!(cat["cards"]["fifa17_3000083"]["kind"], "staff");
|
||||||
|
assert_eq!(cat["cards"]["fifa17_3000083"]["subtype"], 8);
|
||||||
|
assert_eq!(cat["cards"]["fifa17_3000083"]["asset_id"], 3000083);
|
||||||
|
|
||||||
|
let loaded = Fifa17CardCatalog::from_file(&sum.host_catalog).unwrap();
|
||||||
|
assert_eq!(loaded.kind_of("fifa17_20801"), ContentKind::Player);
|
||||||
|
assert_eq!(loaded.kind_of("fifa17_5003012"), ContentKind::Consumable);
|
||||||
|
assert_eq!(loaded.subtype_of("fifa17_5003012"), 201);
|
||||||
|
assert_eq!(loaded.kind_of("fifa17_3000083"), ContentKind::Staff);
|
||||||
|
|
||||||
|
// Manifest: private non_player section with preserved wire ids.
|
||||||
|
let man: serde_json::Value =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(&sum.manifest).unwrap()).unwrap();
|
||||||
|
assert_eq!(man["non_player"]["supported_instances"], 2);
|
||||||
|
let np = man["non_player"]["supported_definitions"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(np.len(), 2);
|
||||||
|
let cons_man = np
|
||||||
|
.iter()
|
||||||
|
.find(|d| d["card_id"] == "fifa17_5003012")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cons_man["wire_ids"], serde_json::json!([100000201]));
|
||||||
|
assert_eq!(cons_man["kind"], "consumable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_apply_mints_non_player_owned_instances() {
|
||||||
|
let items = vec![
|
||||||
|
player(100000001, 20801, 20801, 94),
|
||||||
|
consumable(100000201, 5003012, 201),
|
||||||
|
staff(100000427, 3000083, 8),
|
||||||
|
];
|
||||||
|
let (report, raw) = report_and_raw(&items, "[]", 100000500);
|
||||||
|
let plan = plan_apply(&report, &raw, "fp").unwrap();
|
||||||
|
// 1 player + 2 non-player owned instances, minted via the identical path.
|
||||||
|
assert_eq!(plan.request.owned.len(), 3);
|
||||||
|
assert_eq!(plan.mappings.len(), 3);
|
||||||
|
assert_eq!(plan.supported_instances, 3);
|
||||||
|
assert_eq!(plan.deferred_instances, 0);
|
||||||
|
let cards: BTreeSet<&str> = plan
|
||||||
|
.request
|
||||||
|
.owned
|
||||||
|
.iter()
|
||||||
|
.map(|o| o.card_id.as_str())
|
||||||
|
.collect();
|
||||||
|
assert!(cards.contains("fifa17_5003012"), "consumable minted");
|
||||||
|
assert!(cards.contains("fifa17_3000083"), "staff minted");
|
||||||
|
// Deterministic OwnedItemId per (persona, wire) — same rule as players.
|
||||||
|
let m = plan
|
||||||
|
.mappings
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.wire_id == 100000201)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(m.core_id, owned_item_id(33068179, 100000201));
|
||||||
|
|
||||||
|
// Local preflight passes because the emitted content pack contains the
|
||||||
|
// non-player card_ids too.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let sum = emit_content(&report, dir.path(), "fp").unwrap();
|
||||||
|
let ids = content_card_ids(&sum.content_pack).unwrap();
|
||||||
|
local_core_preflight(&plan, &ids).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deferred_non_player_instances_gate_a_production_apply() {
|
||||||
|
// A supported player + a deferred (unknown-subtype) consumable: the deferred
|
||||||
|
// non-player instance blocks a production apply, allowed only for staging.
|
||||||
|
let items = vec![
|
||||||
|
player(100000001, 20801, 20801, 94),
|
||||||
|
consumable(100000300, 5009999, 999),
|
||||||
|
];
|
||||||
|
let (report, raw) = report_and_raw(&items, "[]", 100000500);
|
||||||
|
let plan = plan_apply(&report, &raw, "fp").unwrap();
|
||||||
|
assert_eq!(plan.deferred_instances, 1, "the deferred consumable counts");
|
||||||
|
assert!(gate_staging(&plan, false).is_err(), "production blocks");
|
||||||
|
assert!(gate_staging(&plan, true).unwrap(), "staging opt-in allows");
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPo
|
|||||||
use openfut_adapter_fifa17::fut::club_response::{
|
use openfut_adapter_fifa17::fut::club_response::{
|
||||||
shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats,
|
shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats,
|
||||||
};
|
};
|
||||||
|
use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind;
|
||||||
use openfut_adapter_fifa17::fut::economy_policy::{
|
use openfut_adapter_fifa17::fut::economy_policy::{
|
||||||
match_reward_total, result_from_end_reason, MatchResult,
|
match_reward_total, result_from_end_reason, MatchResult,
|
||||||
};
|
};
|
||||||
@@ -918,6 +919,14 @@ impl ItemIdentityResolver for Fifa17IdentityResolver {
|
|||||||
rareflag: ident.rareflag,
|
rareflag: ident.rareflag,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delegate content classification to the catalog so `/club` excludes
|
||||||
|
/// consumable/staff cards (they must never render as 0-rated players). An
|
||||||
|
/// unmapped card_id resolves to `Player` (the catalog default) but is already
|
||||||
|
/// dropped by `resolve` returning `None`, so it is never emitted anyway.
|
||||||
|
fn kind_of(&self, item: &CoreOwnedItem) -> ContentKind {
|
||||||
|
self.catalog.kind_of(&item.card_id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The same production resolver reverses a wire id to a Core owned-instance id
|
/// The same production resolver reverses a wire id to a Core owned-instance id
|
||||||
|
|||||||
Reference in New Issue
Block a user