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:
funman300
2026-08-22 18:23:22 +00:00
parent 3c67fea074
commit 6c97bc4e2b
19 changed files with 1348 additions and 270 deletions
+7 -1
View File
@@ -90,7 +90,13 @@ kill_test 11 "FIFA wire item id stored in canonical replacement" \
kill_test 12 "projector rebuilds items independently of shared shaper" \
persisted_read_round_trips_via_reconstructed_canonical_and_extension "$FUT/squad_projection.rs" \
'"itemData": shape_item(item, id, ent),' '"itemData": json!({"id": id.item_id}),'
'"itemData": shape_item(
item,
id,
ent,
ident.discard_value(item),
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
),' '"itemData": json!({"id": id.item_id}),'
kill_test 13 "extension schema version ignored on read" \
unknown_schema_version_is_rejected_not_coerced "$FUT/squad_ext.rs" \
@@ -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}"
);
}
}
}
}
+102 -5
View File
@@ -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(
+1
View File
@@ -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,
@@ -114,6 +114,10 @@ fn oracle_tables() -> (HashMap<String, CoreOwnedItem>, TableIdentity) {
league: String::new(),
club: String::new(),
attributes: [attrs[0], attrs[1], attrs[2], attrs[3], attrs[4], attrs[5]],
// The captured wire carries the club's real per-instance
// contract count, so the round trip proves the PERSISTED number
// reaches the wire rather than a constant.
contract_matches: it["contract"].as_i64(),
},
);
ident.insert(
@@ -335,6 +339,10 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
league: String::new(),
club: String::new(),
attributes: [0; 6],
// The captured `manager` ref is the bare `{id, dream}` form, so the wire
// carries no staff contract to mirror: this instance is untracked and
// must fall back to the pack-fresh default.
contract_matches: None,
};
let kicktakers: Vec<KicktakerRef> =
serde_json::from_value(oracle["kicktakers"].clone()).unwrap();
@@ -409,7 +417,10 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
assert_eq!(mgr_item["id"], oracle["manager"][0]["id"]);
assert_eq!(mgr_item["cardsubtypeid"], 4);
assert_eq!(mgr_item["resourceId"], 1_000_509);
assert_eq!(mgr_item["contract"], STAFF_CONTRACT);
assert_eq!(
mgr_item["contract"], STAFF_CONTRACT,
"this manager instance is untracked, so the pack-fresh fallback shows"
);
assert_eq!(projected["kicktakers"], oracle["kicktakers"]);
assert_eq!(projected["squadType"], oracle["squadType"]);
assert_eq!(projected["chemistry"], oracle["chemistry"]);