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:
funman300
2026-08-21 03:17:57 +00:00
parent ab62440dbf
commit db743ffd1f
13 changed files with 616 additions and 169 deletions
+21 -1
View File
@@ -38,6 +38,11 @@ pub struct Fifa17CardIdentity {
/// FIFA `cardsubtypeid` for a non-player definition (consumable family /
/// staff role), `0` for a player or when absent.
pub subtype: i64,
/// FIFA card-art class. Players default to `asset_id`; kit definitions carry
/// the verified `fcc_kitcards.cardassetid` value (`35`).
pub card_asset_id: u32,
/// Source team id for a club kit. Zero for content kinds that do not use it.
pub team_id: i64,
}
/// The FIFA 17 numeric namespace policy for owned-item wire ids.
@@ -131,6 +136,12 @@ struct RawCard {
/// FIFA `cardsubtypeid` for a non-player entry; absent → `0`.
#[serde(default)]
subtype: i64,
/// Separate card-art id for non-player definitions; absent → `asset_id`.
#[serde(default)]
card_asset_id: Option<u32>,
/// Source team id for a kit definition; absent → `0`.
#[serde(default)]
team_id: Option<i64>,
}
fn default_rareflag() -> i64 {
@@ -187,6 +198,8 @@ impl Fifa17CardCatalog {
rareflag: rc.rareflag,
kind: ContentKind::from_str(&rc.kind),
subtype: rc.subtype,
card_asset_id: rc.card_asset_id.unwrap_or(rc.asset_id),
team_id: rc.team_id.unwrap_or(0),
},
);
}
@@ -397,7 +410,9 @@ mod tests {
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}
"fifa17_3000083":{"asset_id":3000083,"kind":"staff","subtype":8,"rareflag":0},
"fifa17_6300006":{"asset_id":6300006,"kind":"kit","subtype":9,
"card_asset_id":35,"team_id":21,"rareflag":0}
}}"#,
)
.unwrap();
@@ -407,5 +422,10 @@ mod tests {
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);
let kit = cat.lookup("fifa17_6300006").unwrap();
assert_eq!(kit.kind, ContentKind::Kit);
assert_eq!(kit.subtype, 9);
assert_eq!(kit.card_asset_id, 35);
assert_eq!(kit.team_id, 21);
}
}
+98 -15
View File
@@ -11,33 +11,65 @@ use serde_json::{json, Value};
use crate::fut::content_taxonomy::ContentKind;
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item::shape_item;
use crate::fut::item::{shape_item, shape_kit_item};
// Re-exported so existing `club_response::{…}` callers keep working; the types
// are now defined once in `fut::item`.
pub use crate::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats};
pub use crate::fut::item::{
CoreOwnedItem, Fifa17Identity, Fifa17KitIdentity, ItemIdentityResolver, ShapeStats,
};
/// Shape the whole `/club` response. Items without a resolvable real asset id
/// are dropped (counted in `ShapeStats`), never emitted with a fabricated id.
/// Active club-level kit roles, keyed by Core owned-instance id.
#[derive(Debug, Clone, Copy, Default)]
pub struct ActiveKitAssignments<'a> {
pub home: Option<&'a str>,
pub away: Option<&'a str>,
}
/// Shape the player portion of `/club` (the historical/default query).
pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
items: &[CoreOwnedItem],
ent: &impl ReverseEntityResolver,
ident: &I,
) -> (Value, ShapeStats) {
shape_club_response_with_kits(items, ent, ident, ActiveKitAssignments::default())
}
/// Shape `/club` items, including ownership-backed active kit designations.
/// Consumables/staff remain excluded because they use separate wire envelopes.
pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
items: &[CoreOwnedItem],
ent: &impl ReverseEntityResolver,
ident: &I,
active_kits: ActiveKitAssignments<'_>,
) -> (Value, ShapeStats) {
let mut out = Vec::with_capacity(items.len());
let mut stats = ShapeStats::default();
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) {
Some(id) => {
out.push(shape_item(item, id, ent));
stats.emitted += 1;
match ident.kind_of(item) {
ContentKind::Player => match ident.resolve(item) {
Some(id) => {
out.push(shape_item(item, id, ent));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
},
ContentKind::Kit => match ident.resolve_kit(item) {
Some(id) => {
let item_state = if active_kits.home == Some(item.owned_card_id.as_str()) {
"activeHomeKit"
} else if active_kits.away == Some(item.owned_card_id.as_str()) {
"activeAwayKit"
} else {
"free"
};
out.push(shape_kit_item(id, item_state));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
},
ContentKind::Consumable | ContentKind::Staff => {
stats.excluded_non_player += 1;
}
None => stats.dropped_no_asset += 1,
}
}
(json!({ "itemData": out }), stats)
@@ -207,11 +239,15 @@ mod tests {
struct KindMapIdentity {
ids: HashMap<String, Fifa17Identity>,
kinds: HashMap<String, ContentKind>,
kits: HashMap<String, Fifa17KitIdentity>,
}
impl ItemIdentityResolver for KindMapIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.ids.get(&it.card_id).copied()
}
fn resolve_kit(&self, it: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
self.kits.get(&it.card_id).copied()
}
fn kind_of(&self, it: &CoreOwnedItem) -> ContentKind {
self.kinds
.get(&it.card_id)
@@ -235,6 +271,7 @@ mod tests {
("card_consumable".to_string(), id(100000002, 5003012)),
("card_staff".to_string(), id(100000003, 3000083)),
]),
kits: HashMap::new(),
kinds: HashMap::from([
("card_consumable".to_string(), ContentKind::Consumable),
("card_staff".to_string(), ContentKind::Staff),
@@ -262,4 +299,50 @@ mod tests {
assert_eq!(arr[0]["id"], 100000001, "the player survives");
assert_eq!(arr[0]["itemType"], "player");
}
#[test]
fn kits_project_with_owned_active_home_and_away_states() {
let ent = entities();
let kit = |item_id, resource_id, team_id| Fifa17KitIdentity {
item_id,
asset_id: resource_id,
resource_id,
card_asset_id: 35,
subtype: 9,
team_id,
};
let ident = KindMapIdentity {
ids: HashMap::new(),
kits: HashMap::from([
("kit-home".into(), kit(100000010, 6300006, 21)),
("kit-away".into(), kit(100000011, 6400003, 21)),
]),
kinds: HashMap::from([
("kit-home".into(), ContentKind::Kit),
("kit-away".into(), ContentKind::Kit),
]),
};
let items = vec![
item("owned-home", "kit-home", 0, "", "", "", ""),
item("owned-away", "kit-away", 0, "", "", "", ""),
];
let (body, stats) = shape_club_response_with_kits(
&items,
&ent,
&ident,
ActiveKitAssignments {
home: Some("owned-home"),
away: Some("owned-away"),
},
);
assert_eq!(stats.emitted, 2);
assert_eq!(body["itemData"][0]["resourceId"], 6300006);
assert_eq!(body["itemData"][0]["cardassetid"], 35);
assert_eq!(body["itemData"][0]["cardsubtypeid"], 9);
assert_eq!(body["itemData"][0]["teamid"], 21);
assert_eq!(body["itemData"][0]["itemState"], "activeHomeKit");
assert_eq!(body["itemData"][1]["itemState"], "activeAwayKit");
assert!(body["itemData"][0].get("attributeList").is_none());
assert!(body["itemData"][0].get("itemType").is_none());
}
}
+31 -3
View File
@@ -6,7 +6,8 @@
//! (`FUN_18012fd40` atom table). The body is `{"stat":[{contextId,contextValue,
//! type,typeValue}, …]}`:
//! * a GLOBAL bucket (contextId 1, contextValue 0) with player tier counts,
//! staff-by-family, consumables-by-family, and honest zeros for club items;
//! staff/consumable families, owned-kit count, and honest zeros for other
//! club-item families;
//! * per-NATION buckets (contextId 3, contextValue = nation id) with the tier
//! counts the MY CLUB summary panel sums into PLAYERS_EMPLOYED.
//!
@@ -215,12 +216,20 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
}
g.insert(S_CONSUMABLES, cons_total);
// club items: honest zeros (Core holds none; each is read by some panel).
// Club items: kits are Core-owned and counted; unimplemented families stay
// honest zeros.
for sid in [
0x14, 0x1E, 0x28, 0x29, 0x2A, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
0x14, 0x1E, 0x29, 0x2A, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
] {
g.entry(sid).or_insert(0);
}
g.insert(
S_KITS,
items
.iter()
.filter(|item| matches!(item.kind, ContentKind::Kit))
.count() as i64,
);
let mut stat: Vec<Value> = g.iter().map(|(sid, v)| row(1, 0, *sid, *v)).collect();
@@ -297,6 +306,17 @@ mod tests {
team_id: None,
}
}
fn kit() -> ClubStatInput {
ClubStatInput {
kind: ContentKind::Kit,
subtype: 9,
rating: 0,
rare: false,
nation_id: None,
league_id: None,
team_id: Some(21),
}
}
fn global(body: &Value) -> std::collections::HashMap<String, i64> {
body["stat"]
@@ -355,6 +375,14 @@ mod tests {
assert_eq!(g["consumablesTrainingPlayerPlayStyle"], 1);
}
#[test]
fn owned_kits_increment_global_kit_count() {
let items = vec![player(90, false, None), kit(), kit()];
let g = global(&club_stats_body(&items, ContextField::Nation));
assert_eq!(g["players"], 1);
assert_eq!(g["kits"], 2);
}
#[test]
fn nation_buckets_emitted_and_players_excludes_nonplayers() {
let items = vec![
@@ -18,7 +18,7 @@
//! 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 disjoint content classes a FIFA 17 owned item 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)]
@@ -27,6 +27,7 @@ pub enum ContentKind {
Player,
Consumable,
Staff,
Kit,
}
impl ContentKind {
@@ -36,6 +37,7 @@ impl ContentKind {
ContentKind::Player => "player",
ContentKind::Consumable => "consumable",
ContentKind::Staff => "staff",
ContentKind::Kit => "kit",
}
}
@@ -49,6 +51,7 @@ impl ContentKind {
match s {
"consumable" => ContentKind::Consumable,
"staff" => ContentKind::Staff,
"kit" => ContentKind::Kit,
_ => ContentKind::Player,
}
}
@@ -106,6 +109,7 @@ mod tests {
ContentKind::Player,
ContentKind::Consumable,
ContentKind::Staff,
ContentKind::Kit,
] {
assert_eq!(ContentKind::from_str(k.as_str()), k);
}
+35
View File
@@ -66,11 +66,29 @@ pub struct Fifa17Identity {
pub rareflag: i64,
}
/// FIFA-side identity fields needed to render an owned club kit. Unlike player
/// items, kit art and source-team metadata come from `fcc_kitcards`, not Core.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fifa17KitIdentity {
pub item_id: u32,
pub asset_id: u32,
pub resource_id: u32,
pub card_asset_id: u32,
pub subtype: i64,
pub team_id: i64,
}
/// Supplies the FIFA numeric identity for a Core item. Returning `None` means
/// "no real FIFA asset id known" → the caller must not fabricate one.
pub trait ItemIdentityResolver {
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
/// Resolve one owned kit definition. Default `None` preserves existing
/// player-only resolvers; the catalog-backed FIFA17 resolver overrides it.
fn resolve_kit(&self, _item: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
None
}
/// 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
@@ -158,6 +176,23 @@ pub fn shape_item(
})
}
/// Build one FIFA 17 club-kit item. `item_state` is the proven wire enum token:
/// `free`, `activeHomeKit`, or `activeAwayKit`; the client deserializes the
/// latter two to runtime values 101 and 102.
pub fn shape_kit_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
json!({
"id": id.item_id,
"resourceId": id.resource_id,
"assetId": id.asset_id,
"cardassetid": id.card_asset_id,
"cardsubtypeid": id.subtype,
"itemState": item_state,
"owners": 1,
"untradeable": false,
"teamid": id.team_id,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -48,6 +48,9 @@ use std::collections::HashMap;
/// fields are FIFA entity ids that MUST be resolved before reaching Core.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Fifa17ClubQuery {
/// Requested FIFA item family (`player`, `kit`, …). Adapter-owned: Core has
/// no FIFA content taxonomy, so the host applies this filter locally.
pub item_type: Option<String>,
/// Quality filter: `any` (default, always present) or `gold`.
pub level: Option<String>,
/// "Special" filter (`SP`). Semantics UNKNOWN — never applied.
@@ -115,6 +118,7 @@ pub fn parse_club_query(query: &str) -> Fifa17ClubQuery {
None => (pair, String::new()),
};
match k {
"type" => out.item_type = Some(v),
"level" => out.level = Some(v),
"rare" => out.rare = Some(v),
"position" => out.position = Some(v),
@@ -325,6 +329,7 @@ mod tests {
assert_eq!(
q,
Fifa17ClubQuery {
item_type: Some("player".into()),
level: Some("gold".into()),
rare: None,
position: Some("ST".into()),