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
+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]