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!(