diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 84a6adf..244dd06 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -90,6 +90,9 @@ use openfut_adapter_fifa17::fut::store_catalog::{ use openfut_adapter_fifa17::fut::store_session::{ validate_capability, SessionStore, StoreMode, SENTINEL_PACK_ID, }; +use openfut_adapter_fifa17::fut::training_cards::{ + class_accepts_position, training_effect, TRAINING_MAX_AMOUNT, +}; use openfut_identity::ExternalIdentityStore; use rand::{Rng, SeedableRng}; use serde_json::{json, Value}; @@ -1286,27 +1289,68 @@ pub struct CoreMatchReceipt { pub coins_balance: i64, } -/// The one PROVEN consumable effect: grant match-contracts to the target. +/// A consumable effect Core is asked to execute, in Core's closed vocabulary. /// -/// `amount` is the caller's ALREADY-RESOLVED FIFA 17 grant, not a hint: Core -/// owns the mutation, the caller owns the game formula (the same split as +/// The magnitude is the caller's ALREADY-RESOLVED FIFA 17 number, not a hint: +/// Core owns the mutation, the caller owns the game formula (the same split as /// quick-sell, where the host computes `discard_value` and Core performs the -/// atomic sale). `cap` and `default_when_unset` are the client's own constants -/// — Core needs the ceiling to clamp with, and the pack-fresh number to seed an -/// instance it tracks no contract for. +/// atomic sale). The remaining fields are the client's own constants that Core +/// cannot know — a ceiling to clamp with, the pack-fresh number to seed an +/// instance it tracks no contract for, and the authored maximum a training card +/// may grant. +/// +/// This is an ENUM rather than a growing struct because Core dispatches on +/// `kind`: an unproven family must be impossible to express here, not merely +/// discouraged. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct AddContractMatches { - pub amount: i64, - pub cap: i64, - pub default_when_unset: i64, +pub enum ApplyEffect { + /// Grant match-contracts to the target. + AddContractMatches { + amount: i64, + cap: i64, + default_when_unset: i64, + }, + /// Attach an attribute training effect to the target. + /// + /// `attribute_index` is a slot in CORE's six-attribute model, already mapped + /// out of `cardsubtypeid` by the adapter — Core is never told a FIFA + /// attribute name. + ApplyTraining { + attribute_index: i64, + amount: i64, + max_amount: i64, + }, } -impl AddContractMatches { - /// The wire token Core dispatches the effect on. A CONSTANT rather than a - /// caller-supplied string: every other consumable family's effect is - /// unproven and is refused before it can reach Core, so there is no second - /// value this could legitimately take. - pub const KIND: &'static str = "add_contract_matches"; +impl ApplyEffect { + /// The effect exactly as Core's `InstanceEffect` deserialises it. The `kind` + /// tokens are constants here, not caller-supplied strings: every unproven + /// family is refused long before it reaches this point, so there is no other + /// value either arm could legitimately take. + pub fn to_json(self) -> Value { + match self { + ApplyEffect::AddContractMatches { + amount, + cap, + default_when_unset, + } => json!({ + "kind": "add_contract_matches", + "amount": amount, + "cap": cap, + "default_when_unset": default_when_unset, + }), + ApplyEffect::ApplyTraining { + attribute_index, + amount, + max_amount, + } => json!({ + "kind": "apply_training", + "attribute_index": attribute_index, + "amount": amount, + "max_amount": max_amount, + }), + } + } } /// One consumable application to hand to Core's atomic `/consumables/apply` @@ -1321,7 +1365,7 @@ pub struct ConsumableApplyRequest<'a> { pub source_owned_card_id: &'a str, pub target_owned_card_id: &'a str, pub target_kind: &'a str, - pub effect: AddContractMatches, + pub effect: ApplyEffect, } /// Core's authoritative answer for a consumable application. @@ -1593,12 +1637,7 @@ impl CoreEconomy for HttpCoreClient { "source_owned_card_id": req.source_owned_card_id, "target_owned_card_id": req.target_owned_card_id, "target_kind": req.target_kind, - "effect": { - "kind": AddContractMatches::KIND, - "amount": req.effect.amount, - "cap": req.effect.cap, - "default_when_unset": req.effect.default_when_unset, - }, + "effect": req.effect.to_json(), }), )?; // The effect block is REQUIRED even on a replay (Core echoes what it @@ -1769,7 +1808,20 @@ pub fn parse_core_page(v: &Value) -> Result { fn core_item_from_json(e: &Value) -> Option { let card = e.get("card")?; - let attr = |k: &str| card.get(k).and_then(|v| v.as_i64()).unwrap_or(0) as u8; + // Attributes come from Core's per-instance `effective_attributes` when it + // sends them, and only fall back to the immutable definition when it does + // not. That fallback is what keeps an older Core serving this host, but it + // is NOT a default: a Core that knows about training always answers with the + // finished numbers, and reading the definition instead would silently drop + // every applied training off the card the client draws. + let effective = e.get("effective_attributes"); + let attr = |k: &str| { + effective + .and_then(|a| a.get(k)) + .or_else(|| card.get(k)) + .and_then(|v| v.as_i64()) + .unwrap_or(0) as u8 + }; let position = e .get("effective_position") .and_then(|v| v.as_str()) @@ -4960,12 +5012,20 @@ impl Server { ); return error_response(404, "not_owned"); }; - // Only the CONTRACT family's effect is proven — in either of its halves. - // Fitness, healing, position, play-style and training grants are not, and - // answering 200 while changing nothing is the exact failure this route was - // claimed to end. + // Two families are proven far enough to apply: CONTRACT (both halves) + // and attribute TRAINING. Fitness, healing, position, play-style, + // manager-league and the two SQUAD training cards are not, and answering + // 200 while changing nothing is the exact failure this route was claimed + // to end. + // + // Training resolves through the adapter's reversed subtype table, so a + // card whose magnitude is missing or whose subtype is squad-scoped + // yields `None` here and falls into the same refusal as an unreversed + // family. let subtype = source_ident.subtype; - if subtype != PLAYER_CONTRACT_SUBTYPE && subtype != MANAGER_CONTRACT_SUBTYPE { + let training = training_effect(subtype, source_ident.amount); + let is_contract = subtype == PLAYER_CONTRACT_SUBTYPE || subtype == MANAGER_CONTRACT_SUBTYPE; + if !is_contract && training.is_none() { eprintln!( "utas-host owner=RUST route=economy consumable-apply status=409 \ resource={resource_id} wire={target_wire} subtype={subtype} \ @@ -4994,6 +5054,56 @@ impl Server { return error_response(404, "not_owned"); }; let target_kind = self.resolver.kind_of(target_item); + + // TRAINING resolves its whole effect here and skips the contract tier + // machinery entirely: a training card's magnitude is authored on the CARD + // (`fcc_trainingcards.amount`), not selected by the target's tier the way + // a contract grant is. + if let Some(t) = training { + // Attribute training writes an attribute slot, and only a player has + // attributes. Staff, club items and consumables have none, so this is + // a refusal rather than a write to a slot that means nothing. + if target_kind != ContentKind::Player { + eprintln!( + "utas-host owner=RUST route=economy consumable-apply status=409 \ + resource={resource_id} wire={target_wire} target_kind={} \ + outcome=training_target_not_a_player", + target_kind.as_str() + ); + return error_response(409, "training_target_not_a_player"); + } + // A keeper's six slots are DIV/HAN/KIC/REF/SPD/POS and an + // outfielder's are PAC/SHO/PAS/DRI/DEF/PHY. The slot number is the + // same; what it MEANS is not. Applying a GK card to an outfielder + // would silently train a different attribute from the one on the + // card, which is precisely the invisible corruption this gate exists + // to stop. + if !class_accepts_position(t.class, &target_item.position) { + eprintln!( + "utas-host owner=RUST route=economy consumable-apply status=409 \ + resource={resource_id} wire={target_wire} subtype={subtype} \ + target_position={} outcome=training_target_class_mismatch", + target_item.position + ); + return error_response(409, "training_target_class_mismatch"); + } + let effect = ApplyEffect::ApplyTraining { + attribute_index: t.attribute_index, + amount: t.amount, + max_amount: TRAINING_MAX_AMOUNT, + }; + return self.finish_consumable_apply( + econ, + source_item, + target_item, + &target_core_id, + target_kind, + effect, + resource_id, + target_wire, + ); + } + // The grant COLUMN is the TARGET's tier, and each family reads it from a // different place because the two target kinds store their rating // differently. Gate the legal kind first, then take the tier. @@ -5058,10 +5168,53 @@ impl Server { ); return error_response(409, "apply_effect_unproven"); }; + // The tier that selected the grant is contract-only, so it is logged + // here rather than in the shared tail. + eprintln!( + "utas-host owner=RUST route=economy consumable-apply resource={resource_id} \ + wire={target_wire} subtype={subtype} tier={} granted={granted}", + tier.as_str() + ); + let effect = ApplyEffect::AddContractMatches { + amount: granted, + cap: CONTRACT_MATCH_CAP, + default_when_unset: PACK_FRESH_CONTRACT_MATCHES, + }; + self.finish_consumable_apply( + econ, + source_item, + target_item, + &target_core_id, + target_kind, + effect, + resource_id, + target_wire, + ) + } + + /// The half of an apply that is identical for every proven family: the + /// exactly-once key, Core's atomic transaction, and the client's answer. + /// + /// Extracted so a new family cannot accidentally acquire its own idempotency + /// key format, its own error mapping, or its own success payload — the three + /// places where a second implementation would silently diverge from the one + /// the client was proven against. + #[allow(clippy::too_many_arguments)] + fn finish_consumable_apply( + &self, + econ: &dyn CoreEconomy, + source_item: &CoreOwnedItem, + target_item: &CoreOwnedItem, + target_core_id: &str, + target_kind: ContentKind, + effect: ApplyEffect, + resource_id: u32, + target_wire: i64, + ) -> WireResponse { // A successful apply DESTROYS the source instance, so a genuine second - // contract application necessarily names a different source id, while a - // transport retry of the same logical action replays this exact key and - // Core mutates nothing. FIFA 17 consumables are separate owned instances + // application necessarily names a different source id, while a transport + // retry of the same logical action replays this exact key and Core + // mutates nothing. FIFA 17 consumables are separate owned instances // rather than `quantity` stacks — the consumables screen groups them for // display only — so the source instance id is the honest per-action key. let action_identity = format!( @@ -5080,29 +5233,26 @@ impl Server { let req = ConsumableApplyRequest { action_identity: &action_identity, source_owned_card_id: &source_item.owned_card_id, - target_owned_card_id: &target_core_id, + target_owned_card_id: target_core_id, target_kind: core_kind, - effect: AddContractMatches { - amount: granted, - cap: CONTRACT_MATCH_CAP, - default_when_unset: PACK_FRESH_CONTRACT_MATCHES, - }, + effect, }; let outcome = match econ.apply_consumable(&req) { Ok(o) => o, Err(e) => { // Fail closed. NEVER a Python fallback: the oracle would answer - // 200 from its definition route and the player would be told a - // contract was applied that nothing recorded. + // 200 from its definition route and the player would be told an + // effect was applied that nothing recorded. // // Core's DETERMINISTIC refusals are passed through with their own - // status rather than collapsed into 503. A loan target or a kind - // mismatch will never succeed on retry, and 503 means "try again - // later" — reporting one as the other invites the client to - // re-send a request that cannot ever be accepted. 404 is reachable - // only when the source vanishes between our `all_owned` read and - // Core's transaction (the losing side of a concurrent - // double-submit), which is likewise permanent for that request. + // status rather than collapsed into 503. A loan target, a kind + // mismatch, or a slot that already carries training will never + // succeed on retry, and 503 means "try again later" — reporting + // one as the other invites the client to re-send a request that + // cannot ever be accepted. 404 is reachable only when the source + // vanishes between our `all_owned` read and Core's transaction + // (the losing side of a concurrent double-submit), which is + // likewise permanent for that request. let (status, code) = match e { CoreError::Status(400) => (400, "apply_refused"), CoreError::Status(404) => (404, "not_owned"), @@ -5118,10 +5268,8 @@ impl Server { }; eprintln!( "utas-host owner=RUST route=economy consumable-apply resource={resource_id} \ - wire={target_wire} subtype={} tier={} granted={} before={} after={} applied={} \ + wire={target_wire} granted={} before={} after={} applied={} \ source_destroyed={}", - source_ident.subtype, - tier.as_str(), outcome.granted, outcome.before, outcome.after, @@ -5781,19 +5929,35 @@ mod tests { if self.fail { return Err(CoreError::Status(503)); } + // Mirror Core's own arithmetic per effect, so a test asserts against + // what Core would really answer rather than a single family's shape. + let (amount, before, after) = match req.effect { + ApplyEffect::AddContractMatches { + amount, + cap, + default_when_unset, + } => ( + amount, + default_when_unset, + (default_when_unset + amount).min(cap), + ), + // Training records the boost held on the slot, and a slot that + // already carried one is refused before reaching Core, so + // `before` is always 0. + ApplyEffect::ApplyTraining { amount, .. } => (amount, 0, amount), + }; self.applies.lock().push(RecordedApply { target_owned_card_id: req.target_owned_card_id.to_string(), target_kind: req.target_kind.to_string(), - amount: req.effect.amount, + amount, }); - let before = req.effect.default_when_unset; Ok(ConsumableApplyOutcome { applied: true, source_destroyed: true, source_quantity_after: None, - granted: req.effect.amount, + granted: amount, before, - after: (before + req.effect.amount).min(req.effect.cap), + after, }) } fn purchase_item( diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index dc65d56..8696c93 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -2591,3 +2591,118 @@ fn every_ownable_class_projects_on_its_own_arm() { .all(|i| i["cardsubtypeid"] != 0) ); } + +// ── Training apply: verb authority and the Core wire contract ──────────────── + +/// METHOD IS PART OF ROUTE AUTHORITY. The same `item/resource/` path means +/// three different things, and two of them destroy a card. A training apply that +/// slid into the GET arm would read a definition and answer 200 having changed +/// nothing; one that slid into the PUT arm would SELL the card the player asked +/// to spend. Both were live failure modes before the family was read as one unit. +#[test] +fn the_item_resource_family_stays_verb_split_for_training_cards() { + use openfut_utas_host::{classify_economy, EconomyRoute}; + + // 5003011 is a real GK training card (subtype 54, SPEED +10). + let path = "/ut/game/fifa17/item/resource/5003011"; + assert_eq!( + classify_economy("POST", path), + Some(EconomyRoute::ConsumableApply), + "POST must be the apply arm" + ); + assert_eq!( + classify_economy("PUT", path), + Some(EconomyRoute::QuickSellResource), + "PUT must remain quick-sell" + ); + // GET is NOT an economy route at all: it is the read-only definition lookup, + // so it can never reach the apply transaction. + assert_eq!( + classify_economy("GET", path), + None, + "GET must not be an economy route" + ); + assert_eq!(classify("GET", path), Route::Passthrough); + // The bare tail is the definition lookup Rust does claim. + assert_eq!( + classify("GET", "/ut/game/fifa17/item/resource"), + Route::ItemDefs + ); +} + +/// A non-numeric or empty resource id must not be mistaken for an apply — the +/// digit guard is what keeps `item/resource/anything` from reaching Core. +#[test] +fn only_a_numeric_resource_id_can_be_applied() { + use openfut_utas_host::{classify_economy, EconomyRoute}; + for tail in ["", "abc", "5003011x", "50030 11"] { + let path = format!("/ut/game/fifa17/item/resource/{tail}"); + assert_ne!( + classify_economy("POST", &path), + Some(EconomyRoute::ConsumableApply), + "tail {tail:?} must not classify as an apply" + ); + } +} + +/// The effect must serialise EXACTLY as Core's closed `InstanceEffect` +/// vocabulary deserialises it. Core dispatches on `kind`, so a drifted token or +/// a renamed field is a 400 at best and a silently skipped mutation at worst. +#[test] +fn the_training_effect_matches_cores_closed_vocabulary() { + use openfut_utas_host::ApplyEffect; + + let json = ApplyEffect::ApplyTraining { + attribute_index: 4, + amount: 10, + max_amount: 15, + } + .to_json(); + assert_eq!( + json, + serde_json::json!({ + "kind": "apply_training", + "attribute_index": 4, + "amount": 10, + "max_amount": 15, + }) + ); + + // The contract arm must keep its own shape while sharing the enum. + let contract = ApplyEffect::AddContractMatches { + amount: 3, + cap: 99, + default_when_unset: 7, + } + .to_json(); + assert_eq!( + contract, + serde_json::json!({ + "kind": "add_contract_matches", + "amount": 3, + "cap": 99, + "default_when_unset": 7, + }) + ); +} + +/// Every training subtype the adapter can resolve must declare a magnitude no +/// larger than the ceiling the host sends Core. If these ever disagree, Core +/// refuses a legitimate card — a silent, family-wide outage. +#[test] +fn no_shipped_training_card_exceeds_the_declared_ceiling() { + use openfut_adapter_fifa17::fut::training_cards::{training_effect, TRAINING_MAX_AMOUNT}; + + let mut resolved = 0; + for subtype in [51, 52, 53, 54, 55, 56, 61, 62, 63, 64, 65, 66] { + for amount in [5, 10, 15] { + let e = training_effect(subtype, Some(amount)).expect("shipped card resolves"); + assert!( + e.amount <= TRAINING_MAX_AMOUNT, + "subtype {subtype} amount {amount} exceeds the ceiling sent to Core" + ); + resolved += 1; + } + } + assert_eq!(resolved, 36, "all 36 attribute training cards must resolve"); +}