Files
OpenFUT/openfut-adapter-fifa17/src/fut/club_response.rs
T
funman300 9026220533 feat(fifa17): manager contracts, from a Core-owned staff tier
ROOT CAUSE, one line. openfut-import-fifa17 emitted `"overall": 0` for every
non-player Core definition while `d.rating` already held EA's authoritative
`value` -- and the very next block wrote that same number correctly to the
adapter catalog. So the tier existed host-side but never reached Core:
Core overall 0 -> /collection effective_overall 0 -> CoreOwnedItem.rating 0 ->
tier_for_rating(0) = Bronze for a Gold (88) manager. That silent mis-grant is
exactly what the 409 was protecting against, so the refusal was correct.

The emitter now also writes `source_rating`, keeping `overall` at 0. Regenerating
the production pack changes exactly 18 entries and exactly one field each
(source_rating None -> value); same 1710 ids, same fingerprint 28c333f1e833338a.

WHY value IS the tier source, and why the thresholds are the player ladder:
LIVE_PROVEN, not inferred. The client re-rates staff from its own
managercards/*coachcards/physiocards by carddbid and applies discard_level's
65/75 ladder; coach_probe/discard_probe agree 4/4 (manager value 88 -> level 3,
coaches 66 -> level 2). The shipped coach tables corroborate: each family has
exactly 3 tiers x 2 rarities, and only 65/75 splits them 2/2/2.

Manager contracts stop refusing and now resolve the TARGET's tier from
Core-owned state. Still fail-closed everywhere it matters: a coach or physio is
`contract_target_not_a_manager` (only cardsubtypeid 4 is a manager), and a
manager Core carries no source_rating for is `manager_tier_unknown` rather than
a guessed tier. Core's own content_kind token is sent as target_kind, because
Core calls the squad manager `manager` while the catalog classifies it
`staff`+subtype 4.

NOT implemented, unchanged: STORED_MANAGER_BONUS and MATCH_CONTRACT_DECREMENT.
2026-08-22 20:08:26 +00:00

642 lines
24 KiB
Rust

//! Shape OpenFUT Core's semantic owned inventory into the FIFA 17 `/club`
//! response envelope `{"itemData":[ <player item>, … ]}`.
//!
//! This module owns only the **`/club` envelope**; the per-item shape lives in
//! the shared [`crate::fut::item`] primitive so `/club` and squad projection
//! emit byte-identical items. Items whose real FIFA asset id is unknown are
//! **dropped and counted** here (a collection may omit an unrenderable card);
//! squad projection, which cannot omit a starter, refuses instead.
use serde_json::{json, Value};
use crate::fut::content_taxonomy::ContentKind;
use crate::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item::{shape_club_item, shape_item, shape_staff_item, STAFF_CONTRACT};
use crate::fut::item_state;
// Re-exported so existing `club_response::{…}` callers keep working; the types
// are now defined once in `fut::item`.
pub use crate::fut::item::{
CoreOwnedItem, Fifa17ConsumableIdentity, Fifa17Identity, Fifa17KitIdentity,
Fifa17StaffIdentity, ItemIdentityResolver, ShapeStats,
};
/// 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.
///
/// This envelope carries the two families whose record shape it can carry:
/// players and kits, plus the staff family (manager + the four coach families).
/// Consumables have their own route and their own STACK envelope, and the
/// club-customisation families are counted and withheld — see each arm.
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 {
match ident.kind_of(item) {
ContentKind::Player => match ident.resolve(item) {
Some(id) => {
out.push(shape_item(
item,
id,
ent,
ident.discard_value(item),
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
},
// Kit, badge and stadium are ONE cardtype-7 record with one
// client-side resolver; only the equipped designation differs.
ContentKind::Kit | ContentKind::Badge | ContentKind::Stadium => {
match ident.resolve_kit(item) {
Some(id) => {
let state = if active_kits.home == Some(item.owned_card_id.as_str()) {
item_state::ACTIVE_HOME_KIT
} else if active_kits.away == Some(item.owned_card_id.as_str()) {
item_state::ACTIVE_AWAY_KIT
} else {
item_state::FREE
};
out.push(shape_club_item(id, state));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
}
}
// A manager is a staff card: both Core kinds resolve through the one
// staff record shape, discriminated on the wire by `cardsubtypeid`
// (the same set as `ContentKind::is_staff_family`, spelled out here
// because a guard arm would not prove exhaustiveness).
ContentKind::Manager | ContentKind::Staff => match ident.resolve_staff(item) {
Some(id) => {
out.push(shape_staff_item(
id,
item.contract_matches.unwrap_or(STAFF_CONTRACT),
));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
},
// Consumables have their OWN route and their own envelope:
// `GET club/consumables/<category>`, whose element is a stack
// wrapper, not an item (see [`crate::fut::consumables`]). A bare
// consumable item in THIS envelope is accepted by the client and
// silently discarded, so emitting one here would be a 200 that does
// nothing — the worst failure shape in this project. Counted.
ContentKind::Consumable => {
stats.excluded_non_player += 1;
}
// The cardtype-9 families. Unlike kits/badges/stadia these have NO
// database name resolver at all, so the displayed name can only come
// from `localizedName` on the wire. That offset is confirmed
// (`+0xd9`), but "the parser reads it" is NOT "sending it is safe",
// and this project pays for that distinction with a client freeze.
// Counted and withheld rather than guessed: ownership stays
// authoritative in Core either way, and club/stats still counts the
// families so the screen's own numbers are right.
ContentKind::Ball | ContentKind::Misc => {
stats.excluded_non_player += 1;
}
}
}
(json!({ "itemData": out }), stats)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fut::contract_cards::CONTRACT_MATCH_CAP;
use crate::fut::entities::Fifa17Entities;
use std::collections::HashMap;
fn entities() -> Fifa17Entities {
Fifa17Entities::from_maps(
HashMap::from([(13, "Premier League".to_string())]),
HashMap::from([(52, "Argentina".to_string())]),
HashMap::from([(5, "Chelsea".to_string())]),
)
}
fn item(
owned: &str,
card: &str,
rating: u8,
pos: &str,
nation: &str,
league: &str,
club: &str,
) -> CoreOwnedItem {
CoreOwnedItem {
owned_card_id: owned.into(),
card_id: card.into(),
rating,
position: pos.into(),
nation: nation.into(),
league: league.into(),
club: club.into(),
attributes: [90, 88, 70, 85, 40, 78],
// Untracked by default, so these fixtures exercise the pack-fresh
// fallback; a test that cares sets it explicitly.
contract_matches: None,
source_rating: None,
core_content_kind: None,
}
}
/// Test resolver: card_id -> real asset id, item_id from a table. Stands in
/// for the (unresolved-in-production) Core-card→asset mapping.
struct MapIdentity(HashMap<String, Fifa17Identity>);
impl ItemIdentityResolver for MapIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.0.get(&it.card_id).copied()
}
}
#[test]
fn shapes_item_with_full_field_set_and_reverse_ids() {
let ent = entities();
let ident = MapIdentity(HashMap::from([(
"card_ch_1".to_string(),
Fifa17Identity {
item_id: 100000001,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]));
let items = vec![item(
"oc1",
"card_ch_1",
86,
"CDM",
"Argentina",
"Premier League",
"Chelsea",
)];
let (body, stats) = shape_club_response(&items, &ent, &ident);
assert_eq!(stats.emitted, 1);
assert_eq!(stats.dropped_no_asset, 0);
let it = &body["itemData"][0];
assert_eq!(it["id"], 100000001);
assert_eq!(it["resourceId"], 20801);
assert_eq!(it["assetId"], 20801);
assert_eq!(
it["definitionId"], 20801,
"version byte 0 => resourceId==assetId==definitionId"
);
assert_eq!(it["rating"], 86);
assert_eq!(it["preferredPosition"], "CDM");
assert_eq!(it["leagueId"], 13);
assert_eq!(it["teamid"], 5);
assert_eq!(it["nation"], 52);
assert_eq!(it["itemType"], "player");
assert_eq!(it["rareflag"], 1);
assert_eq!(it["contract"], 7);
assert_eq!(it["fitness"], 99);
assert_eq!(it["attributeList"].as_array().unwrap().len(), 6);
assert_eq!(it["attributeList"][0], json!({"index":0,"value":90}));
}
#[test]
fn drops_items_without_a_real_asset_id_never_faking() {
let ent = entities();
// Empty identity map == the current synthetic-catalogue reality.
let ident = MapIdentity(HashMap::new());
let items = vec![item(
"oc1",
"card_pl_001",
84,
"ST",
"England",
"Premier League",
"Northgate United",
)];
let (body, stats) = shape_club_response(&items, &ent, &ident);
assert_eq!(stats.emitted, 0);
assert_eq!(stats.dropped_no_asset, 1);
assert_eq!(
body["itemData"].as_array().unwrap().len(),
0,
"no fabricated ids emitted"
);
}
#[test]
fn unresolved_entity_names_become_neutral_zero_not_dropped() {
let ent = entities();
let ident = MapIdentity(HashMap::from([(
"card_x".to_string(),
Fifa17Identity {
item_id: 100000002,
asset_id: 158023,
resource_id: 158023,
rareflag: 1,
},
)]));
// Synthetic club "Northgate United" has no FIFA team id.
let items = vec![item(
"oc2",
"card_x",
84,
"ST",
"England",
"Premier League",
"Northgate United",
)];
let (body, _) = shape_club_response(&items, &ent, &ident);
let it = &body["itemData"][0];
assert_eq!(
it["teamid"], 0,
"unknown club -> neutral 0, item still emitted"
);
assert_eq!(it["leagueId"], 13);
assert_eq!(it["nation"], 0, "England not in the test nation map -> 0");
}
#[test]
fn envelope_is_itemdata_object() {
let ent = entities();
let ident = MapIdentity(HashMap::new());
let (body, _) = shape_club_response(&[], &ent, &ident);
assert!(body.get("itemData").unwrap().is_array());
assert_eq!(
body.as_object().unwrap().len(),
1,
"only itemData at top level"
);
}
/// A resolver that resolves an asset id for EVERY item (so exclusion is not
/// an artifact of a missing asset) but classifies some card_ids as non-player
/// via an explicit kind table.
struct KindMapIdentity {
ids: HashMap<String, Fifa17Identity>,
kinds: HashMap<String, ContentKind>,
kits: HashMap<String, Fifa17KitIdentity>,
staff: HashMap<String, Fifa17StaffIdentity>,
}
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 resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
self.staff.get(&it.card_id).copied()
}
fn kind_of(&self, it: &CoreOwnedItem) -> ContentKind {
self.kinds
.get(&it.card_id)
.copied()
.unwrap_or(ContentKind::Player)
}
}
#[test]
fn consumables_are_excluded_but_staff_is_shaped() {
let ent = entities();
let id = |item_id: u32, asset: u32| Fifa17Identity {
item_id,
asset_id: asset,
resource_id: asset,
rareflag: 1,
};
let ident = KindMapIdentity {
ids: HashMap::from([
("card_player".to_string(), id(100000001, 20801)),
("card_consumable".to_string(), id(100000002, 5003012)),
]),
kits: HashMap::new(),
staff: HashMap::from([(
"card_staff".to_string(),
Fifa17StaffIdentity {
item_id: 100000003,
resource_id: 3000083,
subtype: 8,
nation: 0,
league_id: 0,
team_id: 0,
},
)]),
kinds: HashMap::from([
("card_consumable".to_string(), ContentKind::Consumable),
("card_staff".to_string(), ContentKind::Staff),
]),
};
let items = vec![
item(
"oc1",
"card_player",
86,
"ST",
"Argentina",
"Premier League",
"Chelsea",
),
item("oc2", "card_consumable", 0, "", "", "", ""),
item("oc3", "card_staff", 0, "", "", "", ""),
];
let (body, stats) = shape_club_response(&items, &ent, &ident);
assert_eq!(
stats.emitted, 2,
"the player and the staff card are emitted"
);
assert_eq!(
stats.excluded_non_player, 1,
"only the consumable is excluded; staff has a wire envelope of its own"
);
assert_eq!(stats.dropped_no_asset, 0);
let arr = body["itemData"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["id"], 100000001, "the player survives");
assert_eq!(arr[0]["itemType"], "player");
let coach = &arr[1];
assert_eq!(coach["id"], 100000003);
assert_eq!(coach["resourceId"], 3000083);
assert_eq!(coach["cardsubtypeid"], 8);
assert_eq!(coach["itemType"], "staff");
assert_eq!(
coach["contract"], STAFF_CONTRACT,
"this fixture is UNTRACKED (contract_matches None), so the wire shows \
the pack-fresh fallback — not because the shaper hardcodes it"
);
assert!(
coach.get("nation").is_none()
&& coach.get("leagueId").is_none()
&& coach.get("teamid").is_none(),
"a COACH has no nation/league/team column in the client's tables, so \
those keys must be absent rather than invented as zeroes"
);
assert!(
coach.get("attributeList").is_none() && coach.get("preferredPosition").is_none(),
"both survive the client's merge and are read by the card view-model"
);
}
#[test]
fn manager_carries_the_chemistry_fields_only_the_server_can_supply() {
let ent = entities();
let ident = KindMapIdentity {
ids: HashMap::new(),
kits: HashMap::new(),
staff: HashMap::from([(
"card_manager".to_string(),
Fifa17StaffIdentity {
item_id: 100004871,
resource_id: 1000509,
subtype: 4,
nation: 45,
league_id: 53,
team_id: 241,
},
)]),
kinds: HashMap::from([("card_manager".to_string(), ContentKind::Staff)]),
};
let items = vec![item("oc-mgr", "card_manager", 0, "", "", "", "")];
let (body, stats) = shape_club_response(&items, &ent, &ident);
assert_eq!(stats.emitted, 1);
let mgr = &body["itemData"][0];
assert_eq!(
mgr["cardsubtypeid"], 4,
"subtype alone selects managercards"
);
assert_eq!(
mgr["resourceId"], 1000509,
"the merge key is read RAW: it must equal the carddbid with no version byte"
);
// rec+0xde / rec+0xe0 / rec+0x94 — the merge never writes these, so an
// omission here is an unrecoverable blank flag and zero chemistry.
assert_eq!(mgr["nation"], 45);
assert_eq!(mgr["leagueId"], 53);
assert_eq!(mgr["teamid"], 241);
assert_eq!(
mgr["contract"], STAFF_CONTRACT,
"untracked fixture => pack-fresh fallback"
);
assert_eq!(mgr["itemState"], "free");
assert_eq!(mgr["owners"], 1);
let keys: Vec<&String> = mgr.as_object().unwrap().keys().collect();
assert_eq!(
keys.len(),
11,
"exactly the 11 justified keys, no more: {keys:?}"
);
}
/// `/club` is the screen a contract apply is judged on: if the envelope keeps
/// reporting the pack-fresh count, a committed apply is invisible and the
/// operator sees a 200 that did nothing. Both families must carry the number
/// Core persisted.
#[test]
fn club_reports_the_contract_core_persisted_for_players_and_staff() {
let ent = entities();
let ident = KindMapIdentity {
ids: HashMap::from([(
"card_player".to_string(),
Fifa17Identity {
item_id: 100000001,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]),
kits: HashMap::new(),
staff: HashMap::from([(
"card_manager".to_string(),
Fifa17StaffIdentity {
item_id: 100004871,
resource_id: 1000509,
subtype: 4,
nation: 45,
league_id: 53,
team_id: 241,
},
)]),
kinds: HashMap::from([
("card_player".to_string(), ContentKind::Player),
("card_manager".to_string(), ContentKind::Staff),
]),
};
// A player mid-way through its contracts, a fully topped-up manager, and
// one untracked player that must fall back.
let mut played = item(
"oc-played",
"card_player",
86,
"ST",
"Argentina",
"Premier League",
"Chelsea",
);
played.contract_matches = Some(3);
let mut manager = item("oc-mgr", "card_manager", 0, "", "", "", "");
manager.contract_matches = Some(CONTRACT_MATCH_CAP);
let untracked = item(
"oc-fresh",
"card_player",
86,
"ST",
"Argentina",
"Premier League",
"Chelsea",
);
let (body, stats) = shape_club_response(&[played, manager, untracked], &ent, &ident);
assert_eq!(stats.emitted, 3);
let arr = body["itemData"].as_array().unwrap();
assert_eq!(arr[0]["contract"], 3, "the player's persisted count");
assert_eq!(
arr[1]["contract"], CONTRACT_MATCH_CAP,
"staff read the same persisted field, not STAFF_CONTRACT"
);
assert_eq!(
arr[2]["contract"], PACK_FRESH_CONTRACT_MATCHES,
"only an untracked instance falls back"
);
}
#[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(),
staff: 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());
}
/// Kit, badge and stadium are one cardtype-7 record and MUST all project.
/// Ball and league logo are cardtype 9, have no database name resolver, and
/// stay withheld until `localizedName` is established as safe to send.
/// Counting a family in club/stats while never shaping it is the divergence
/// this test pins: the wire set and the withheld set are both asserted.
#[test]
fn cardtype7_club_items_project_and_cardtype9_stay_withheld() {
let ent = entities();
let kit_id = |item_id, resource, subtype, art| Fifa17KitIdentity {
item_id,
asset_id: resource,
resource_id: resource,
card_asset_id: art,
subtype,
team_id: 21,
};
let ident = KindMapIdentity {
ids: HashMap::new(),
kinds: HashMap::from([
("c_kit".to_string(), ContentKind::Kit),
("c_badge".to_string(), ContentKind::Badge),
("c_stadium".to_string(), ContentKind::Stadium),
("c_ball".to_string(), ContentKind::Ball),
("c_logo".to_string(), ContentKind::Misc),
]),
kits: HashMap::from([
("c_kit".to_string(), kit_id(1, 6_300_006, 9, 35)),
("c_badge".to_string(), kit_id(2, 6_000_005, 11, 39)),
("c_stadium".to_string(), kit_id(3, 6_200_000, 10, 36)),
// Resolvable on purpose: withholding must be a decision about the
// FAMILY, not an accident of a missing identity.
("c_ball".to_string(), kit_id(4, 8_120_194, 30, 37)),
("c_logo".to_string(), kit_id(5, 8_010_015, 31, 40)),
]),
staff: HashMap::new(),
};
let items: Vec<CoreOwnedItem> = ["c_kit", "c_badge", "c_stadium", "c_ball", "c_logo"]
.iter()
.map(|c| item(&format!("oc_{c}"), c, 0, "", "", "", ""))
.collect();
let (body, stats) = shape_club_response_with_kits(
&items,
&ent,
&ident,
ActiveKitAssignments {
home: None,
away: None,
},
);
let arr = body["itemData"].as_array().unwrap();
assert_eq!(stats.emitted, 3, "kit + badge + stadium");
assert_eq!(stats.excluded_non_player, 2, "ball + league logo withheld");
assert_eq!(stats.dropped_no_asset, 0, "withholding is not a drop");
let subtypes: Vec<i64> = arr
.iter()
.map(|i| i["cardsubtypeid"].as_i64().unwrap())
.collect();
assert_eq!(subtypes, vec![9, 11, 10]);
// teamid only where the caption resolves TeamName_Abbr15_<teamid>.
assert_eq!(arr[0]["teamid"], 21, "kit");
assert_eq!(arr[1]["teamid"], 21, "badge");
assert!(
arr[2].get("teamid").is_none(),
"stadium caption reads assetId"
);
for it in arr {
assert!(
item_state::is_recovered(it["itemState"].as_str().unwrap()),
"every emitted state must be a recovered token"
);
}
}
}