|
|
|
@@ -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]
|
|
|
|
|