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:
funman300
2026-08-22 22:59:46 +00:00
parent 4936654f84
commit 3c28b0d1af
2 changed files with 333 additions and 54 deletions
+115
View File
@@ -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");
}