db743ffd1f
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.
349 lines
12 KiB
Rust
349 lines
12 KiB
Rust
//! Shape OpenFUT Core's semantic owned inventory into the FIFA 17 `/club`
|
|
//! response envelope `{"itemData":[ <player item>, … ]}`.
|
|
//!
|
|
//! This module owns only the **`/club` envelope**; the per-item shape lives in
|
|
//! the shared [`crate::fut::item`] primitive so `/club` and squad projection
|
|
//! emit byte-identical items. Items whose real FIFA asset id is unknown are
|
|
//! **dropped and counted** here (a collection may omit an unrenderable card);
|
|
//! squad projection, which cannot omit a starter, refuses instead.
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
use crate::fut::content_taxonomy::ContentKind;
|
|
use crate::fut::entities::ReverseEntityResolver;
|
|
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, Fifa17KitIdentity, ItemIdentityResolver, ShapeStats,
|
|
};
|
|
|
|
/// 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 {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
(json!({ "itemData": out }), stats)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::fut::entities::Fifa17Entities;
|
|
use std::collections::HashMap;
|
|
|
|
fn entities() -> Fifa17Entities {
|
|
Fifa17Entities::from_maps(
|
|
HashMap::from([(13, "Premier League".to_string())]),
|
|
HashMap::from([(52, "Argentina".to_string())]),
|
|
HashMap::from([(5, "Chelsea".to_string())]),
|
|
)
|
|
}
|
|
|
|
fn item(
|
|
owned: &str,
|
|
card: &str,
|
|
rating: u8,
|
|
pos: &str,
|
|
nation: &str,
|
|
league: &str,
|
|
club: &str,
|
|
) -> CoreOwnedItem {
|
|
CoreOwnedItem {
|
|
owned_card_id: owned.into(),
|
|
card_id: card.into(),
|
|
rating,
|
|
position: pos.into(),
|
|
nation: nation.into(),
|
|
league: league.into(),
|
|
club: club.into(),
|
|
attributes: [90, 88, 70, 85, 40, 78],
|
|
}
|
|
}
|
|
|
|
/// Test resolver: card_id -> real asset id, item_id from a table. Stands in
|
|
/// for the (unresolved-in-production) Core-card→asset mapping.
|
|
struct MapIdentity(HashMap<String, Fifa17Identity>);
|
|
impl ItemIdentityResolver for MapIdentity {
|
|
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
|
self.0.get(&it.card_id).copied()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn shapes_item_with_full_field_set_and_reverse_ids() {
|
|
let ent = entities();
|
|
let ident = MapIdentity(HashMap::from([(
|
|
"card_ch_1".to_string(),
|
|
Fifa17Identity {
|
|
item_id: 100000001,
|
|
asset_id: 20801,
|
|
resource_id: 20801,
|
|
rareflag: 1,
|
|
},
|
|
)]));
|
|
let items = vec![item(
|
|
"oc1",
|
|
"card_ch_1",
|
|
86,
|
|
"CDM",
|
|
"Argentina",
|
|
"Premier League",
|
|
"Chelsea",
|
|
)];
|
|
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
|
assert_eq!(stats.emitted, 1);
|
|
assert_eq!(stats.dropped_no_asset, 0);
|
|
let it = &body["itemData"][0];
|
|
assert_eq!(it["id"], 100000001);
|
|
assert_eq!(it["resourceId"], 20801);
|
|
assert_eq!(it["assetId"], 20801);
|
|
assert_eq!(
|
|
it["definitionId"], 20801,
|
|
"version byte 0 => resourceId==assetId==definitionId"
|
|
);
|
|
assert_eq!(it["rating"], 86);
|
|
assert_eq!(it["preferredPosition"], "CDM");
|
|
assert_eq!(it["leagueId"], 13);
|
|
assert_eq!(it["teamid"], 5);
|
|
assert_eq!(it["nation"], 52);
|
|
assert_eq!(it["itemType"], "player");
|
|
assert_eq!(it["rareflag"], 1);
|
|
assert_eq!(it["contract"], 7);
|
|
assert_eq!(it["fitness"], 99);
|
|
assert_eq!(it["attributeList"].as_array().unwrap().len(), 6);
|
|
assert_eq!(it["attributeList"][0], json!({"index":0,"value":90}));
|
|
}
|
|
|
|
#[test]
|
|
fn drops_items_without_a_real_asset_id_never_faking() {
|
|
let ent = entities();
|
|
// Empty identity map == the current synthetic-catalogue reality.
|
|
let ident = MapIdentity(HashMap::new());
|
|
let items = vec![item(
|
|
"oc1",
|
|
"card_pl_001",
|
|
84,
|
|
"ST",
|
|
"England",
|
|
"Premier League",
|
|
"Northgate United",
|
|
)];
|
|
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
|
assert_eq!(stats.emitted, 0);
|
|
assert_eq!(stats.dropped_no_asset, 1);
|
|
assert_eq!(
|
|
body["itemData"].as_array().unwrap().len(),
|
|
0,
|
|
"no fabricated ids emitted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unresolved_entity_names_become_neutral_zero_not_dropped() {
|
|
let ent = entities();
|
|
let ident = MapIdentity(HashMap::from([(
|
|
"card_x".to_string(),
|
|
Fifa17Identity {
|
|
item_id: 100000002,
|
|
asset_id: 158023,
|
|
resource_id: 158023,
|
|
rareflag: 1,
|
|
},
|
|
)]));
|
|
// Synthetic club "Northgate United" has no FIFA team id.
|
|
let items = vec![item(
|
|
"oc2",
|
|
"card_x",
|
|
84,
|
|
"ST",
|
|
"England",
|
|
"Premier League",
|
|
"Northgate United",
|
|
)];
|
|
let (body, _) = shape_club_response(&items, &ent, &ident);
|
|
let it = &body["itemData"][0];
|
|
assert_eq!(
|
|
it["teamid"], 0,
|
|
"unknown club -> neutral 0, item still emitted"
|
|
);
|
|
assert_eq!(it["leagueId"], 13);
|
|
assert_eq!(it["nation"], 0, "England not in the test nation map -> 0");
|
|
}
|
|
|
|
#[test]
|
|
fn envelope_is_itemdata_object() {
|
|
let ent = entities();
|
|
let ident = MapIdentity(HashMap::new());
|
|
let (body, _) = shape_club_response(&[], &ent, &ident);
|
|
assert!(body.get("itemData").unwrap().is_array());
|
|
assert_eq!(
|
|
body.as_object().unwrap().len(),
|
|
1,
|
|
"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>,
|
|
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)
|
|
.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)),
|
|
]),
|
|
kits: HashMap::new(),
|
|
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");
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|