diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 244dd06..cce637b 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -91,7 +91,7 @@ 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, + ceiling_for, class_accepts_position, training_effect, }; use openfut_identity::ExternalIdentityStore; use rand::{Rng, SeedableRng}; @@ -1316,7 +1316,8 @@ pub enum ApplyEffect { /// out of `cardsubtypeid` by the adapter — Core is never told a FIFA /// attribute name. ApplyTraining { - attribute_index: i64, + /// `None` = the rare card that boosts all six attributes. + attribute_index: Option, amount: i64, max_amount: i64, }, @@ -1344,6 +1345,9 @@ impl ApplyEffect { amount, max_amount, } => json!({ + // `attribute_index` is deliberately null for the rare all-six + // card: Core reads absence as "every slot", so omitting the key + // or sending 0 would silently train pace only. "kind": "apply_training", "attribute_index": attribute_index, "amount": amount, @@ -5087,10 +5091,14 @@ impl Server { ); return error_response(409, "training_target_class_mismatch"); } + // The ceiling is per-family: 15 for a single attribute, 10 for the + // rare all-six card. Sending the single-attribute ceiling for an + // all-six card would let Core accept a +15 all-six boost that EA + // never authored. let effect = ApplyEffect::ApplyTraining { attribute_index: t.attribute_index, amount: t.amount, - max_amount: TRAINING_MAX_AMOUNT, + max_amount: ceiling_for(&t), }; return self.finish_consumable_apply( econ, diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index 8696c93..7bb3cfc 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -2653,7 +2653,7 @@ fn the_training_effect_matches_cores_closed_vocabulary() { use openfut_utas_host::ApplyEffect; let json = ApplyEffect::ApplyTraining { - attribute_index: 4, + attribute_index: Some(4), amount: 10, max_amount: 15, } @@ -2668,6 +2668,24 @@ fn the_training_effect_matches_cores_closed_vocabulary() { }) ); + // The rare all-six card MUST serialise a null slot. Sending 0 would train + // pace alone, and omitting the key would leave Core guessing. + let all_six = ApplyEffect::ApplyTraining { + attribute_index: None, + amount: 10, + max_amount: 10, + } + .to_json(); + assert_eq!( + all_six, + serde_json::json!({ + "kind": "apply_training", + "attribute_index": null, + "amount": 10, + "max_amount": 10, + }) + ); + // The contract arm must keep its own shape while sharing the enum. let contract = ApplyEffect::AddContractMatches { amount: 3, @@ -2691,18 +2709,33 @@ fn the_training_effect_matches_cores_closed_vocabulary() { /// 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}; + use openfut_adapter_fifa17::fut::training_cards::{ + ceiling_for, training_effect, TRAINING_ALL_MAX_AMOUNT, 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, + e.amount <= ceiling_for(&e), "subtype {subtype} amount {amount} exceeds the ceiling sent to Core" ); + assert_eq!(ceiling_for(&e), TRAINING_MAX_AMOUNT); resolved += 1; } } - assert_eq!(resolved, 36, "all 36 attribute training cards must resolve"); + assert_eq!(resolved, 36, "all 36 single-attribute cards must resolve"); + + // The six rare all-six cards carry their own, lower ceiling. + let mut rare = 0; + for subtype in [57, 67] { + for amount in [3, 6, 10] { + let e = training_effect(subtype, Some(amount)).expect("rare card resolves"); + assert_eq!(e.attribute_index, None); + assert_eq!(ceiling_for(&e), TRAINING_ALL_MAX_AMOUNT); + rare += 1; + } + } + assert_eq!(rare, 6, "all 6 rare all-six cards must resolve"); }