diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index 0493a91..ff9757e 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -27,3 +27,4 @@ pub mod squad_ext; pub mod squad_projection; pub mod store_catalog; pub mod store_session; +pub mod training_cards; diff --git a/openfut-adapter-fifa17/src/fut/training_cards.rs b/openfut-adapter-fifa17/src/fut/training_cards.rs new file mode 100644 index 0000000..998859a --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/training_cards.rs @@ -0,0 +1,241 @@ +//! 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, 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. + pub attribute_index: i64, + pub amount: i64, +} + +/// The largest magnitude EA authors for an attribute training card. +/// +/// `fcc_trainingcards` authors exactly 5, 10 and 15 for every attribute family. +/// It is 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; + +/// 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 two SQUAD training subtypes, deliberately NOT supported. +/// +/// 57 (`FUT_FITNESS_UC`) and 67 (`FUT_FITNESS_MC`) sit in the client's training +/// UI bucket and live in `fcc_trainingcards`, but they are the only two whose +/// `single-target` byte (`rec+0xc0`) is 0: they act on a SQUAD, not on one +/// instance, and they move fitness rather than an attribute. Neither the squad +/// scope nor a fitness model is reversed, so they fail closed rather than being +/// mistaken for a +3 attribute card. +pub const SQUAD_TRAINING_SUBTYPES: &[i64] = &[57, 67]; + +/// 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) = GK_TRAINING + .iter() + .find(|&&(s, _)| s == subtype) + .map(|&(_, slot)| (TrainingClass::Goalkeeper, slot)) + .or_else(|| { + OUTFIELD_TRAINING + .iter() + .find(|&&(s, _)| s == subtype) + .map(|&(_, slot)| (TrainingClass::Outfield, slot)) + })?; + + let amount = amount?; + if !(1..=TRAINING_MAX_AMOUNT).contains(&amount) { + return None; + } + + Some(TrainingEffect { + class, + attribute_index, + 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, 4); // SPEED + assert_eq!(training_effect(56, Some(10)).unwrap().attribute_index, 3); // REFLEXES + assert_eq!(training_effect(65, Some(10)).unwrap().attribute_index, 5); // HEADING + assert_eq!(training_effect(66, Some(10)).unwrap().attribute_index, 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 + }) + .collect(); + slots.sort_unstable(); + assert_eq!(slots, vec![0, 1, 2, 3, 4, 5]); + } + } + + /// The squad-scoped pair share the training table and UI bucket but are not + /// attribute training; resolving them would apply a fitness magnitude to + /// whatever attribute slot 0 happens to be. + #[test] + fn squad_training_subtypes_are_not_attribute_training() { + for &s in SQUAD_TRAINING_SUBTYPES { + assert_eq!(training_effect(s, Some(3)), None); + } + } + + /// 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"); + } + } +}