feat(fifa17): real player-contract consumable apply, replacing the probe
POST /ut/game/fifa17/item/resource/<rid> {"apply":[{"id":N}]} now performs a
durable atomic contract application instead of falling through to Python.
THE RULE. grant = fcc_contractcards[card][tier(TARGET.rating)], then
min(99, contract + grant). The column is keyed on the TARGET's tier, NOT the
card's own -- all 36 cells of EA's shipped table match the published FIFA 17
matrix, and staging discriminates the two readings outright: a bronze-RARE
card on a rating-89 player granted 3 (the gold column), where the card-level
reading predicts 15.
No client binary reads fcc_contractcards -- a string scan of every .exe/.dll
in the install finds it referenced nowhere, and CardsDLL reads only 14 fcc_
tables (fcc_discardcoins among them, which is why quick-sell prices locally).
Consumable effects are server-authoritative, so EA's shipped table is the only
non-invented source and the client renders whatever we persist and re-serve.
The host computes the grant, Core owns the mutation -- the same split
quick-sell already uses (host prices via discard_value, Core performs
sell_item), and what migration 0027 means by "Core defines NO per-category
formula".
FAILS CLOSED, never 200-and-do-nothing: manager contracts 409 because staff
ratings are unimported so the target tier is unknowable; every other family
409 as unproven; batch 400; unresolvable operand 404. Core's deterministic
refusals pass through with their own status instead of collapsing to 503,
which would tell the client to retry a request that can never succeed.
`contract: 7` stops being a hardcode in shape_item/shape_staff_item and
becomes the fallback for an instance Core tracks no contract for. `fitness: 99`
is the same class of hardcode and is deliberately untouched.
CLEAN CUTOVER: Route::ConsumableApplyProbe, its handler, apply_probe_enabled,
the OPENFUT_FIFA17_APPLY_PROBE gate and both probe scripts are deleted. A
handler no classifier can reach is this repo's recurring defect class, and the
new economy arm preempts the probe. fifa17-migration-rehearse.py also drove
the probe (spelled "apply probe", so an apply-probe grep missed it) and would
have eaten a card off the rehearsal profile; retargeted to a non-mutating
assertion.
Not implemented, on purpose: the stored-manager bonus (real mechanic, rule
appears in no shipped table -- guessing it would corrupt the proven part) and
contract decrement per match (nothing spends contracts yet).
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
use crate::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item::{shape_club_item, shape_item, shape_staff_item, STAFF_CONTRACT};
|
||||
use crate::fut::item_state;
|
||||
@@ -54,7 +55,13 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
|
||||
match ident.kind_of(item) {
|
||||
ContentKind::Player => match ident.resolve(item) {
|
||||
Some(id) => {
|
||||
out.push(shape_item(item, id, ent, ident.discard_value(item)));
|
||||
out.push(shape_item(
|
||||
item,
|
||||
id,
|
||||
ent,
|
||||
ident.discard_value(item),
|
||||
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
||||
));
|
||||
stats.emitted += 1;
|
||||
}
|
||||
None => stats.dropped_no_asset += 1,
|
||||
@@ -83,7 +90,10 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
|
||||
// because a guard arm would not prove exhaustiveness).
|
||||
ContentKind::Manager | ContentKind::Staff => match ident.resolve_staff(item) {
|
||||
Some(id) => {
|
||||
out.push(shape_staff_item(id, STAFF_CONTRACT));
|
||||
out.push(shape_staff_item(
|
||||
id,
|
||||
item.contract_matches.unwrap_or(STAFF_CONTRACT),
|
||||
));
|
||||
stats.emitted += 1;
|
||||
}
|
||||
None => stats.dropped_no_asset += 1,
|
||||
@@ -116,6 +126,7 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fut::contract_cards::CONTRACT_MATCH_CAP;
|
||||
use crate::fut::entities::Fifa17Entities;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -145,6 +156,9 @@ mod tests {
|
||||
league: league.into(),
|
||||
club: club.into(),
|
||||
attributes: [90, 88, 70, 85, 40, 78],
|
||||
// Untracked by default, so these fixtures exercise the pack-fresh
|
||||
// fallback; a test that cares sets it explicitly.
|
||||
contract_matches: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +375,11 @@ mod tests {
|
||||
assert_eq!(coach["resourceId"], 3000083);
|
||||
assert_eq!(coach["cardsubtypeid"], 8);
|
||||
assert_eq!(coach["itemType"], "staff");
|
||||
assert_eq!(coach["contract"], STAFF_CONTRACT);
|
||||
assert_eq!(
|
||||
coach["contract"], STAFF_CONTRACT,
|
||||
"this fixture is UNTRACKED (contract_matches None), so the wire shows \
|
||||
the pack-fresh fallback — not because the shaper hardcodes it"
|
||||
);
|
||||
assert!(
|
||||
coach.get("nation").is_none()
|
||||
&& coach.get("leagueId").is_none()
|
||||
@@ -411,7 +429,10 @@ mod tests {
|
||||
assert_eq!(mgr["nation"], 45);
|
||||
assert_eq!(mgr["leagueId"], 53);
|
||||
assert_eq!(mgr["teamid"], 241);
|
||||
assert_eq!(mgr["contract"], STAFF_CONTRACT);
|
||||
assert_eq!(
|
||||
mgr["contract"], STAFF_CONTRACT,
|
||||
"untracked fixture => pack-fresh fallback"
|
||||
);
|
||||
assert_eq!(mgr["itemState"], "free");
|
||||
assert_eq!(mgr["owners"], 1);
|
||||
let keys: Vec<&String> = mgr.as_object().unwrap().keys().collect();
|
||||
@@ -422,6 +443,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `/club` is the screen a contract apply is judged on: if the envelope keeps
|
||||
/// reporting the pack-fresh count, a committed apply is invisible and the
|
||||
/// operator sees a 200 that did nothing. Both families must carry the number
|
||||
/// Core persisted.
|
||||
#[test]
|
||||
fn club_reports_the_contract_core_persisted_for_players_and_staff() {
|
||||
let ent = entities();
|
||||
let ident = KindMapIdentity {
|
||||
ids: HashMap::from([(
|
||||
"card_player".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000001,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
)]),
|
||||
kits: HashMap::new(),
|
||||
staff: HashMap::from([(
|
||||
"card_manager".to_string(),
|
||||
Fifa17StaffIdentity {
|
||||
item_id: 100004871,
|
||||
resource_id: 1000509,
|
||||
subtype: 4,
|
||||
nation: 45,
|
||||
league_id: 53,
|
||||
team_id: 241,
|
||||
},
|
||||
)]),
|
||||
kinds: HashMap::from([
|
||||
("card_player".to_string(), ContentKind::Player),
|
||||
("card_manager".to_string(), ContentKind::Staff),
|
||||
]),
|
||||
};
|
||||
// A player mid-way through its contracts, a fully topped-up manager, and
|
||||
// one untracked player that must fall back.
|
||||
let mut played = item(
|
||||
"oc-played",
|
||||
"card_player",
|
||||
86,
|
||||
"ST",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
);
|
||||
played.contract_matches = Some(3);
|
||||
let mut manager = item("oc-mgr", "card_manager", 0, "", "", "", "");
|
||||
manager.contract_matches = Some(CONTRACT_MATCH_CAP);
|
||||
let untracked = item(
|
||||
"oc-fresh",
|
||||
"card_player",
|
||||
86,
|
||||
"ST",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
);
|
||||
|
||||
let (body, stats) = shape_club_response(&[played, manager, untracked], &ent, &ident);
|
||||
assert_eq!(stats.emitted, 3);
|
||||
let arr = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(arr[0]["contract"], 3, "the player's persisted count");
|
||||
assert_eq!(
|
||||
arr[1]["contract"], CONTRACT_MATCH_CAP,
|
||||
"staff read the same persisted field, not STAFF_CONTRACT"
|
||||
);
|
||||
assert_eq!(
|
||||
arr[2]["contract"], PACK_FRESH_CONTRACT_MATCHES,
|
||||
"only an untracked instance falls back"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kits_project_with_owned_active_home_and_away_states() {
|
||||
let ent = entities();
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
//! FIFA 17 **contract consumables** — the shipped EA grant table.
|
||||
//!
|
||||
//! A contract card adds match-contracts to a TARGET card. The number granted is
|
||||
//! selected by two keys: the consumable's own `resourceId` (which card it is)
|
||||
//! and the **TARGET's** rating tier (bronze/silver/gold). The target's tier, not
|
||||
//! the card's — a gold contract card dropped on a bronze player grants the
|
||||
//! BRONZE column. Getting that backwards silently mis-credits every apply, so it
|
||||
//! is stated here as the module's first invariant.
|
||||
//!
|
||||
//! ## Provenance
|
||||
//!
|
||||
//! [`CONTRACT_CARDS`] is the shipped EA table `fcc_contractcards`, transcribed
|
||||
//! verbatim. It was cross-validated cell by cell against the published FIFA 17
|
||||
//! contract matrix: **36 of 36 cells agree** (12 cards × 3 tiers; the 99-special
|
||||
//! is not part of the published matrix). That is the whole basis for these
|
||||
//! numbers — do not compute, interpolate or "correct" them. The table is
|
||||
//! deliberately NOT monotonic in the target's tier: `5001003` grants 15 to a
|
||||
//! bronze target, 11 to a silver one and 13 to a gold one. A "fix" that made it
|
||||
//! monotonic would be an invention.
|
||||
//!
|
||||
//! ## Why the server must own the effect
|
||||
//!
|
||||
//! No client binary reads this table. A full string scan of every `.exe` and
|
||||
//! `.dll` in the FIFA 17 install finds `fcc_contractcards` referenced **nowhere**
|
||||
//! — the client ships the rows but never queries them, so it cannot compute the
|
||||
//! grant and cannot second-guess ours. The effect is therefore
|
||||
//! server-authoritative, and this table is the only non-invented source for it.
|
||||
//!
|
||||
//! ## Deliberately NOT implemented
|
||||
//!
|
||||
//! The "stored managers give up to 50% bonus contracts" mechanic. Its rule is
|
||||
//! UNKNOWN: we have neither the multiplier's rounding, nor which stored managers
|
||||
//! count, nor whether it stacks. Guessing it would corrupt the proven part of the
|
||||
//! grant, so it is absent rather than approximated. This note is the record; it
|
||||
//! is not a TODO, and nothing here reserves a hook for it.
|
||||
//!
|
||||
//! ## Family gating
|
||||
//!
|
||||
//! [`PLAYER_CONTRACT_SUBTYPE`] (201) applies to PLAYERS only and
|
||||
//! [`MANAGER_CONTRACT_SUBTYPE`] (202) to MANAGERS/STAFF only. 202 cannot be
|
||||
//! honoured today for a reason that is a data gap, not a policy: staff ratings
|
||||
//! are not imported, so a manager target's tier cannot be determined honestly and
|
||||
//! there is no defensible column to read. The caller must REFUSE a 202 apply
|
||||
//! rather than default the tier — inventing gold (or bronze, or the card's own
|
||||
//! tier) would silently pay out the wrong number with no error anywhere.
|
||||
//!
|
||||
//! A contract also cannot be applied to a LOAN item. This crate models no loan
|
||||
//! state, so that gate — like the 201/202 family check — is the caller's: this
|
||||
//! module answers only "how many matches does card X grant a tier-Y target".
|
||||
|
||||
/// `(resource_id, [bronze, silver, gold])` — the grant a contract card makes to
|
||||
/// a target of each tier, keyed by the consumable's FIFA `resourceId`.
|
||||
///
|
||||
/// Rows `5001001`–`5001006` are player contracts (`cardsubtypeid` 201),
|
||||
/// `5001007`–`5001012` their manager counterparts (202), and `5001013` is the
|
||||
/// EASFC 99-contract special (201). Sorted by `resource_id`; keys are unique.
|
||||
const CONTRACT_CARDS: [(u32, [i64; 3]); 13] = [
|
||||
(5_001_001, [8, 2, 1]),
|
||||
(5_001_002, [10, 10, 8]),
|
||||
(5_001_003, [15, 11, 13]),
|
||||
(5_001_004, [15, 6, 3]),
|
||||
(5_001_005, [20, 24, 18]),
|
||||
(5_001_006, [28, 24, 28]),
|
||||
(5_001_007, [8, 2, 1]),
|
||||
(5_001_008, [8, 10, 8]),
|
||||
(5_001_009, [11, 11, 13]),
|
||||
(5_001_010, [15, 6, 3]),
|
||||
(5_001_011, [18, 24, 18]),
|
||||
(5_001_012, [24, 24, 28]),
|
||||
(5_001_013, [99, 99, 99]),
|
||||
];
|
||||
|
||||
/// Which column of [`CONTRACT_CARDS`] a target's rating selects.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ContractTier {
|
||||
Bronze,
|
||||
Silver,
|
||||
Gold,
|
||||
}
|
||||
|
||||
impl ContractTier {
|
||||
/// Column index into a [`CONTRACT_CARDS`] row.
|
||||
const fn column(self) -> usize {
|
||||
match self {
|
||||
ContractTier::Bronze => 0,
|
||||
ContractTier::Silver => 1,
|
||||
ContractTier::Gold => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Card tier from a rating: gold `>= 75`, silver `65..=74`, bronze `< 65`.
|
||||
///
|
||||
/// These are the client's OWN card-level thresholds, not cutoffs chosen here:
|
||||
/// they are the same ladder [`super::discard::discard_level`] reads to key
|
||||
/// `fcc_discardcoins` (`3` if `>= 75`, `2` if `65..=74`, else `1`). The two
|
||||
/// tables index the same three tiers, so a rating that prices as gold also
|
||||
/// contracts as gold.
|
||||
pub fn tier_for_rating(rating: u8) -> ContractTier {
|
||||
if rating >= 75 {
|
||||
ContractTier::Gold
|
||||
} else if rating >= 65 {
|
||||
ContractTier::Silver
|
||||
} else {
|
||||
ContractTier::Bronze
|
||||
}
|
||||
}
|
||||
|
||||
/// Matches granted by contract consumable `resource_id` against a target of
|
||||
/// `tier`.
|
||||
///
|
||||
/// `None` when `resource_id` is not a known contract card — the caller must
|
||||
/// refuse, never substitute a floor or a neighbouring row. A consumable outside
|
||||
/// the 13 rows has no proven grant, and an invented one is a silent mis-credit.
|
||||
pub fn contract_grant(resource_id: u32, tier: ContractTier) -> Option<i64> {
|
||||
CONTRACT_CARDS
|
||||
.iter()
|
||||
.find(|&&(id, _)| id == resource_id)
|
||||
.map(|&(_, grants)| grants[tier.column()])
|
||||
}
|
||||
|
||||
/// Hard ceiling on match-contracts held by one player or manager: `new =
|
||||
/// min(99, current + grant)`. A card that would overflow the cap is not an
|
||||
/// error — the surplus is simply lost, as in retail.
|
||||
pub const CONTRACT_MATCH_CAP: i64 = 99;
|
||||
|
||||
/// Contracts a pack-fresh player or manager starts with.
|
||||
///
|
||||
/// This is the FIFA-side default for an instance Core tracks no contract for:
|
||||
/// Core stores NULL for "untracked", and the game-specific number to substitute
|
||||
/// lives here rather than in Core.
|
||||
pub const PACK_FRESH_CONTRACT_MATCHES: i64 = 7;
|
||||
|
||||
/// `cardsubtypeid` of a PLAYER contract card. Applies to players only.
|
||||
pub const PLAYER_CONTRACT_SUBTYPE: i64 = 201;
|
||||
|
||||
/// `cardsubtypeid` of a MANAGER contract card. Applies to managers/staff only,
|
||||
/// and is currently unservable — see the module header's family-gating note.
|
||||
pub const MANAGER_CONTRACT_SUBTYPE: i64 = 202;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The three tiers are the client's own rating ladder, so the boundaries are
|
||||
/// exact: 64/65 and 74/75. An off-by-one here reads the wrong COLUMN and
|
||||
/// silently grants the wrong number.
|
||||
#[test]
|
||||
fn tier_boundaries_are_the_clients_own_thresholds() {
|
||||
assert_eq!(tier_for_rating(0), ContractTier::Bronze);
|
||||
assert_eq!(tier_for_rating(64), ContractTier::Bronze);
|
||||
assert_eq!(tier_for_rating(65), ContractTier::Silver);
|
||||
assert_eq!(tier_for_rating(74), ContractTier::Silver);
|
||||
assert_eq!(tier_for_rating(75), ContractTier::Gold);
|
||||
assert_eq!(tier_for_rating(99), ContractTier::Gold);
|
||||
}
|
||||
|
||||
/// The tier ladder must stay locked to the discard ladder it was taken from:
|
||||
/// both index the same three card tiers, and a divergence would mean one of
|
||||
/// the two is no longer the client's.
|
||||
#[test]
|
||||
fn the_tier_ladder_is_the_discard_ladder() {
|
||||
for rating in 0..=99u8 {
|
||||
let expected = match crate::fut::discard::discard_level(rating) {
|
||||
1 => ContractTier::Bronze,
|
||||
2 => ContractTier::Silver,
|
||||
_ => ContractTier::Gold,
|
||||
};
|
||||
assert_eq!(tier_for_rating(rating), expected, "rating {rating}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Spot-check across both families, including the row that proves the table
|
||||
/// is not monotonic and the 99-special that has no published row.
|
||||
#[test]
|
||||
fn grant_matrix_cells_are_the_shipped_ea_values() {
|
||||
// Player contracts.
|
||||
assert_eq!(contract_grant(5_001_001, ContractTier::Bronze), Some(8));
|
||||
assert_eq!(contract_grant(5_001_001, ContractTier::Gold), Some(1));
|
||||
assert_eq!(contract_grant(5_001_004, ContractTier::Silver), Some(6));
|
||||
assert_eq!(contract_grant(5_001_006, ContractTier::Silver), Some(24));
|
||||
// Manager contracts. 5001008 is where the two families diverge: the
|
||||
// player row grants 10 to a bronze target, the manager row 8.
|
||||
assert_eq!(contract_grant(5_001_002, ContractTier::Bronze), Some(10));
|
||||
assert_eq!(contract_grant(5_001_008, ContractTier::Bronze), Some(8));
|
||||
assert_eq!(contract_grant(5_001_011, ContractTier::Gold), Some(18));
|
||||
// The EASFC special pays 99 on every tier.
|
||||
for tier in [
|
||||
ContractTier::Bronze,
|
||||
ContractTier::Silver,
|
||||
ContractTier::Gold,
|
||||
] {
|
||||
assert_eq!(contract_grant(5_001_013, tier), Some(99), "{tier:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The matrix is authored EA data, NOT a formula: `5001003` grants MORE to a
|
||||
/// bronze target (15) than to a gold one (13), and dips at silver (11). Any
|
||||
/// "corrected" monotonic table fails here.
|
||||
#[test]
|
||||
fn the_matrix_is_deliberately_not_monotonic() {
|
||||
let bronze = contract_grant(5_001_003, ContractTier::Bronze).unwrap();
|
||||
let silver = contract_grant(5_001_003, ContractTier::Silver).unwrap();
|
||||
let gold = contract_grant(5_001_003, ContractTier::Gold).unwrap();
|
||||
assert_eq!((bronze, silver, gold), (15, 11, 13));
|
||||
assert!(bronze > gold, "bronze target out-grants gold on this row");
|
||||
assert!(silver < gold, "and silver is the trough, not the middle");
|
||||
}
|
||||
|
||||
/// A consumable outside the 13 contract rows has NO proven grant. Returning
|
||||
/// `None` is what lets the caller refuse; a floor or a nearest-row guess
|
||||
/// would be a silent mis-credit.
|
||||
#[test]
|
||||
fn a_non_contract_resource_id_has_no_grant() {
|
||||
// 5003012 is a training card — a different consumable family entirely.
|
||||
for tier in [
|
||||
ContractTier::Bronze,
|
||||
ContractTier::Silver,
|
||||
ContractTier::Gold,
|
||||
] {
|
||||
assert_eq!(contract_grant(5_003_012, tier), None);
|
||||
assert_eq!(contract_grant(0, tier), None);
|
||||
assert_eq!(contract_grant(5_001_000, tier), None, "just below the run");
|
||||
assert_eq!(contract_grant(5_001_014, tier), None, "just above the run");
|
||||
}
|
||||
}
|
||||
|
||||
/// The table is a lookup keyed on exact ids: 13 rows, no duplicates, sorted.
|
||||
/// A duplicated key would make `find` silently prefer whichever came first.
|
||||
#[test]
|
||||
fn the_table_keys_are_unique_and_sorted() {
|
||||
for pair in CONTRACT_CARDS.windows(2) {
|
||||
assert!(pair[0].0 < pair[1].0, "{:?} then {:?}", pair[0], pair[1]);
|
||||
}
|
||||
assert_eq!(CONTRACT_CARDS.len(), 13);
|
||||
}
|
||||
|
||||
/// Every grant is a real number of matches within the cap — the cap can
|
||||
/// truncate an ADDITION, but no single card grants more than a full card.
|
||||
#[test]
|
||||
fn every_grant_is_positive_and_within_the_cap() {
|
||||
for (id, grants) in CONTRACT_CARDS {
|
||||
for grant in grants {
|
||||
assert!(
|
||||
grant > 0 && grant <= CONTRACT_MATCH_CAP,
|
||||
"{id} grants {grant}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,13 @@ pub struct CoreOwnedItem {
|
||||
pub club: String,
|
||||
/// [pace, shooting, passing, dribbling, defending, physical].
|
||||
pub attributes: [u8; 6],
|
||||
/// Match-contracts remaining on this instance, as persisted by Core.
|
||||
/// `None` = Core tracks none, so the caller substitutes the pack-fresh
|
||||
/// default ([`super::contract_cards::PACK_FRESH_CONTRACT_MATCHES`] for a
|
||||
/// player, [`STAFF_CONTRACT`] for staff). Core deliberately stores NULL for
|
||||
/// "untracked" rather than seeding a number, so the game-specific default
|
||||
/// stays on this side of the boundary.
|
||||
pub contract_matches: Option<i64>,
|
||||
}
|
||||
|
||||
/// The FIFA-side numeric identity of an owned item. `asset_id` MUST be a real
|
||||
@@ -294,11 +301,21 @@ pub fn legacy_discard_value(rating: u8) -> i64 {
|
||||
/// identical by construction. `id` is the owned instance's resolved FIFA
|
||||
/// identity — pass the resolver's answer for *this* owned copy so two copies of
|
||||
/// one definition stay distinct on the wire.
|
||||
///
|
||||
/// `discard_value` and `contract` are explicit scalars for the same reason: both
|
||||
/// are per-instance numbers this shaper must not invent. `contract` used to be a
|
||||
/// hardcoded `7`, which made every card look pack-fresh no matter how many
|
||||
/// matches it had played or how many contracts had been applied to it. The
|
||||
/// caller passes [`CoreOwnedItem::contract_matches`] resolved against
|
||||
/// [`super::contract_cards::PACK_FRESH_CONTRACT_MATCHES`] — the substitution for
|
||||
/// Core's "untracked" NULL belongs to the caller, because the default is
|
||||
/// FIFA-specific and Core stores no number to speak for it.
|
||||
pub fn shape_item(
|
||||
item: &CoreOwnedItem,
|
||||
id: Fifa17Identity,
|
||||
ent: &impl ReverseEntityResolver,
|
||||
discard_value: i64,
|
||||
contract: i64,
|
||||
) -> Value {
|
||||
let asset = id.asset_id;
|
||||
let league_id = ent.league_id(&item.league).unwrap_or(0);
|
||||
@@ -334,7 +351,7 @@ pub fn shape_item(
|
||||
// "our own data showing through" bug the Python oracle fixed by forcing
|
||||
// this off for owned copies (item_def keeps `true`; instances do not).
|
||||
"untradeable": false,
|
||||
"contract": 7,
|
||||
"contract": contract,
|
||||
"fitness": 99,
|
||||
"discardValue": discard_value,
|
||||
})
|
||||
@@ -378,12 +395,16 @@ pub fn shape_club_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
|
||||
item
|
||||
}
|
||||
|
||||
/// Contracts remaining on an owned staff card.
|
||||
/// Pack-fresh contracts on a staff card — the FALLBACK for an instance Core
|
||||
/// tracks no contract for, no longer an unconditional constant.
|
||||
///
|
||||
/// Staff consume contracts exactly as players do (`rec+0x8c`), and the client
|
||||
/// refuses to start a match when the manager's has run out. Core does not model
|
||||
/// staff contracts, so this mirrors the constant [`shape_item`] already emits
|
||||
/// for players rather than inventing a second, different default.
|
||||
/// refuses to start a match when the manager's has run out. A caller holding an
|
||||
/// owned item passes `item.contract_matches.unwrap_or(STAFF_CONTRACT)`, so a
|
||||
/// tracked staff instance now reports its real remaining matches and only an
|
||||
/// untracked one falls back here. The value matches
|
||||
/// [`super::contract_cards::PACK_FRESH_CONTRACT_MATCHES`] rather than inventing a
|
||||
/// second, different default for the staff families.
|
||||
pub const STAFF_CONTRACT: i64 = 7;
|
||||
|
||||
/// Build one FIFA 17 staff item (manager or coach).
|
||||
@@ -525,6 +546,7 @@ pub const CONSUMABLE_UNTRADEABLE: bool = true;
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fut::content_taxonomy::STADIUM_SUBTYPE;
|
||||
use crate::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
|
||||
use crate::fut::entities::Fifa17Entities;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -546,6 +568,8 @@ mod tests {
|
||||
league: "Premier League".into(),
|
||||
club: "Chelsea".into(),
|
||||
attributes: [90, 88, 70, 85, 40, 78],
|
||||
// Untracked by default; the contract tests below set it explicitly.
|
||||
contract_matches: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,6 +586,7 @@ mod tests {
|
||||
},
|
||||
&ent,
|
||||
legacy_discard_value(86),
|
||||
PACK_FRESH_CONTRACT_MATCHES,
|
||||
);
|
||||
assert_eq!(it["id"], 100000001, "wire instance id");
|
||||
assert_eq!(it["resourceId"], 20801);
|
||||
@@ -596,6 +621,7 @@ mod tests {
|
||||
},
|
||||
&ent,
|
||||
legacy_discard_value(84),
|
||||
PACK_FRESH_CONTRACT_MATCHES,
|
||||
);
|
||||
let b = shape_item(
|
||||
&item("oc-b", "fifa17_101490", 84, "ST"),
|
||||
@@ -607,6 +633,7 @@ mod tests {
|
||||
},
|
||||
&ent,
|
||||
legacy_discard_value(84),
|
||||
PACK_FRESH_CONTRACT_MATCHES,
|
||||
);
|
||||
assert_eq!(
|
||||
a["resourceId"], b["resourceId"],
|
||||
@@ -636,6 +663,7 @@ mod tests {
|
||||
},
|
||||
&ent,
|
||||
legacy_discard_value(92),
|
||||
PACK_FRESH_CONTRACT_MATCHES,
|
||||
);
|
||||
assert_eq!(
|
||||
it["resourceId"], 117617092,
|
||||
@@ -650,6 +678,74 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `contract` USED to be a hardcoded `7`, so every card looked pack-fresh no
|
||||
/// matter what Core had persisted — a contract consumable could be applied,
|
||||
/// committed and then be invisible on the very screen that spends it. The
|
||||
/// shaper must emit the number it was PASSED.
|
||||
#[test]
|
||||
fn contract_is_the_passed_value_not_a_constant() {
|
||||
let ent = entities();
|
||||
let id = Fifa17Identity {
|
||||
item_id: 100000001,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
};
|
||||
let base = item("oc1", "card_ch_1", 86, "CDM");
|
||||
let it = shape_item(&base, id, &ent, legacy_discard_value(86), 22);
|
||||
assert_eq!(it["contract"], 22, "the passed count, not 7");
|
||||
// The whole 0..=99 range reaches the wire verbatim, including a spent
|
||||
// card (0) and a capped one (99) — no clamping, no substitution.
|
||||
for contract in [0, 1, 7, 22, 99] {
|
||||
let it = shape_item(&base, id, &ent, legacy_discard_value(86), contract);
|
||||
assert_eq!(it["contract"], contract);
|
||||
}
|
||||
// `fitness` is the same class of hardcode and deliberately out of scope
|
||||
// here; asserting it keeps this test honest about what it proved.
|
||||
assert_eq!(it["fitness"], 99);
|
||||
}
|
||||
|
||||
/// Core stores NULL for an instance it tracks no contract for, so the
|
||||
/// FIFA-specific pack-fresh default is substituted by the CALLER — the same
|
||||
/// `unwrap_or` both production call sites use.
|
||||
#[test]
|
||||
fn an_untracked_instance_falls_back_to_pack_fresh() {
|
||||
let ent = entities();
|
||||
let id = Fifa17Identity {
|
||||
item_id: 100000001,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
};
|
||||
let mut untracked = item("oc1", "card_ch_1", 86, "CDM");
|
||||
untracked.contract_matches = None;
|
||||
let it = shape_item(
|
||||
&untracked,
|
||||
id,
|
||||
&ent,
|
||||
legacy_discard_value(86),
|
||||
untracked
|
||||
.contract_matches
|
||||
.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
||||
);
|
||||
assert_eq!(it["contract"], PACK_FRESH_CONTRACT_MATCHES);
|
||||
assert_eq!(it["contract"], 7, "the proven pack-fresh count");
|
||||
|
||||
// A tracked instance is NOT overwritten by the fallback.
|
||||
let mut tracked = item("oc1", "card_ch_1", 86, "CDM");
|
||||
tracked.contract_matches = Some(31);
|
||||
let it = shape_item(
|
||||
&tracked,
|
||||
id,
|
||||
&ent,
|
||||
legacy_discard_value(86),
|
||||
tracked
|
||||
.contract_matches
|
||||
.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
||||
);
|
||||
assert_eq!(it["contract"], 31);
|
||||
}
|
||||
|
||||
/// The GK-training card the real profile owns: `5003012`, art 3, subtype 54,
|
||||
/// rating 85, amount 15. Its key set is the acceptance criterion.
|
||||
fn training_consumable() -> Fifa17ConsumableIdentity {
|
||||
@@ -741,6 +837,7 @@ mod tests {
|
||||
},
|
||||
&ent,
|
||||
legacy_discard_value(86),
|
||||
PACK_FRESH_CONTRACT_MATCHES,
|
||||
);
|
||||
emitted.push(player["itemState"].as_str().unwrap().to_string());
|
||||
let staff = shape_staff_item(
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod club_response;
|
||||
pub mod club_stats;
|
||||
pub mod consumables;
|
||||
pub mod content_taxonomy;
|
||||
pub mod contract_cards;
|
||||
pub mod discard;
|
||||
pub mod economy;
|
||||
pub mod economy_policy;
|
||||
|
||||
@@ -35,6 +35,7 @@ use std::collections::HashMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item::{
|
||||
shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver, STAFF_CONTRACT,
|
||||
@@ -166,7 +167,13 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
.unwrap_or(0);
|
||||
players.push(json!({
|
||||
"index": index,
|
||||
"itemData": shape_item(item, id, ent, ident.discard_value(item)),
|
||||
"itemData": shape_item(
|
||||
item,
|
||||
id,
|
||||
ent,
|
||||
ident.discard_value(item),
|
||||
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
||||
),
|
||||
"kitNumber": kit,
|
||||
}));
|
||||
}
|
||||
@@ -199,10 +206,14 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
// An owned manager with no resolvable FIFA staff identity is omitted
|
||||
// (non-fatal, like /club dropping an unrenderable card) rather than emitted
|
||||
// with a fabricated id.
|
||||
let manager = match input.manager.as_ref().and_then(|m| ident.resolve_staff(m)) {
|
||||
Some(id) => json!([{
|
||||
let manager = match input
|
||||
.manager
|
||||
.as_ref()
|
||||
.and_then(|m| ident.resolve_staff(m).map(|id| (m, id)))
|
||||
{
|
||||
Some((mgr, id)) => json!([{
|
||||
"id": id.item_id,
|
||||
"itemData": shape_staff_item(id, STAFF_CONTRACT),
|
||||
"itemData": shape_staff_item(id, mgr.contract_matches.unwrap_or(STAFF_CONTRACT)),
|
||||
"dream": false,
|
||||
}]),
|
||||
None => json!([]),
|
||||
@@ -287,6 +298,7 @@ mod tests {
|
||||
league: "l".into(),
|
||||
club: "c".into(),
|
||||
attributes: [80, 80, 80, 80, 40, 80],
|
||||
contract_matches: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,7 +514,12 @@ mod tests {
|
||||
)]),
|
||||
);
|
||||
let mut input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
|
||||
input.manager = Some(owned_item("oc-mgr", "fifa17_mgr"));
|
||||
// A manager mid-way through its contracts: the projection must report
|
||||
// Core's persisted count, not the pack-fresh constant, or the pre-match
|
||||
// screen contradicts the club screen.
|
||||
let mut manager = owned_item("oc-mgr", "fifa17_mgr");
|
||||
manager.contract_matches = Some(12);
|
||||
input.manager = Some(manager);
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!("expected Projected");
|
||||
};
|
||||
@@ -521,7 +538,7 @@ mod tests {
|
||||
"nation": 45,
|
||||
"leagueId": 53,
|
||||
"teamid": 241,
|
||||
"contract": STAFF_CONTRACT,
|
||||
"contract": 12,
|
||||
"itemState": "free",
|
||||
"owners": 1,
|
||||
"untradeable": false,
|
||||
|
||||
Reference in New Issue
Block a user