3 Commits

Author SHA1 Message Date
funman300 a9bac8be8e chore: bump openfut-core to a45155e (attribute training effect) 2026-08-22 22:59:46 +00:00
funman300 3c28b0d1af 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.
2026-08-22 22:59:46 +00:00
funman300 4936654f84 feat(fifa17): map training subtypes to attribute slots
Which attribute a FIFA 17 training card trains is recoverable after all --
not from a table, but from the client's own dispatch: `FUN_18013f4d0`
derives a consumable's whole presentation from `cardsubtypeid` and writes an
attribute selector to `rec+0xbc`. Paired with `fcc_trainingcards.amount`
(TABLE_PROVEN, matching the wire 8/8), that settles both halves of the
effect for all 36 attribute training cards -- 12 families x 5/10/15, 18
goalkeeper and 18 outfield.

The subtype order is NOT the slot order: 54 is SPEED at slot 4 while 56 is
REFLEXES at slot 3, and 65 is HEADING at slot 5 while 66 is DEFENDING at
slot 4. Reading them sequentially trains a different stat from the one on
the card, invisibly, so the table is explicit and a test pins those four.

The two SQUAD training cards (57, 67) share the table and the client's
training UI bucket but are the only ones whose single-target byte is 0: they
act on a squad and move fitness, not an attribute. They resolve to None and
fail closed rather than being mistaken for a +3 attribute card.

A keeper's six slots mean DIV/HAN/KIC/REF/SPD/POS and an outfielder's mean
PAC/SHO/PAS/DRI/DEF/PHY -- same numbers, different attributes -- so the
class gate is not cosmetic.
2026-08-22 22:59:46 +00:00
5 changed files with 576 additions and 55 deletions
+1
View File
@@ -27,3 +27,4 @@ pub mod squad_ext;
pub mod squad_projection;
pub mod store_catalog;
pub mod store_session;
pub mod training_cards;
@@ -0,0 +1,241 @@
//! FIFA 17 attribute training cards: which attribute a card trains, and by how
//! much.
//!
//! ## Where this comes from
//!
//! Two independent shipped sources, no invention:
//!
//! * **which attribute** — `cardsubtypeid`. `FUN_18013f4d0` derives a
//! consumable's whole presentation from that one field, and for the training
//! families it writes an attribute selector to `rec+0xbc` and the magnitude to
//! `rec+0xbf`. The selector per subtype is recorded in
//! `fifa17-recon/data/consumables.json` (`subtypes[].bc`, with the client's own
//! `FUT_UC_*` / `FUT_MC_*` string for each). STATIC_REVERSED.
//! * **how much** — `fcc_trainingcards.amount`, EA's shipped table. Every owned
//! consumable's wire `amount` matches that column 8/8. TABLE_PROVEN.
//!
//! ## Why the effect is ours to define at all
//!
//! No binary in the FIFA 17 install reads `fcc_trainingcards` at any casing, so
//! unlike quick-sell (`fcc_discardcoins`, which the client DOES read) there is no
//! client-side oracle for a consumable effect and never will be. The client ACKs
//! an apply on transport code alone and then re-reads state. Whatever the server
//! durably stores and re-serves IS what the player sees. That makes the
//! *magnitude* and the *target attribute* recoverable facts — the two above — and
//! everything about the effect's LIFECYCLE a server policy we must state
//! explicitly rather than pretend to have reversed. See
//! `TRAINING_MATCH_EXPIRY` below.
//!
//! ## Slot numbering
//!
//! The `attribute_index` this module produces is a slot in CORE's six-attribute
//! model (0 pace, 1 shooting, 2 passing, 3 dribbling, 4 defending, 5 physical),
//! not a FIFA attribute id. A goalkeeper's six attributes occupy those same six
//! slots on the wire — DIV/HAN/KIC/REF/SPD/POS in that order — which is why a GK
//! card and an outfield card can share one slot vocabulary.
/// Which class of player a training card may be applied to.
///
/// FIFA 17 authors the two families separately (`FUT_UC_*` for keepers,
/// `FUT_MC_*` for outfielders) and their slots mean different attributes, so
/// applying one to the wrong class would silently train the wrong stat.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrainingClass {
Goalkeeper,
Outfield,
}
/// A resolved training effect: one attribute slot, one magnitude, one legal
/// target class.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TrainingEffect {
pub class: TrainingClass,
/// Slot in Core's six-attribute model.
pub attribute_index: i64,
pub amount: i64,
}
/// The largest magnitude EA authors for an attribute training card.
///
/// `fcc_trainingcards` authors exactly 5, 10 and 15 for every attribute family.
/// It is declared to Core on every apply so Core can refuse a larger boost than
/// any real card could grant, which is what keeps the closed vocabulary from
/// being a blank cheque.
pub const TRAINING_MAX_AMOUNT: i64 = 15;
/// GK attribute training subtypes → Core attribute slot.
///
/// The client's own order is DIV, HAN, KIC, REF, SPD, POS, and `bc` follows it;
/// note that the subtype ids do NOT (54 is SPEED at slot 4, 56 is REFLEXES at
/// slot 3). Reading these off in subtype order instead of `bc` order is exactly
/// the mistake this table exists to prevent.
const GK_TRAINING: &[(i64, i64)] = &[
(51, 0), // FUT_UC_DIVING
(52, 1), // FUT_UC_HANDLING
(53, 2), // FUT_UC_KICKING
(56, 3), // FUT_UC_REFLEXES
(54, 4), // FUT_UC_SPEED
(55, 5), // FUT_UC_POSITIONING
];
/// Outfield attribute training subtypes → Core attribute slot.
///
/// Same trap as the keepers: 65 is HEADING at slot 5 (Core's `physical`) and 66
/// is DEFENDING at slot 4.
const OUTFIELD_TRAINING: &[(i64, i64)] = &[
(61, 0), // FUT_MC_PACE
(62, 1), // FUT_MC_SHOOTING
(63, 2), // FUT_MC_PASSING
(64, 3), // FUT_MC_DRIBBLING
(66, 4), // FUT_MC_DEFENDING
(65, 5), // FUT_MC_HEADING -> Core's `physical` slot
];
/// The two SQUAD training subtypes, deliberately NOT supported.
///
/// 57 (`FUT_FITNESS_UC`) and 67 (`FUT_FITNESS_MC`) sit in the client's training
/// UI bucket and live in `fcc_trainingcards`, but they are the only two whose
/// `single-target` byte (`rec+0xc0`) is 0: they act on a SQUAD, not on one
/// instance, and they move fitness rather than an attribute. Neither the squad
/// scope nor a fitness model is reversed, so they fail closed rather than being
/// mistaken for a +3 attribute card.
pub const SQUAD_TRAINING_SUBTYPES: &[i64] = &[57, 67];
/// Resolve a consumable into a training effect, or `None` if it is not an
/// attribute training card.
///
/// `amount` is the wire/catalog magnitude for the card. It is required: the
/// client parser initialises its amount temp to `-1` and reads it signed, so a
/// missing magnitude is not "zero", it is a card that would draw and grant
/// nonsense. Absent or out-of-range, this refuses.
pub fn training_effect(subtype: i64, amount: Option<i64>) -> Option<TrainingEffect> {
let (class, attribute_index) = GK_TRAINING
.iter()
.find(|&&(s, _)| s == subtype)
.map(|&(_, slot)| (TrainingClass::Goalkeeper, slot))
.or_else(|| {
OUTFIELD_TRAINING
.iter()
.find(|&&(s, _)| s == subtype)
.map(|&(_, slot)| (TrainingClass::Outfield, slot))
})?;
let amount = amount?;
if !(1..=TRAINING_MAX_AMOUNT).contains(&amount) {
return None;
}
Some(TrainingEffect {
class,
attribute_index,
amount,
})
}
/// Whether a target playing in `position` may receive `class` training.
///
/// The client's own `pos` vocabulary numbers GK 0 and gives every outfield role
/// its own id, so the distinction is exactly "is the target a keeper".
pub fn class_accepts_position(class: TrainingClass, position: &str) -> bool {
let is_gk = position.eq_ignore_ascii_case("GK");
match class {
TrainingClass::Goalkeeper => is_gk,
TrainingClass::Outfield => !is_gk,
}
}
/// What clears an applied training effect, if anything.
///
/// UNKNOWN, and deliberately recorded as a constant so it cannot be quietly
/// assumed. FIFA 17 ships no table describing a training lifetime, the client
/// holds no consumable-effect logic to reverse one from, and "training is
/// temporary in FUT" is a recollection about other titles, not evidence about
/// this one. Until an experiment settles it, an applied effect PERSISTS, and no
/// code decrements or expires it.
pub const TRAINING_MATCH_EXPIRY: &str = "UNKNOWN";
#[cfg(test)]
mod tests {
use super::*;
/// The slot must come from `bc`, never from the subtype's ordinal position.
/// 54/56 (keeper) and 65/66 (outfield) are the pairs that catch a
/// sequential misreading.
#[test]
fn out_of_order_subtypes_map_to_their_reversed_slots() {
assert_eq!(training_effect(54, Some(10)).unwrap().attribute_index, 4); // SPEED
assert_eq!(training_effect(56, Some(10)).unwrap().attribute_index, 3); // REFLEXES
assert_eq!(training_effect(65, Some(10)).unwrap().attribute_index, 5); // HEADING
assert_eq!(training_effect(66, Some(10)).unwrap().attribute_index, 4); // DEFENDING
}
/// Every attribute training subtype resolves, and the two families cover
/// Core's six slots exactly once each.
#[test]
fn both_families_cover_all_six_slots_exactly_once() {
for (family, subtypes) in [
(TrainingClass::Goalkeeper, GK_TRAINING),
(TrainingClass::Outfield, OUTFIELD_TRAINING),
] {
let mut slots: Vec<i64> = subtypes
.iter()
.map(|&(s, _)| {
let e = training_effect(s, Some(5)).expect("subtype resolves");
assert_eq!(e.class, family);
e.attribute_index
})
.collect();
slots.sort_unstable();
assert_eq!(slots, vec![0, 1, 2, 3, 4, 5]);
}
}
/// The squad-scoped pair share the training table and UI bucket but are not
/// attribute training; resolving them would apply a fitness magnitude to
/// whatever attribute slot 0 happens to be.
#[test]
fn squad_training_subtypes_are_not_attribute_training() {
for &s in SQUAD_TRAINING_SUBTYPES {
assert_eq!(training_effect(s, Some(3)), None);
}
}
/// A missing magnitude is a refusal, not a zero: the client reads the byte
/// signed from a -1 initial value.
#[test]
fn a_missing_or_impossible_amount_refuses() {
assert_eq!(training_effect(52, None), None);
assert_eq!(training_effect(52, Some(0)), None);
assert_eq!(training_effect(52, Some(-1)), None);
assert_eq!(training_effect(52, Some(TRAINING_MAX_AMOUNT + 1)), None);
}
/// Only the shipped magnitudes are accepted, and all three are.
#[test]
fn the_three_authored_magnitudes_all_resolve() {
for a in [5, 10, 15] {
assert_eq!(training_effect(61, Some(a)).unwrap().amount, a);
}
}
/// Family/target gating is the whole reason `class` exists.
#[test]
fn each_family_accepts_only_its_own_target_class() {
assert!(class_accepts_position(TrainingClass::Goalkeeper, "GK"));
assert!(!class_accepts_position(TrainingClass::Goalkeeper, "ST"));
assert!(class_accepts_position(TrainingClass::Outfield, "ST"));
assert!(!class_accepts_position(TrainingClass::Outfield, "GK"));
// The wire's casing is not guaranteed to be ours.
assert!(class_accepts_position(TrainingClass::Goalkeeper, "gk"));
}
/// A non-training consumable must never resolve here — contracts (201/202),
/// healing (211-218), fitness (219/220), position (91-110) and play styles
/// (250-273) all share the consumable space.
#[test]
fn other_consumable_families_do_not_resolve_as_training() {
for s in [201, 202, 211, 218, 219, 220, 91, 110, 250, 271, 300] {
assert_eq!(training_effect(s, Some(5)), None, "subtype {s} resolved");
}
}
}
+218 -54
View File
@@ -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<CorePage, CoreError> {
fn core_item_from_json(e: &Value) -> Option<CoreOwnedItem> {
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(
+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");
}