feat(fifa17): project owned kits with active home/away designation

Closes the server side of the FUT kit selector. Ownership stays generic in
Core (submodule bump: club_kit_assignments + GET/PUT /club/kits); this
commit adds the FIFA17 representation, the host projection and importer
support.

adapter:
* ContentKind::Kit ("kit") so kits are classified alongside player/staff/
  consumable instead of being mistaken for 0-rated players.
* Fifa17CardIdentity carries card_asset_id and team_id; RawCard keeps both
  optional because the emitted catalog writes null for non-kit definitions.
* shape_kit_item emits only the fields the client's kit path reads
  (id/resourceId/assetId/cardassetid/cardsubtypeid/itemState/owners/
  untradeable/teamid) — no attributeList, no itemType.
* itemState on the wire is the STRING token activeHomeKit/activeAwayKit;
  the 101/102 integers are the client's post-deserialisation runtime enum
  (item+0x5c) and are never emitted.
* club_stats S_KITS (0x28) now counts owned kits instead of a hard zero.

host:
* CoreKitAssignments + CoreAccess::get_active_kits (GET /club/kits),
  defaulting to no active kits so a Core without the endpoint degrades
  instead of fabricating a designation.
* handle_club classifies type=player|kit, rejects any other type with an
  empty page and outcome=unsupported_type, and now always fetches
  unpaginated from Core: kind and transfer-pile membership are host-side
  concepts Core cannot express, so filtering and pagination must both
  happen after shaping or pages come back short.

importer:
* ItemClass::Kit (cardsubtypeid == 9 and resourceId in 6_300_000..=6_400_654),
  kit counts/balances, and card_asset_id/team_id carried into the emitted
  catalog and manifest.
* a kit group missing cardassetid == 35 or teamid is DEFERRED
  (missing_kit_render_metadata) rather than defaulted; conflicting render
  metadata across instances defers as render_metadata_conflict.

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