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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
+1
-1
Submodule openfut-core updated: 233df1d99d...e8be289660
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -233,6 +233,7 @@ fn item(
|
||||
league: league.into(),
|
||||
club: club.into(),
|
||||
attributes: [90, 88, 70, 85, 40, 78],
|
||||
contract_matches: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
Executable
+176
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Executable
+216
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user