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
@@ -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();