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
+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(