diff --git a/fifa17-recon/docs/ENDPOINT_MAP.md b/fifa17-recon/docs/ENDPOINT_MAP.md index 42e822c..5c954a5 100644 --- a/fifa17-recon/docs/ENDPOINT_MAP.md +++ b/fifa17-recon/docs/ENDPOINT_MAP.md @@ -939,7 +939,18 @@ freezes any of these — GAPs are "feature missing", not "crash". | 4 | FutViewCards | `0x1801293d0` | GET `ut/%s/item` | `itemData`(0x16b) → **array[card-item]** via `0x18013fe00` [FREEZE-RISK] | HANDLED (utas `/item` `defs_route` serves `itemData`) | HIGH | | 5 | FutActivateCard | `0x1801642c0` | PUT `ut/%s/item` (FUT_CLUB_ACTIVATE_ITEM_DP) | **none** (immediate `ret`) | ack — `{}` fine | HIGH | | 6 | FutApplyCard | `0x18012a710` | PUT `ut/%s/item` (apply by itemId) | `itemData`(0x16b) → **array[updated card-item]** via `0x18013fe00` [FREEZE-RISK] | GAP | HIGH | -| 7 | FutApplyCardByRes | `0x18012ad10` | PUT `ut/%s/item` (apply by resourceId) | `itemData`(0x16b) → **array[updated card-item]** [FREEZE-RISK] | GAP | HIGH | +| 7 | FutApplyCardByRes | `0x18012ad10` | **POST** `ut/%s/item/resource/` (apply by resourceId) | `itemData`(0x16b) → **array[updated card-item]** [FREEZE-RISK] | **SERVED** (Rust host, contracts + attribute training) | HIGH | + +> **Rows 6 and 7 are NOT the same route.** `ApplyCardByRes` carries urlIndex +> `0x0e`, which resolves to `ut/%s/item/resource` — not `ut/%s/item` +> (`plan-2026-08-05-pack-opening.md:505-506`, shared with `DiscardCardByRes` and +> `MoveCardByRes`). The verb is **POST**, live-proven by a real-client capture: +> `POST /ut/game/fifa17/item/resource/5001004` `{"apply":[{"id":100000003}]}`. +> This row previously read `PUT ut/%s/item` for both, and that conflation is what +> kept the "apply must ride `PUT ut/%s/item`" hypothesis alive +> (`CLIENT_ROUTE_SURFACE.md:104-106`) until the POST capture settled it — every +> observed `PUT ut/%s/item` is a pile MOVE, never an apply. + | 8 | FutDiscardCard | `0x180127300` | DELETE `ut/delete/%s/item` (CardsDiscardCard) | `items`(0x171) → **array[int ids]** [FREEZE-RISK]; `totalCredits`(0x326) → int; `id`(0x15c) → int | GAP | HIGH | | 9 | FutDiscardCardByRes | `0x1801279c0` | DELETE `ut/delete/%s/item` (by res) | `totalCredits`(0x326) → int | GAP | HIGH | | 10 | FutMoveCard | `0x180128600` | PUT `ut/%s/item` (move) | `itemData`(0x16b) → **array** [FREEZE-RISK]; `chemistry`(0x81) → bool | GAP | HIGH | diff --git a/openfut-adapter-fifa17/src/fut/training_cards.rs b/openfut-adapter-fifa17/src/fut/training_cards.rs index 998859a..e869d78 100644 --- a/openfut-adapter-fifa17/src/fut/training_cards.rs +++ b/openfut-adapter-fifa17/src/fut/training_cards.rs @@ -45,24 +45,31 @@ pub enum TrainingClass { Outfield, } -/// A resolved training effect: one attribute slot, one magnitude, one legal -/// target class. +/// 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. - pub attribute_index: i64, + /// 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 an attribute training card. +/// The largest magnitude EA authors for a SINGLE-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. +/// `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; @@ -91,15 +98,30 @@ const OUTFIELD_TRAINING: &[(i64, i64)] = &[ (65, 5), // FUT_MC_HEADING -> Core's `physical` slot ]; -/// The two SQUAD training subtypes, deliberately NOT supported. +/// The RARE training cards, which boost ALL SIX attributes at once. /// -/// 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]; +/// 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. @@ -109,19 +131,25 @@ pub const SQUAD_TRAINING_SUBTYPES: &[i64] = &[57, 67]; /// 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 + let (class, attribute_index, ceiling) = GK_TRAINING .iter() .find(|&&(s, _)| s == subtype) - .map(|&(_, slot)| (TrainingClass::Goalkeeper, slot)) + .map(|&(_, slot)| (TrainingClass::Goalkeeper, Some(slot), TRAINING_MAX_AMOUNT)) .or_else(|| { OUTFIELD_TRAINING .iter() .find(|&&(s, _)| s == subtype) - .map(|&(_, slot)| (TrainingClass::Outfield, slot)) + .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..=TRAINING_MAX_AMOUNT).contains(&amount) { + if !(1..=ceiling).contains(&amount) { return None; } @@ -132,6 +160,16 @@ pub fn training_effect(subtype: i64, amount: Option) -> Option 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 @@ -163,10 +201,22 @@ mod tests { /// 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 + 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 @@ -183,6 +233,7 @@ mod tests { 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(); @@ -190,16 +241,36 @@ mod tests { } } - /// 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. + /// 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 squad_training_subtypes_are_not_attribute_training() { - for &s in SQUAD_TRAINING_SUBTYPES { - assert_eq!(training_effect(s, Some(3)), None); + 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]