feat(fifa17): project owned kits with active home/away designation
Closes the server side of the FUT kit selector. Ownership stays generic in
Core (submodule bump: club_kit_assignments + GET/PUT /club/kits); this
commit adds the FIFA17 representation, the host projection and importer
support.
adapter:
* ContentKind::Kit ("kit") so kits are classified alongside player/staff/
consumable instead of being mistaken for 0-rated players.
* Fifa17CardIdentity carries card_asset_id and team_id; RawCard keeps both
optional because the emitted catalog writes null for non-kit definitions.
* shape_kit_item emits only the fields the client's kit path reads
(id/resourceId/assetId/cardassetid/cardsubtypeid/itemState/owners/
untradeable/teamid) — no attributeList, no itemType.
* itemState on the wire is the STRING token activeHomeKit/activeAwayKit;
the 101/102 integers are the client's post-deserialisation runtime enum
(item+0x5c) and are never emitted.
* club_stats S_KITS (0x28) now counts owned kits instead of a hard zero.
host:
* CoreKitAssignments + CoreAccess::get_active_kits (GET /club/kits),
defaulting to no active kits so a Core without the endpoint degrades
instead of fabricating a designation.
* handle_club classifies type=player|kit, rejects any other type with an
empty page and outcome=unsupported_type, and now always fetches
unpaginated from Core: kind and transfer-pile membership are host-side
concepts Core cannot express, so filtering and pagination must both
happen after shaping or pages come back short.
importer:
* ItemClass::Kit (cardsubtypeid == 9 and resourceId in 6_300_000..=6_400_654),
kit counts/balances, and card_asset_id/team_id carried into the emitted
catalog and manifest.
* a kit group missing cardassetid == 35 or teamid is DEFERRED
(missing_kit_render_metadata) rather than defaulted; conflicting render
metadata across instances defers as render_metadata_conflict.
staging: sold-staging-up.py seeds two owned kits (6300006 home / 6400003
away, team 21) plus both active designations so the projection can be
verified over HTTP before involving the client.
This commit is contained in:
@@ -166,10 +166,14 @@ pub enum ItemClass {
|
||||
PlayerCard,
|
||||
Consumable,
|
||||
Staff,
|
||||
Kit,
|
||||
Other,
|
||||
}
|
||||
|
||||
pub fn classify(item: &Item) -> ItemClass {
|
||||
if item.cardsubtypeid == Some(9) && (6_300_000..=6_400_654).contains(&item.resource_id) {
|
||||
return ItemClass::Kit;
|
||||
}
|
||||
match item.item_type.as_str() {
|
||||
"staff" => ItemClass::Staff,
|
||||
"player" => {
|
||||
@@ -189,12 +193,13 @@ pub struct ItemCounts {
|
||||
pub player_cards: usize,
|
||||
pub consumables: usize,
|
||||
pub staff: usize,
|
||||
pub kits: usize,
|
||||
pub other: usize,
|
||||
}
|
||||
|
||||
impl ItemCounts {
|
||||
pub fn balances(&self) -> bool {
|
||||
self.player_cards + self.consumables + self.staff + self.other == self.total
|
||||
self.player_cards + self.consumables + self.staff + self.kits + self.other == self.total
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +213,7 @@ pub fn count_items(profile: &Profile) -> ItemCounts {
|
||||
ItemClass::PlayerCard => c.player_cards += 1,
|
||||
ItemClass::Consumable => c.consumables += 1,
|
||||
ItemClass::Staff => c.staff += 1,
|
||||
ItemClass::Kit => c.kits += 1,
|
||||
ItemClass::Other => c.other += 1,
|
||||
}
|
||||
}
|
||||
@@ -524,21 +530,23 @@ pub fn plan_definitions(
|
||||
|
||||
// ------------------------------------------------------- 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).
|
||||
/// An honest, profile-derived non-player CardDefinition proposal, keyed by
|
||||
/// `fifa17_<resourceId>`. Neutral player fields are supplied at emit time; FIFA
|
||||
/// render metadata stays here and in the adapter catalog, never generic Core.
|
||||
#[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`.
|
||||
/// Base asset id when the source carries one.
|
||||
pub asset_id: Option<i64>,
|
||||
pub kind: ContentKind,
|
||||
/// FIFA `cardsubtypeid` (consumable family / staff role selector).
|
||||
/// FIFA `cardsubtypeid` (consumable family, staff role, or kit family).
|
||||
pub subtype: i64,
|
||||
/// Honest functional label (e.g. "Player Contract", "GK Coach").
|
||||
/// Non-player card-art id (`35` for kits), when present.
|
||||
pub card_asset_id: Option<i64>,
|
||||
/// Source team id for a kit definition, when present.
|
||||
pub team_id: Option<i64>,
|
||||
/// Honest functional label (e.g. "Player Contract", "GK Coach", "Kit").
|
||||
pub name: String,
|
||||
/// Wire ids of every owned copy of this resourceId (preserved).
|
||||
pub wire_ids: Vec<i64>,
|
||||
@@ -563,6 +571,8 @@ pub struct NonPlayerPlan {
|
||||
pub consumables: usize,
|
||||
/// Count of SUPPORTED staff definitions.
|
||||
pub staff: usize,
|
||||
/// Count of SUPPORTED kit definitions.
|
||||
pub kits: usize,
|
||||
}
|
||||
|
||||
impl NonPlayerPlan {
|
||||
@@ -572,15 +582,16 @@ impl NonPlayerPlan {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Plan non-player CardDefinitions. Consumable, Staff, and Kit groups must agree
|
||||
/// on their definition-level metadata across every owned copy; disagreement or
|
||||
/// missing required metadata defers the whole group, never fabricates a value.
|
||||
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) {
|
||||
if matches!(
|
||||
classify(it),
|
||||
ItemClass::Consumable | ItemClass::Staff | ItemClass::Kit
|
||||
) {
|
||||
groups.entry(it.resource_id).or_default().push(it);
|
||||
}
|
||||
}
|
||||
@@ -622,6 +633,21 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
||||
continue;
|
||||
};
|
||||
|
||||
let card_asset_id = items[0].cardassetid;
|
||||
let team_id = items[0].teamid;
|
||||
if items
|
||||
.iter()
|
||||
.any(|item| item.cardassetid != card_asset_id || item.teamid != team_id)
|
||||
{
|
||||
plan.deferred.push(DeferredNonPlayer {
|
||||
resource_id,
|
||||
subtype: Some(subtype),
|
||||
wire_ids,
|
||||
reason: "render_metadata_conflict".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let (kind, label) = match class {
|
||||
ItemClass::Consumable => match consumable_family(subtype) {
|
||||
Some((_family, label)) => (ContentKind::Consumable, label),
|
||||
@@ -647,15 +673,33 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
||||
continue;
|
||||
}
|
||||
},
|
||||
_ => unreachable!("only Consumable/Staff were grouped"),
|
||||
ItemClass::Kit if subtype == 9 => {
|
||||
if card_asset_id != Some(35) || team_id.is_none() {
|
||||
plan.deferred.push(DeferredNonPlayer {
|
||||
resource_id,
|
||||
subtype: Some(subtype),
|
||||
wire_ids,
|
||||
reason: "missing_kit_render_metadata".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
(ContentKind::Kit, "Kit")
|
||||
}
|
||||
_ => unreachable!("only Consumable/Staff/Kit were grouped"),
|
||||
};
|
||||
|
||||
plan.supported.push(NonPlayerDefinition {
|
||||
card_id: format!("fifa17_{resource_id}"),
|
||||
resource_id,
|
||||
asset_id: items[0].asset_id,
|
||||
asset_id: if class == ItemClass::Kit {
|
||||
Some(resource_id)
|
||||
} else {
|
||||
items[0].asset_id
|
||||
},
|
||||
kind,
|
||||
subtype,
|
||||
card_asset_id,
|
||||
team_id,
|
||||
name: label.to_string(),
|
||||
wire_ids,
|
||||
});
|
||||
@@ -670,6 +714,11 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
||||
.iter()
|
||||
.filter(|d| d.kind == ContentKind::Staff)
|
||||
.count();
|
||||
plan.kits = plan
|
||||
.supported
|
||||
.iter()
|
||||
.filter(|definition| definition.kind == ContentKind::Kit)
|
||||
.count();
|
||||
plan
|
||||
}
|
||||
|
||||
@@ -815,10 +864,11 @@ impl Report {
|
||||
let mut b = Vec::new();
|
||||
if !self.counts.balances() {
|
||||
b.push(format!(
|
||||
"item-type accounting does not balance ({}+{}+{}+{} != {})",
|
||||
"item-type accounting does not balance ({}+{}+{}+{}+{} != {})",
|
||||
self.counts.player_cards,
|
||||
self.counts.consumables,
|
||||
self.counts.staff,
|
||||
self.counts.kits,
|
||||
self.counts.other,
|
||||
self.counts.total
|
||||
));
|
||||
@@ -911,11 +961,12 @@ impl std::fmt::Display for Report {
|
||||
)?;
|
||||
writeln!(
|
||||
f,
|
||||
"\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} other={} balances={}",
|
||||
"\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} kits={} other={} balances={}",
|
||||
self.counts.total,
|
||||
self.counts.player_cards,
|
||||
self.counts.consumables,
|
||||
self.counts.staff,
|
||||
self.counts.kits,
|
||||
self.counts.other,
|
||||
self.counts.balances()
|
||||
)?;
|
||||
@@ -976,10 +1027,11 @@ impl std::fmt::Display for Report {
|
||||
}
|
||||
writeln!(
|
||||
f,
|
||||
"\nNON-PLAYER CONTENT (consumable/staff) supported={} (consumables={} staff={}) deferred_groups={} deferred_instances={}",
|
||||
"\nNON-PLAYER CONTENT (consumable/staff/kit) supported={} (consumables={} staff={} kits={}) deferred_groups={} deferred_instances={}",
|
||||
np.supported.len(),
|
||||
np.consumables,
|
||||
np.staff,
|
||||
np.kits,
|
||||
np.deferred.len(),
|
||||
np.deferred_instances()
|
||||
)?;
|
||||
@@ -1130,6 +1182,8 @@ pub fn emit_content(
|
||||
cards.insert(
|
||||
d.card_id.clone(),
|
||||
serde_json::json!({
|
||||
"card_asset_id": d.card_asset_id,
|
||||
"team_id": d.team_id,
|
||||
"asset_id": d.asset_id.unwrap_or(d.resource_id),
|
||||
"version": 0,
|
||||
"rareflag": 0,
|
||||
@@ -1195,6 +1249,8 @@ pub fn emit_content(
|
||||
.iter()
|
||||
.map(|d| {
|
||||
serde_json::json!({
|
||||
"card_asset_id": d.card_asset_id,
|
||||
"team_id": d.team_id,
|
||||
"card_id": d.card_id,
|
||||
"resource_id": d.resource_id,
|
||||
"asset_id": d.asset_id,
|
||||
|
||||
@@ -76,6 +76,9 @@ pub struct Item {
|
||||
/// Staff/contract `contract` count. Permissive.
|
||||
#[serde(default)]
|
||||
pub contract: Option<i64>,
|
||||
/// Owned-item lifecycle state (`activeHomeKit` / `activeAwayKit` for kits).
|
||||
#[serde(rename = "itemState", default)]
|
||||
pub item_state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
||||
@@ -72,11 +72,21 @@ fn classification_balances_across_disjoint_classes() {
|
||||
player(100000002, VER5_176580, 176580, 92),
|
||||
r#"{"id":100000239,"resourceId":5003012,"assetId":5003012,"itemType":"player","rating":85}"#.to_string(),
|
||||
r#"{"id":100000427,"resourceId":3000083,"itemType":"staff"}"#.to_string(),
|
||||
r#"{"id":100000500,"resourceId":6300006,"assetId":6300006,
|
||||
"cardsubtypeid":9,"cardassetid":35,"teamid":21,"itemState":"activeHomeKit"}"#
|
||||
.to_string(),
|
||||
];
|
||||
let c = count_items(&profile(&items, "[]", 100000500));
|
||||
let c = count_items(&profile(&items, "[]", 100000501));
|
||||
assert_eq!(
|
||||
(c.total, c.player_cards, c.consumables, c.staff, c.other),
|
||||
(4, 2, 1, 1, 0)
|
||||
(
|
||||
c.total,
|
||||
c.player_cards,
|
||||
c.consumables,
|
||||
c.staff,
|
||||
c.kits,
|
||||
c.other
|
||||
),
|
||||
(5, 2, 1, 1, 1, 0)
|
||||
);
|
||||
assert!(c.balances());
|
||||
}
|
||||
@@ -649,6 +659,14 @@ fn staff(id: i64, resource: i64, subtype: i64) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn kit(id: i64, resource: i64, team_id: i64, item_state: &str) -> String {
|
||||
format!(
|
||||
r#"{{"id":{id},"resourceId":{resource},"assetId":{resource},
|
||||
"cardsubtypeid":9,"cardassetid":35,"teamid":{team_id},
|
||||
"itemState":"{item_state}","owners":1,"untradeable":false}}"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
|
||||
// The exact record set from the ticket: distinct resourceIds, so each is its
|
||||
@@ -676,6 +694,7 @@ fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
|
||||
);
|
||||
assert_eq!(plan.consumables, 17);
|
||||
assert_eq!(plan.staff, 3);
|
||||
assert_eq!(plan.kits, 0);
|
||||
assert!(plan.deferred.is_empty(), "0 deferred: {:?}", plan.deferred);
|
||||
|
||||
// Honest labels + kinds resolve from the taxonomy (spot checks).
|
||||
@@ -695,6 +714,17 @@ fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
|
||||
assert_eq!(by_id("fifa17_3000003").name, "GK Coach");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kit_missing_render_metadata_defers() {
|
||||
let item = r#"{"id":100000501,"resourceId":6300006,"assetId":6300006,
|
||||
"cardsubtypeid":9,"itemState":"activeHomeKit"}"#
|
||||
.to_string();
|
||||
let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000600));
|
||||
assert!(plan.supported.is_empty());
|
||||
assert_eq!(plan.deferred.len(), 1);
|
||||
assert_eq!(plan.deferred[0].reason, "missing_kit_render_metadata");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_subtype_consumable_defers_never_fabricated() {
|
||||
let plan = plan_non_player_definitions(&profile(
|
||||
@@ -763,6 +793,7 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
|
||||
player(100000001, 20801, 20801, 94),
|
||||
consumable(100000201, 5003012, 201), // Player Contract
|
||||
staff(100000427, 3000083, 8), // Fitness Coach
|
||||
kit(100000500, 6300006, 21, "activeHomeKit"),
|
||||
];
|
||||
let rep = analyze(
|
||||
&profile(&items, "[]", 100000500),
|
||||
@@ -771,16 +802,16 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
|
||||
&none(),
|
||||
);
|
||||
assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
|
||||
assert_eq!(rep.non_player.supported.len(), 2);
|
||||
assert_eq!(rep.non_player.supported.len(), 3);
|
||||
|
||||
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.non_player_definitions, 3);
|
||||
assert_eq!(sum.non_player_instances, 3);
|
||||
assert_eq!(
|
||||
sum.catalog_entries, 3,
|
||||
"player + 2 non-player catalog entries"
|
||||
sum.catalog_entries, 4,
|
||||
"player + 3 non-player catalog entries"
|
||||
);
|
||||
|
||||
// Content pack: neutral non-player CardDefinition with honest name.
|
||||
@@ -807,21 +838,29 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
|
||||
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);
|
||||
assert_eq!(cat["cards"]["fifa17_6300006"]["kind"], "kit");
|
||||
assert_eq!(cat["cards"]["fifa17_6300006"]["subtype"], 9);
|
||||
assert_eq!(cat["cards"]["fifa17_6300006"]["card_asset_id"], 35);
|
||||
assert_eq!(cat["cards"]["fifa17_6300006"]["team_id"], 21);
|
||||
|
||||
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);
|
||||
assert_eq!(loaded.kind_of("fifa17_6300006"), ContentKind::Kit);
|
||||
let loaded_kit = loaded.lookup("fifa17_6300006").unwrap();
|
||||
assert_eq!(loaded_kit.card_asset_id, 35);
|
||||
assert_eq!(loaded_kit.team_id, 21);
|
||||
|
||||
// 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);
|
||||
assert_eq!(man["non_player"]["supported_instances"], 3);
|
||||
let np = man["non_player"]["supported_definitions"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
assert_eq!(np.len(), 2);
|
||||
assert_eq!(np.len(), 3);
|
||||
let cons_man = np
|
||||
.iter()
|
||||
.find(|d| d["card_id"] == "fifa17_5003012")
|
||||
@@ -836,13 +875,14 @@ fn plan_apply_mints_non_player_owned_instances() {
|
||||
player(100000001, 20801, 20801, 94),
|
||||
consumable(100000201, 5003012, 201),
|
||||
staff(100000427, 3000083, 8),
|
||||
kit(100000500, 6300006, 21, "activeHomeKit"),
|
||||
];
|
||||
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);
|
||||
// Player, consumable, staff, and kit instances mint through one generic path.
|
||||
assert_eq!(plan.request.owned.len(), 4);
|
||||
assert_eq!(plan.mappings.len(), 4);
|
||||
assert_eq!(plan.supported_instances, 4);
|
||||
assert_eq!(plan.deferred_instances, 0);
|
||||
let cards: BTreeSet<&str> = plan
|
||||
.request
|
||||
@@ -852,6 +892,7 @@ fn plan_apply_mints_non_player_owned_instances() {
|
||||
.collect();
|
||||
assert!(cards.contains("fifa17_5003012"), "consumable minted");
|
||||
assert!(cards.contains("fifa17_3000083"), "staff minted");
|
||||
assert!(cards.contains("fifa17_6300006"), "kit minted");
|
||||
// Deterministic OwnedItemId per (persona, wire) — same rule as players.
|
||||
let m = plan
|
||||
.mappings
|
||||
|
||||
Reference in New Issue
Block a user