From 6c97bc4e2badeae4251cb53f19c4f778c6901200 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 22 Aug 2026 18:23:22 +0000 Subject: [PATCH] feat(fifa17): real player-contract consumable apply, replacing the probe POST /ut/game/fifa17/item/resource/ {"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). --- openfut-adapter-fifa17/mutation-battery.sh | 8 +- .../src/fut/club_response.rs | 101 +++- .../src/fut/contract_cards.rs | 251 +++++++++ openfut-adapter-fifa17/src/fut/item.rs | 107 +++- openfut-adapter-fifa17/src/fut/mod.rs | 1 + .../src/fut/squad_projection.rs | 29 +- .../tests/squad_projection.rs | 13 +- openfut-core | 2 +- openfut-utas-host/src/economy_store.rs | 20 +- openfut-utas-host/src/lib.rs | 516 +++++++++++++----- openfut-utas-host/src/market.rs | 27 +- openfut-utas-host/tests/economy_failure.rs | 16 +- openfut-utas-host/tests/host_test.rs | 1 + scripts/fifa17-apply-gate-off.py | 41 -- scripts/fifa17-apply-probe-smoke.py | 66 --- scripts/fifa17-apply-smoke.py | 176 ++++++ scripts/fifa17-apply-snapshot.py | 13 +- scripts/fifa17-contract-apply-validate.py | 216 ++++++++ scripts/fifa17-migration-rehearse.py | 14 +- 19 files changed, 1348 insertions(+), 270 deletions(-) create mode 100644 openfut-adapter-fifa17/src/fut/contract_cards.rs delete mode 100755 scripts/fifa17-apply-gate-off.py delete mode 100755 scripts/fifa17-apply-probe-smoke.py create mode 100755 scripts/fifa17-apply-smoke.py create mode 100755 scripts/fifa17-contract-apply-validate.py diff --git a/openfut-adapter-fifa17/mutation-battery.sh b/openfut-adapter-fifa17/mutation-battery.sh index 97da256..5977719 100755 --- a/openfut-adapter-fifa17/mutation-battery.sh +++ b/openfut-adapter-fifa17/mutation-battery.sh @@ -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" \ diff --git a/openfut-adapter-fifa17/src/fut/club_response.rs b/openfut-adapter-fifa17/src/fut/club_response.rs index b3a7b19..20be7a2 100644 --- a/openfut-adapter-fifa17/src/fut/club_response.rs +++ b/openfut-adapter-fifa17/src/fut/club_response.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( 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( // 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( #[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(); diff --git a/openfut-adapter-fifa17/src/fut/contract_cards.rs b/openfut-adapter-fifa17/src/fut/contract_cards.rs new file mode 100644 index 0000000..4aabba8 --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/contract_cards.rs @@ -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 { + 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}" + ); + } + } + } +} diff --git a/openfut-adapter-fifa17/src/fut/item.rs b/openfut-adapter-fifa17/src/fut/item.rs index de29c5d..b13ceeb 100644 --- a/openfut-adapter-fifa17/src/fut/item.rs +++ b/openfut-adapter-fifa17/src/fut/item.rs @@ -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, } /// 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( diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index 7e1844c..0493a91 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -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; diff --git a/openfut-adapter-fifa17/src/fut/squad_projection.rs b/openfut-adapter-fifa17/src/fut/squad_projection.rs index 38a06d6..501a9d7 100644 --- a/openfut-adapter-fifa17/src/fut/squad_projection.rs +++ b/openfut-adapter-fifa17/src/fut/squad_projection.rs @@ -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( .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( // 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, diff --git a/openfut-adapter-fifa17/tests/squad_projection.rs b/openfut-adapter-fifa17/tests/squad_projection.rs index 03f6384..6a471cd 100644 --- a/openfut-adapter-fifa17/tests/squad_projection.rs +++ b/openfut-adapter-fifa17/tests/squad_projection.rs @@ -114,6 +114,10 @@ fn oracle_tables() -> (HashMap, 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 = 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"]); diff --git a/openfut-core b/openfut-core index 233df1d..e8be289 160000 --- a/openfut-core +++ b/openfut-core @@ -1 +1 @@ -Subproject commit 233df1d99d856ad95c89a8d31c66beff6e2cb34f +Subproject commit e8be28966092b25f47cd517cf84f8467e75b30e6 diff --git a/openfut-utas-host/src/economy_store.rs b/openfut-utas-host/src/economy_store.rs index 3706250..ca10611 100644 --- a/openfut-utas-host/src/economy_store.rs +++ b/openfut-utas-host/src/economy_store.rs @@ -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 { 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 { + // 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, } } diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 184341e..51abf62 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -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/` — consumable APPLICATION - /// (`ApplyCardByRes`, task id `0x0e`), captured live 2026-08-21 as - /// `{"apply":[{"id":}]}`. 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//item` — bulk quick-sell. QuickSellBody, + /// `POST …/item/resource/` — apply one CONSUMABLE to one owned + /// target (`ApplyCardByRes`, task id `0x0e`), live-captured 2026-08-21 as + /// `{"apply":[{"id":}]}`. 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/` — 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 { 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, + 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; + /// 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; } impl HttpCoreClient { @@ -1505,6 +1580,45 @@ impl CoreEconomy for HttpCoreClient { coins_balance: json_i64(&v, "coins_balance")?, }) } + + fn apply_consumable( + &self, + req: &ConsumableApplyRequest<'_>, + ) -> Result { + 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 { 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 { 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::().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/` — STAGING-ONLY consumable-apply - /// diagnostic. Returns `None` (→ Python passthrough, today's behaviour) unless - /// `OPENFUT_FIFA17_APPLY_PROBE=1`. + /// `POST …/item/resource/` — apply one consumable to one owned + /// target (`ApplyCardByRes`, task id `0x0e`), live-captured 2026-08-21 as + /// `{"apply":[{"id":}]}`. /// - /// 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 { - 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 "_", 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::().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 { .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 = 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 { + // 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//item/resource/. - 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] diff --git a/openfut-utas-host/src/market.rs b/openfut-utas-host/src/market.rs index 94a0512..1fb457f 100644 --- a/openfut-utas-host/src/market.rs +++ b/openfut-utas-host/src/market.rs @@ -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( 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 { + // 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, } } diff --git a/openfut-utas-host/tests/economy_failure.rs b/openfut-utas-host/tests/economy_failure.rs index 1a0d7eb..a0cb454 100644 --- a/openfut-utas-host/tests/economy_failure.rs +++ b/openfut-utas-host/tests/economy_failure.rs @@ -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 { + if self.trip("apply_consumable") { + return Err(Self::injected()); + } + self.inner.apply_consumable(req) + } } /// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index 15caf23..8a889fd 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -233,6 +233,7 @@ fn item( league: league.into(), club: club.into(), attributes: [90, 88, 70, 85, 40, 78], + contract_matches: None, } } diff --git a/scripts/fifa17-apply-gate-off.py b/scripts/fifa17-apply-gate-off.py deleted file mode 100755 index be9b673..0000000 --- a/scripts/fifa17-apply-gate-off.py +++ /dev/null @@ -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")) diff --git a/scripts/fifa17-apply-probe-smoke.py b/scripts/fifa17-apply-probe-smoke.py deleted file mode 100755 index d9f05d8..0000000 --- a/scripts/fifa17-apply-probe-smoke.py +++ /dev/null @@ -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")) diff --git a/scripts/fifa17-apply-smoke.py b/scripts/fifa17-apply-smoke.py new file mode 100755 index 0000000..bf351fa --- /dev/null +++ b/scripts/fifa17-apply-smoke.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Smoke-test the REAL consumable-apply route against the RUNNING staging host. + +`POST ut//item/resource/` 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:->`), 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) diff --git a/scripts/fifa17-apply-snapshot.py b/scripts/fifa17-apply-snapshot.py index b3dced5..9dc9e1e 100755 --- a/scripts/fifa17-apply-snapshot.py +++ b/scripts/fifa17-apply-snapshot.py @@ -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 diff --git a/scripts/fifa17-contract-apply-validate.py b/scripts/fifa17-contract-apply-validate.py new file mode 100755 index 0000000..1339bce --- /dev/null +++ b/scripts/fifa17-contract-apply-validate.py @@ -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()) diff --git a/scripts/fifa17-migration-rehearse.py b/scripts/fifa17-migration-rehearse.py index 958c5ee..6d56630 100755 --- a/scripts/fifa17-migration-rehearse.py +++ b/scripts/fifa17-migration-rehearse.py @@ -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: