Compare commits
3 Commits
4156dc5810
...
a9bac8be8e
| Author | SHA1 | Date | |
|---|---|---|---|
| a9bac8be8e | |||
| 3c28b0d1af | |||
| 4936654f84 |
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule openfut-core updated: 82c3d2c85a...a45155e0c5
+218
-54
@@ -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(
|
||||
|
||||
@@ -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