From 3c28b0d1afe3e2e96d5a048145488cc52672d739 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 22 Aug 2026 22:59:46 +0000 Subject: [PATCH] feat(fifa17): apply training cards, and project trained attributes Opens the 409 `apply_effect_unproven` gate for attribute training, the second family after contracts to have its effect settled rather than merely its magnitude. `ApplyEffect` replaces the single-family `AddContractMatches` struct: Core dispatches on `kind`, so an unproven family must be impossible to express, not merely discouraged. The shared half of an apply -- the exactly-once key, Core's transaction, the error mapping and the client's payload -- is now one `finish_consumable_apply`, so a new family cannot quietly acquire its own idempotency format or its own success shape. Two refusals are training-specific and both prevent silent corruption rather than merely being tidy: a non-player target has no attributes to write, and a cross-class target would train a different attribute from the one printed on the card, because a keeper's slots mean DIV/HAN/KIC/REF/SPD/POS where an outfielder's mean PAC/SHO/PAS/DRI/DEF/PHY. `attributeList` now prefers Core's `effective_attributes` and only falls back to the immutable definition when Core does not send them -- reading the definition regardless would silently drop every applied training off the card the client draws. Tests pin the verb split on `item/resource/` (POST applies, PUT stays quick-sell, GET is not an economy route at all), the digit guard, the exact JSON Core deserialises for both effects, and that no shipped training card exceeds the ceiling the host declares to Core. --- openfut-utas-host/src/lib.rs | 272 +++++++++++++++++++++------ openfut-utas-host/tests/host_test.rs | 115 +++++++++++ 2 files changed, 333 insertions(+), 54 deletions(-) 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"); +}