feat(fifa17): real player-contract consumable apply, replacing the probe

POST /ut/game/fifa17/item/resource/<rid> {"apply":[{"id":N}]} now performs a
durable atomic contract application instead of falling through to Python.

THE RULE. grant = fcc_contractcards[card][tier(TARGET.rating)], then
min(99, contract + grant). The column is keyed on the TARGET's tier, NOT the
card's own -- all 36 cells of EA's shipped table match the published FIFA 17
matrix, and staging discriminates the two readings outright: a bronze-RARE
card on a rating-89 player granted 3 (the gold column), where the card-level
reading predicts 15.

No client binary reads fcc_contractcards -- a string scan of every .exe/.dll
in the install finds it referenced nowhere, and CardsDLL reads only 14 fcc_
tables (fcc_discardcoins among them, which is why quick-sell prices locally).
Consumable effects are server-authoritative, so EA's shipped table is the only
non-invented source and the client renders whatever we persist and re-serve.

The host computes the grant, Core owns the mutation -- the same split
quick-sell already uses (host prices via discard_value, Core performs
sell_item), and what migration 0027 means by "Core defines NO per-category
formula".

FAILS CLOSED, never 200-and-do-nothing: manager contracts 409 because staff
ratings are unimported so the target tier is unknowable; every other family
409 as unproven; batch 400; unresolvable operand 404. Core's deterministic
refusals pass through with their own status instead of collapsing to 503,
which would tell the client to retry a request that can never succeed.

`contract: 7` stops being a hardcode in shape_item/shape_staff_item and
becomes the fallback for an instance Core tracks no contract for. `fitness: 99`
is the same class of hardcode and is deliberately untouched.

CLEAN CUTOVER: Route::ConsumableApplyProbe, its handler, apply_probe_enabled,
the OPENFUT_FIFA17_APPLY_PROBE gate and both probe scripts are deleted. A
handler no classifier can reach is this repo's recurring defect class, and the
new economy arm preempts the probe. fifa17-migration-rehearse.py also drove
the probe (spelled "apply probe", so an apply-probe grep missed it) and would
have eaten a card off the rehearsal profile; retargeted to a non-mutating
assertion.

Not implemented, on purpose: the stored-manager bonus (real mechanic, rule
appears in no shipped table -- guessing it would corrupt the proven part) and
contract decrement per match (nothing spends contracts yet).
This commit is contained in:
funman300
2026-08-22 18:23:22 +00:00
parent 3c67fea074
commit 6c97bc4e2b
19 changed files with 1348 additions and 270 deletions
+18 -2
View File
@@ -24,6 +24,7 @@ use rand::Rng;
use serde_json::{json, Value};
use openfut_adapter_fifa17::fut::club_response::shape_club_response;
use openfut_adapter_fifa17::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
use openfut_adapter_fifa17::fut::economy_policy::pack_price;
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
use openfut_adapter_fifa17::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
@@ -106,6 +107,9 @@ fn core_owned(m: &Minted) -> CoreOwnedItem {
league: m.card.league.clone(),
club: m.card.club.clone(),
attributes: m.card.attributes,
// Freshly minted by a pack/Store open, so Core tracks no contract for it
// yet: the shaper substitutes the pack-fresh default.
contract_matches: None,
}
}
@@ -167,6 +171,10 @@ fn shape_minted(deps: &StoreDeps<'_>, minted: &[Minted]) -> Vec<Value> {
id,
deps.entities,
deps.assets.discard_value(&item),
// A pack-pulled card is by definition pack-fresh, so it carries
// the default rather than a persisted count: Core has not yet
// stored this instance, let alone applied a contract to it.
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
))
})
.collect()
@@ -464,8 +472,8 @@ mod tests {
use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
use crate::{
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyPurchase, EconomySale,
EconomySaleReceipt,
ConsumableApplyOutcome, ConsumableApplyRequest, CoreMatchCompletion, CoreMatchReceipt,
EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt,
};
// ── Recording economy double ────────────────────────────────────────────
@@ -608,6 +616,13 @@ mod tests {
// Match completion is not exercised by the Store/quick-sell paths.
Err(CoreError::Status(501))
}
fn apply_consumable(
&self,
_req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
// Consumable apply is not exercised by the Store/quick-sell paths.
Err(CoreError::Status(501))
}
}
// ── Identity / entity / lookup doubles ──────────────────────────────────
@@ -986,6 +1001,7 @@ mod tests {
league: "Premier League".into(),
club: "Arsenal".into(),
attributes: [rating; 6],
contract_matches: None,
}
}
+386 -130
View File
@@ -60,6 +60,10 @@ use openfut_adapter_fifa17::fut::consumables::consumables_response;
use openfut_adapter_fifa17::fut::content_taxonomy::{
consumable_families_for_category, consumable_family, position_group, ContentKind, PositionGroup,
};
use openfut_adapter_fifa17::fut::contract_cards::{
contract_grant, tier_for_rating, CONTRACT_MATCH_CAP, MANAGER_CONTRACT_SUBTYPE,
PACK_FRESH_CONTRACT_MATCHES, PLAYER_CONTRACT_SUBTYPE,
};
use openfut_adapter_fifa17::fut::discard;
use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver};
use openfut_adapter_fifa17::fut::item::CONSUMABLE_UNTRADEABLE;
@@ -186,17 +190,6 @@ pub enum Route {
/// Container type is load-bearing (object-where-array froze a live client);
/// the handler picks it from the path. Constant band 150..15000.
MarketData,
/// `POST …/item/resource/<resourceId>` — consumable APPLICATION
/// (`ApplyCardByRes`, task id `0x0e`), captured live 2026-08-21 as
/// `{"apply":[{"id":<target>}]}`. The source consumable is the RESOURCE id in
/// the path; the targets are owned-item wire ids in the body.
///
/// This classifies unconditionally so the route table stays a pure function of
/// (method, path) and remains testable, but the handler is a STAGING-ONLY
/// DIAGNOSTIC: without `OPENFUT_FIFA17_APPLY_PROBE=1` it declines and the
/// request falls through to the Python passthrough exactly as it does today.
/// The effect of a consumable is UNREVERSED, so nothing is ever mutated here.
ConsumableApplyProbe,
/// Anything else — proxied verbatim to the Python oracle.
Passthrough,
}
@@ -296,16 +289,6 @@ pub fn classify(method: &str, path: &str) -> Route {
Some("clubUser") if get => Route::FeatureOffEmpty,
Some("user/list") if get => Route::FeatureOffEmpty,
Some("item/resource") if get => Route::ItemDefs,
// The apply re-uses the item-definition PATH with a different VERB and a
// trailing resource id, which is why it fell through to Python: the
// `item/resource` arm above is GET-only. Live-captured 2026-08-21.
Some(t)
if post
&& t.strip_prefix("item/resource/")
.is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) =>
{
Route::ConsumableApplyProbe
}
Some("defid") if get => Route::ItemDefs,
Some(t) if get && (t == "marketdata" || t.starts_with("marketdata/")) => Route::MarketData,
_ => Route::Passthrough,
@@ -412,6 +395,18 @@ pub enum EconomyRoute {
QuickSellPath,
/// `POST /ut/delete/game/<sku>/item` — bulk quick-sell.
QuickSellBody,
/// `POST …/item/resource/<resourceId>` — apply one CONSUMABLE to one owned
/// target (`ApplyCardByRes`, task id `0x0e`), live-captured 2026-08-21 as
/// `{"apply":[{"id":<target>}]}`. The source consumable is the RESOURCE id in
/// the path; the target is an owned-item wire id in the body.
///
/// THIS PATH SERVES THREE VERBS and conflating any two of them consumes or
/// sells the wrong card: GET is the definition lookup ([`Route::ItemDefs`]),
/// POST is this apply, PUT is [`Self::QuickSellResource`]. That is not
/// hypothetical — the Python oracle maps `item/resource` method-agnostically
/// to its definition route, so an unclaimed verb there answers 200 with a
/// definition list, mutating nothing while the client reports success.
ConsumableApply,
/// `PUT …/item/resource/<resourceId>` — CONSUMABLE quick-sell, keyed by the
/// stack's resource id rather than an owned instance, with an EMPTY body.
///
@@ -628,6 +623,19 @@ pub fn classify_economy(method: &str, path: &str) -> Option<EconomyRoute> {
Some(t) if post && is_purchased_tail(t) => Some(EconomyRoute::PackOpen),
Some(t) if get && is_purchased_tail(t) => Some(EconomyRoute::PackReveal),
Some(t) if delete && is_item_id_tail(t) => Some(EconomyRoute::QuickSellPath),
// MUST stay adjacent to the PUT arm below so the `item/resource/` family
// is read as one unit: same path, three verbs (GET definition lookup,
// POST apply, PUT consumable quick-sell). It is an ECONOMY route because
// a successful apply destroys the source card, and `try_handle_economy`
// is the barrier that guarantees a matched route can never ALSO fall
// through to Python and be applied twice.
Some(t)
if post
&& t.strip_prefix("item/resource/")
.is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) =>
{
Some(EconomyRoute::ConsumableApply)
}
Some(t)
if put
&& t.strip_prefix("item/resource/")
@@ -1277,6 +1285,63 @@ pub struct CoreMatchReceipt {
pub coins_balance: i64,
}
/// The one PROVEN consumable effect: grant match-contracts to the target.
///
/// `amount` is the caller's ALREADY-RESOLVED FIFA 17 grant, not a hint: Core
/// owns the mutation, the caller owns the game formula (the same split as
/// quick-sell, where the host computes `discard_value` and Core performs the
/// atomic sale). `cap` and `default_when_unset` are the client's own constants
/// — Core needs the ceiling to clamp with, and the pack-fresh number to seed an
/// instance it tracks no contract for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AddContractMatches {
pub amount: i64,
pub cap: i64,
pub default_when_unset: i64,
}
impl AddContractMatches {
/// The wire token Core dispatches the effect on. A CONSTANT rather than a
/// caller-supplied string: every other consumable family's effect is
/// unproven and is refused before it can reach Core, so there is no second
/// value this could legitimately take.
pub const KIND: &'static str = "add_contract_matches";
}
/// One consumable application to hand to Core's atomic `/consumables/apply`
/// transaction, which destroys the source instance and mutates the target in a
/// single durable step.
///
/// `action_identity` is the exactly-once key. `target_kind` is Core's own
/// lowercase `ContentKind` token for the target, so Core never has to infer what
/// it is mutating.
pub struct ConsumableApplyRequest<'a> {
pub action_identity: &'a str,
pub source_owned_card_id: &'a str,
pub target_owned_card_id: &'a str,
pub target_kind: &'a str,
pub effect: AddContractMatches,
}
/// Core's authoritative answer for a consumable application.
///
/// `applied` is `false` on an idempotent REPLAY of the same `action_identity`:
/// nothing was mutated and every field below echoes the RECORDED outcome, so a
/// replay must never be read as a fresh grant.
///
/// `source_quantity_after` is `None` whenever the source is not quantity-modelled
/// — which is always, for FIFA 17: consumables are separate owned instances and
/// a successful apply destroys exactly one of them.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsumableApplyOutcome {
pub applied: bool,
pub source_destroyed: bool,
pub source_quantity_after: Option<i64>,
pub granted: i64,
pub before: i64,
pub after: i64,
}
/// The host's authoritative economy transport to Core. Every method is a single
/// durable Core transaction. **Fail-closed:** on any transport/status/parse
/// error the caller MUST surface a controlled error and NEVER fall back to
@@ -1311,6 +1376,16 @@ pub trait CoreEconomy: Send + Sync {
/// replay/duplicate returns `applied = false` with the canonical result, and
/// any error is surfaced (never a Python fallback).
fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result<CoreMatchReceipt, CoreError>;
/// Apply one consumable to one target in Core's atomic, exactly-once
/// `/consumables/apply` transaction: the source instance is destroyed and the
/// target mutated together, or neither happens. A replayed
/// `action_identity` returns `applied = false` with the recorded outcome, and
/// any error is surfaced (never a Python fallback — the oracle would answer
/// this path 200 from its definition route and consume nothing).
fn apply_consumable(
&self,
req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError>;
}
impl HttpCoreClient {
@@ -1505,6 +1580,45 @@ impl CoreEconomy for HttpCoreClient {
coins_balance: json_i64(&v, "coins_balance")?,
})
}
fn apply_consumable(
&self,
req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
let v = self.core_post(
"consumables/apply",
&json!({
"action_identity": req.action_identity,
"source_owned_card_id": req.source_owned_card_id,
"target_owned_card_id": req.target_owned_card_id,
"target_kind": req.target_kind,
"effect": {
"kind": AddContractMatches::KIND,
"amount": req.effect.amount,
"cap": req.effect.cap,
"default_when_unset": req.effect.default_when_unset,
},
}),
)?;
// The effect block is REQUIRED even on a replay (Core echoes what it
// recorded). Missing it means the caller cannot tell what the target now
// holds, so it is a parse error rather than a defaulted zero.
let effect = v
.get("effect")
.ok_or_else(|| CoreError::Parse("missing `effect` object".into()))?;
Ok(ConsumableApplyOutcome {
applied: v.get("applied").and_then(Value::as_bool).unwrap_or(false),
source_destroyed: v
.get("source_destroyed")
.and_then(Value::as_bool)
.unwrap_or(false),
// Absent or null both mean "not a quantity-modelled source".
source_quantity_after: v.get("source_quantity_after").and_then(Value::as_i64),
granted: json_i64(effect, "granted")?,
before: json_i64(effect, "before")?,
after: json_i64(effect, "after")?,
})
}
}
/// Serialize an [`EconomySale`] into the `POST /economy/settle-sale` JSON body.
@@ -1681,6 +1795,7 @@ fn core_item_from_json(e: &Value) -> Option<CoreOwnedItem> {
attr("defending"),
attr("physical"),
],
contract_matches: e.get("contract_matches").and_then(|v| v.as_i64()),
})
}
@@ -1713,6 +1828,10 @@ fn core_item_from_definition(card: &Value) -> Option<CoreOwnedItem> {
attr("defending"),
attr("physical"),
],
// A definition is not an instance, so it holds no contracts — the same
// reasoning as the empty `owned_card_id` above. `None` makes the caller
// substitute the pack-fresh default instead of reading a fabricated 0.
contract_matches: None,
})
}
@@ -3786,6 +3905,20 @@ impl Server {
};
handle_quick_sell_path(id, &deps)
}
EconomyRoute::ConsumableApply => {
// Classification already guaranteed ASCII digits. A value that
// does not fit a FIFA resource id is not one of the known
// contract cards, so it is REFUSED here rather than `?`-ed:
// returning `None` from this function would let a mutation fall
// through to Python, i.e. a second writer.
match ut_tail(path)
.and_then(|t| t.strip_prefix("item/resource/"))
.and_then(|d| d.parse::<u32>().ok())
{
Some(rid) => self.handle_consumable_apply(rid, body, svc.econ.as_ref()),
None => error_response(409, "apply_effect_unproven"),
}
}
EconomyRoute::QuickSellResource => {
// The stack's resource id names a DEFINITION, so pick the owned
// copy deterministically: Core's own order, i.e. the same first
@@ -4100,22 +4233,15 @@ impl Server {
Route::FeatureOffEmpty => self.handle_feature_off_empty(path),
Route::Season => self.handle_season(path),
Route::ItemDefs => self.handle_item_defs(target),
Route::ConsumableApplyProbe => {
match self.handle_consumable_apply_probe(target, body) {
Some(resp) => resp,
// Gate off: identical to today — proxy it verbatim.
None => self.passthrough(method, target, headers, body),
}
}
Route::MarketData => self.handle_marketdata(path, target),
Route::SecurityQuestion => self.handle_security_question(method, target, headers),
Route::Passthrough => self.passthrough(method, target, headers, body),
}
}
/// Proxy a request verbatim to the Python oracle. Extracted so the declined
/// consumable-apply probe takes EXACTLY this path — with the gate off there is
/// no behavioural difference from before the probe existed.
/// Proxy a request verbatim to the Python oracle. Extracted so every route
/// that declines to answer takes EXACTLY this one path, and so the log line
/// naming an unclaimed request has a single home.
fn passthrough(
&self,
method: &str,
@@ -4739,15 +4865,22 @@ impl Server {
json_status(200, &non_economy::item_defs_body(&ids))
}
/// `POST …/item/resource/<resourceId>` — STAGING-ONLY consumable-apply
/// diagnostic. Returns `None` (→ Python passthrough, today's behaviour) unless
/// `OPENFUT_FIFA17_APPLY_PROBE=1`.
/// `POST …/item/resource/<resourceId>` — apply one consumable to one owned
/// target (`ApplyCardByRes`, task id `0x0e`), live-captured 2026-08-21 as
/// `{"apply":[{"id":<target>}]}`.
///
/// NON-AUTHORITATIVE BY CONSTRUCTION. It consumes no source card, mutates no
/// target, touches no contract/fitness/chemistry/training/injury state, mints
/// no coins and changes no ownership. It exists to observe what the client
/// does with a success, because the EFFECT of a consumable is unreversed and
/// implementing one on an inferred value is not acceptable.
/// The MUTATION is Core's: it destroys the source instance and raises the
/// target's contracts in one transaction. The FORMULA is the caller's, which
/// is this: the number of matches granted is selected by the TARGET's rating
/// tier, not by the consumable's own tier, and the table is not monotonic, so
/// it can only be looked up ([`contract_grant`]) — never interpolated.
///
/// Only the CONTRACT family is served, and only its PLAYER half (subtype
/// 201). Everything else fails closed. A 200-and-do-nothing here is precisely
/// the defect this route was claimed to end: the Python oracle maps
/// `item/resource` method-agnostically to its definition route, so an
/// unclaimed apply returns a definition list, consumes nothing, and the
/// client reports success.
///
/// RESPONSE SHAPE, from static RE rather than convenience: the apply
/// completion handler (CardsDLL `0x180035520`) tests exactly one field,
@@ -4755,70 +4888,192 @@ impl Server {
/// `EVENT_CARDS_APPLY_CARD_FAILURE` otherwise. It never inspects the body —
/// unlike the move ack (`0x180128600`), which builds per-item verdict records
/// and fails on an EMPTY vector. The response object's constructor
/// (`0x1800a4ce0`) initialises its record vector EMPTY, so empty is a legal
/// parse result here. `{"itemData":[]}` is therefore the smallest candidate
/// consistent with both the client and the oracle, whose `item/resource` route
/// is method-agnostic and answers this path with an `itemData` object.
/// It is a PROBE, not a proven contract.
fn handle_consumable_apply_probe(&self, target: &str, body: &[u8]) -> Option<WireResponse> {
if !apply_probe_enabled() {
return None;
}
let path = target.split('?').next().unwrap_or(target);
let resource_id: i64 = path.rsplit('/').next().and_then(|s| s.parse().ok())?;
/// (`0x1800a4ce0`) initialises its record vector EMPTY, so `{"itemData":[]}`
/// is a legal parse result, and it is what the live client accepted.
fn handle_consumable_apply(
&self,
resource_id: u32,
body: &[u8],
econ: &dyn CoreEconomy,
) -> WireResponse {
let targets = parse_apply_targets(body);
// `apply` is an ARRAY, but only len==1 has ever been observed. Batch
// semantics (atomic? partial?) are unknown, so a multi-target request is
// `apply` is an ARRAY, but only len == 1 has ever been observed. Batch
// semantics (atomic? partial? one source per target?) are unknown, and a
// consumable application is unreversed, so a multi-target request is
// reported and refused rather than guessed at.
if targets.len() != 1 {
eprintln!(
"utas-host owner=RUST route=apply-probe status=refused resource={resource_id} \
targets={} reason=batch_semantics_unproven body={}",
targets.len(),
String::from_utf8_lossy(&body[..body.len().min(256)])
"utas-host owner=RUST route=economy consumable-apply status=400 \
resource={resource_id} targets={} outcome=apply_batch_unsupported",
targets.len()
);
return Some(error_response(400, "apply_batch_unsupported"));
return error_response(400, "apply_batch_unsupported");
}
// Read-only identification of both operands, so the capture names what was
// applied to what. No write path is reachable from here.
let owned = self.core.all_owned().unwrap_or_default();
// A Core card id is "<sku>_<resourceId>", so the path's resource id names
// the DEFINITION directly; no new resolver method is needed for a probe.
let is_source = |it: &CoreOwnedItem| {
it.card_id
.rsplit_once('_')
.and_then(|(_, n)| n.parse::<i64>().ok())
== Some(resource_id)
let target_wire = targets[0];
let owned = match self.core.all_owned() {
Ok(o) => o,
Err(e) => {
eprintln!(
"utas-host ERROR route=economy consumable-apply status=503 \
resource={resource_id} wire={target_wire} err={e:?}"
);
return error_response(503, "core_unavailable");
}
};
let copies = owned.iter().filter(|it| is_source(it)).count();
let source_desc = match owned.iter().find(|it| is_source(it)) {
Some(it) => format!(
"owned kind={:?} subtype={} copies={}",
self.resolver.kind_of(it),
self.resolver.subtype_of(it),
copies
),
None => "NOT_OWNED".to_string(),
// The path's resource id names a DEFINITION, so pick the owned copy the
// same deterministic way quick-sell does: the FIRST matching copy in
// Core's own order, which is the copy whose wire id the consumables
// screen already published as the stack's `item`. The card consumed is
// therefore the one the screen showed the player.
let source = owned.iter().find_map(|it| {
self.resolver
.resolve_consumable(it)
.filter(|c| c.resource_id == resource_id)
.map(|c| (it, c))
});
let Some((source_item, source_ident)) = source else {
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=404 \
resource={resource_id} wire={target_wire} outcome=not_owned"
);
return error_response(404, "not_owned");
};
// Reverse the wire id through the identity store -- never a guess.
let target_desc = match self.resolver.owned_id_for_wire(targets[0]) {
Some(core_id) => match owned.iter().find(|it| it.owned_card_id == core_id) {
Some(it) => format!(
"owned card={} rating={} kind={:?}",
it.card_id,
it.rating,
self.resolver.kind_of(it)
),
None => format!("known_wire_id={core_id} NOT_IN_CLUB"),
match source_ident.subtype {
PLAYER_CONTRACT_SUBTYPE => {}
MANAGER_CONTRACT_SUBTYPE => {
// The grant is selected by the TARGET's rating tier, and staff
// ratings are not imported: a manager's rating lives in the
// `value` column of `managercards`/`*coachcards`/`physiocards`
// (see `Fifa17IdentityResolver::discard_value`) and Core models a
// non-player's `overall` as 0. So there is no honest tier for a
// manager target — inventing one would silently grant the wrong
// number of matches, unreversibly. Refuse until staff ratings
// are imported.
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=409 \
resource={resource_id} wire={target_wire} subtype={} \
outcome=manager_contract_unsupported reason=staff_ratings_not_imported",
source_ident.subtype
);
return error_response(409, "manager_contract_unsupported");
}
other => {
// Only the contract family's effect is proven. Fitness, healing,
// position, play-style and training grants are not, and answering
// 200 while changing nothing is the exact failure this route was
// claimed to end.
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=409 \
resource={resource_id} wire={target_wire} subtype={other} \
outcome=apply_effect_unproven"
);
return error_response(409, "apply_effect_unproven");
}
}
// Reverse the target's wire id through the identity store — never a
// guess, and never the wire id itself.
let target = self
.resolver
.owned_id_for_wire(target_wire)
.and_then(|core_id| {
owned
.iter()
.find(|it| it.owned_card_id == core_id)
.map(|it| (core_id, it))
});
let Some((target_core_id, target_item)) = target else {
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=404 \
resource={resource_id} wire={target_wire} outcome=not_owned"
);
return error_response(404, "not_owned");
};
// Subtype 201 is the PLAYER contract; the client's own family gating
// sends manager contracts (202) to staff. A player contract on a
// non-player has no proven effect at all.
let target_kind = self.resolver.kind_of(target_item);
if target_kind != ContentKind::Player {
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=409 \
resource={resource_id} wire={target_wire} target_kind={} \
outcome=contract_target_not_a_player",
target_kind.as_str()
);
return error_response(409, "contract_target_not_a_player");
}
let Some(granted) = contract_grant(resource_id, tier_for_rating(target_item.rating)) else {
// The subtype said "player contract" while the resource id is not one
// of the 13 known rows: a catalog inconsistency, not a licence to
// substitute a neighbouring row's number.
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=409 \
resource={resource_id} wire={target_wire} rating={} \
outcome=apply_effect_unproven reason=resource_not_a_contract_card",
target_item.rating
);
return error_response(409, "apply_effect_unproven");
};
// A successful apply DESTROYS the source instance, so a genuine second
// contract application necessarily names a different source id, while a
// transport retry of the same logical action replays this exact key and
// Core mutates nothing. FIFA 17 consumables are separate owned instances
// rather than `quantity` stacks — the consumables screen groups them for
// display only — so the source instance id is the honest per-action key.
let action_identity = format!(
"fifa17:apply:{}->{}",
source_item.owned_card_id, target_core_id
);
let req = ConsumableApplyRequest {
action_identity: &action_identity,
source_owned_card_id: &source_item.owned_card_id,
target_owned_card_id: &target_core_id,
target_kind: target_kind.as_str(),
effect: AddContractMatches {
amount: granted,
cap: CONTRACT_MATCH_CAP,
default_when_unset: PACK_FRESH_CONTRACT_MATCHES,
},
None => "UNRESOLVED_WIRE_ID".to_string(),
};
let outcome = match econ.apply_consumable(&req) {
Ok(o) => o,
Err(e) => {
// Fail closed. NEVER a Python fallback: the oracle would answer
// 200 from its definition route and the player would be told a
// contract was applied that nothing recorded.
//
// Core's DETERMINISTIC refusals are passed through with their own
// status rather than collapsed into 503. A loan target or a kind
// mismatch will never succeed on retry, and 503 means "try again
// later" — reporting one as the other invites the client to
// re-send a request that cannot ever be accepted. 404 is reachable
// only when the source vanishes between our `all_owned` read and
// Core's transaction (the losing side of a concurrent
// double-submit), which is likewise permanent for that request.
let (status, code) = match e {
CoreError::Status(400) => (400, "apply_refused"),
CoreError::Status(404) => (404, "not_owned"),
CoreError::Status(409) => (409, "apply_refused"),
_ => (503, "core_unavailable"),
};
eprintln!(
"utas-host ERROR route=economy consumable-apply status={status} \
resource={resource_id} wire={target_wire} err={e:?}"
);
return error_response(status, code);
}
};
eprintln!(
"utas-host owner=RUST route=apply-probe status=200 PROBE_ONLY resource={resource_id} \
source={source_desc} target={} target_item={target_desc} mutated=NOTHING",
targets[0]
"utas-host owner=RUST route=economy consumable-apply resource={resource_id} \
wire={target_wire} subtype={} granted={} before={} after={} applied={} \
source_destroyed={}",
source_ident.subtype,
outcome.granted,
outcome.before,
outcome.after,
outcome.applied,
outcome.source_destroyed
);
Some(json_text_status(200, "{\"itemData\":[]}".to_string()))
json_text_status(200, "{\"itemData\":[]}".to_string())
}
/// `GET …/marketdata[/pricelimits]` — suggested pricing. `/pricelimits` returns
@@ -5069,18 +5324,6 @@ fn parse_apply_targets(body: &[u8]) -> Vec<i64> {
.unwrap_or_default()
}
/// Whether the STAGING-ONLY consumable-apply diagnostic answers.
///
/// OFF unless `OPENFUT_FIFA17_APPLY_PROBE=1`. With it off the route falls through
/// to the Python passthrough, i.e. byte-for-byte today's behaviour, so production
/// cannot accidentally serve a diagnostic. The probe exists ONLY to observe the
/// client's success path: the consumable EFFECT is unreversed, so it consumes
/// nothing and mutates nothing.
fn apply_probe_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_APPLY_PROBE").as_deref() == Ok("1"))
}
/// Whether to log unclaimed (passthrough) request BODIES.
///
/// OFF unless `OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1`, and capped at 512 bytes.
@@ -5458,6 +5701,13 @@ mod tests {
coins_balance: self.balance,
})
}
fn apply_consumable(
&self,
_req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
// Consumable apply is not exercised through this double.
Err(CoreError::Status(501))
}
fn purchase_item(
&self,
_cost: i64,
@@ -5849,6 +6099,7 @@ mod tests {
league: "Premier League".into(),
club: "Chelsea".into(),
attributes: [90, 90, 80, 91, 33, 80],
contract_matches: None,
}
}
@@ -6462,6 +6713,9 @@ mod tests {
/// One path, three verbs. GET is the definition lookup, POST applies the
/// consumable, PUT quick-sells it. All three were live-captured; conflating
/// any two of them sells or consumes the wrong thing.
///
/// Apply and quick-sell are ECONOMY routes, classified before `classify()`
/// ever runs, so they must resolve there and never fall through to Python.
#[test]
fn item_resource_path_dispatches_on_verb() {
assert_eq!(
@@ -6469,54 +6723,56 @@ mod tests {
Route::ItemDefs
);
assert_eq!(
classify("POST", "/ut/game/fifa17/item/resource/5001004"),
Route::ConsumableApplyProbe
classify_economy("POST", "/ut/game/fifa17/item/resource/5001004"),
Some(EconomyRoute::ConsumableApply)
);
// The quick-sell is an ECONOMY route, classified before `classify()`
// ever runs, so it must resolve there and not fall through to Python.
assert_eq!(
classify_economy("PUT", "/ut/game/fifa17/item/resource/5003068"),
Some(EconomyRoute::QuickSellResource)
);
assert_eq!(
classify_economy("PUT", "/ut/v2/game/fifa17/item/resource/5003068"),
Some(EconomyRoute::QuickSellResource)
);
// The bare `item` PUT is the pile move and must not be captured.
assert_eq!(
classify_economy("PUT", "/ut/game/fifa17/item"),
Some(EconomyRoute::MoveItems)
);
// A non-numeric tail is not a resource id.
// A non-numeric tail is not a resource id, so it is NEITHER route — and
// it must not become an apply, which would consume a card on a path the
// client never builds.
assert_eq!(
classify_economy("POST", "/ut/game/fifa17/item/resource/bogus"),
None
);
assert_eq!(
classify_economy("PUT", "/ut/game/fifa17/item/resource/bogus"),
None
);
}
/// The apply re-uses the definition-lookup PATH with a different VERB, which
/// is exactly why it went unclaimed. Lock that boundary.
#[test]
fn consumable_apply_is_classified_by_verb_and_resource_id() {
// Live-captured 2026-08-21: POST ut/<sku>/item/resource/<resourceId>.
assert_eq!(
classify("POST", "/ut/game/fifa17/item/resource/5001004"),
Route::ConsumableApplyProbe
);
assert_eq!(
classify("POST", "/ut/v2/game/fifa17/item/resource/5001004"),
Route::ConsumableApplyProbe
);
// A non-numeric tail is not a resource id, so it is not the apply.
assert_eq!(
classify("POST", "/ut/game/fifa17/item/resource/bogus"),
Route::Passthrough
);
// The definition lookup keeps the path under its own verb.
}
/// Retail FIFA 17 issues part of the item family under `/ut/v2/game/`, so the
/// same three verbs must land identically under both prefixes: `ut_tail`
/// normalises them and nothing downstream may depend on which was used.
#[test]
fn item_resource_verbs_are_prefix_agnostic() {
assert_eq!(
classify("GET", "/ut/game/fifa17/item/resource"),
classify("GET", "/ut/v2/game/fifa17/item/resource"),
Route::ItemDefs
);
assert_eq!(
classify_economy("POST", "/ut/v2/game/fifa17/item/resource/5001004"),
Some(EconomyRoute::ConsumableApply)
);
assert_eq!(
classify_economy("PUT", "/ut/v2/game/fifa17/item/resource/5003068"),
Some(EconomyRoute::QuickSellResource)
);
assert_eq!(
classify_economy("POST", "/ut/v2/game/fifa17/item/resource/bogus"),
None
);
}
#[test]
+24 -3
View File
@@ -26,6 +26,7 @@
use serde_json::{json, Value};
use openfut_adapter_fifa17::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
use openfut_adapter_fifa17::fut::entities::ReverseEntityResolver;
use openfut_adapter_fifa17::fut::item::{shape_item, ItemIdentityResolver};
use openfut_adapter_fifa17::fut::item_state;
@@ -284,7 +285,19 @@ pub fn resolve_market_list<E: ReverseEntityResolver>(
let identity = resolver.resolve(&owned);
let resource_id = identity.map(|id| id.resource_id as i64);
let item_json = identity
.map(|id| shape_item(&owned, id, ent, resolver.discard_value(&owned)))
.map(|id| {
shape_item(
&owned,
id,
ent,
resolver.discard_value(&owned),
// A listing snapshot must show the contract the seller's card
// actually holds, so a part-used card cannot render as fresh.
owned
.contract_matches
.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
)
})
.and_then(|card| serde_json::to_string(&card).ok());
Some(ResolvedListing {
item_id,
@@ -806,8 +819,8 @@ pub async fn handle_move_items(
mod tests {
use super::*;
use crate::{
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyGrantItem,
EconomyPurchase, EconomySale, EconomySaleReceipt,
ConsumableApplyOutcome, ConsumableApplyRequest, CoreMatchCompletion, CoreMatchReceipt,
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
};
use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
@@ -968,6 +981,13 @@ mod tests {
// Match completion is not exercised through the market double.
Err(CoreError::Status(501))
}
fn apply_consumable(
&self,
_req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
// Consumable apply is not exercised through the market double.
Err(CoreError::Status(501))
}
}
// ---- SquadWireResolver double -----------------------------------------
@@ -1019,6 +1039,7 @@ mod tests {
league: String::new(),
club: String::new(),
attributes: [80, 80, 80, 80, 80, 80],
contract_matches: None,
}
}