diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index c616576..e03040f 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -186,6 +186,17 @@ pub enum Route { /// Container type is load-bearing (object-where-array froze a live client); /// the handler picks it from the path. Constant band 150..15000. MarketData, + /// `POST …/item/resource/` — consumable APPLICATION + /// (`ApplyCardByRes`, task id `0x0e`), captured live 2026-08-21 as + /// `{"apply":[{"id":}]}`. The source consumable is the RESOURCE id in + /// the path; the targets are owned-item wire ids in the body. + /// + /// This classifies unconditionally so the route table stays a pure function of + /// (method, path) and remains testable, but the handler is a STAGING-ONLY + /// DIAGNOSTIC: without `OPENFUT_FIFA17_APPLY_PROBE=1` it declines and the + /// request falls through to the Python passthrough exactly as it does today. + /// The effect of a consumable is UNREVERSED, so nothing is ever mutated here. + ConsumableApplyProbe, /// Anything else — proxied verbatim to the Python oracle. Passthrough, } @@ -285,6 +296,16 @@ 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, @@ -4019,55 +4040,69 @@ 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 => { - // 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). - 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) => { - eprintln!( - "utas-host ERROR passthrough failed method={method} path={target}: {e}" - ); - WireResponse { - status: 502, - headers: vec![( - "Content-Type".to_string(), - "application/json".to_string(), - )], - body: br#"{"error":"upstream unavailable"}"#.to_vec(), - transport: ResponseTransport::Normal, - } - } - }; - eprintln!( - "utas-host owner=PYTHON_FALLBACK method={} path={} status={}", - method, path, resp.status - ); - resp - } + 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. + fn passthrough( + &self, + method: &str, + target: &str, + headers: &[(String, String)], + body: &[u8], + ) -> WireResponse { + let path = target.split('?').next().unwrap_or(target); + // 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). + 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) => { + eprintln!("utas-host ERROR passthrough failed method={method} path={target}: {e}"); + WireResponse { + status: 502, + headers: vec![("Content-Type".to_string(), "application/json".to_string())], + body: br#"{"error":"upstream unavailable"}"#.to_vec(), + transport: ResponseTransport::Normal, + } + } + }; + eprintln!( + "utas-host owner=PYTHON_FALLBACK method={} path={} status={}", + method, path, resp.status + ); + resp + } + /// Monotonic seconds since server start — the clock for session/pending TTLs. fn now(&self) -> f64 { self.start.elapsed().as_secs_f64() @@ -4644,6 +4679,88 @@ impl Server { json_status(200, &non_economy::item_defs_body(&ids)) } + /// `POST …/item/resource/` — STAGING-ONLY consumable-apply + /// diagnostic. Returns `None` (→ Python passthrough, today's behaviour) unless + /// `OPENFUT_FIFA17_APPLY_PROBE=1`. + /// + /// 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. + /// + /// RESPONSE SHAPE, from static RE rather than convenience: the apply + /// completion handler (CardsDLL `0x180035520`) tests exactly one field, + /// `[obj+0x1c]`, and raises `EVENT_CARDS_APPLY_CARD_SUCCESS` when it is zero, + /// `EVENT_CARDS_APPLY_CARD_FAILURE` otherwise. It never inspects the body — + /// unlike the move ack (`0x180128600`), which builds per-item verdict records + /// and fails on an EMPTY vector. The response object's constructor + /// (`0x1800a4ce0`) initialises its record vector EMPTY, so empty is a legal + /// parse result here. `{"itemData":[]}` is therefore the smallest candidate + /// consistent with both the client and the oracle, whose `item/resource` route + /// is method-agnostic and answers this path with an `itemData` object. + /// It is a PROBE, not a proven contract. + fn handle_consumable_apply_probe(&self, target: &str, body: &[u8]) -> Option { + if !apply_probe_enabled() { + return None; + } + let path = target.split('?').next().unwrap_or(target); + let resource_id: i64 = path.rsplit('/').next().and_then(|s| s.parse().ok())?; + 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 + // 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)]) + ); + return Some(error_response(400, "apply_batch_unsupported")); + } + // Read-only identification of both operands, so the capture names what was + // applied to what. No write path is reachable from here. + let owned = self.core.all_owned().unwrap_or_default(); + // A Core card id is "_", so the path's resource id names + // the DEFINITION directly; no new resolver method is needed for a probe. + let is_source = |it: &CoreOwnedItem| { + it.card_id + .rsplit_once('_') + .and_then(|(_, n)| n.parse::().ok()) + == Some(resource_id) + }; + let 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(), + }; + // 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"), + }, + None => "UNRESOLVED_WIRE_ID".to_string(), + }; + 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] + ); + Some(json_text_status(200, "{\"itemData\":[]}".to_string())) + } + /// `GET …/marketdata[/pricelimits]` — suggested pricing. `/pricelimits` returns /// the bare ARRAY (one band per queried defId); any other `/marketdata` returns /// the OBJECT band. Container type is chosen from the path (load-bearing: the @@ -4876,6 +4993,34 @@ fn commerce_settings_enabled() -> bool { *ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_COMMERCE_SETTINGS").as_deref() == Ok("1")) } +/// Wire ids from a consumable-apply body: `{"apply":[{"id":N}, …]}`. +/// +/// Captured live 2026-08-21. Only `len == 1` has ever been observed; the caller +/// refuses anything else rather than invent batch semantics. +fn parse_apply_targets(body: &[u8]) -> Vec { + serde_json::from_slice::(body) + .ok() + .and_then(|v| v.get("apply").and_then(|a| a.as_array()).cloned()) + .map(|a| { + a.iter() + .filter_map(|e| e.get("id").and_then(|i| i.as_i64())) + .collect() + }) + .unwrap_or_default() +} + +/// Whether the STAGING-ONLY consumable-apply diagnostic answers. +/// +/// OFF unless `OPENFUT_FIFA17_APPLY_PROBE=1`. With it off the route falls through +/// to the Python passthrough, i.e. byte-for-byte today's behaviour, so production +/// cannot accidentally serve a diagnostic. The probe exists ONLY to observe the +/// client's success path: the consumable EFFECT is unreversed, so it consumes +/// nothing and mutates nothing. +fn apply_probe_enabled() -> bool { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_APPLY_PROBE").as_deref() == Ok("1")) +} + /// Whether to log unclaimed (passthrough) request BODIES. /// /// OFF unless `OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1`, and capped at 512 bytes. @@ -6254,6 +6399,50 @@ mod tests { .is_empty()); } + /// The apply re-uses the definition-lookup PATH with a different VERB, which + /// is exactly why it went unclaimed. Lock that boundary. + #[test] + fn consumable_apply_is_classified_by_verb_and_resource_id() { + // Live-captured 2026-08-21: POST ut//item/resource/. + assert_eq!( + classify("POST", "/ut/game/fifa17/item/resource/5001004"), + Route::ConsumableApplyProbe + ); + assert_eq!( + classify("POST", "/ut/v2/game/fifa17/item/resource/5001004"), + Route::ConsumableApplyProbe + ); + // A non-numeric tail is not a resource id, so it is not the apply. + assert_eq!( + classify("POST", "/ut/game/fifa17/item/resource/bogus"), + Route::Passthrough + ); + // The definition lookup keeps the path under its own verb. + assert_eq!( + classify("GET", "/ut/game/fifa17/item/resource"), + Route::ItemDefs + ); + } + + #[test] + fn apply_targets_parse_from_the_captured_body() { + // The exact bytes the client sent, 2026-08-21. + assert_eq!( + parse_apply_targets(br#"{"apply":[{"id":100000003}]}"#), + vec![100000003] + ); + // A batch is parsed but the handler refuses it: semantics unproven. + assert_eq!( + parse_apply_targets(br#"{"apply":[{"id":1},{"id":2}]}"#), + vec![1, 2] + ); + // Never invent a target. + assert!(parse_apply_targets(b"").is_empty()); + assert!(parse_apply_targets(br#"{"apply":[]}"#).is_empty()); + assert!(parse_apply_targets(br#"{"nope":[{"id":7}]}"#).is_empty()); + assert!(parse_apply_targets(br#"{"apply":[{"noid":7}]}"#).is_empty()); + } + #[test] fn marketdata_container_types_are_load_bearing() { // /pricelimits MUST be a bare ARRAY (object-where-array froze a live client).