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/<rid>` (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.
This commit is contained in:
@@ -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/<rid>` 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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user