diff --git a/fifa17-recon/docs/CLIENT_ROUTE_SURFACE.md b/fifa17-recon/docs/CLIENT_ROUTE_SURFACE.md index a6e34aa..d345b41 100644 --- a/fifa17-recon/docs/CLIENT_ROUTE_SURFACE.md +++ b/fifa17-recon/docs/CLIENT_ROUTE_SURFACE.md @@ -136,3 +136,56 @@ host now names every unclaimed request: ``` utas-host owner=PYTHON route=passthrough method=GET path=/ut/... body_len=N ``` + +## CONSUMABLE APPLY — LIVE_PROVEN (2026-08-21) + +Captured end to end on staging, operator applying a bronze player contract: + +``` +POST /ut/game/fifa17/item/resource/5001004 +{"apply":[{"id":100000003}]} +``` + +| element | value | where | +|---|---|---| +| source consumable | resource id `5001004` (player contract, subtype 201) | **path** | +| target item(s) | wire instance `100000003` (= squad slot 0 GK, resourceId 200389) | **body**, `apply[]` | +| verb | `POST` | | + +**There is no `/apply` endpoint** — the apply re-uses `ut/%s/item/resource`, which +we already serve for **GET** (item-definition lookup). The **POST** verb on that +path is the mutation, and nothing claimed it, so it fell through to Python. This +is the wire form of the `ApplyCardByRes` task (id `0x0e`) -- "apply card **by +res**ource" -- which is why the source is a definition id rather than an instance +id. + +`apply` is an ARRAY, so one consumable resource can name several targets in a +single request. Whether the client ever batches is unobserved. + +Corroborating UI evidence from the same session: applying to a PLAYER offered +only the subtype-201 card and withheld both subtype-202 manager contracts, +independently confirming the `201 = player_contract / 202 = manager_contract` +split. + +Fail-closed confirmed: with the upstream dead the request 502s and Core is left +EXACTLY unchanged (coins, owned count, and the source card all identical). + +### Not yet known +* the **response shape** the client expects on success; +* the **effect** -- how many matches a contract grants. Our own catalog carries + `contract: 7` for `5001004`, documented as "the number of matches the card + grants", but that is observed profile data, i.e. INFERRED, not reversed. No + effect is implemented on that basis. + +## Consumables category `development` is unmapped (client really asks) + +The new passthrough/route logging caught the client requesting + +``` +GET /ut/game/fifa17/club/consumables/development -> outcome=unknown_category emitted=0 +``` + +`consumable_families_for_category` has no `development` arm, so the screen is +served empty. The client demonstrably asks for it, which is exactly the condition +that function's own doc says should add an arm. Which families it should map to +is NOT guessed here. diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index e75802c..c616576 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -4025,12 +4025,23 @@ impl Server { // Name the request BEFORE forwarding. On staging the upstream is // deliberately dead, so this line is the only record of what the // client asked for -- which is exactly how an unclaimed route is - // discovered (see docs/CLIENT_ROUTE_SURFACE.md). Bodies are not - // logged; the path and method are enough to identify a route. + // discovered (see docs/CLIENT_ROUTE_SURFACE.md). eprintln!( "utas-host owner=PYTHON route=passthrough method={method} path={target} body_len={}", body.len() ); + // The BODY is what identifies an unknown mutation's operands, but + // it is also the one place a request can carry something we should + // not write to a log, so it is opt-in and capped. Staging probe + // only: OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1. + if passthrough_body_logging() && !body.is_empty() { + let cap = body.len().min(512); + eprintln!( + "utas-host PASSTHROUGH-BODY path={target} bytes={} body={}", + body.len(), + String::from_utf8_lossy(&body[..cap]) + ); + } let resp = match self.pass.forward(method, target, headers, body) { Ok(r) => r, Err(e) => { @@ -4865,6 +4876,19 @@ fn commerce_settings_enabled() -> bool { *ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_COMMERCE_SETTINGS").as_deref() == Ok("1")) } +/// Whether to log unclaimed (passthrough) request BODIES. +/// +/// OFF unless `OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1`, and capped at 512 bytes. +/// A body is what names an unknown mutation's operands -- it is how the +/// consumable-apply payload was recovered -- but it is also the one place a +/// request could carry something that should not reach a log, so it is opt-in +/// and intended for staging probes only. +fn passthrough_body_logging() -> bool { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *ENABLED + .get_or_init(|| std::env::var("OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY").as_deref() == Ok("1")) +} + /// Whether quick-sell pricing uses the client's own `fcc_discardcoins` table /// instead of the legacy rating-only ladder. ///