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"]);
+18 -2
View File
@@ -24,6 +24,7 @@ use rand::Rng;
use serde_json::{json, Value};
use openfut_adapter_fifa17::fut::club_response::shape_club_response;
use openfut_adapter_fifa17::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
use openfut_adapter_fifa17::fut::economy_policy::pack_price;
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
use openfut_adapter_fifa17::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
@@ -106,6 +107,9 @@ fn core_owned(m: &Minted) -> CoreOwnedItem {
league: m.card.league.clone(),
club: m.card.club.clone(),
attributes: m.card.attributes,
// Freshly minted by a pack/Store open, so Core tracks no contract for it
// yet: the shaper substitutes the pack-fresh default.
contract_matches: None,
}
}
@@ -167,6 +171,10 @@ fn shape_minted(deps: &StoreDeps<'_>, minted: &[Minted]) -> Vec<Value> {
id,
deps.entities,
deps.assets.discard_value(&item),
// A pack-pulled card is by definition pack-fresh, so it carries
// the default rather than a persisted count: Core has not yet
// stored this instance, let alone applied a contract to it.
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
))
})
.collect()
@@ -464,8 +472,8 @@ mod tests {
use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
use crate::{
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyPurchase, EconomySale,
EconomySaleReceipt,
ConsumableApplyOutcome, ConsumableApplyRequest, CoreMatchCompletion, CoreMatchReceipt,
EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt,
};
// ── Recording economy double ────────────────────────────────────────────
@@ -608,6 +616,13 @@ mod tests {
// Match completion is not exercised by the Store/quick-sell paths.
Err(CoreError::Status(501))
}
fn apply_consumable(
&self,
_req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
// Consumable apply is not exercised by the Store/quick-sell paths.
Err(CoreError::Status(501))
}
}
// ── Identity / entity / lookup doubles ──────────────────────────────────
@@ -986,6 +1001,7 @@ mod tests {
league: "Premier League".into(),
club: "Arsenal".into(),
attributes: [rating; 6],
contract_matches: None,
}
}
+386 -130
View File
@@ -60,6 +60,10 @@ use openfut_adapter_fifa17::fut::consumables::consumables_response;
use openfut_adapter_fifa17::fut::content_taxonomy::{
consumable_families_for_category, consumable_family, position_group, ContentKind, PositionGroup,
};
use openfut_adapter_fifa17::fut::contract_cards::{
contract_grant, tier_for_rating, CONTRACT_MATCH_CAP, MANAGER_CONTRACT_SUBTYPE,
PACK_FRESH_CONTRACT_MATCHES, PLAYER_CONTRACT_SUBTYPE,
};
use openfut_adapter_fifa17::fut::discard;
use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver};
use openfut_adapter_fifa17::fut::item::CONSUMABLE_UNTRADEABLE;
@@ -186,17 +190,6 @@ pub enum Route {
/// Container type is load-bearing (object-where-array froze a live client);
/// the handler picks it from the path. Constant band 150..15000.
MarketData,
/// `POST …/item/resource/<resourceId>` — consumable APPLICATION
/// (`ApplyCardByRes`, task id `0x0e`), captured live 2026-08-21 as
/// `{"apply":[{"id":<target>}]}`. The source consumable is the RESOURCE id in
/// the path; the targets are owned-item wire ids in the body.
///
/// This classifies unconditionally so the route table stays a pure function of
/// (method, path) and remains testable, but the handler is a STAGING-ONLY
/// DIAGNOSTIC: without `OPENFUT_FIFA17_APPLY_PROBE=1` it declines and the
/// request falls through to the Python passthrough exactly as it does today.
/// The effect of a consumable is UNREVERSED, so nothing is ever mutated here.
ConsumableApplyProbe,
/// Anything else — proxied verbatim to the Python oracle.
Passthrough,
}
@@ -296,16 +289,6 @@ pub fn classify(method: &str, path: &str) -> Route {
Some("clubUser") if get => Route::FeatureOffEmpty,
Some("user/list") if get => Route::FeatureOffEmpty,
Some("item/resource") if get => Route::ItemDefs,
// The apply re-uses the item-definition PATH with a different VERB and a
// trailing resource id, which is why it fell through to Python: the
// `item/resource` arm above is GET-only. Live-captured 2026-08-21.
Some(t)
if post
&& t.strip_prefix("item/resource/")
.is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) =>
{
Route::ConsumableApplyProbe
}
Some("defid") if get => Route::ItemDefs,
Some(t) if get && (t == "marketdata" || t.starts_with("marketdata/")) => Route::MarketData,
_ => Route::Passthrough,
@@ -412,6 +395,18 @@ pub enum EconomyRoute {
QuickSellPath,
/// `POST /ut/delete/game/<sku>/item` — bulk quick-sell.
QuickSellBody,
/// `POST …/item/resource/<resourceId>` — apply one CONSUMABLE to one owned
/// target (`ApplyCardByRes`, task id `0x0e`), live-captured 2026-08-21 as
/// `{"apply":[{"id":<target>}]}`. The source consumable is the RESOURCE id in
/// the path; the target is an owned-item wire id in the body.
///
/// THIS PATH SERVES THREE VERBS and conflating any two of them consumes or
/// sells the wrong card: GET is the definition lookup ([`Route::ItemDefs`]),
/// POST is this apply, PUT is [`Self::QuickSellResource`]. That is not
/// hypothetical — the Python oracle maps `item/resource` method-agnostically
/// to its definition route, so an unclaimed verb there answers 200 with a
/// definition list, mutating nothing while the client reports success.
ConsumableApply,
/// `PUT …/item/resource/<resourceId>` — CONSUMABLE quick-sell, keyed by the
/// stack's resource id rather than an owned instance, with an EMPTY body.
///
@@ -628,6 +623,19 @@ pub fn classify_economy(method: &str, path: &str) -> Option<EconomyRoute> {
Some(t) if post && is_purchased_tail(t) => Some(EconomyRoute::PackOpen),
Some(t) if get && is_purchased_tail(t) => Some(EconomyRoute::PackReveal),
Some(t) if delete && is_item_id_tail(t) => Some(EconomyRoute::QuickSellPath),
// MUST stay adjacent to the PUT arm below so the `item/resource/` family
// is read as one unit: same path, three verbs (GET definition lookup,
// POST apply, PUT consumable quick-sell). It is an ECONOMY route because
// a successful apply destroys the source card, and `try_handle_economy`
// is the barrier that guarantees a matched route can never ALSO fall
// through to Python and be applied twice.
Some(t)
if post
&& t.strip_prefix("item/resource/")
.is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) =>
{
Some(EconomyRoute::ConsumableApply)
}
Some(t)
if put
&& t.strip_prefix("item/resource/")
@@ -1277,6 +1285,63 @@ pub struct CoreMatchReceipt {
pub coins_balance: i64,
}
/// The one PROVEN consumable effect: grant match-contracts to the target.
///
/// `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
/// 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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AddContractMatches {
pub amount: i64,
pub cap: i64,
pub default_when_unset: 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";
}
/// One consumable application to hand to Core's atomic `/consumables/apply`
/// transaction, which destroys the source instance and mutates the target in a
/// single durable step.
///
/// `action_identity` is the exactly-once key. `target_kind` is Core's own
/// lowercase `ContentKind` token for the target, so Core never has to infer what
/// it is mutating.
pub struct ConsumableApplyRequest<'a> {
pub action_identity: &'a str,
pub source_owned_card_id: &'a str,
pub target_owned_card_id: &'a str,
pub target_kind: &'a str,
pub effect: AddContractMatches,
}
/// Core's authoritative answer for a consumable application.
///
/// `applied` is `false` on an idempotent REPLAY of the same `action_identity`:
/// nothing was mutated and every field below echoes the RECORDED outcome, so a
/// replay must never be read as a fresh grant.
///
/// `source_quantity_after` is `None` whenever the source is not quantity-modelled
/// — which is always, for FIFA 17: consumables are separate owned instances and
/// a successful apply destroys exactly one of them.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsumableApplyOutcome {
pub applied: bool,
pub source_destroyed: bool,
pub source_quantity_after: Option<i64>,
pub granted: i64,
pub before: i64,
pub after: i64,
}
/// The host's authoritative economy transport to Core. Every method is a single
/// durable Core transaction. **Fail-closed:** on any transport/status/parse
/// error the caller MUST surface a controlled error and NEVER fall back to
@@ -1311,6 +1376,16 @@ pub trait CoreEconomy: Send + Sync {
/// replay/duplicate returns `applied = false` with the canonical result, and
/// any error is surfaced (never a Python fallback).
fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result<CoreMatchReceipt, CoreError>;
/// Apply one consumable to one target in Core's atomic, exactly-once
/// `/consumables/apply` transaction: the source instance is destroyed and the
/// target mutated together, or neither happens. A replayed
/// `action_identity` returns `applied = false` with the recorded outcome, and
/// any error is surfaced (never a Python fallback — the oracle would answer
/// this path 200 from its definition route and consume nothing).
fn apply_consumable(
&self,
req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError>;
}
impl HttpCoreClient {
@@ -1505,6 +1580,45 @@ impl CoreEconomy for HttpCoreClient {
coins_balance: json_i64(&v, "coins_balance")?,
})
}
fn apply_consumable(
&self,
req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
let v = self.core_post(
"consumables/apply",
&json!({
"action_identity": req.action_identity,
"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,
},
}),
)?;
// The effect block is REQUIRED even on a replay (Core echoes what it
// recorded). Missing it means the caller cannot tell what the target now
// holds, so it is a parse error rather than a defaulted zero.
let effect = v
.get("effect")
.ok_or_else(|| CoreError::Parse("missing `effect` object".into()))?;
Ok(ConsumableApplyOutcome {
applied: v.get("applied").and_then(Value::as_bool).unwrap_or(false),
source_destroyed: v
.get("source_destroyed")
.and_then(Value::as_bool)
.unwrap_or(false),
// Absent or null both mean "not a quantity-modelled source".
source_quantity_after: v.get("source_quantity_after").and_then(Value::as_i64),
granted: json_i64(effect, "granted")?,
before: json_i64(effect, "before")?,
after: json_i64(effect, "after")?,
})
}
}
/// Serialize an [`EconomySale`] into the `POST /economy/settle-sale` JSON body.
@@ -1681,6 +1795,7 @@ fn core_item_from_json(e: &Value) -> Option<CoreOwnedItem> {
attr("defending"),
attr("physical"),
],
contract_matches: e.get("contract_matches").and_then(|v| v.as_i64()),
})
}
@@ -1713,6 +1828,10 @@ fn core_item_from_definition(card: &Value) -> Option<CoreOwnedItem> {
attr("defending"),
attr("physical"),
],
// A definition is not an instance, so it holds no contracts — the same
// reasoning as the empty `owned_card_id` above. `None` makes the caller
// substitute the pack-fresh default instead of reading a fabricated 0.
contract_matches: None,
})
}
@@ -3786,6 +3905,20 @@ impl Server {
};
handle_quick_sell_path(id, &deps)
}
EconomyRoute::ConsumableApply => {
// Classification already guaranteed ASCII digits. A value that
// does not fit a FIFA resource id is not one of the known
// contract cards, so it is REFUSED here rather than `?`-ed:
// returning `None` from this function would let a mutation fall
// through to Python, i.e. a second writer.
match ut_tail(path)
.and_then(|t| t.strip_prefix("item/resource/"))
.and_then(|d| d.parse::<u32>().ok())
{
Some(rid) => self.handle_consumable_apply(rid, body, svc.econ.as_ref()),
None => error_response(409, "apply_effect_unproven"),
}
}
EconomyRoute::QuickSellResource => {
// The stack's resource id names a DEFINITION, so pick the owned
// copy deterministically: Core's own order, i.e. the same first
@@ -4100,22 +4233,15 @@ impl Server {
Route::FeatureOffEmpty => self.handle_feature_off_empty(path),
Route::Season => self.handle_season(path),
Route::ItemDefs => self.handle_item_defs(target),
Route::ConsumableApplyProbe => {
match self.handle_consumable_apply_probe(target, body) {
Some(resp) => resp,
// Gate off: identical to today — proxy it verbatim.
None => self.passthrough(method, target, headers, body),
}
}
Route::MarketData => self.handle_marketdata(path, target),
Route::SecurityQuestion => self.handle_security_question(method, target, headers),
Route::Passthrough => self.passthrough(method, target, headers, body),
}
}
/// Proxy a request verbatim to the Python oracle. Extracted so the declined
/// consumable-apply probe takes EXACTLY this path — with the gate off there is
/// no behavioural difference from before the probe existed.
/// Proxy a request verbatim to the Python oracle. Extracted so every route
/// that declines to answer takes EXACTLY this one path, and so the log line
/// naming an unclaimed request has a single home.
fn passthrough(
&self,
method: &str,
@@ -4739,15 +4865,22 @@ impl Server {
json_status(200, &non_economy::item_defs_body(&ids))
}
/// `POST …/item/resource/<resourceId>` — STAGING-ONLY consumable-apply
/// diagnostic. Returns `None` (→ Python passthrough, today's behaviour) unless
/// `OPENFUT_FIFA17_APPLY_PROBE=1`.
/// `POST …/item/resource/<resourceId>` — apply one consumable to one owned
/// target (`ApplyCardByRes`, task id `0x0e`), live-captured 2026-08-21 as
/// `{"apply":[{"id":<target>}]}`.
///
/// NON-AUTHORITATIVE BY CONSTRUCTION. It consumes no source card, mutates no
/// target, touches no contract/fitness/chemistry/training/injury state, mints
/// no coins and changes no ownership. It exists to observe what the client
/// does with a success, because the EFFECT of a consumable is unreversed and
/// implementing one on an inferred value is not acceptable.
/// The MUTATION is Core's: it destroys the source instance and raises the
/// target's contracts in one transaction. The FORMULA is the caller's, which
/// is this: the number of matches granted is selected by the TARGET's rating
/// tier, not by the consumable's own tier, and the table is not monotonic, so
/// it can only be looked up ([`contract_grant`]) — never interpolated.
///
/// Only the CONTRACT family is served, and only its PLAYER half (subtype
/// 201). Everything else fails closed. A 200-and-do-nothing here is precisely
/// the defect this route was claimed to end: the Python oracle maps
/// `item/resource` method-agnostically to its definition route, so an
/// unclaimed apply returns a definition list, consumes nothing, and the
/// client reports success.
///
/// RESPONSE SHAPE, from static RE rather than convenience: the apply
/// completion handler (CardsDLL `0x180035520`) tests exactly one field,
@@ -4755,70 +4888,192 @@ impl Server {
/// `EVENT_CARDS_APPLY_CARD_FAILURE` otherwise. It never inspects the body —
/// unlike the move ack (`0x180128600`), which builds per-item verdict records
/// and fails on an EMPTY vector. The response object's constructor
/// (`0x1800a4ce0`) initialises its record vector EMPTY, so empty is a legal
/// parse result here. `{"itemData":[]}` is therefore the smallest candidate
/// consistent with both the client and the oracle, whose `item/resource` route
/// is method-agnostic and answers this path with an `itemData` object.
/// It is a PROBE, not a proven contract.
fn handle_consumable_apply_probe(&self, target: &str, body: &[u8]) -> Option<WireResponse> {
if !apply_probe_enabled() {
return None;
}
let path = target.split('?').next().unwrap_or(target);
let resource_id: i64 = path.rsplit('/').next().and_then(|s| s.parse().ok())?;
/// (`0x1800a4ce0`) initialises its record vector EMPTY, so `{"itemData":[]}`
/// is a legal parse result, and it is what the live client accepted.
fn handle_consumable_apply(
&self,
resource_id: u32,
body: &[u8],
econ: &dyn CoreEconomy,
) -> WireResponse {
let targets = parse_apply_targets(body);
// `apply` is an ARRAY, but only len==1 has ever been observed. Batch
// semantics (atomic? partial?) are unknown, so a multi-target request is
// `apply` is an ARRAY, but only len == 1 has ever been observed. Batch
// semantics (atomic? partial? one source per target?) are unknown, and a
// consumable application is unreversed, so a multi-target request is
// reported and refused rather than guessed at.
if targets.len() != 1 {
eprintln!(
"utas-host owner=RUST route=apply-probe status=refused resource={resource_id} \
targets={} reason=batch_semantics_unproven body={}",
targets.len(),
String::from_utf8_lossy(&body[..body.len().min(256)])
"utas-host owner=RUST route=economy consumable-apply status=400 \
resource={resource_id} targets={} outcome=apply_batch_unsupported",
targets.len()
);
return Some(error_response(400, "apply_batch_unsupported"));
return error_response(400, "apply_batch_unsupported");
}
// Read-only identification of both operands, so the capture names what was
// applied to what. No write path is reachable from here.
let owned = self.core.all_owned().unwrap_or_default();
// A Core card id is "<sku>_<resourceId>", so the path's resource id names
// the DEFINITION directly; no new resolver method is needed for a probe.
let is_source = |it: &CoreOwnedItem| {
it.card_id
.rsplit_once('_')
.and_then(|(_, n)| n.parse::<i64>().ok())
== Some(resource_id)
let target_wire = targets[0];
let owned = match self.core.all_owned() {
Ok(o) => o,
Err(e) => {
eprintln!(
"utas-host ERROR route=economy consumable-apply status=503 \
resource={resource_id} wire={target_wire} err={e:?}"
);
return error_response(503, "core_unavailable");
}
};
let copies = owned.iter().filter(|it| is_source(it)).count();
let source_desc = match owned.iter().find(|it| is_source(it)) {
Some(it) => format!(
"owned kind={:?} subtype={} copies={}",
self.resolver.kind_of(it),
self.resolver.subtype_of(it),
copies
),
None => "NOT_OWNED".to_string(),
// The path's resource id names a DEFINITION, so pick the owned copy the
// same deterministic way quick-sell does: the FIRST matching copy in
// Core's own order, which is the copy whose wire id the consumables
// screen already published as the stack's `item`. The card consumed is
// therefore the one the screen showed the player.
let source = owned.iter().find_map(|it| {
self.resolver
.resolve_consumable(it)
.filter(|c| c.resource_id == resource_id)
.map(|c| (it, c))
});
let Some((source_item, source_ident)) = source else {
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=404 \
resource={resource_id} wire={target_wire} outcome=not_owned"
);
return error_response(404, "not_owned");
};
// Reverse the wire id through the identity store -- never a guess.
let target_desc = match self.resolver.owned_id_for_wire(targets[0]) {
Some(core_id) => match owned.iter().find(|it| it.owned_card_id == core_id) {
Some(it) => format!(
"owned card={} rating={} kind={:?}",
it.card_id,
it.rating,
self.resolver.kind_of(it)
),
None => format!("known_wire_id={core_id} NOT_IN_CLUB"),
match source_ident.subtype {
PLAYER_CONTRACT_SUBTYPE => {}
MANAGER_CONTRACT_SUBTYPE => {
// The grant is selected by the TARGET's rating tier, and staff
// ratings are not imported: a manager's rating lives in the
// `value` column of `managercards`/`*coachcards`/`physiocards`
// (see `Fifa17IdentityResolver::discard_value`) and Core models a
// non-player's `overall` as 0. So there is no honest tier for a
// manager target — inventing one would silently grant the wrong
// number of matches, unreversibly. Refuse until staff ratings
// are imported.
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=409 \
resource={resource_id} wire={target_wire} subtype={} \
outcome=manager_contract_unsupported reason=staff_ratings_not_imported",
source_ident.subtype
);
return error_response(409, "manager_contract_unsupported");
}
other => {
// Only the contract family's effect is proven. 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.
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=409 \
resource={resource_id} wire={target_wire} subtype={other} \
outcome=apply_effect_unproven"
);
return error_response(409, "apply_effect_unproven");
}
}
// Reverse the target's wire id through the identity store — never a
// guess, and never the wire id itself.
let target = self
.resolver
.owned_id_for_wire(target_wire)
.and_then(|core_id| {
owned
.iter()
.find(|it| it.owned_card_id == core_id)
.map(|it| (core_id, it))
});
let Some((target_core_id, target_item)) = target else {
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=404 \
resource={resource_id} wire={target_wire} outcome=not_owned"
);
return error_response(404, "not_owned");
};
// Subtype 201 is the PLAYER contract; the client's own family gating
// sends manager contracts (202) to staff. A player contract on a
// non-player has no proven effect at all.
let target_kind = self.resolver.kind_of(target_item);
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=contract_target_not_a_player",
target_kind.as_str()
);
return error_response(409, "contract_target_not_a_player");
}
let Some(granted) = contract_grant(resource_id, tier_for_rating(target_item.rating)) else {
// The subtype said "player contract" while the resource id is not one
// of the 13 known rows: a catalog inconsistency, not a licence to
// substitute a neighbouring row's number.
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=409 \
resource={resource_id} wire={target_wire} rating={} \
outcome=apply_effect_unproven reason=resource_not_a_contract_card",
target_item.rating
);
return error_response(409, "apply_effect_unproven");
};
// 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
// 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!(
"fifa17:apply:{}->{}",
source_item.owned_card_id, target_core_id
);
let req = ConsumableApplyRequest {
action_identity: &action_identity,
source_owned_card_id: &source_item.owned_card_id,
target_owned_card_id: &target_core_id,
target_kind: target_kind.as_str(),
effect: AddContractMatches {
amount: granted,
cap: CONTRACT_MATCH_CAP,
default_when_unset: PACK_FRESH_CONTRACT_MATCHES,
},
None => "UNRESOLVED_WIRE_ID".to_string(),
};
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.
//
// 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.
let (status, code) = match e {
CoreError::Status(400) => (400, "apply_refused"),
CoreError::Status(404) => (404, "not_owned"),
CoreError::Status(409) => (409, "apply_refused"),
_ => (503, "core_unavailable"),
};
eprintln!(
"utas-host ERROR route=economy consumable-apply status={status} \
resource={resource_id} wire={target_wire} err={e:?}"
);
return error_response(status, code);
}
};
eprintln!(
"utas-host owner=RUST route=apply-probe status=200 PROBE_ONLY resource={resource_id} \
source={source_desc} target={} target_item={target_desc} mutated=NOTHING",
targets[0]
"utas-host owner=RUST route=economy consumable-apply resource={resource_id} \
wire={target_wire} subtype={} granted={} before={} after={} applied={} \
source_destroyed={}",
source_ident.subtype,
outcome.granted,
outcome.before,
outcome.after,
outcome.applied,
outcome.source_destroyed
);
Some(json_text_status(200, "{\"itemData\":[]}".to_string()))
json_text_status(200, "{\"itemData\":[]}".to_string())
}
/// `GET …/marketdata[/pricelimits]` — suggested pricing. `/pricelimits` returns
@@ -5069,18 +5324,6 @@ fn parse_apply_targets(body: &[u8]) -> Vec<i64> {
.unwrap_or_default()
}
/// Whether the STAGING-ONLY consumable-apply diagnostic answers.
///
/// OFF unless `OPENFUT_FIFA17_APPLY_PROBE=1`. With it off the route falls through
/// to the Python passthrough, i.e. byte-for-byte today's behaviour, so production
/// cannot accidentally serve a diagnostic. The probe exists ONLY to observe the
/// client's success path: the consumable EFFECT is unreversed, so it consumes
/// nothing and mutates nothing.
fn apply_probe_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_APPLY_PROBE").as_deref() == Ok("1"))
}
/// Whether to log unclaimed (passthrough) request BODIES.
///
/// OFF unless `OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1`, and capped at 512 bytes.
@@ -5458,6 +5701,13 @@ mod tests {
coins_balance: self.balance,
})
}
fn apply_consumable(
&self,
_req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
// Consumable apply is not exercised through this double.
Err(CoreError::Status(501))
}
fn purchase_item(
&self,
_cost: i64,
@@ -5849,6 +6099,7 @@ mod tests {
league: "Premier League".into(),
club: "Chelsea".into(),
attributes: [90, 90, 80, 91, 33, 80],
contract_matches: None,
}
}
@@ -6462,6 +6713,9 @@ mod tests {
/// One path, three verbs. GET is the definition lookup, POST applies the
/// consumable, PUT quick-sells it. All three were live-captured; conflating
/// any two of them sells or consumes the wrong thing.
///
/// Apply and quick-sell are ECONOMY routes, classified before `classify()`
/// ever runs, so they must resolve there and never fall through to Python.
#[test]
fn item_resource_path_dispatches_on_verb() {
assert_eq!(
@@ -6469,54 +6723,56 @@ mod tests {
Route::ItemDefs
);
assert_eq!(
classify("POST", "/ut/game/fifa17/item/resource/5001004"),
Route::ConsumableApplyProbe
classify_economy("POST", "/ut/game/fifa17/item/resource/5001004"),
Some(EconomyRoute::ConsumableApply)
);
// The quick-sell is an ECONOMY route, classified before `classify()`
// ever runs, so it must resolve there and not fall through to Python.
assert_eq!(
classify_economy("PUT", "/ut/game/fifa17/item/resource/5003068"),
Some(EconomyRoute::QuickSellResource)
);
assert_eq!(
classify_economy("PUT", "/ut/v2/game/fifa17/item/resource/5003068"),
Some(EconomyRoute::QuickSellResource)
);
// The bare `item` PUT is the pile move and must not be captured.
assert_eq!(
classify_economy("PUT", "/ut/game/fifa17/item"),
Some(EconomyRoute::MoveItems)
);
// A non-numeric tail is not a resource id.
// A non-numeric tail is not a resource id, so it is NEITHER route — and
// it must not become an apply, which would consume a card on a path the
// client never builds.
assert_eq!(
classify_economy("POST", "/ut/game/fifa17/item/resource/bogus"),
None
);
assert_eq!(
classify_economy("PUT", "/ut/game/fifa17/item/resource/bogus"),
None
);
}
/// The apply re-uses the definition-lookup PATH with a different VERB, which
/// is exactly why it went unclaimed. Lock that boundary.
#[test]
fn consumable_apply_is_classified_by_verb_and_resource_id() {
// Live-captured 2026-08-21: POST ut/<sku>/item/resource/<resourceId>.
assert_eq!(
classify("POST", "/ut/game/fifa17/item/resource/5001004"),
Route::ConsumableApplyProbe
);
assert_eq!(
classify("POST", "/ut/v2/game/fifa17/item/resource/5001004"),
Route::ConsumableApplyProbe
);
// A non-numeric tail is not a resource id, so it is not the apply.
assert_eq!(
classify("POST", "/ut/game/fifa17/item/resource/bogus"),
Route::Passthrough
);
// The definition lookup keeps the path under its own verb.
}
/// Retail FIFA 17 issues part of the item family under `/ut/v2/game/`, so the
/// same three verbs must land identically under both prefixes: `ut_tail`
/// normalises them and nothing downstream may depend on which was used.
#[test]
fn item_resource_verbs_are_prefix_agnostic() {
assert_eq!(
classify("GET", "/ut/game/fifa17/item/resource"),
classify("GET", "/ut/v2/game/fifa17/item/resource"),
Route::ItemDefs
);
assert_eq!(
classify_economy("POST", "/ut/v2/game/fifa17/item/resource/5001004"),
Some(EconomyRoute::ConsumableApply)
);
assert_eq!(
classify_economy("PUT", "/ut/v2/game/fifa17/item/resource/5003068"),
Some(EconomyRoute::QuickSellResource)
);
assert_eq!(
classify_economy("POST", "/ut/v2/game/fifa17/item/resource/bogus"),
None
);
}
#[test]
+24 -3
View File
@@ -26,6 +26,7 @@
use serde_json::{json, Value};
use openfut_adapter_fifa17::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
use openfut_adapter_fifa17::fut::entities::ReverseEntityResolver;
use openfut_adapter_fifa17::fut::item::{shape_item, ItemIdentityResolver};
use openfut_adapter_fifa17::fut::item_state;
@@ -284,7 +285,19 @@ pub fn resolve_market_list<E: ReverseEntityResolver>(
let identity = resolver.resolve(&owned);
let resource_id = identity.map(|id| id.resource_id as i64);
let item_json = identity
.map(|id| shape_item(&owned, id, ent, resolver.discard_value(&owned)))
.map(|id| {
shape_item(
&owned,
id,
ent,
resolver.discard_value(&owned),
// A listing snapshot must show the contract the seller's card
// actually holds, so a part-used card cannot render as fresh.
owned
.contract_matches
.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
)
})
.and_then(|card| serde_json::to_string(&card).ok());
Some(ResolvedListing {
item_id,
@@ -806,8 +819,8 @@ pub async fn handle_move_items(
mod tests {
use super::*;
use crate::{
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyGrantItem,
EconomyPurchase, EconomySale, EconomySaleReceipt,
ConsumableApplyOutcome, ConsumableApplyRequest, CoreMatchCompletion, CoreMatchReceipt,
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
};
use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
@@ -968,6 +981,13 @@ mod tests {
// Match completion is not exercised through the market double.
Err(CoreError::Status(501))
}
fn apply_consumable(
&self,
_req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
// Consumable apply is not exercised through the market double.
Err(CoreError::Status(501))
}
}
// ---- SquadWireResolver double -----------------------------------------
@@ -1019,6 +1039,7 @@ mod tests {
league: String::new(),
club: String::new(),
attributes: [80, 80, 80, 80, 80, 80],
contract_matches: None,
}
}
+13 -3
View File
@@ -30,9 +30,10 @@ use openfut_utas_host::async_bridge::AsyncBridge;
use openfut_utas_host::market_store::MarketStore;
use openfut_utas_host::pile_store::PileStore;
use openfut_utas_host::{
build_content_pool, CoreAccess, CoreEconomy, CoreError, CoreMatchCompletion, CoreMatchReceipt,
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
EconomyServices, Fifa17IdentityResolver, HttpCoreClient, PassClient, Server, WireResponse,
build_content_pool, ConsumableApplyOutcome, ConsumableApplyRequest, CoreAccess, CoreEconomy,
CoreError, CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyGrantItem,
EconomyPurchase, EconomySale, EconomySaleReceipt, EconomyServices, Fifa17IdentityResolver,
HttpCoreClient, PassClient, Server, WireResponse,
};
use parking_lot::Mutex;
use serde_json::Value;
@@ -146,6 +147,15 @@ impl CoreEconomy for FaultEconomy {
}
self.inner.complete_match(m)
}
fn apply_consumable(
&self,
req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
if self.trip("apply_consumable") {
return Err(Self::injected());
}
self.inner.apply_consumable(req)
}
}
/// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can
+1
View File
@@ -233,6 +233,7 @@ fn item(
league: league.into(),
club: club.into(),
attributes: [90, 88, 70, 85, 40, 78],
contract_matches: None,
}
}
-41
View File
@@ -1,41 +0,0 @@
#!/usr/bin/env python3
"""With OPENFUT_FIFA17_APPLY_PROBE unset the apply MUST fall through to the
Python passthrough -- i.e. exactly the behaviour that existed before the probe.
That is the production-safety claim, so prove it rather than assert it."""
import subprocess
import urllib.error
import urllib.request
LOG = "/home/alex/openfut-sold-staging/logs/utas-host.log"
mark = sum(1 for _ in open(LOG))
before = subprocess.run(["python3", "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py"],
capture_output=True, text=True).stdout
req = urllib.request.Request(
"http://127.0.0.1:8299/ut/game/fifa17/item/resource/5001004",
data=b'{"apply":[{"id":100000003}]}',
headers={"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"},
method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as r:
st, body = r.status, r.read().decode()
except urllib.error.HTTPError as e:
st, body = e.code, e.read().decode()
print(" status=%s body=%s" % (st, body))
print("--- log with the gate OFF ---")
lines = list(open(LOG))[mark:]
for line in lines:
if any(k in line for k in ("apply-probe", "passthrough", "PYTHON")):
print(" " + line.rstrip()[:150])
probe_served = any("apply-probe" in line for line in lines)
went_python = any("owner=PYTHON" in line for line in lines)
after = subprocess.run(["python3", "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py"],
capture_output=True, text=True).stdout
print("\n probe served (must be False): %s" % probe_served)
print(" proxied to Python (must be True): %s" % went_python)
print(" Core unchanged: %s" % (before == after))
print("\nRESULT: %s" % ("OK -- gate fails closed"
if (not probe_served and went_python and before == after)
else "FAILED"))
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env python3
"""Smoke-test the consumable-apply probe against the RUNNING staging host.
Replays the exact request the client sent, plus the batch and unknown-target
edges, then proves Core is byte-identical afterwards. No client needed.
"""
import json
import subprocess
import urllib.error
import urllib.request
BASE = "http://127.0.0.1:8299"
PATH = "/ut/game/fifa17/item/resource/5001004"
H = {"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"}
LOG = "/home/alex/openfut-sold-staging/logs/utas-host.log"
def post(path, payload):
req = urllib.request.Request(
BASE + path, data=payload, headers=H, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
before = subprocess.run(["python3", "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py"],
capture_output=True, text=True).stdout
mark = sum(1 for _ in open(LOG))
print("=== 1. the EXACT request captured from the client ===")
st, body = post(PATH, b'{"apply":[{"id":100000003}]}')
print(" status=%s body=%s" % (st, body))
ok1 = st == 200 and json.loads(body) == {"itemData": []}
print("\n=== 2. batch: semantics unproven, must be REFUSED not guessed ===")
st2, body2 = post(PATH, b'{"apply":[{"id":100000003},{"id":100000004}]}')
print(" status=%s body=%s" % (st2, body2[:90]))
ok2 = st2 == 400 and "apply_batch_unsupported" in body2
print("\n=== 3. unknown target: observed, never invented ===")
st3, body3 = post(PATH, b'{"apply":[{"id":999999999}]}')
print(" status=%s body=%s" % (st3, body3))
ok3 = st3 == 200
print("\n=== 4. unowned source resource ===")
st4, body4 = post("/ut/game/fifa17/item/resource/1234567", b'{"apply":[{"id":100000003}]}')
print(" status=%s body=%s" % (st4, body4))
ok4 = st4 == 200
print("\n=== host log ===")
for line in list(open(LOG))[mark:]:
if "apply-probe" in line:
print(" " + line.rstrip()[:190])
after = subprocess.run(["python3", "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py"],
capture_output=True, text=True).stdout
same = before == after
print("\n=== 5. Core unchanged by all four requests: %s ===" % ("YES" if same else "NO"))
if not same:
for b, a in zip(before.splitlines(), after.splitlines()):
if b != a:
print(" BEFORE %s\n AFTER %s" % (b.strip(), a.strip()))
print("\nRESULT: %s" % ("OK" if all([ok1, ok2, ok3, ok4, same]) else "FAILED"))
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Smoke-test the REAL consumable-apply route against the RUNNING staging host.
`POST ut/<sku>/item/resource/<resourceId>` is now Rust-owned and it MUTATES:
Core destroys the source consumable instance and raises the target's
match-contracts in ONE transaction. There is no `OPENFUT_FIFA17_APPLY_PROBE`
gate any more and no staging-only diagnostic -- the route is unconditional --
so what needs proving changed. This checks the two halves that matter:
* every REFUSAL leaves Core byte-identical (fail closed; nothing half-applied);
* the one accepted apply consumes exactly ONE copy, and the contract number the
host logged is the number the client can actually read back off the wire.
It deliberately does NOT re-derive the FIFA 17 grant matrix. Duplicating those
13 rows here would create a second source of truth that could silently disagree
with `openfut-adapter-fifa17::fut::contract_cards`, which is the authority.
Instead the host's own `granted/before/after` are checked for internal
consistency (`after == min(99, before + granted)`) and against the projected
wire state.
Replaying the accepted request is NOT idempotent and is not attempted: a
successful apply DESTROYS the source instance, so a second POST legitimately
consumes the NEXT owned copy. Idempotency is Core's, keyed on the source
instance id (`fifa17:apply:<source>-><target>`), and a transport retry of one
logical action replays that key rather than this HTTP request.
"""
import json
import re
import subprocess
import urllib.error
import urllib.request
BASE = "http://127.0.0.1:8299"
SKU = "fifa17"
LOG = "/home/alex/openfut-sold-staging/logs/utas-host.log"
SNAPSHOT = "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py"
# A PLAYER contract card (cardsubtypeid 201) owned on staging: the one accepted
# apply. It must match the snapshot script's SOURCE_RES so the copy count below
# is the count of THIS stack.
CONTRACT_RES = 5001004
# Position modifier: a real owned family whose apply effect is NOT proven.
POSITION_RES = 5003068
# Manager contract (cardsubtypeid 202): unservable until staff ratings are
# imported, because the grant is keyed on the TARGET's rating tier.
MANAGER_RES = 5001010
UNOWNED_RES = 1234567
TARGET_WIRE = 100000003
UNKNOWN_WIRE = 999999999
H = {"X-OpenFUT-Game": SKU, "Content-Type": "application/json"}
results = []
def post(resource_id, wires):
body = json.dumps({"apply": [{"id": w} for w in wires]}).encode()
req = urllib.request.Request(
"%s/ut/game/%s/item/resource/%d" % (BASE, SKU, resource_id),
data=body, headers=H, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
def snapshot():
out = subprocess.run(["python3", SNAPSHOT], capture_output=True, text=True)
if out.returncode != 0:
raise SystemExit("snapshot failed: %s" % out.stderr.strip())
return json.loads(out.stdout)
def apply_log_since(mark):
with open(LOG) as f:
return [ln.rstrip() for ln in list(f)[mark:] if "consumable-apply" in ln]
def check(name, ok, detail=""):
results.append(ok)
print(" [%s] %s%s" % ("OK" if ok else "FAIL", name,
(" -- " + detail) if detail else ""))
def skip(name, detail):
print(" [SKIP] %s -- %s" % (name, detail))
def stack_count(snap):
"""Owned copies of CONTRACT_RES, or 0 once the last one is consumed (the
stack disappears from the consumables screen entirely)."""
return ((snap.get("source_stack") or {}).get("count")) or 0
with open(LOG) as f:
mark = sum(1 for _ in f)
base = snapshot()
print("=== every refusal must leave Core byte-identical ===")
REFUSALS = [
("batch semantics unproven", CONTRACT_RES,
[TARGET_WIRE, TARGET_WIRE + 1], 400, "apply_batch_unsupported"),
("unknown target wire id", CONTRACT_RES,
[UNKNOWN_WIRE], 404, "not_owned"),
("source consumable not owned", UNOWNED_RES,
[TARGET_WIRE], 404, "not_owned"),
("non-contract family fails closed", POSITION_RES,
[TARGET_WIRE], 409, "apply_effect_unproven"),
("manager contract refused: staff ratings not imported", MANAGER_RES,
[TARGET_WIRE], 409, "manager_contract_unsupported"),
]
for name, res, wires, want_status, want_code in REFUSALS:
status, body = post(res, wires)
got = "status=%s body=%s" % (status, body[:80])
# A family-gate case can only be exercised if this profile owns such a card;
# source resolution runs first, so an unowned one answers 404 not_owned. Say
# so rather than scoring a pass or a failure that means nothing.
if want_status != 404 and status == 404 and "not_owned" in body:
skip(name, "profile owns no resource %d" % res)
continue
check(name, status == want_status and want_code in body, got)
check("Core unchanged by every refusal", snapshot() == base)
print("\n=== the one accepted apply must mutate, exactly once ===")
status, body = post(CONTRACT_RES, [TARGET_WIRE])
parsed = None
try:
parsed = json.loads(body)
except ValueError:
pass
check("client-shaped ack", status == 200 and parsed == {"itemData": []},
"status=%s body=%s" % (status, body[:80]))
after = snapshot()
lines = apply_log_since(mark)
granted_lines = [ln for ln in lines if "granted=" in ln]
fields = {}
if granted_lines:
fields = dict(re.findall(r"(subtype|granted|before|after|applied)=(-?\w+)",
granted_lines[-1]))
check("host logged the grant it applied",
{"subtype", "granted", "before", "after"} <= set(fields),
granted_lines[-1] if granted_lines else "no consumable-apply grant line")
if {"subtype", "granted", "before", "after"} <= set(fields):
granted = int(fields["granted"])
before_n = int(fields["before"])
after_n = int(fields["after"])
check("player contract subtype", fields["subtype"] == "201",
"subtype=%s" % fields["subtype"])
check("Core applied a real grant", granted > 0, "granted=%d" % granted)
check("cap respected: after == min(99, before + granted)",
after_n == min(99, before_n + granted),
"before=%d granted=%d after=%d" % (before_n, granted, after_n))
check("not a replay", fields.get("applied") == "true",
"applied=%s" % fields.get("applied"))
wire_contract = (after.get("target") or {}).get("contract")
check("the wire shows what Core recorded", wire_contract == after_n,
"wire=%s core=%s" % (wire_contract, after_n))
check("exactly one source copy consumed",
stack_count(after) == stack_count(base) - 1,
"before=%s after=%s" % (stack_count(base), stack_count(after)))
check("exactly one owned row destroyed",
after["owned_rows"] == base["owned_rows"] - 1,
"before=%s after=%s" % (base["owned_rows"], after["owned_rows"]))
check("coins untouched: an apply is not a sale",
after["coins"] == base["coins"],
"before=%s after=%s" % (base["coins"], after["coins"]))
print("\n=== host log ===")
for line in lines:
print(" " + line[:200])
ok = all(results)
print("\nRESULT: %s" % ("OK" if ok else "FAILED"))
raise SystemExit(0 if ok else 1)
+10 -3
View File
@@ -1,7 +1,14 @@
#!/usr/bin/env python3
"""Core snapshot around the consumable-apply probe: coins, ownership, the source
consumable's copies, and the target's mutable state. Run before and after; the
probe must change NOTHING."""
"""Core snapshot around a consumable APPLY: coins, ownership, the source
consumable's copies, and the target's mutable state. Run before and after.
A player-contract apply must move EXACTLY three things: the source consumable
loses one copy, the target's `contract` rises to min(99, before + grant), and
nothing else coins in particular must not move, because an apply is not an
economy credit. Everything else in this snapshot is here to prove it stayed put.
(Superseded for acceptance by fifa17-contract-apply-validate.py, which asserts
the deltas itself; this remains the raw before/after dump for eyeballing.)"""
import json
import sys
import urllib.request
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""Acceptance harness for FIFA17 player-contract consumable APPLY (staging).
Exercises the real route end to end and asserts the full observable contract:
* the target's `contract` goes from B to min(99, B + grant), where `grant` is
read from the shipped EA table `fcc_contractcards` INDEPENDENTLY of the Rust
implementation (this is a cross-check, not a mirror);
* exactly one source copy is consumed;
* coins do NOT move (an apply is not an economy credit);
* replaying the exhausted resource fails closed rather than granting again;
* a manager contract and a non-contract consumable both fail closed.
Read-only against production by construction: every URL is the staging port.
"""
import argparse
import json
import sqlite3
import sys
import urllib.error
import urllib.request
HOST = "http://127.0.0.1:8299"
CORE_DB = "/home/alex/openfut-sold-staging/staging-core.db"
HDRS = {"X-OpenFUT-Game": "fifa17"}
TABLE = "/home/alex/OpenFUT/fifa17-recon/data/tables/fcc_contractcards.json"
PLAYER_CONTRACT_SUBTYPE = 201
MANAGER_CONTRACT_SUBTYPE = 202
CAP = 99
FAILURES = []
def check(label, got, want):
ok = got == want
print(f" [{'OK ' if ok else 'FAIL'}] {label}: got {got!r} want {want!r}")
if not ok:
FAILURES.append(label)
return ok
def req(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(HOST + path, data=data, headers=HDRS, method=method)
if data:
r.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(r, timeout=30) as resp:
raw = resp.read()
return resp.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e:
raw = e.read()
try:
return e.code, json.loads(raw)
except Exception:
return e.code, raw.decode(errors="replace")
def grant_from_table(resource_id, target_rating):
"""The authoritative grant, read straight from EA's shipped table.
Column is selected by the TARGET's tier (bronze <65, silver 65..74, gold
>=75) NOT by the card's own tier. Verified 36/36 against the published
FIFA 17 matrix.
"""
d = json.load(open(TABLE))
rows = d if isinstance(d, list) else (d.get("rows") or list(d.values())[0])
row = next((r for r in rows if r["carddbid"] == resource_id), None)
if row is None:
return None
col = "bronze" if target_rating < 65 else ("silver" if target_rating < 75 else "gold")
return row[col]
def core_snapshot():
con = sqlite3.connect(f"file:{CORE_DB}?mode=ro", uri=True)
try:
snap = {
"coins": con.execute("SELECT coins FROM clubs LIMIT 1").fetchone()[0],
"owned": con.execute("SELECT COUNT(*) FROM owned_cards").fetchone()[0],
"by_kind": dict(
con.execute("SELECT content_kind, COUNT(*) FROM owned_cards GROUP BY 1")
),
}
cols = [r[1] for r in con.execute("PRAGMA table_info(owned_cards)")]
snap["has_contract_column"] = "contract_matches" in cols
if snap["has_contract_column"]:
snap["contracts_set"] = con.execute(
"SELECT COUNT(*) FROM owned_cards WHERE contract_matches IS NOT NULL"
).fetchone()[0]
return snap
finally:
con.close()
def club_players():
"""Every owned player on the wire, with its contract, keyed by wire id."""
out = {}
for pile in ("/ut/game/fifa17/club?type=player&start=0&count=200",):
_, body = req("GET", pile)
for it in (body or {}).get("itemData") or []:
if it.get("itemType") == "player":
out[it["id"]] = it
return out
def consumable_stacks():
_, body = req("GET", "/ut/game/fifa17/club/consumables/development")
return {s["resourceId"]: s for s in (body or {}).get("itemData") or []}
def pick_source(stacks, subtype_range):
for rid, s in sorted(stacks.items()):
if rid in subtype_range:
return rid, s
return None, None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--source", type=int, default=None,
help="contract resource id to apply (default: first owned player contract)")
ap.add_argument("--target", type=int, default=None,
help="target player wire id (default: lowest-rated owned player)")
args = ap.parse_args()
print("== BEFORE ==")
before = core_snapshot()
print(json.dumps(before, indent=2, sort_keys=True))
if not before["has_contract_column"]:
print("FATAL: migration 0028 not applied — owned_cards has no contract_matches column")
return 2
stacks = consumable_stacks()
players = club_players()
if not players:
print("FATAL: no owned players on the wire")
return 2
player_contracts = {r: s for r, s in stacks.items() if 5001001 <= r <= 5001006 or r == 5001013}
src = args.source or next(iter(sorted(player_contracts)), None)
if src is None:
print("FATAL: no owned PLAYER contract consumable to apply")
return 2
# Lowest-rated target maximises the observable delta (a bronze target draws
# the largest column) and exercises the tier selector rather than assuming gold.
tgt_id = args.target or min(players, key=lambda i: players[i].get("rating", 0))
tgt = players[tgt_id]
rating = tgt["rating"]
grant = grant_from_table(src, rating)
c_before = tgt.get("contract")
expect_after = min(CAP, c_before + grant)
print(f"\n== APPLY ==\n source resource {src} (stack count {stacks[src].get('count')})")
print(f" target wire {tgt_id} rating {rating} -> tier "
f"{'bronze' if rating < 65 else 'silver' if rating < 75 else 'gold'}")
print(f" table grant {grant}; contract {c_before} -> expect {expect_after}")
status, body = req("POST", f"/ut/game/fifa17/item/resource/{src}",
{"apply": [{"id": tgt_id}]})
print(f" HTTP {status} {json.dumps(body)[:200] if body is not None else ''}")
check("apply status", status, 200)
check("apply body", body, {"itemData": []})
print("\n== AFTER ==")
after = core_snapshot()
players2 = club_players()
stacks2 = consumable_stacks()
print(json.dumps(after, indent=2, sort_keys=True))
check("coins unchanged", after["coins"], before["coins"])
check("one owned row consumed", after["owned"], before["owned"] - 1)
check("one consumable consumed",
after["by_kind"].get("consumable", 0), before["by_kind"].get("consumable", 0) - 1)
check("target contract granted", players2.get(tgt_id, {}).get("contract"), expect_after)
check("source stack decremented",
(stacks2.get(src) or {}).get("count", 0), (stacks[src].get("count") or 1) - 1)
# Untouched players must not have drifted.
drifted = [i for i, p in players2.items()
if i != tgt_id and p.get("contract") != players.get(i, {}).get("contract")]
check("no collateral contract changes", drifted, [])
print("\n== FAIL-CLOSED CASES ==")
exhausted = (stacks2.get(src) or {}).get("count", 0) == 0
if exhausted:
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{src}", {"apply": [{"id": tgt_id}]})
check("replay of exhausted resource refused", s, 404)
mgr = next((r for r in stacks2 if 5001007 <= r <= 5001012), None)
if mgr:
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{mgr}", {"apply": [{"id": tgt_id}]})
check("manager contract fails closed (staff ratings unimported)", s, 409)
other = next((r for r in stacks2 if not (5001001 <= r <= 5001013)), None)
if other:
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{other}", {"apply": [{"id": tgt_id}]})
check("unproven family fails closed", s, 409)
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{src}",
{"apply": [{"id": tgt_id}, {"id": tgt_id}]})
check("batch apply refused", s in (400, 404), True)
print("\n== RESULT ==")
if FAILURES:
print("FAILED: " + ", ".join(FAILURES))
return 1
print(f"PASS — contract {c_before} -> {expect_after} on wire {tgt_id}, "
f"one copy of {src} consumed, coins flat")
return 0
if __name__ == "__main__":
sys.exit(main())
+10 -4
View File
@@ -77,10 +77,15 @@ try:
print(json.dumps(checks, indent=2, sort_keys=True))
print("\n=== apply probe MUST be off in the candidate ===")
# The consumable apply is Rust-owned and MUTATES, so the rehearsal must not
# send one that would succeed: a two-target body is refused (400
# apply_batch_unsupported) before anything is resolved or written. That
# refusal is only reachable if the candidate host CLAIMS the route -- a 502
# means it fell through to the (dead) upstream, i.e. the cutover is missing.
print("\n=== the candidate must OWN the consumable apply ===")
req = urllib.request.Request(
"http://127.0.0.1:%d/ut/game/fifa17/item/resource/5001004" % HOST_PORT,
data=b'{"apply":[{"id":100000003}]}',
data=b'{"apply":[{"id":100000003},{"id":100000004}]}',
headers={"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"},
method="POST")
try:
@@ -89,8 +94,9 @@ try:
except urllib.error.HTTPError as e:
st, body = e.code, e.read().decode()
print(" status=%s body=%s" % (st, body))
print(" probe OFF (must be 502 upstream-unavailable): %s"
% (st == 502 and "upstream" in body))
print(" apply route Rust-owned, nothing mutated (must be 400 "
"apply_batch_unsupported): %s"
% (st == 400 and "apply_batch_unsupported" in body))
finally:
for p in procs:
try: