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 /
|
/// FIFA `cardsubtypeid` for a non-player definition (consumable family /
|
||||||
/// staff role), `0` for a player or when absent.
|
/// staff role), `0` for a player or when absent.
|
||||||
pub subtype: i64,
|
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.
|
/// 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`.
|
/// FIFA `cardsubtypeid` for a non-player entry; absent → `0`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
subtype: i64,
|
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 {
|
fn default_rareflag() -> i64 {
|
||||||
@@ -187,6 +198,8 @@ impl Fifa17CardCatalog {
|
|||||||
rareflag: rc.rareflag,
|
rareflag: rc.rareflag,
|
||||||
kind: ContentKind::from_str(&rc.kind),
|
kind: ContentKind::from_str(&rc.kind),
|
||||||
subtype: rc.subtype,
|
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":{
|
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||||
"fifa17_20801":{"asset_id":20801,"kind":"player","subtype":0},
|
"fifa17_20801":{"asset_id":20801,"kind":"player","subtype":0},
|
||||||
"fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54,"rareflag":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();
|
.unwrap();
|
||||||
@@ -407,5 +422,10 @@ mod tests {
|
|||||||
assert_eq!(cat.kind_of("fifa17_3000083"), ContentKind::Staff);
|
assert_eq!(cat.kind_of("fifa17_3000083"), ContentKind::Staff);
|
||||||
assert_eq!(cat.subtype_of("fifa17_3000083"), 8);
|
assert_eq!(cat.subtype_of("fifa17_3000083"), 8);
|
||||||
assert_eq!(cat.lookup("fifa17_5003012").unwrap().rareflag, 0);
|
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::content_taxonomy::ContentKind;
|
||||||
use crate::fut::entities::ReverseEntityResolver;
|
use crate::fut::entities::ReverseEntityResolver;
|
||||||
use crate::fut::item::shape_item;
|
use crate::fut::item::{shape_item, shape_kit_item};
|
||||||
// Re-exported so existing `club_response::{…}` callers keep working; the types
|
// Re-exported so existing `club_response::{…}` callers keep working; the types
|
||||||
// are now defined once in `fut::item`.
|
// 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
|
/// Active club-level kit roles, keyed by Core owned-instance id.
|
||||||
/// are dropped (counted in `ShapeStats`), never emitted with a fabricated 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>(
|
pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
|
||||||
items: &[CoreOwnedItem],
|
items: &[CoreOwnedItem],
|
||||||
ent: &impl ReverseEntityResolver,
|
ent: &impl ReverseEntityResolver,
|
||||||
ident: &I,
|
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) {
|
) -> (Value, ShapeStats) {
|
||||||
let mut out = Vec::with_capacity(items.len());
|
let mut out = Vec::with_capacity(items.len());
|
||||||
let mut stats = ShapeStats::default();
|
let mut stats = ShapeStats::default();
|
||||||
for item in items {
|
for item in items {
|
||||||
// Exclude non-player content (consumables/staff): a `/club` player list
|
match ident.kind_of(item) {
|
||||||
// must never render them as 0-rated players. Counted, never emitted.
|
ContentKind::Player => match ident.resolve(item) {
|
||||||
if ident.kind_of(item) != ContentKind::Player {
|
|
||||||
stats.excluded_non_player += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match ident.resolve(item) {
|
|
||||||
Some(id) => {
|
Some(id) => {
|
||||||
out.push(shape_item(item, id, ent));
|
out.push(shape_item(item, id, ent));
|
||||||
stats.emitted += 1;
|
stats.emitted += 1;
|
||||||
}
|
}
|
||||||
None => stats.dropped_no_asset += 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)
|
(json!({ "itemData": out }), stats)
|
||||||
@@ -207,11 +239,15 @@ mod tests {
|
|||||||
struct KindMapIdentity {
|
struct KindMapIdentity {
|
||||||
ids: HashMap<String, Fifa17Identity>,
|
ids: HashMap<String, Fifa17Identity>,
|
||||||
kinds: HashMap<String, ContentKind>,
|
kinds: HashMap<String, ContentKind>,
|
||||||
|
kits: HashMap<String, Fifa17KitIdentity>,
|
||||||
}
|
}
|
||||||
impl ItemIdentityResolver for KindMapIdentity {
|
impl ItemIdentityResolver for KindMapIdentity {
|
||||||
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||||
self.ids.get(&it.card_id).copied()
|
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 {
|
fn kind_of(&self, it: &CoreOwnedItem) -> ContentKind {
|
||||||
self.kinds
|
self.kinds
|
||||||
.get(&it.card_id)
|
.get(&it.card_id)
|
||||||
@@ -235,6 +271,7 @@ mod tests {
|
|||||||
("card_consumable".to_string(), id(100000002, 5003012)),
|
("card_consumable".to_string(), id(100000002, 5003012)),
|
||||||
("card_staff".to_string(), id(100000003, 3000083)),
|
("card_staff".to_string(), id(100000003, 3000083)),
|
||||||
]),
|
]),
|
||||||
|
kits: HashMap::new(),
|
||||||
kinds: HashMap::from([
|
kinds: HashMap::from([
|
||||||
("card_consumable".to_string(), ContentKind::Consumable),
|
("card_consumable".to_string(), ContentKind::Consumable),
|
||||||
("card_staff".to_string(), ContentKind::Staff),
|
("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]["id"], 100000001, "the player survives");
|
||||||
assert_eq!(arr[0]["itemType"], "player");
|
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,
|
//! (`FUN_18012fd40` atom table). The body is `{"stat":[{contextId,contextValue,
|
||||||
//! type,typeValue}, …]}`:
|
//! type,typeValue}, …]}`:
|
||||||
//! * a GLOBAL bucket (contextId 1, contextValue 0) with player tier counts,
|
//! * 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
|
//! * per-NATION buckets (contextId 3, contextValue = nation id) with the tier
|
||||||
//! counts the MY CLUB summary panel sums into PLAYERS_EMPLOYED.
|
//! 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);
|
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 [
|
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.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();
|
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,
|
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> {
|
fn global(body: &Value) -> std::collections::HashMap<String, i64> {
|
||||||
body["stat"]
|
body["stat"]
|
||||||
@@ -355,6 +375,14 @@ mod tests {
|
|||||||
assert_eq!(g["consumablesTrainingPlayerPlayStyle"], 1);
|
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]
|
#[test]
|
||||||
fn nation_buckets_emitted_and_players_excludes_nonplayers() {
|
fn nation_buckets_emitted_and_players_excludes_nonplayers() {
|
||||||
let items = vec![
|
let items = vec![
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
//! resolves to `None` — the caller DEFERS it (mirroring the player NoName gate),
|
//! resolves to `None` — the caller DEFERS it (mirroring the player NoName gate),
|
||||||
//! never fabricating a family.
|
//! 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`
|
/// the default so a catalog authored before this taxonomy existed (no `kind`
|
||||||
/// field) still classifies every entry as a player, unchanged.
|
/// field) still classifies every entry as a player, unchanged.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
@@ -27,6 +27,7 @@ pub enum ContentKind {
|
|||||||
Player,
|
Player,
|
||||||
Consumable,
|
Consumable,
|
||||||
Staff,
|
Staff,
|
||||||
|
Kit,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ContentKind {
|
impl ContentKind {
|
||||||
@@ -36,6 +37,7 @@ impl ContentKind {
|
|||||||
ContentKind::Player => "player",
|
ContentKind::Player => "player",
|
||||||
ContentKind::Consumable => "consumable",
|
ContentKind::Consumable => "consumable",
|
||||||
ContentKind::Staff => "staff",
|
ContentKind::Staff => "staff",
|
||||||
|
ContentKind::Kit => "kit",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ impl ContentKind {
|
|||||||
match s {
|
match s {
|
||||||
"consumable" => ContentKind::Consumable,
|
"consumable" => ContentKind::Consumable,
|
||||||
"staff" => ContentKind::Staff,
|
"staff" => ContentKind::Staff,
|
||||||
|
"kit" => ContentKind::Kit,
|
||||||
_ => ContentKind::Player,
|
_ => ContentKind::Player,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +109,7 @@ mod tests {
|
|||||||
ContentKind::Player,
|
ContentKind::Player,
|
||||||
ContentKind::Consumable,
|
ContentKind::Consumable,
|
||||||
ContentKind::Staff,
|
ContentKind::Staff,
|
||||||
|
ContentKind::Kit,
|
||||||
] {
|
] {
|
||||||
assert_eq!(ContentKind::from_str(k.as_str()), k);
|
assert_eq!(ContentKind::from_str(k.as_str()), k);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,11 +66,29 @@ pub struct Fifa17Identity {
|
|||||||
pub rareflag: i64,
|
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
|
/// Supplies the FIFA numeric identity for a Core item. Returning `None` means
|
||||||
/// "no real FIFA asset id known" → the caller must not fabricate one.
|
/// "no real FIFA asset id known" → the caller must not fabricate one.
|
||||||
pub trait ItemIdentityResolver {
|
pub trait ItemIdentityResolver {
|
||||||
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
|
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
|
||||||
|
|
||||||
|
/// 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
|
/// Classify a Core item's definition as player/consumable/staff. Defaults to
|
||||||
/// [`ContentKind::Player`] so existing resolvers keep their behaviour; a
|
/// [`ContentKind::Player`] so existing resolvers keep their behaviour; a
|
||||||
/// catalog-backed resolver overrides this to consult its `kind_of`, letting
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ use std::collections::HashMap;
|
|||||||
/// fields are FIFA entity ids that MUST be resolved before reaching Core.
|
/// fields are FIFA entity ids that MUST be resolved before reaching Core.
|
||||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
pub struct Fifa17ClubQuery {
|
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`.
|
/// Quality filter: `any` (default, always present) or `gold`.
|
||||||
pub level: Option<String>,
|
pub level: Option<String>,
|
||||||
/// "Special" filter (`SP`). Semantics UNKNOWN — never applied.
|
/// "Special" filter (`SP`). Semantics UNKNOWN — never applied.
|
||||||
@@ -115,6 +118,7 @@ pub fn parse_club_query(query: &str) -> Fifa17ClubQuery {
|
|||||||
None => (pair, String::new()),
|
None => (pair, String::new()),
|
||||||
};
|
};
|
||||||
match k {
|
match k {
|
||||||
|
"type" => out.item_type = Some(v),
|
||||||
"level" => out.level = Some(v),
|
"level" => out.level = Some(v),
|
||||||
"rare" => out.rare = Some(v),
|
"rare" => out.rare = Some(v),
|
||||||
"position" => out.position = Some(v),
|
"position" => out.position = Some(v),
|
||||||
@@ -325,6 +329,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
q,
|
q,
|
||||||
Fifa17ClubQuery {
|
Fifa17ClubQuery {
|
||||||
|
item_type: Some("player".into()),
|
||||||
level: Some("gold".into()),
|
level: Some("gold".into()),
|
||||||
rare: None,
|
rare: None,
|
||||||
position: Some("ST".into()),
|
position: Some("ST".into()),
|
||||||
|
|||||||
+1
-1
Submodule openfut-core updated: 2fb835200f...f0550e2ae1
@@ -166,10 +166,14 @@ pub enum ItemClass {
|
|||||||
PlayerCard,
|
PlayerCard,
|
||||||
Consumable,
|
Consumable,
|
||||||
Staff,
|
Staff,
|
||||||
|
Kit,
|
||||||
Other,
|
Other,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn classify(item: &Item) -> ItemClass {
|
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() {
|
match item.item_type.as_str() {
|
||||||
"staff" => ItemClass::Staff,
|
"staff" => ItemClass::Staff,
|
||||||
"player" => {
|
"player" => {
|
||||||
@@ -189,12 +193,13 @@ pub struct ItemCounts {
|
|||||||
pub player_cards: usize,
|
pub player_cards: usize,
|
||||||
pub consumables: usize,
|
pub consumables: usize,
|
||||||
pub staff: usize,
|
pub staff: usize,
|
||||||
|
pub kits: usize,
|
||||||
pub other: usize,
|
pub other: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ItemCounts {
|
impl ItemCounts {
|
||||||
pub fn balances(&self) -> bool {
|
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::PlayerCard => c.player_cards += 1,
|
||||||
ItemClass::Consumable => c.consumables += 1,
|
ItemClass::Consumable => c.consumables += 1,
|
||||||
ItemClass::Staff => c.staff += 1,
|
ItemClass::Staff => c.staff += 1,
|
||||||
|
ItemClass::Kit => c.kits += 1,
|
||||||
ItemClass::Other => c.other += 1,
|
ItemClass::Other => c.other += 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,21 +530,23 @@ pub fn plan_definitions(
|
|||||||
|
|
||||||
// ------------------------------------------------------- non-player content
|
// ------------------------------------------------------- non-player content
|
||||||
|
|
||||||
/// An honest, profile-derived NON-player CardDefinition proposal (consumable or
|
/// An honest, profile-derived non-player CardDefinition proposal, keyed by
|
||||||
/// staff), keyed by `fifa17_<resourceId>`. Neutral player fields are supplied at
|
/// `fifa17_<resourceId>`. Neutral player fields are supplied at emit time; FIFA
|
||||||
/// emit time; this carries only the identity + honest functional `name` (the
|
/// render metadata stays here and in the adapter catalog, never generic Core.
|
||||||
/// taxonomy label, never a marketing name).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct NonPlayerDefinition {
|
pub struct NonPlayerDefinition {
|
||||||
pub card_id: String,
|
pub card_id: String,
|
||||||
pub resource_id: i64,
|
pub resource_id: i64,
|
||||||
/// Base asset id when the source carries one (consumables: `== resource_id`);
|
/// Base asset id when the source carries one.
|
||||||
/// staff carry no `assetId`, so this is `None`.
|
|
||||||
pub asset_id: Option<i64>,
|
pub asset_id: Option<i64>,
|
||||||
pub kind: ContentKind,
|
pub kind: ContentKind,
|
||||||
/// FIFA `cardsubtypeid` (consumable family / staff role selector).
|
/// FIFA `cardsubtypeid` (consumable family, staff role, or kit family).
|
||||||
pub subtype: i64,
|
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,
|
pub name: String,
|
||||||
/// Wire ids of every owned copy of this resourceId (preserved).
|
/// Wire ids of every owned copy of this resourceId (preserved).
|
||||||
pub wire_ids: Vec<i64>,
|
pub wire_ids: Vec<i64>,
|
||||||
@@ -563,6 +571,8 @@ pub struct NonPlayerPlan {
|
|||||||
pub consumables: usize,
|
pub consumables: usize,
|
||||||
/// Count of SUPPORTED staff definitions.
|
/// Count of SUPPORTED staff definitions.
|
||||||
pub staff: usize,
|
pub staff: usize,
|
||||||
|
/// Count of SUPPORTED kit definitions.
|
||||||
|
pub kits: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NonPlayerPlan {
|
impl NonPlayerPlan {
|
||||||
@@ -572,15 +582,16 @@ impl NonPlayerPlan {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plan the non-player (consumable + staff) CardDefinitions. Groups Consumable
|
/// Plan non-player CardDefinitions. Consumable, Staff, and Kit groups must agree
|
||||||
/// and Staff items by `resourceId`; each group must agree on `cardsubtypeid`
|
/// on their definition-level metadata across every owned copy; disagreement or
|
||||||
/// across copies (a disagreement DEFERS with `subtype_conflict`), then resolves
|
/// missing required metadata defers the whole group, never fabricates a value.
|
||||||
/// the family (consumable) or role (staff) via the adapter's evidence-based
|
|
||||||
/// taxonomy. A missing or unknown `cardsubtypeid` DEFERS — never a placeholder.
|
|
||||||
pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
||||||
let mut groups: BTreeMap<i64, Vec<&Item>> = BTreeMap::new();
|
let mut groups: BTreeMap<i64, Vec<&Item>> = BTreeMap::new();
|
||||||
for it in &profile.items {
|
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);
|
groups.entry(it.resource_id).or_default().push(it);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -622,6 +633,21 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
|||||||
continue;
|
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 {
|
let (kind, label) = match class {
|
||||||
ItemClass::Consumable => match consumable_family(subtype) {
|
ItemClass::Consumable => match consumable_family(subtype) {
|
||||||
Some((_family, label)) => (ContentKind::Consumable, label),
|
Some((_family, label)) => (ContentKind::Consumable, label),
|
||||||
@@ -647,15 +673,33 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
|||||||
continue;
|
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 {
|
plan.supported.push(NonPlayerDefinition {
|
||||||
card_id: format!("fifa17_{resource_id}"),
|
card_id: format!("fifa17_{resource_id}"),
|
||||||
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,
|
kind,
|
||||||
subtype,
|
subtype,
|
||||||
|
card_asset_id,
|
||||||
|
team_id,
|
||||||
name: label.to_string(),
|
name: label.to_string(),
|
||||||
wire_ids,
|
wire_ids,
|
||||||
});
|
});
|
||||||
@@ -670,6 +714,11 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|d| d.kind == ContentKind::Staff)
|
.filter(|d| d.kind == ContentKind::Staff)
|
||||||
.count();
|
.count();
|
||||||
|
plan.kits = plan
|
||||||
|
.supported
|
||||||
|
.iter()
|
||||||
|
.filter(|definition| definition.kind == ContentKind::Kit)
|
||||||
|
.count();
|
||||||
plan
|
plan
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,10 +864,11 @@ impl Report {
|
|||||||
let mut b = Vec::new();
|
let mut b = Vec::new();
|
||||||
if !self.counts.balances() {
|
if !self.counts.balances() {
|
||||||
b.push(format!(
|
b.push(format!(
|
||||||
"item-type accounting does not balance ({}+{}+{}+{} != {})",
|
"item-type accounting does not balance ({}+{}+{}+{}+{} != {})",
|
||||||
self.counts.player_cards,
|
self.counts.player_cards,
|
||||||
self.counts.consumables,
|
self.counts.consumables,
|
||||||
self.counts.staff,
|
self.counts.staff,
|
||||||
|
self.counts.kits,
|
||||||
self.counts.other,
|
self.counts.other,
|
||||||
self.counts.total
|
self.counts.total
|
||||||
));
|
));
|
||||||
@@ -911,11 +961,12 @@ impl std::fmt::Display for Report {
|
|||||||
)?;
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
f,
|
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.total,
|
||||||
self.counts.player_cards,
|
self.counts.player_cards,
|
||||||
self.counts.consumables,
|
self.counts.consumables,
|
||||||
self.counts.staff,
|
self.counts.staff,
|
||||||
|
self.counts.kits,
|
||||||
self.counts.other,
|
self.counts.other,
|
||||||
self.counts.balances()
|
self.counts.balances()
|
||||||
)?;
|
)?;
|
||||||
@@ -976,10 +1027,11 @@ impl std::fmt::Display for Report {
|
|||||||
}
|
}
|
||||||
writeln!(
|
writeln!(
|
||||||
f,
|
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.supported.len(),
|
||||||
np.consumables,
|
np.consumables,
|
||||||
np.staff,
|
np.staff,
|
||||||
|
np.kits,
|
||||||
np.deferred.len(),
|
np.deferred.len(),
|
||||||
np.deferred_instances()
|
np.deferred_instances()
|
||||||
)?;
|
)?;
|
||||||
@@ -1130,6 +1182,8 @@ pub fn emit_content(
|
|||||||
cards.insert(
|
cards.insert(
|
||||||
d.card_id.clone(),
|
d.card_id.clone(),
|
||||||
serde_json::json!({
|
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),
|
"asset_id": d.asset_id.unwrap_or(d.resource_id),
|
||||||
"version": 0,
|
"version": 0,
|
||||||
"rareflag": 0,
|
"rareflag": 0,
|
||||||
@@ -1195,6 +1249,8 @@ pub fn emit_content(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|d| {
|
.map(|d| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
|
"card_asset_id": d.card_asset_id,
|
||||||
|
"team_id": d.team_id,
|
||||||
"card_id": d.card_id,
|
"card_id": d.card_id,
|
||||||
"resource_id": d.resource_id,
|
"resource_id": d.resource_id,
|
||||||
"asset_id": d.asset_id,
|
"asset_id": d.asset_id,
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ pub struct Item {
|
|||||||
/// Staff/contract `contract` count. Permissive.
|
/// Staff/contract `contract` count. Permissive.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub contract: Option<i64>,
|
pub contract: Option<i64>,
|
||||||
|
/// Owned-item lifecycle state (`activeHomeKit` / `activeAwayKit` for kits).
|
||||||
|
#[serde(rename = "itemState", default)]
|
||||||
|
pub item_state: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
|||||||
@@ -72,11 +72,21 @@ fn classification_balances_across_disjoint_classes() {
|
|||||||
player(100000002, VER5_176580, 176580, 92),
|
player(100000002, VER5_176580, 176580, 92),
|
||||||
r#"{"id":100000239,"resourceId":5003012,"assetId":5003012,"itemType":"player","rating":85}"#.to_string(),
|
r#"{"id":100000239,"resourceId":5003012,"assetId":5003012,"itemType":"player","rating":85}"#.to_string(),
|
||||||
r#"{"id":100000427,"resourceId":3000083,"itemType":"staff"}"#.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!(
|
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());
|
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]
|
#[test]
|
||||||
fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
|
fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
|
||||||
// The exact record set from the ticket: distinct resourceIds, so each is its
|
// 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.consumables, 17);
|
||||||
assert_eq!(plan.staff, 3);
|
assert_eq!(plan.staff, 3);
|
||||||
|
assert_eq!(plan.kits, 0);
|
||||||
assert!(plan.deferred.is_empty(), "0 deferred: {:?}", plan.deferred);
|
assert!(plan.deferred.is_empty(), "0 deferred: {:?}", plan.deferred);
|
||||||
|
|
||||||
// Honest labels + kinds resolve from the taxonomy (spot checks).
|
// 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");
|
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]
|
#[test]
|
||||||
fn unknown_subtype_consumable_defers_never_fabricated() {
|
fn unknown_subtype_consumable_defers_never_fabricated() {
|
||||||
let plan = plan_non_player_definitions(&profile(
|
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),
|
player(100000001, 20801, 20801, 94),
|
||||||
consumable(100000201, 5003012, 201), // Player Contract
|
consumable(100000201, 5003012, 201), // Player Contract
|
||||||
staff(100000427, 3000083, 8), // Fitness Coach
|
staff(100000427, 3000083, 8), // Fitness Coach
|
||||||
|
kit(100000500, 6300006, 21, "activeHomeKit"),
|
||||||
];
|
];
|
||||||
let rep = analyze(
|
let rep = analyze(
|
||||||
&profile(&items, "[]", 100000500),
|
&profile(&items, "[]", 100000500),
|
||||||
@@ -771,16 +802,16 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
|
|||||||
&none(),
|
&none(),
|
||||||
);
|
);
|
||||||
assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
|
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 dir = tempfile::tempdir().unwrap();
|
||||||
let sum = emit_content(&rep, dir.path(), "fp").unwrap();
|
let sum = emit_content(&rep, dir.path(), "fp").unwrap();
|
||||||
assert_eq!(sum.definitions, 1, "one player definition");
|
assert_eq!(sum.definitions, 1, "one player definition");
|
||||||
assert_eq!(sum.non_player_definitions, 2);
|
assert_eq!(sum.non_player_definitions, 3);
|
||||||
assert_eq!(sum.non_player_instances, 2);
|
assert_eq!(sum.non_player_instances, 3);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sum.catalog_entries, 3,
|
sum.catalog_entries, 4,
|
||||||
"player + 2 non-player catalog entries"
|
"player + 3 non-player catalog entries"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Content pack: neutral non-player CardDefinition with honest name.
|
// 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"]["kind"], "staff");
|
||||||
assert_eq!(cat["cards"]["fifa17_3000083"]["subtype"], 8);
|
assert_eq!(cat["cards"]["fifa17_3000083"]["subtype"], 8);
|
||||||
assert_eq!(cat["cards"]["fifa17_3000083"]["asset_id"], 3000083);
|
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();
|
let loaded = Fifa17CardCatalog::from_file(&sum.host_catalog).unwrap();
|
||||||
assert_eq!(loaded.kind_of("fifa17_20801"), ContentKind::Player);
|
assert_eq!(loaded.kind_of("fifa17_20801"), ContentKind::Player);
|
||||||
assert_eq!(loaded.kind_of("fifa17_5003012"), ContentKind::Consumable);
|
assert_eq!(loaded.kind_of("fifa17_5003012"), ContentKind::Consumable);
|
||||||
assert_eq!(loaded.subtype_of("fifa17_5003012"), 201);
|
assert_eq!(loaded.subtype_of("fifa17_5003012"), 201);
|
||||||
assert_eq!(loaded.kind_of("fifa17_3000083"), ContentKind::Staff);
|
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.
|
// Manifest: private non_player section with preserved wire ids.
|
||||||
let man: serde_json::Value =
|
let man: serde_json::Value =
|
||||||
serde_json::from_str(&std::fs::read_to_string(&sum.manifest).unwrap()).unwrap();
|
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"]
|
let np = man["non_player"]["supported_definitions"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(np.len(), 2);
|
assert_eq!(np.len(), 3);
|
||||||
let cons_man = np
|
let cons_man = np
|
||||||
.iter()
|
.iter()
|
||||||
.find(|d| d["card_id"] == "fifa17_5003012")
|
.find(|d| d["card_id"] == "fifa17_5003012")
|
||||||
@@ -836,13 +875,14 @@ fn plan_apply_mints_non_player_owned_instances() {
|
|||||||
player(100000001, 20801, 20801, 94),
|
player(100000001, 20801, 20801, 94),
|
||||||
consumable(100000201, 5003012, 201),
|
consumable(100000201, 5003012, 201),
|
||||||
staff(100000427, 3000083, 8),
|
staff(100000427, 3000083, 8),
|
||||||
|
kit(100000500, 6300006, 21, "activeHomeKit"),
|
||||||
];
|
];
|
||||||
let (report, raw) = report_and_raw(&items, "[]", 100000500);
|
let (report, raw) = report_and_raw(&items, "[]", 100000500);
|
||||||
let plan = plan_apply(&report, &raw, "fp").unwrap();
|
let plan = plan_apply(&report, &raw, "fp").unwrap();
|
||||||
// 1 player + 2 non-player owned instances, minted via the identical path.
|
// Player, consumable, staff, and kit instances mint through one generic path.
|
||||||
assert_eq!(plan.request.owned.len(), 3);
|
assert_eq!(plan.request.owned.len(), 4);
|
||||||
assert_eq!(plan.mappings.len(), 3);
|
assert_eq!(plan.mappings.len(), 4);
|
||||||
assert_eq!(plan.supported_instances, 3);
|
assert_eq!(plan.supported_instances, 4);
|
||||||
assert_eq!(plan.deferred_instances, 0);
|
assert_eq!(plan.deferred_instances, 0);
|
||||||
let cards: BTreeSet<&str> = plan
|
let cards: BTreeSet<&str> = plan
|
||||||
.request
|
.request
|
||||||
@@ -852,6 +892,7 @@ fn plan_apply_mints_non_player_owned_instances() {
|
|||||||
.collect();
|
.collect();
|
||||||
assert!(cards.contains("fifa17_5003012"), "consumable minted");
|
assert!(cards.contains("fifa17_5003012"), "consumable minted");
|
||||||
assert!(cards.contains("fifa17_3000083"), "staff minted");
|
assert!(cards.contains("fifa17_3000083"), "staff minted");
|
||||||
|
assert!(cards.contains("fifa17_6300006"), "kit minted");
|
||||||
// Deterministic OwnedItemId per (persona, wire) — same rule as players.
|
// Deterministic OwnedItemId per (persona, wire) — same rule as players.
|
||||||
let m = plan
|
let m = plan
|
||||||
.mappings
|
.mappings
|
||||||
|
|||||||
+131
-88
@@ -51,7 +51,8 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy};
|
use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy};
|
||||||
use openfut_adapter_fifa17::fut::club_response::{
|
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::club_stats::{club_stats_body, ClubStatInput, ContextField};
|
||||||
use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind;
|
use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind;
|
||||||
@@ -636,6 +637,13 @@ pub struct CoreReplaceResult {
|
|||||||
pub slots_written: usize,
|
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
|
/// How the host reaches Core. The adapter never sees this — the host owns the
|
||||||
/// transport, mirroring the architecture rule. Tests inject a fake.
|
/// transport, mirroring the architecture rule. Tests inject a fake.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[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> {
|
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
|
||||||
Err(CoreError::Parse(
|
Err(CoreError::Parse(
|
||||||
"Core SBC access is not implemented".into(),
|
"Core SBC access is not implemented".into(),
|
||||||
@@ -872,6 +886,32 @@ impl CoreAccess for HttpCoreClient {
|
|||||||
Ok(())
|
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> {
|
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
@@ -1608,6 +1648,24 @@ impl Fifa17IdentityResolver {
|
|||||||
pub fn definition_identity(&self, card_id: &str) -> Option<(i64, ContentKind)> {
|
pub fn definition_identity(&self, card_id: &str) -> Option<(i64, ContentKind)> {
|
||||||
self.catalog.lookup(card_id).map(|c| (c.rareflag, c.kind))
|
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 {
|
impl ItemIdentityResolver for Fifa17IdentityResolver {
|
||||||
@@ -1615,36 +1673,32 @@ impl ItemIdentityResolver for Fifa17IdentityResolver {
|
|||||||
// Definition identity first: an unmapped card is dropped (never faked).
|
// Definition identity first: an unmapped card is dropped (never faked).
|
||||||
let ident = self.catalog.lookup(&item.card_id)?;
|
let ident = self.catalog.lookup(&item.card_id)?;
|
||||||
// Instance identity: stable, persistent, reversible wire id.
|
// Instance identity: stable, persistent, reversible wire id.
|
||||||
let wire = match self.store.resolve_or_allocate(
|
let wire = self.wire_for(item)?;
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Some(Fifa17Identity {
|
Some(Fifa17Identity {
|
||||||
// Wire ids live in 1e8..9e8 (policy) — well within u32.
|
item_id: wire,
|
||||||
item_id: wire as u32,
|
|
||||||
asset_id: ident.asset_id,
|
asset_id: ident.asset_id,
|
||||||
resource_id: ident.resource_id,
|
resource_id: ident.resource_id,
|
||||||
rareflag: ident.rareflag,
|
rareflag: ident.rareflag,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delegate content classification to the catalog so `/club` excludes
|
fn resolve_kit(&self, item: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
|
||||||
/// consumable/staff cards (they must never render as 0-rated players). An
|
let ident = self.catalog.lookup(&item.card_id)?;
|
||||||
/// unmapped card_id resolves to `Player` (the catalog default) but is already
|
if ident.kind != ContentKind::Kit {
|
||||||
/// dropped by `resolve` returning `None`, so it is never emitted anyway.
|
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 {
|
fn kind_of(&self, item: &CoreOwnedItem) -> ContentKind {
|
||||||
self.catalog.kind_of(&item.card_id)
|
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
|
/// 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.
|
/// it must not also appear here. Empty = show everything Core owns.
|
||||||
pub hidden: &'a std::collections::HashSet<String>,
|
pub hidden: &'a std::collections::HashSet<String>,
|
||||||
|
pub active_kits: &'a CoreKitAssignments,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filter already-shaped `/club` items to SPECIALS (`rareflag > 1`) and paginate
|
/// 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).
|
/// JSON body (UTAS must never 401/403; an empty result is the safe degrade).
|
||||||
pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) {
|
pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) {
|
||||||
let raw = parse_club_query(query);
|
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) {
|
let core_q = match map_to_core(&raw, deps.entities) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Unknown FIFA id — never a raw-id passthrough, never a guess.
|
|
||||||
return (
|
return (
|
||||||
json_response(&json!({ "itemData": [] })),
|
json_response(&json!({ "itemData": [] })),
|
||||||
ClubLog {
|
ClubLog {
|
||||||
@@ -1754,46 +1826,47 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
|
|||||||
total: 0,
|
total: 0,
|
||||||
emitted: 0,
|
emitted: 0,
|
||||||
dropped_no_asset: 0,
|
dropped_no_asset: 0,
|
||||||
offset: raw.start.map(|s| s as i64),
|
offset: raw.start.map(|value| value as i64),
|
||||||
limit: raw.count.map(|c| c 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);
|
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();
|
let mut base = core_q.clone();
|
||||||
base.offset = None;
|
base.offset = None;
|
||||||
base.limit = None;
|
base.limit = None;
|
||||||
return match deps.core.query_owned(&base.to_query_pairs()) {
|
let mut filter = summarize(&base.to_query_pairs());
|
||||||
|
if !filter.is_empty() {
|
||||||
|
filter.push(',');
|
||||||
|
}
|
||||||
|
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) => {
|
Ok(page) => {
|
||||||
// Exclude hidden instances BEFORE shaping: a card on the transfer
|
// Kind and transfer-pile membership live outside generic Core, so
|
||||||
// list is not in the club, so it must not consume a page slot.
|
// filtering and pagination must happen here over the final set.
|
||||||
let visible: Vec<CoreOwnedItem> = page
|
let visible: Vec<CoreOwnedItem> = page
|
||||||
.items
|
.items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|it| !deps.hidden.contains(&it.owned_card_id))
|
.filter(|item| !deps.hidden.contains(&item.owned_card_id))
|
||||||
|
.filter(|item| deps.assets.kind_of(item) == requested_kind)
|
||||||
.collect();
|
.collect();
|
||||||
let (body, stats) = shape_club_response(&visible, deps.entities, deps.assets);
|
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
|
let all = body
|
||||||
.get("itemData")
|
.get("itemData")
|
||||||
.and_then(|v| v.as_array())
|
.and_then(Value::as_array)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let (paged, total) = if core_q.special {
|
let (paged, total) = if core_q.special {
|
||||||
@@ -1806,7 +1879,7 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
|
|||||||
json_response(&json!({ "itemData": paged })),
|
json_response(&json!({ "itemData": paged })),
|
||||||
ClubLog {
|
ClubLog {
|
||||||
outcome: "ok",
|
outcome: "ok",
|
||||||
filter: local_desc,
|
filter,
|
||||||
total,
|
total,
|
||||||
emitted,
|
emitted,
|
||||||
dropped_no_asset: stats.dropped_no_asset,
|
dropped_no_asset: stats.dropped_no_asset,
|
||||||
@@ -1815,43 +1888,8 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(error) => {
|
||||||
eprintln!("utas-host ERROR /club (local-filter) core query failed: {e}");
|
eprintln!("utas-host ERROR /club core query failed: {error}");
|
||||||
(
|
|
||||||
json_response(&json!({ "itemData": [] })),
|
|
||||||
ClubLog {
|
|
||||||
outcome: "core_error",
|
|
||||||
filter: local_desc,
|
|
||||||
total: 0,
|
|
||||||
emitted: 0,
|
|
||||||
dropped_no_asset: 0,
|
|
||||||
offset,
|
|
||||||
limit,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
match deps.core.query_owned(&pairs) {
|
|
||||||
Ok(page) => {
|
|
||||||
let (body, stats): (Value, ShapeStats) =
|
|
||||||
shape_club_response(&page.items, deps.entities, deps.assets);
|
|
||||||
(
|
|
||||||
json_response(&body),
|
|
||||||
ClubLog {
|
|
||||||
outcome: "ok",
|
|
||||||
filter,
|
|
||||||
total: page.total,
|
|
||||||
emitted: stats.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}");
|
|
||||||
(
|
(
|
||||||
json_response(&json!({ "itemData": [] })),
|
json_response(&json!({ "itemData": [] })),
|
||||||
ClubLog {
|
ClubLog {
|
||||||
@@ -3382,11 +3420,16 @@ impl Server {
|
|||||||
Route::Club => {
|
Route::Club => {
|
||||||
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
|
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
|
||||||
let hidden = self.club_hidden_ids();
|
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 {
|
let deps = ClubDeps {
|
||||||
core: self.core.as_ref(),
|
core: self.core.as_ref(),
|
||||||
entities: self.entities.as_ref(),
|
entities: self.entities.as_ref(),
|
||||||
assets: self.resolver.as_ref(),
|
assets: self.resolver.as_ref(),
|
||||||
hidden: &hidden,
|
hidden: &hidden,
|
||||||
|
active_kits: &active_kits,
|
||||||
};
|
};
|
||||||
let (resp, log) = handle_club(query, &deps);
|
let (resp, log) = handle_club(query, &deps);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ use openfut_identity::JsonIdentityStore;
|
|||||||
use openfut_utas_host::account_store::AccountStore;
|
use openfut_utas_host::account_store::AccountStore;
|
||||||
use openfut_utas_host::{
|
use openfut_utas_host::{
|
||||||
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
||||||
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, CorePage,
|
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState,
|
||||||
CoreReplaceRequest, CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver,
|
CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreSquadRead,
|
||||||
HttpCoreClient, PassClient, Route, Server, SquadDeps,
|
CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route, Server, SquadDeps,
|
||||||
};
|
};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use serde_json::Value;
|
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.
|
// oc2 has an active transfer-market listing.
|
||||||
let hidden: std::collections::HashSet<String> = ["oc2".to_string()].into_iter().collect();
|
let hidden: std::collections::HashSet<String> = ["oc2".to_string()].into_iter().collect();
|
||||||
|
let active_kits = CoreKitAssignments::default();
|
||||||
let deps = ClubDeps {
|
let deps = ClubDeps {
|
||||||
core: core.as_ref(),
|
core: core.as_ref(),
|
||||||
entities: &ents,
|
entities: &ents,
|
||||||
assets: resolver.as_ref(),
|
assets: resolver.as_ref(),
|
||||||
hidden: &hidden,
|
hidden: &hidden,
|
||||||
|
active_kits: &active_kits,
|
||||||
};
|
};
|
||||||
let (resp, log) = handle_club("", &deps);
|
let (resp, log) = handle_club("", &deps);
|
||||||
let v: Value = serde_json::from_slice(&resp.body).unwrap();
|
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);
|
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 none: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
let deps_all = ClubDeps {
|
let deps_all = ClubDeps {
|
||||||
core: core.as_ref(),
|
core: core.as_ref(),
|
||||||
entities: &ents,
|
entities: &ents,
|
||||||
assets: resolver.as_ref(),
|
assets: resolver.as_ref(),
|
||||||
hidden: &none,
|
hidden: &none,
|
||||||
|
active_kits: &active_kits,
|
||||||
};
|
};
|
||||||
let (resp3, log3) = handle_club("", &deps_all);
|
let (resp3, log3) = handle_club("", &deps_all);
|
||||||
let v3: Value = serde_json::from_slice(&resp3.body).unwrap();
|
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);
|
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 ─────────────────────────────────────────────────
|
// ── /club served from Core ─────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -149,6 +149,14 @@ SELLER_SQUAD_CARDS = [
|
|||||||
DISPOSABLE_ITEM = "owned-a-disposable"
|
DISPOSABLE_ITEM = "owned-a-disposable"
|
||||||
DISPOSABLE_CARD = "fifa17_232273" # Nelson Atiagli LB 51, rareflag 1
|
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
|
READY_TIMEOUT_S = 60.0
|
||||||
|
|
||||||
|
|
||||||
@@ -439,6 +447,47 @@ def materialise(lay: Layout) -> None:
|
|||||||
shutil.copy2(safe_path(src), safe_path(dst))
|
shutil.copy2(safe_path(src), safe_path(dst))
|
||||||
ok(f"FIFA17 content copied from {CONTENT_SRC} (outside production state)")
|
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:
|
def assert_seed_cards_resolvable(lay: Layout) -> None:
|
||||||
"""Core's content preflight rejects any owned card whose card_id is not a loaded
|
"""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)}
|
pack_ids = {c["id"] for c in json.load(fh)}
|
||||||
with open(safe_path(lay.catalog)) as fh:
|
with open(safe_path(lay.catalog)) as fh:
|
||||||
catalog_ids = set(json.load(fh)["cards"])
|
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_pack = sorted(set(wanted) - pack_ids)
|
||||||
missing_cat = sorted(set(wanted) - catalog_ids)
|
missing_cat = sorted(set(wanted) - catalog_ids)
|
||||||
if missing_pack or missing_cat:
|
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, "
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, "
|
||||||
"acquired_at) VALUES (?, ?, ?, 0, ?)",
|
"acquired_at) VALUES (?, ?, ?, 0, ?)",
|
||||||
[(item, SELLER_CLUB, card, TS) for item, card in SELLER_SQUAD_CARDS]
|
[(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(
|
conn.execute(
|
||||||
"INSERT INTO squads (id, club_id, name, formation, created_at, "
|
"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)
|
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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
ok(
|
ok(
|
||||||
f"seeded Seller A ({PERSONA_NAME}, persona {PERSONA_ID}, {SELLER_COINS} coins, "
|
f"seeded Seller A ({PERSONA_NAME}, persona {PERSONA_ID}, {SELLER_COINS} coins, "
|
||||||
f"{len(SELLER_SQUAD_CARDS)} starters + 1 disposable) and Buyer B "
|
f"{len(SELLER_SQUAD_CARDS)} starters + 1 disposable + "
|
||||||
f"({BUYER_COINS} coins)"
|
f"{len(STAGING_KITS)} active kits) and Buyer B ({BUYER_COINS} coins)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user