//! FIFA 17 attribute training cards: which attribute a card trains, and by how //! much. //! //! ## Where this comes from //! //! Two independent shipped sources, no invention: //! //! * **which attribute** — `cardsubtypeid`. `FUN_18013f4d0` derives a //! consumable's whole presentation from that one field, and for the training //! families it writes an attribute selector to `rec+0xbc` and the magnitude to //! `rec+0xbf`. The selector per subtype is recorded in //! `fifa17-recon/data/consumables.json` (`subtypes[].bc`, with the client's own //! `FUT_UC_*` / `FUT_MC_*` string for each). STATIC_REVERSED. //! * **how much** — `fcc_trainingcards.amount`, EA's shipped table. Every owned //! consumable's wire `amount` matches that column 8/8. TABLE_PROVEN. //! //! ## Why the effect is ours to define at all //! //! No binary in the FIFA 17 install reads `fcc_trainingcards` at any casing, so //! unlike quick-sell (`fcc_discardcoins`, which the client DOES read) there is no //! client-side oracle for a consumable effect and never will be. The client ACKs //! an apply on transport code alone and then re-reads state. Whatever the server //! durably stores and re-serves IS what the player sees. That makes the //! *magnitude* and the *target attribute* recoverable facts — the two above — and //! everything about the effect's LIFECYCLE a server policy we must state //! explicitly rather than pretend to have reversed. See //! `TRAINING_MATCH_EXPIRY` below. //! //! ## Slot numbering //! //! The `attribute_index` this module produces is a slot in CORE's six-attribute //! model (0 pace, 1 shooting, 2 passing, 3 dribbling, 4 defending, 5 physical), //! not a FIFA attribute id. A goalkeeper's six attributes occupy those same six //! slots on the wire — DIV/HAN/KIC/REF/SPD/POS in that order — which is why a GK //! card and an outfield card can share one slot vocabulary. /// Which class of player a training card may be applied to. /// /// FIFA 17 authors the two families separately (`FUT_UC_*` for keepers, /// `FUT_MC_*` for outfielders) and their slots mean different attributes, so /// applying one to the wrong class would silently train the wrong stat. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TrainingClass { Goalkeeper, Outfield, } /// A resolved training effect: one attribute slot (or all six), one magnitude, /// one legal target class. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TrainingEffect { pub class: TrainingClass, /// Slot in Core's six-attribute model, or `None` for the rare card that /// boosts ALL SIX. pub attribute_index: Option, pub amount: i64, } /// The largest magnitude EA authors for a SINGLE-attribute training card. /// /// `fcc_trainingcards` authors exactly 5, 10 and 15 for all twelve /// single-attribute families. Declared to Core on every apply so Core can refuse /// a larger boost than any real card could grant, which is what keeps the closed /// vocabulary from being a blank cheque. pub const TRAINING_MAX_AMOUNT: i64 = 15; /// The largest magnitude EA authors for the RARE all-six training card. /// /// The two all-six subtypes author 3/6/10 only, so a +15 all-six card does not /// exist and must not be describable to Core. pub const TRAINING_ALL_MAX_AMOUNT: i64 = 10; /// GK attribute training subtypes → Core attribute slot. /// /// The client's own order is DIV, HAN, KIC, REF, SPD, POS, and `bc` follows it; /// note that the subtype ids do NOT (54 is SPEED at slot 4, 56 is REFLEXES at /// slot 3). Reading these off in subtype order instead of `bc` order is exactly /// the mistake this table exists to prevent. const GK_TRAINING: &[(i64, i64)] = &[ (51, 0), // FUT_UC_DIVING (52, 1), // FUT_UC_HANDLING (53, 2), // FUT_UC_KICKING (56, 3), // FUT_UC_REFLEXES (54, 4), // FUT_UC_SPEED (55, 5), // FUT_UC_POSITIONING ]; /// Outfield attribute training subtypes → Core attribute slot. /// /// Same trap as the keepers: 65 is HEADING at slot 5 (Core's `physical`) and 66 /// is DEFENDING at slot 4. const OUTFIELD_TRAINING: &[(i64, i64)] = &[ (61, 0), // FUT_MC_PACE (62, 1), // FUT_MC_SHOOTING (63, 2), // FUT_MC_PASSING (64, 3), // FUT_MC_DRIBBLING (66, 4), // FUT_MC_DEFENDING (65, 5), // FUT_MC_HEADING -> Core's `physical` slot ]; /// The RARE training cards, which boost ALL SIX attributes at once. /// /// These were previously mistaken for "squad fitness" on the strength of the /// reversed label `FUT_FITNESS_UC` / `FUT_FITNESS_MC`. That label is /// tool-authored (`build_consumables.py` names the 7th element of its attribute /// array) and has no documented provenance; four independent facts say all-six: /// /// * each family holds exactly 21 rows = 7 card types x 3 levels, and the /// published FIFA 17 card list is 6 single attributes + 1 "ALL"; /// * these six rows are the ONLY ones in the table with `weightrare = 2`; all 36 /// single-attribute rows are `weightrare = 0`. The published list marks the /// ALL card RARE and every single-attribute card non-rare; /// * their amounts are exactly 3/6/10, matching the published ALL card's /// +3 bronze / +6 silver / +10 gold, while single attributes are 5/10/15; /// * `bc = 6` is one past the six real slots (0..5) -- an "all" sentinel -- and /// `c0 = 0` reads as "not single-ATTRIBUTE", not "not single-target". /// /// The genuine squad-fitness card is subtype 220 in a DIFFERENT table /// (`fcc_healingcards`, amounts 10/20/30), and player fitness is 219 — that /// family is separately identified and remains unsupported. const ALL_ATTRIBUTE_TRAINING: &[(i64, TrainingClass)] = &[ (57, TrainingClass::Goalkeeper), (67, TrainingClass::Outfield), ]; /// Resolve a consumable into a training effect, or `None` if it is not an /// attribute training card. /// /// `amount` is the wire/catalog magnitude for the card. It is required: the /// client parser initialises its amount temp to `-1` and reads it signed, so a /// missing magnitude is not "zero", it is a card that would draw and grant /// nonsense. Absent or out-of-range, this refuses. pub fn training_effect(subtype: i64, amount: Option) -> Option { let (class, attribute_index, ceiling) = GK_TRAINING .iter() .find(|&&(s, _)| s == subtype) .map(|&(_, slot)| (TrainingClass::Goalkeeper, Some(slot), TRAINING_MAX_AMOUNT)) .or_else(|| { OUTFIELD_TRAINING .iter() .find(|&&(s, _)| s == subtype) .map(|&(_, slot)| (TrainingClass::Outfield, Some(slot), TRAINING_MAX_AMOUNT)) }) .or_else(|| { ALL_ATTRIBUTE_TRAINING .iter() .find(|&&(s, _)| s == subtype) .map(|&(_, class)| (class, None, TRAINING_ALL_MAX_AMOUNT)) })?; let amount = amount?; if !(1..=ceiling).contains(&amount) { return None; } Some(TrainingEffect { class, attribute_index, amount, }) } /// The ceiling Core must be told for a resolved effect: the two families author /// different maxima, and sending the wrong one either lets an impossible boost /// through or refuses a legitimate card. pub fn ceiling_for(effect: &TrainingEffect) -> i64 { match effect.attribute_index { Some(_) => TRAINING_MAX_AMOUNT, None => TRAINING_ALL_MAX_AMOUNT, } } /// Whether a target playing in `position` may receive `class` training. /// /// The client's own `pos` vocabulary numbers GK 0 and gives every outfield role /// its own id, so the distinction is exactly "is the target a keeper". pub fn class_accepts_position(class: TrainingClass, position: &str) -> bool { let is_gk = position.eq_ignore_ascii_case("GK"); match class { TrainingClass::Goalkeeper => is_gk, TrainingClass::Outfield => !is_gk, } } /// What clears an applied training effect, if anything. /// /// UNKNOWN, and deliberately recorded as a constant so it cannot be quietly /// assumed. FIFA 17 ships no table describing a training lifetime, the client /// holds no consumable-effect logic to reverse one from, and "training is /// temporary in FUT" is a recollection about other titles, not evidence about /// this one. Until an experiment settles it, an applied effect PERSISTS, and no /// code decrements or expires it. pub const TRAINING_MATCH_EXPIRY: &str = "UNKNOWN"; #[cfg(test)] mod tests { use super::*; /// The slot must come from `bc`, never from the subtype's ordinal position. /// 54/56 (keeper) and 65/66 (outfield) are the pairs that catch a /// sequential misreading. #[test] fn out_of_order_subtypes_map_to_their_reversed_slots() { assert_eq!( training_effect(54, Some(10)).unwrap().attribute_index, Some(4) ); // SPEED assert_eq!( training_effect(56, Some(10)).unwrap().attribute_index, Some(3) ); // REFLEXES assert_eq!( training_effect(65, Some(10)).unwrap().attribute_index, Some(5) ); // HEADING assert_eq!( training_effect(66, Some(10)).unwrap().attribute_index, Some(4) ); // DEFENDING } /// Every attribute training subtype resolves, and the two families cover /// Core's six slots exactly once each. #[test] fn both_families_cover_all_six_slots_exactly_once() { for (family, subtypes) in [ (TrainingClass::Goalkeeper, GK_TRAINING), (TrainingClass::Outfield, OUTFIELD_TRAINING), ] { let mut slots: Vec = subtypes .iter() .map(|&(s, _)| { let e = training_effect(s, Some(5)).expect("subtype resolves"); assert_eq!(e.class, family); e.attribute_index .expect("single-attribute card names a slot") }) .collect(); slots.sort_unstable(); assert_eq!(slots, vec![0, 1, 2, 3, 4, 5]); } } /// The rare pair boost ALL SIX attributes, so they resolve with NO slot. /// Reading them as single-attribute would apply their magnitude to whatever /// slot 0 happens to be and drop the other five. #[test] fn the_rare_cards_boost_all_six_attributes() { for (subtype, class) in [ (57, TrainingClass::Goalkeeper), (67, TrainingClass::Outfield), ] { for amount in [3, 6, 10] { let e = training_effect(subtype, Some(amount)).expect("rare card resolves"); assert_eq!(e.attribute_index, None, "subtype {subtype} must be all-six"); assert_eq!(e.class, class); assert_eq!(e.amount, amount); assert_eq!(ceiling_for(&e), TRAINING_ALL_MAX_AMOUNT); } } } /// The all-six card authors 3/6/10 only. A +15 all-six card does not exist, /// and letting one through would grant 90 attribute points from a card that /// grants at most 60. #[test] fn the_rare_card_cannot_carry_a_single_attribute_magnitude() { assert_eq!(training_effect(57, Some(15)), None); assert_eq!(training_effect(67, Some(15)), None); // ...while a single-attribute card still may. assert_eq!(training_effect(51, Some(15)).unwrap().amount, 15); } /// A missing magnitude is a refusal, not a zero: the client reads the byte /// signed from a -1 initial value. #[test] fn a_missing_or_impossible_amount_refuses() { assert_eq!(training_effect(52, None), None); assert_eq!(training_effect(52, Some(0)), None); assert_eq!(training_effect(52, Some(-1)), None); assert_eq!(training_effect(52, Some(TRAINING_MAX_AMOUNT + 1)), None); } /// Only the shipped magnitudes are accepted, and all three are. #[test] fn the_three_authored_magnitudes_all_resolve() { for a in [5, 10, 15] { assert_eq!(training_effect(61, Some(a)).unwrap().amount, a); } } /// Family/target gating is the whole reason `class` exists. #[test] fn each_family_accepts_only_its_own_target_class() { assert!(class_accepts_position(TrainingClass::Goalkeeper, "GK")); assert!(!class_accepts_position(TrainingClass::Goalkeeper, "ST")); assert!(class_accepts_position(TrainingClass::Outfield, "ST")); assert!(!class_accepts_position(TrainingClass::Outfield, "GK")); // The wire's casing is not guaranteed to be ours. assert!(class_accepts_position(TrainingClass::Goalkeeper, "gk")); } /// A non-training consumable must never resolve here — contracts (201/202), /// healing (211-218), fitness (219/220), position (91-110) and play styles /// (250-273) all share the consumable space. #[test] fn other_consumable_families_do_not_resolve_as_training() { for s in [201, 202, 211, 218, 219, 220, 91, 110, 250, 271, 300] { assert_eq!(training_effect(s, Some(5)), None, "subtype {s} resolved"); } } }