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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
+1
-1
Submodule openfut-core updated: 2fb835200f...f0550e2ae1
@@ -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
|
||||
|
||||
+149
-106
@@ -51,7 +51,8 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy};
|
||||
use openfut_adapter_fifa17::fut::club_response::{
|
||||
shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats,
|
||||
shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17Identity,
|
||||
Fifa17KitIdentity, ItemIdentityResolver,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput, ContextField};
|
||||
use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind;
|
||||
@@ -636,6 +637,13 @@ pub struct CoreReplaceResult {
|
||||
pub slots_written: usize,
|
||||
}
|
||||
|
||||
/// Core owned-instance ids assigned to the club's two active kit roles.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct CoreKitAssignments {
|
||||
pub home_owned_card_id: Option<String>,
|
||||
pub away_owned_card_id: Option<String>,
|
||||
}
|
||||
|
||||
/// How the host reaches Core. The adapter never sees this — the host owns the
|
||||
/// transport, mirroring the architecture rule. Tests inject a fake.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -698,6 +706,12 @@ pub trait CoreAccess: Send + Sync {
|
||||
))
|
||||
}
|
||||
|
||||
/// Ownership-backed active home/away kit ids (`GET /club/kits`). A Core
|
||||
/// without the endpoint projects no active kits rather than fabricating one.
|
||||
fn get_active_kits(&self) -> Result<CoreKitAssignments, CoreError> {
|
||||
Ok(CoreKitAssignments::default())
|
||||
}
|
||||
|
||||
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
|
||||
Err(CoreError::Parse(
|
||||
"Core SBC access is not implemented".into(),
|
||||
@@ -872,6 +886,32 @@ impl CoreAccess for HttpCoreClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_active_kits(&self) -> Result<CoreKitAssignments, CoreError> {
|
||||
let response = self
|
||||
.client
|
||||
.get(format!("{}/club/kits", self.base_url))
|
||||
.header("X-OpenFUT-Game", &self.game)
|
||||
.send()
|
||||
.map_err(|error| CoreError::Http(error.to_string()))?;
|
||||
let status = response.status().as_u16();
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(CoreError::Status(status));
|
||||
}
|
||||
let body: Value = response
|
||||
.json()
|
||||
.map_err(|error| CoreError::Parse(error.to_string()))?;
|
||||
let owned_id = |slot: &str| {
|
||||
body.get(slot)
|
||||
.and_then(|item| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
};
|
||||
Ok(CoreKitAssignments {
|
||||
home_owned_card_id: owned_id("home"),
|
||||
away_owned_card_id: owned_id("away"),
|
||||
})
|
||||
}
|
||||
|
||||
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
|
||||
let response = self
|
||||
.client
|
||||
@@ -1608,6 +1648,24 @@ impl Fifa17IdentityResolver {
|
||||
pub fn definition_identity(&self, card_id: &str) -> Option<(i64, ContentKind)> {
|
||||
self.catalog.lookup(card_id).map(|c| (c.rareflag, c.kind))
|
||||
}
|
||||
|
||||
fn wire_for(&self, item: &CoreOwnedItem) -> Option<u32> {
|
||||
match self.store.resolve_or_allocate(
|
||||
Fifa17WireItemIdPolicy::GAME,
|
||||
Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
|
||||
&item.owned_card_id,
|
||||
Fifa17WireItemIdPolicy::owned_item_base_floor(),
|
||||
) {
|
||||
Ok(wire) => Some(wire as u32),
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"utas-host ERROR identity store alloc failed for {}: {error}",
|
||||
item.owned_card_id
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ItemIdentityResolver for Fifa17IdentityResolver {
|
||||
@@ -1615,36 +1673,32 @@ impl ItemIdentityResolver for Fifa17IdentityResolver {
|
||||
// Definition identity first: an unmapped card is dropped (never faked).
|
||||
let ident = self.catalog.lookup(&item.card_id)?;
|
||||
// Instance identity: stable, persistent, reversible wire id.
|
||||
let wire = match self.store.resolve_or_allocate(
|
||||
Fifa17WireItemIdPolicy::GAME,
|
||||
Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
|
||||
&item.owned_card_id,
|
||||
Fifa17WireItemIdPolicy::owned_item_base_floor(),
|
||||
) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
// Infrastructure failure allocating a wire id: drop this item
|
||||
// (freeze-safe) and log — never emit an unstable/fake id.
|
||||
eprintln!(
|
||||
"utas-host ERROR identity store alloc failed for {}: {e}",
|
||||
item.owned_card_id
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let wire = self.wire_for(item)?;
|
||||
Some(Fifa17Identity {
|
||||
// Wire ids live in 1e8..9e8 (policy) — well within u32.
|
||||
item_id: wire as u32,
|
||||
item_id: wire,
|
||||
asset_id: ident.asset_id,
|
||||
resource_id: ident.resource_id,
|
||||
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 resolve_kit(&self, item: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
|
||||
let ident = self.catalog.lookup(&item.card_id)?;
|
||||
if ident.kind != ContentKind::Kit {
|
||||
return None;
|
||||
}
|
||||
Some(Fifa17KitIdentity {
|
||||
item_id: self.wire_for(item)?,
|
||||
asset_id: ident.asset_id,
|
||||
resource_id: ident.resource_id,
|
||||
card_asset_id: ident.card_asset_id,
|
||||
subtype: ident.subtype,
|
||||
team_id: ident.team_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Delegate content classification to the catalog. Unknown definitions
|
||||
/// retain the backward-compatible Player default but fail identity resolution.
|
||||
fn kind_of(&self, item: &CoreOwnedItem) -> ContentKind {
|
||||
self.catalog.kind_of(&item.card_id)
|
||||
}
|
||||
@@ -1684,6 +1738,7 @@ pub struct ClubDeps<'a> {
|
||||
/// ACTIVE transfer-market listing. In FIFA a listed card has LEFT the club, so
|
||||
/// it must not also appear here. Empty = show everything Core owns.
|
||||
pub hidden: &'a std::collections::HashSet<String>,
|
||||
pub active_kits: &'a CoreKitAssignments,
|
||||
}
|
||||
|
||||
/// Filter already-shaped `/club` items to SPECIALS (`rareflag > 1`) and paginate
|
||||
@@ -1742,10 +1797,27 @@ fn paginate_items(items: &[Value], offset: Option<i64>, limit: Option<i64>) -> (
|
||||
/// JSON body (UTAS must never 401/403; an empty result is the safe degrade).
|
||||
pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) {
|
||||
let raw = parse_club_query(query);
|
||||
let requested_kind = match raw.item_type.as_deref() {
|
||||
None | Some("player") => ContentKind::Player,
|
||||
Some("kit") => ContentKind::Kit,
|
||||
Some(other) => {
|
||||
return (
|
||||
json_response(&json!({ "itemData": [] })),
|
||||
ClubLog {
|
||||
outcome: "unsupported_type",
|
||||
filter: format!("type={other}"),
|
||||
total: 0,
|
||||
emitted: 0,
|
||||
dropped_no_asset: 0,
|
||||
offset: raw.start.map(|value| value as i64),
|
||||
limit: raw.count.map(|value| value as i64),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
let core_q = match map_to_core(&raw, deps.entities) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
// Unknown FIFA id — never a raw-id passthrough, never a guess.
|
||||
return (
|
||||
json_response(&json!({ "itemData": [] })),
|
||||
ClubLog {
|
||||
@@ -1754,104 +1826,70 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
|
||||
total: 0,
|
||||
emitted: 0,
|
||||
dropped_no_asset: 0,
|
||||
offset: raw.start.map(|s| s as i64),
|
||||
limit: raw.count.map(|c| c as i64),
|
||||
offset: raw.start.map(|value| value as i64),
|
||||
limit: raw.count.map(|value| value as i64),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
let pairs = core_q.to_query_pairs();
|
||||
let filter = summarize(&pairs);
|
||||
let (offset, limit) = (core_q.offset, core_q.limit);
|
||||
|
||||
// Host-side filters Core cannot express:
|
||||
// * "Special" (rare=SP): rareflag lives in the FIFA catalog, not Core.
|
||||
// * listed-card exclusion: transfer-market listings are host-owned state.
|
||||
// Either way Core must NOT paginate — it would paginate the unfiltered set
|
||||
// and return short pages. So fetch everything matching the OTHER filters,
|
||||
// exclude/shape locally, then paginate the filtered set here. When neither
|
||||
// applies (the common case) the fast Core-paginated path below is unchanged.
|
||||
if core_q.special || !deps.hidden.is_empty() {
|
||||
// Log which host-side filter forced local pagination.
|
||||
let local_desc = match (core_q.special, deps.hidden.len()) {
|
||||
(true, 0) => format!("{filter},rare=SP"),
|
||||
(true, n) => format!("{filter},rare=SP,hidden={n}"),
|
||||
(false, n) => format!("{filter},hidden={n}"),
|
||||
};
|
||||
let mut base = core_q.clone();
|
||||
base.offset = None;
|
||||
base.limit = None;
|
||||
return match deps.core.query_owned(&base.to_query_pairs()) {
|
||||
Ok(page) => {
|
||||
// Exclude hidden instances BEFORE shaping: a card on the transfer
|
||||
// list is not in the club, so it must not consume a page slot.
|
||||
let visible: Vec<CoreOwnedItem> = page
|
||||
.items
|
||||
.into_iter()
|
||||
.filter(|it| !deps.hidden.contains(&it.owned_card_id))
|
||||
.collect();
|
||||
let (body, stats) = shape_club_response(&visible, deps.entities, deps.assets);
|
||||
let all = body
|
||||
.get("itemData")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let (paged, total) = if core_q.special {
|
||||
special_filter_page(&all, offset, limit)
|
||||
} else {
|
||||
paginate_items(&all, offset, limit)
|
||||
};
|
||||
let emitted = paged.len();
|
||||
(
|
||||
json_response(&json!({ "itemData": paged })),
|
||||
ClubLog {
|
||||
outcome: "ok",
|
||||
filter: local_desc,
|
||||
total,
|
||||
emitted,
|
||||
dropped_no_asset: stats.dropped_no_asset,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("utas-host ERROR /club (local-filter) core query failed: {e}");
|
||||
(
|
||||
json_response(&json!({ "itemData": [] })),
|
||||
ClubLog {
|
||||
outcome: "core_error",
|
||||
filter: local_desc,
|
||||
total: 0,
|
||||
emitted: 0,
|
||||
dropped_no_asset: 0,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
)
|
||||
}
|
||||
};
|
||||
let mut base = core_q.clone();
|
||||
base.offset = None;
|
||||
base.limit = None;
|
||||
let mut filter = summarize(&base.to_query_pairs());
|
||||
if !filter.is_empty() {
|
||||
filter.push(',');
|
||||
}
|
||||
match deps.core.query_owned(&pairs) {
|
||||
filter.push_str(&format!("type={}", requested_kind.as_str()));
|
||||
if core_q.special {
|
||||
filter.push_str(",rare=SP");
|
||||
}
|
||||
if !deps.hidden.is_empty() {
|
||||
filter.push_str(&format!(",hidden={}", deps.hidden.len()));
|
||||
}
|
||||
|
||||
match deps.core.query_owned(&base.to_query_pairs()) {
|
||||
Ok(page) => {
|
||||
let (body, stats): (Value, ShapeStats) =
|
||||
shape_club_response(&page.items, deps.entities, deps.assets);
|
||||
// Kind and transfer-pile membership live outside generic Core, so
|
||||
// filtering and pagination must happen here over the final set.
|
||||
let visible: Vec<CoreOwnedItem> = page
|
||||
.items
|
||||
.into_iter()
|
||||
.filter(|item| !deps.hidden.contains(&item.owned_card_id))
|
||||
.filter(|item| deps.assets.kind_of(item) == requested_kind)
|
||||
.collect();
|
||||
let active = ActiveKitAssignments {
|
||||
home: deps.active_kits.home_owned_card_id.as_deref(),
|
||||
away: deps.active_kits.away_owned_card_id.as_deref(),
|
||||
};
|
||||
let (body, stats) =
|
||||
shape_club_response_with_kits(&visible, deps.entities, deps.assets, active);
|
||||
let all = body
|
||||
.get("itemData")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let (paged, total) = if core_q.special {
|
||||
special_filter_page(&all, offset, limit)
|
||||
} else {
|
||||
paginate_items(&all, offset, limit)
|
||||
};
|
||||
let emitted = paged.len();
|
||||
(
|
||||
json_response(&body),
|
||||
json_response(&json!({ "itemData": paged })),
|
||||
ClubLog {
|
||||
outcome: "ok",
|
||||
filter,
|
||||
total: page.total,
|
||||
emitted: stats.emitted,
|
||||
total,
|
||||
emitted,
|
||||
dropped_no_asset: stats.dropped_no_asset,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
// Degrade to a valid empty page; DO NOT fall back to Python.
|
||||
eprintln!("utas-host ERROR /club core query failed: {e}");
|
||||
Err(error) => {
|
||||
eprintln!("utas-host ERROR /club core query failed: {error}");
|
||||
(
|
||||
json_response(&json!({ "itemData": [] })),
|
||||
ClubLog {
|
||||
@@ -3382,11 +3420,16 @@ impl Server {
|
||||
Route::Club => {
|
||||
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
|
||||
let hidden = self.club_hidden_ids();
|
||||
let active_kits = self.core.get_active_kits().unwrap_or_else(|error| {
|
||||
eprintln!("utas-host WARN active kit read unavailable: {error}");
|
||||
CoreKitAssignments::default()
|
||||
});
|
||||
let deps = ClubDeps {
|
||||
core: self.core.as_ref(),
|
||||
entities: self.entities.as_ref(),
|
||||
assets: self.resolver.as_ref(),
|
||||
hidden: &hidden,
|
||||
active_kits: &active_kits,
|
||||
};
|
||||
let (resp, log) = handle_club(query, &deps);
|
||||
eprintln!(
|
||||
|
||||
@@ -15,9 +15,9 @@ use openfut_identity::JsonIdentityStore;
|
||||
use openfut_utas_host::account_store::AccountStore;
|
||||
use openfut_utas_host::{
|
||||
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
||||
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, CorePage,
|
||||
CoreReplaceRequest, CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver,
|
||||
HttpCoreClient, PassClient, Route, Server, SquadDeps,
|
||||
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState,
|
||||
CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreSquadRead,
|
||||
CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route, Server, SquadDeps,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
@@ -338,11 +338,13 @@ fn club_excludes_listed_items_and_paginates_the_visible_set() {
|
||||
|
||||
// oc2 has an active transfer-market listing.
|
||||
let hidden: std::collections::HashSet<String> = ["oc2".to_string()].into_iter().collect();
|
||||
let active_kits = CoreKitAssignments::default();
|
||||
let deps = ClubDeps {
|
||||
core: core.as_ref(),
|
||||
entities: &ents,
|
||||
assets: resolver.as_ref(),
|
||||
hidden: &hidden,
|
||||
active_kits: &active_kits,
|
||||
};
|
||||
let (resp, log) = handle_club("", &deps);
|
||||
let v: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
@@ -370,13 +372,14 @@ fn club_excludes_listed_items_and_paginates_the_visible_set() {
|
||||
);
|
||||
assert_eq!(log2.total, 2);
|
||||
|
||||
// Nothing hidden → the fast Core-paginated path, all three visible.
|
||||
// Nothing hidden → all three player items remain visible.
|
||||
let none: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let deps_all = ClubDeps {
|
||||
core: core.as_ref(),
|
||||
entities: &ents,
|
||||
assets: resolver.as_ref(),
|
||||
hidden: &none,
|
||||
active_kits: &active_kits,
|
||||
};
|
||||
let (resp3, log3) = handle_club("", &deps_all);
|
||||
let v3: Value = serde_json::from_slice(&resp3.body).unwrap();
|
||||
@@ -384,6 +387,67 @@ fn club_excludes_listed_items_and_paginates_the_visible_set() {
|
||||
assert_eq!(log3.total, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn club_projects_only_owned_kits_with_active_designations() {
|
||||
let core = Arc::new(FakeCore::new(
|
||||
vec![
|
||||
item(
|
||||
"player",
|
||||
"card_player",
|
||||
90,
|
||||
"ST",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
),
|
||||
item("home", "kit_home", 0, "", "", "", ""),
|
||||
item("away", "kit_away", 0, "", "", "", ""),
|
||||
],
|
||||
3,
|
||||
));
|
||||
let catalog = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"card_player":{"asset_id":20801},
|
||||
"kit_home":{"asset_id":6300006,"kind":"kit","subtype":9,
|
||||
"card_asset_id":35,"team_id":21,"rareflag":0},
|
||||
"kit_away":{"asset_id":6400003,"kind":"kit","subtype":9,
|
||||
"card_asset_id":35,"team_id":21,"rareflag":0}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let resolver = Arc::new(Fifa17IdentityResolver::new(
|
||||
catalog,
|
||||
Arc::new(JsonIdentityStore::open(unique_store_path()).unwrap()),
|
||||
));
|
||||
let ents = entities();
|
||||
let hidden = std::collections::HashSet::new();
|
||||
let active_kits = CoreKitAssignments {
|
||||
home_owned_card_id: Some("home".into()),
|
||||
away_owned_card_id: Some("away".into()),
|
||||
};
|
||||
let deps = ClubDeps {
|
||||
core: core.as_ref(),
|
||||
entities: &ents,
|
||||
assets: resolver.as_ref(),
|
||||
hidden: &hidden,
|
||||
active_kits: &active_kits,
|
||||
};
|
||||
|
||||
let (kit_response, kit_log) = handle_club("type=kit", &deps);
|
||||
let kits: Value = serde_json::from_slice(&kit_response.body).unwrap();
|
||||
assert_eq!(kit_log.total, 2);
|
||||
assert_eq!(kits["itemData"][0]["itemState"], "activeHomeKit");
|
||||
assert_eq!(kits["itemData"][1]["itemState"], "activeAwayKit");
|
||||
assert_eq!(kits["itemData"][0]["cardassetid"], 35);
|
||||
assert_eq!(kits["itemData"][0]["teamid"], 21);
|
||||
|
||||
let (player_response, player_log) = handle_club("type=player", &deps);
|
||||
let players: Value = serde_json::from_slice(&player_response.body).unwrap();
|
||||
assert_eq!(player_log.total, 1);
|
||||
assert_eq!(players["itemData"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(players["itemData"][0]["itemType"], "player");
|
||||
}
|
||||
|
||||
// ── /club served from Core ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -149,6 +149,14 @@ SELLER_SQUAD_CARDS = [
|
||||
DISPOSABLE_ITEM = "owned-a-disposable"
|
||||
DISPOSABLE_CARD = "fifa17_232273" # Nelson Atiagli LB 51, rareflag 1
|
||||
|
||||
# Two authoritative modern kit-card definitions for one real source team. These
|
||||
# are staging fixtures derived from fcc_kitcards, not synthetic FIFA identities.
|
||||
KIT_TEAM_ID = 21
|
||||
STAGING_KITS = [
|
||||
("home", "owned-a-kit-home", "fifa17_6300006", 6_300_006),
|
||||
("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003),
|
||||
]
|
||||
|
||||
READY_TIMEOUT_S = 60.0
|
||||
|
||||
|
||||
@@ -439,6 +447,47 @@ def materialise(lay: Layout) -> None:
|
||||
shutil.copy2(safe_path(src), safe_path(dst))
|
||||
ok(f"FIFA17 content copied from {CONTENT_SRC} (outside production state)")
|
||||
|
||||
with open(safe_path(lay.cards)) as fh:
|
||||
definitions = json.load(fh)
|
||||
with open(safe_path(lay.catalog)) as fh:
|
||||
catalog = json.load(fh)
|
||||
existing = {definition["id"] for definition in definitions}
|
||||
for _slot, _owned_id, card_id, resource_id in STAGING_KITS:
|
||||
if card_id not in existing:
|
||||
definitions.append({
|
||||
"id": card_id,
|
||||
"name": "Kit",
|
||||
"overall": 0,
|
||||
"position": "",
|
||||
"nation": "",
|
||||
"league": "",
|
||||
"club": "",
|
||||
"pace": 0,
|
||||
"shooting": 0,
|
||||
"passing": 0,
|
||||
"dribbling": 0,
|
||||
"defending": 0,
|
||||
"physical": 0,
|
||||
"rarity": "bronze",
|
||||
"image_path": None,
|
||||
})
|
||||
catalog["cards"][card_id] = {
|
||||
"asset_id": resource_id,
|
||||
"version": 0,
|
||||
"rareflag": 0,
|
||||
"kind": "kit",
|
||||
"subtype": 9,
|
||||
"card_asset_id": 35,
|
||||
"team_id": KIT_TEAM_ID,
|
||||
}
|
||||
with open(safe_path(lay.cards), "w") as fh:
|
||||
json.dump(definitions, fh, indent=2)
|
||||
fh.write("\n")
|
||||
with open(safe_path(lay.catalog), "w") as fh:
|
||||
json.dump(catalog, fh, indent=2)
|
||||
fh.write("\n")
|
||||
ok("added ownership-backed home/away kit fixtures from fcc_kitcards")
|
||||
|
||||
|
||||
def assert_seed_cards_resolvable(lay: Layout) -> None:
|
||||
"""Core's content preflight rejects any owned card whose card_id is not a loaded
|
||||
@@ -448,7 +497,11 @@ def assert_seed_cards_resolvable(lay: Layout) -> None:
|
||||
pack_ids = {c["id"] for c in json.load(fh)}
|
||||
with open(safe_path(lay.catalog)) as fh:
|
||||
catalog_ids = set(json.load(fh)["cards"])
|
||||
wanted = [c for _, c in SELLER_SQUAD_CARDS] + [DISPOSABLE_CARD]
|
||||
wanted = (
|
||||
[card for _, card in SELLER_SQUAD_CARDS]
|
||||
+ [DISPOSABLE_CARD]
|
||||
+ [card for _, _, card, _ in STAGING_KITS]
|
||||
)
|
||||
missing_pack = sorted(set(wanted) - pack_ids)
|
||||
missing_cat = sorted(set(wanted) - catalog_ids)
|
||||
if missing_pack or missing_cat:
|
||||
@@ -587,7 +640,11 @@ def seed_core_db(lay: Layout) -> None:
|
||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, "
|
||||
"acquired_at) VALUES (?, ?, ?, 0, ?)",
|
||||
[(item, SELLER_CLUB, card, TS) for item, card in SELLER_SQUAD_CARDS]
|
||||
+ [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, TS)],
|
||||
+ [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, TS)]
|
||||
+ [
|
||||
(owned_id, SELLER_CLUB, card_id, TS)
|
||||
for _slot, owned_id, card_id, _resource_id in STAGING_KITS
|
||||
],
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO squads (id, club_id, name, formation, created_at, "
|
||||
@@ -602,12 +659,20 @@ def seed_core_db(lay: Layout) -> None:
|
||||
for idx, (item, _) in enumerate(SELLER_SQUAD_CARDS)
|
||||
],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
[
|
||||
(SELLER_CLUB, slot, owned_id, TS)
|
||||
for slot, owned_id, _card_id, _resource_id in STAGING_KITS
|
||||
],
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
ok(
|
||||
f"seeded Seller A ({PERSONA_NAME}, persona {PERSONA_ID}, {SELLER_COINS} coins, "
|
||||
f"{len(SELLER_SQUAD_CARDS)} starters + 1 disposable) and Buyer B "
|
||||
f"({BUYER_COINS} coins)"
|
||||
f"{len(SELLER_SQUAD_CARDS)} starters + 1 disposable + "
|
||||
f"{len(STAGING_KITS)} active kits) and Buyer B ({BUYER_COINS} coins)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user