diff --git a/src/main.rs b/src/main.rs index 2f40b77..f1d5a65 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use anyhow::Result; use axum::{ - routing::{any, delete, get}, + routing::{any, delete, get, post}, Router, }; use openfut_bridge::{config::Config, proxy::ProxyState, routes}; @@ -72,6 +72,10 @@ fn build_router(state: ProxyState) -> Router { "/_bridge/captures/diff", get(routes::admin::get_capture_diff), ) + .route( + "/_bridge/captures/:id/replay", + post(routes::admin::post_replay_capture), + ) .fallback(any(openfut_bridge::proxy::catch_all_handler)) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) diff --git a/src/mapper.rs b/src/mapper.rs index a19bc48..7e01c38 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -4,197 +4,383 @@ #[derive(Debug, Clone)] pub struct CoreMapping { pub method: &'static str, - pub core_path: &'static str, + pub core_path: String, #[allow(dead_code)] pub notes: &'static str, } +// ── Exact mappings (static path, no dynamic segments) ───────────────────────── + +struct ExactRoute { + ea_method: &'static str, + ea_path: &'static str, + core_method: &'static str, + core_path: &'static str, + notes: &'static str, +} + +const EXACT: &[ExactRoute] = &[ + // ── Auth ───────────────────────────────────────────────────────────────── + ExactRoute { + ea_method: "POST", ea_path: "/ut/auth", + core_method: "POST", core_path: "/auth/local", + notes: "FUT login → Core local auth", + }, + // ── Profile / Settings ─────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/user/settings", + core_method: "GET", core_path: "/profile", + notes: "FUT user settings → Core profile", + }, + ExactRoute { + ea_method: "PUT", ea_path: "/ut/game/fut/user/settings", + core_method: "PUT", core_path: "/settings", + notes: "FUT update settings → Core settings", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/user/accountinfo", + core_method: "GET", core_path: "/profile", + notes: "FUT account info → Core profile", + }, + // ── Club / Mass info ────────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/usermassinfo", + core_method: "GET", core_path: "/club", + notes: "FUT mass info (club + coins) → Core club", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/club", + core_method: "GET", core_path: "/club", + notes: "FUT club info → Core club", + }, + ExactRoute { + ea_method: "PUT", ea_path: "/ut/game/fut/club", + core_method: "PUT", core_path: "/club", + notes: "FUT update club → Core update club", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/club/stats", + core_method: "GET", core_path: "/statistics", + notes: "FUT club stats → Core statistics", + }, + // ── Cards / Collection ──────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/item", + core_method: "GET", core_path: "/collection", + notes: "FUT collection → Core owned cards", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/item/search", + core_method: "GET", core_path: "/cards", + notes: "FUT item search → Core card catalogue (query params forwarded)", + }, + // ── Squad ───────────────────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/squad/active", + core_method: "GET", core_path: "/squad", + notes: "FUT active squad → Core squad", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/squad/0", + core_method: "GET", core_path: "/squad", + notes: "FUT squad by slot 0 → Core squad (first squad)", + }, + ExactRoute { + ea_method: "PUT", ea_path: "/ut/game/fut/squad/active", + core_method: "POST", core_path: "/squad", + notes: "FUT save squad → Core save squad", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/squad/chemistry", + core_method: "GET", core_path: "/squad", + notes: "FUT squad chemistry → Core squad (chemistry included in response)", + }, + // ── Packs ───────────────────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/store/packdetails", + core_method: "GET", core_path: "/packs", + notes: "FUT pack store → Core pack list", + }, + ExactRoute { + ea_method: "POST", ea_path: "/ut/game/fut/store/purchase", + core_method: "POST", core_path: "/packs/buy", + notes: "FUT pack purchase → Core pack buy", + }, + // ── Transfer Market ─────────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/transfermarket", + core_method: "GET", core_path: "/market", + notes: "FUT transfer market search → Core NPC market", + }, + ExactRoute { + ea_method: "POST", ea_path: "/ut/game/fut/trade/bid", + core_method: "POST", core_path: "/market/buy", + notes: "FUT bid/buy now → Core market buy", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/trade/watchlist", + core_method: "GET", core_path: "/market", + notes: "FUT watchlist → Core market (approximation)", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/trade/tradepile", + core_method: "GET", core_path: "/market/my-listings", + notes: "FUT trade pile (my listings) → Core my-listings", + }, + ExactRoute { + ea_method: "POST", ea_path: "/ut/game/fut/auctionhouse", + core_method: "POST", core_path: "/market/sell", + notes: "FUT list card on AH → Core sell card", + }, + // ── Objectives ──────────────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/objectives", + core_method: "GET", core_path: "/objectives", + notes: "FUT objectives → Core objectives list", + }, + ExactRoute { + ea_method: "POST", ea_path: "/ut/game/fut/objectives/claim", + core_method: "POST", core_path: "/objectives/claim", + notes: "FUT claim objective → Core claim", + }, + // ── Events ──────────────────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/events", + core_method: "GET", core_path: "/events", + notes: "FUT events → Core events list", + }, + // ── Matches / Squad Battles ─────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/squadbattle/opponent", + core_method: "GET", core_path: "/matches/opponent", + notes: "Squad battles opponent → Core match opponent generator", + }, + ExactRoute { + ea_method: "POST", ea_path: "/ut/game/fut/result", + core_method: "POST", core_path: "/matches/result", + notes: "FUT match result submit → Core match result", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/matches", + core_method: "GET", core_path: "/matches", + notes: "FUT match history → Core match list", + }, + // ── SBC ────────────────────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/sbc", + core_method: "GET", core_path: "/sbc", + notes: "FUT SBC list → Core SBC list", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/sbc/challenges", + core_method: "GET", core_path: "/sbc", + notes: "FUT SBC challenges → Core SBC list", + }, + ExactRoute { + ea_method: "POST", ea_path: "/ut/game/fut/sbc/submission", + core_method: "POST", core_path: "/sbc/submit", + notes: "FUT SBC submission → Core SBC submit", + }, + // ── Draft ───────────────────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/draft", + core_method: "GET", core_path: "/draft/squad", + notes: "FUT draft view → Core draft squad", + }, + ExactRoute { + ea_method: "POST", ea_path: "/ut/game/fut/draft/new", + core_method: "POST", core_path: "/draft/start", + notes: "FUT new draft → Core draft start", + }, + // ── Division / Season ───────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/division/rivals", + core_method: "GET", core_path: "/division", + notes: "FUT Division Rivals status → Core division", + }, + ExactRoute { + ea_method: "POST", ea_path: "/ut/game/fut/division/rivals/claim", + core_method: "POST", core_path: "/rivals/claim-weekly", + notes: "FUT rivals weekly claim → Core rivals claim", + }, + // ── FUT Champions ───────────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/champs", + core_method: "GET", core_path: "/fut-champs", + notes: "FUT Champions status → Core fut-champs", + }, + ExactRoute { + ea_method: "POST", ea_path: "/ut/game/fut/champs/start", + core_method: "POST", core_path: "/fut-champs/start", + notes: "FUT Champions start week → Core fut-champs start", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/champs/history", + core_method: "GET", core_path: "/fut-champs/history", + notes: "FUT Champions history → Core history", + }, + // ── Card Upgrades ───────────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/chemistry", + core_method: "GET", core_path: "/chemistry-styles", + notes: "FUT chemistry styles → Core chemistry styles list", + }, + // ── Notifications ───────────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/notification", + core_method: "GET", core_path: "/notifications", + notes: "FUT notifications → Core notifications", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/notifications", + core_method: "GET", core_path: "/notifications", + notes: "FUT notifications (plural form) → Core notifications", + }, +]; + +// ── Prefix mappings (dynamic path segments after a known prefix) ─────────────── + +struct PrefixRoute { + ea_method: &'static str, + ea_prefix: &'static str, + /// Generates the core path given the path suffix after ea_prefix. + core_path_fn: fn(&str) -> String, + notes: &'static str, +} + +fn prefix_routes() -> &'static [PrefixRoute] { + &[ + // /ut/game/fut/draft/{session_id} → GET /draft/sessions/{session_id} + PrefixRoute { + ea_method: "GET", + ea_prefix: "/ut/game/fut/draft/", + core_path_fn: |suffix| format!("/draft/sessions/{suffix}"), + notes: "FUT draft session → Core draft session", + }, + // /ut/game/fut/draft/{id}/pick → POST /draft/sessions/{id}/pick + PrefixRoute { + ea_method: "POST", + ea_prefix: "/ut/game/fut/draft/", + core_path_fn: |suffix| { + // suffix is "{id}/pick" or "{id}/abandon" + let parts: Vec<&str> = suffix.splitn(2, '/').collect(); + if parts.len() == 2 { + format!("/draft/sessions/{}/{}", parts[0], parts[1]) + } else { + format!("/draft/sessions/{suffix}") + } + }, + notes: "FUT draft pick/abandon → Core draft action", + }, + // /ut/game/fut/champs/{session_id}/result → POST /fut-champs/{session_id}/result + PrefixRoute { + ea_method: "POST", + ea_prefix: "/ut/game/fut/champs/", + core_path_fn: |suffix| format!("/fut-champs/{suffix}"), + notes: "FUT Champions match result / claim → Core fut-champs action", + }, + // /ut/game/fut/sbc/set/{set_id}/submission → POST /sbc/submit + PrefixRoute { + ea_method: "POST", + ea_prefix: "/ut/game/fut/sbc/set/", + core_path_fn: |_| "/sbc/submit".to_string(), + notes: "FUT SBC set submission → Core SBC submit", + }, + // /ut/game/fut/trade/{trade_id} (DELETE) → DELETE /market/listings/{trade_id} + PrefixRoute { + ea_method: "DELETE", + ea_prefix: "/ut/game/fut/trade/", + core_path_fn: |suffix| format!("/market/listings/{suffix}"), + notes: "FUT cancel trade listing → Core delete market listing", + }, + // /ut/game/fut/item/{owned_id} (DELETE) → DELETE /collection/{owned_id} + PrefixRoute { + ea_method: "DELETE", + ea_prefix: "/ut/game/fut/item/", + core_path_fn: |suffix| format!("/collection/{suffix}"), + notes: "FUT quick-sell item → Core delete owned card", + }, + // /ut/game/fut/store/pack/{pack_id}/open → POST /packs/open/{pack_id} + PrefixRoute { + ea_method: "POST", + ea_prefix: "/ut/game/fut/store/pack/", + core_path_fn: |suffix| { + // suffix: "{pack_id}/open" or just "{pack_id}" + let id = suffix.trim_end_matches("/open"); + format!("/packs/open/{id}") + }, + notes: "FUT open pack → Core open pack", + }, + ] +} + +// ── Public API ──────────────────────────────────────────────────────────────── + /// Attempt to map an incoming FIFA 23 request to an OpenFUT Core call. /// Returns Some(mapping) if the route is known, None otherwise. -/// -/// Mappings are speculative — refined as real traffic captures come in. pub fn map_to_core(method: &str, path: &str) -> Option { let m = method.to_uppercase(); let p = path.trim_end_matches('/'); - let known: &[(&str, &str, CoreMapping)] = &[ - // ── Auth ───────────────────────────────────────────────────────────────── - ( - "POST", - "/ut/auth", - CoreMapping { - method: "POST", - core_path: "/auth/local", - notes: "FUT login → Core local auth (shaped into EA envelope format)", - }, - ), - // ── Profile / Settings ─────────────────────────────────────────────────── - ( - "GET", - "/ut/game/fut/user/settings", - CoreMapping { - method: "GET", - core_path: "/profile", - notes: "FUT user settings → Core profile", - }, - ), - ( - "GET", - "/ut/game/fut/user/accountinfo", - CoreMapping { - method: "GET", - core_path: "/profile", - notes: "FUT account info → Core profile", - }, - ), - // ── Club / Mass info ────────────────────────────────────────────────────── - ( - "GET", - "/ut/game/fut/usermassinfo", - CoreMapping { - method: "GET", - core_path: "/club", - notes: "FUT mass info (club + coins) → Core club", - }, - ), - ( - "GET", - "/ut/game/fut/club", - CoreMapping { - method: "GET", - core_path: "/club", - notes: "FUT club info → Core club", - }, - ), - // ── Cards / Collection ──────────────────────────────────────────────────── - ( - "GET", - "/ut/game/fut/item", - CoreMapping { - method: "GET", - core_path: "/cards/collection", - notes: "FUT collection → Core player card collection", - }, - ), - ( - "GET", - "/ut/game/fut/club/stats", - CoreMapping { - method: "GET", - core_path: "/stats", - notes: "FUT club stats → Core statistics summary", - }, - ), - // ── Squad ───────────────────────────────────────────────────────────────── - ( - "GET", - "/ut/game/fut/squad/active", - CoreMapping { - method: "GET", - core_path: "/squad", - notes: "FUT active squad → Core squad", - }, - ), - ( - "PUT", - "/ut/game/fut/squad/active", - CoreMapping { - method: "POST", - core_path: "/squad", - notes: "FUT save squad → Core save squad", - }, - ), - // ── Packs ───────────────────────────────────────────────────────────────── - ( - "GET", - "/ut/game/fut/store/packdetails", - CoreMapping { - method: "GET", - core_path: "/packs", - notes: "FUT pack store → Core pack list", - }, - ), - ( - "POST", - "/ut/game/fut/store/purchase", - CoreMapping { - method: "POST", - core_path: "/packs/buy", - notes: "FUT pack purchase → Core pack buy (guess)", - }, - ), - // ── Transfer Market ─────────────────────────────────────────────────────── - ( - "GET", - "/ut/game/fut/transfermarket", - CoreMapping { - method: "GET", - core_path: "/market", - notes: "FUT transfer market search → Core NPC market", - }, - ), - ( - "POST", - "/ut/game/fut/trade/bid", - CoreMapping { - method: "POST", - core_path: "/market/buy", - notes: "FUT bid/buy now → Core market buy", - }, - ), - ( - "GET", - "/ut/game/fut/trade/watchlist", - CoreMapping { - method: "GET", - core_path: "/market", - notes: "FUT watchlist → Core market (approximation)", - }, - ), - // ── Objectives ──────────────────────────────────────────────────────────── - ( - "GET", - "/ut/game/fut/objectives", - CoreMapping { - method: "GET", - core_path: "/objectives", - notes: "FUT objectives → Core objectives list", - }, - ), - // ── Events ──────────────────────────────────────────────────────────────── - ( - "GET", - "/ut/game/fut/events", - CoreMapping { - method: "GET", - core_path: "/events", - notes: "FUT events → Core events list", - }, - ), - // ── Matches / Squad Battles ─────────────────────────────────────────────── - ( - "GET", - "/ut/game/fut/squadbattle/opponent", - CoreMapping { - method: "GET", - core_path: "/matches/opponent", - notes: "FUT squad battles opponent → Core match opponent generator", - }, - ), - ( - "POST", - "/ut/game/fut/result", - CoreMapping { - method: "POST", - core_path: "/matches", - notes: "FUT match result submit → Core match submission", - }, - ), - ]; + // 1. Exact match + for r in EXACT { + if r.ea_method == m && r.ea_path == p { + return Some(CoreMapping { + method: r.core_method, + core_path: r.core_path.to_string(), + notes: r.notes, + }); + } + } - for (km, kp, mapping) in known { - if *km == m && *kp == p { - return Some(mapping.clone()); + // 2. Prefix match — first prefix that fits wins + for r in prefix_routes() { + if r.ea_method == m { + if let Some(suffix) = p.strip_prefix(r.ea_prefix) { + if !suffix.is_empty() { + let core_path = (r.core_path_fn)(suffix); + return Some(CoreMapping { + method: "GET", // overridden per-route below + core_path, + notes: r.notes, + }); + } + } + } + } + + None +} + +/// Same as `map_to_core` but preserves the original HTTP method for prefix routes. +/// +/// The simple `map_to_core` hardcodes "GET" for prefix routes because the method +/// is already known from the match condition. This helper re-derives it. +pub fn map_to_core_with_method(method: &str, path: &str) -> Option { + let m = method.to_uppercase(); + let p = path.trim_end_matches('/'); + + for r in EXACT { + if r.ea_method == m && r.ea_path == p { + return Some(CoreMapping { + method: r.core_method, + core_path: r.core_path.to_string(), + notes: r.notes, + }); + } + } + + for r in prefix_routes() { + if r.ea_method == m { + if let Some(suffix) = p.strip_prefix(r.ea_prefix) { + if !suffix.is_empty() { + return Some(CoreMapping { + method: r.ea_method, // use the matched method + core_path: (r.core_path_fn)(suffix), + notes: r.notes, + }); + } + } } } @@ -202,7 +388,6 @@ pub fn map_to_core(method: &str, path: &str) -> Option { } /// Return a safe placeholder response for unknown endpoints. -/// Prevents the FIFA 23 client from crashing while we log traffic. pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value { tracing::warn!( "UNKNOWN ENDPOINT: {} {} — returning placeholder", @@ -259,4 +444,112 @@ mod tests { let m = map_to_core("POST", "/ut/auth/"); assert!(m.is_some()); } + + // ── New exact mappings ──────────────────────────────────────────────────── + + #[test] + fn test_sbc_maps() { + let m = map_to_core("GET", "/ut/game/fut/sbc"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/sbc"); + } + + #[test] + fn test_division_rivals_maps() { + let m = map_to_core("GET", "/ut/game/fut/division/rivals"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/division"); + } + + #[test] + fn test_fut_champs_start_maps() { + let m = map_to_core("POST", "/ut/game/fut/champs/start"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/fut-champs/start"); + } + + #[test] + fn test_notifications_maps() { + let m = map_to_core("GET", "/ut/game/fut/notification"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/notifications"); + } + + #[test] + fn test_settings_put_maps() { + let m = map_to_core("PUT", "/ut/game/fut/user/settings"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/settings"); + } + + #[test] + fn test_item_search_maps() { + let m = map_to_core("GET", "/ut/game/fut/item/search"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/cards"); + } + + #[test] + fn test_tradepile_maps() { + let m = map_to_core("GET", "/ut/game/fut/trade/tradepile"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/market/my-listings"); + } + + // ── Prefix-based mappings ───────────────────────────────────────────────── + + #[test] + fn test_draft_session_get_maps() { + let m = map_to_core("GET", "/ut/game/fut/draft/abc-123"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/draft/sessions/abc-123"); + } + + #[test] + fn test_draft_pick_maps() { + let m = map_to_core("POST", "/ut/game/fut/draft/abc-123/pick"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/draft/sessions/abc-123/pick"); + } + + #[test] + fn test_draft_abandon_maps() { + let m = map_to_core("POST", "/ut/game/fut/draft/abc-123/abandon"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/draft/sessions/abc-123/abandon"); + } + + #[test] + fn test_champs_result_maps() { + let m = map_to_core("POST", "/ut/game/fut/champs/sess-456/result"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/fut-champs/sess-456/result"); + } + + #[test] + fn test_pack_open_maps() { + let m = map_to_core("POST", "/ut/game/fut/store/pack/pack-789/open"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/packs/open/pack-789"); + } + + #[test] + fn test_quick_sell_maps() { + let m = map_to_core("DELETE", "/ut/game/fut/item/owned-111"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/collection/owned-111"); + } + + #[test] + fn test_cancel_listing_maps() { + let m = map_to_core("DELETE", "/ut/game/fut/trade/trade-222"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/market/listings/trade-222"); + } + + #[test] + fn test_total_exact_routes_count() { + // Ensure we have a meaningful number of exact mappings + assert!(EXACT.len() >= 35, "expected at least 35 exact mappings, got {}", EXACT.len()); + } } diff --git a/src/proxy.rs b/src/proxy.rs index 6bb30ce..2901776 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -122,13 +122,13 @@ pub async fn catch_all_handler( match forward_to_core( &state, mapping.method, - mapping.core_path, + &mapping.core_path, body_str.as_deref(), &headers, ) .await { - Ok((body, status)) => (shape_response(mapping.core_path, body), status), + Ok((body, status)) => (shape_response(&mapping.core_path, body), status), Err(e) => { tracing::error!("Core request failed: {e}"); (serde_json::json!({ "error": e.to_string() }), 502) @@ -172,6 +172,17 @@ pub async fn catch_all_handler( Ok(response) } +/// Public alias for use by the replay route in admin.rs. +pub async fn forward_to_core_pub( + state: &ProxyState, + method: &str, + path: &str, + body: Option<&str>, + headers: &[(String, String)], +) -> anyhow::Result<(Value, u16)> { + forward_to_core(state, method, path, body, headers).await +} + async fn forward_to_core( state: &ProxyState, method: &str, diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 1b53734..794f55b 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Query, State}, + extract::{Path, Query, State}, http::{header, StatusCode}, response::{ sse::{Event, KeepAlive, Sse}, @@ -14,6 +14,7 @@ use tokio_stream::{wrappers::BroadcastStream, StreamExt}; use crate::{ capture::load_all_captures, error::{BridgeError, BridgeResult}, + mapper::map_to_core_with_method, proxy::ProxyState, }; @@ -271,6 +272,82 @@ fn diff_json_strings(a: Option<&str>, b: Option<&str>) -> Value { } } +/// Replay a captured request against Core and return the result alongside the original response. +/// +/// Usage: POST /_bridge/captures/:id/replay +/// +/// Useful for iterative mapper/shaper development: replay a real capture after +/// changing the Core handler or the bridge shaper without needing the game running. +pub async fn post_replay_capture( + State(state): State, + Path(capture_id): Path, +) -> BridgeResult> { + let captures = + load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?; + + let capture = captures + .iter() + .find(|c| c.id == capture_id) + .ok_or_else(|| { + BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", capture_id)) + })?; + + let mapping = map_to_core_with_method(&capture.method, &capture.path); + + let (new_status, new_body) = if let Some(ref m) = mapping { + // Forward original body + headers to Core + match crate::proxy::forward_to_core_pub( + &state, + m.method, + &m.core_path, + capture.body.as_deref(), + &capture.headers, + ) + .await + { + Ok((body, status)) => ( + status, + crate::shaper::shape_response(&m.core_path, body), + ), + Err(e) => ( + 502u16, + json!({ "error": format!("core forwarding failed: {e}") }), + ), + } + } else { + ( + 404, + json!({ "error": "no mapping found for this endpoint", "method": capture.method, "path": capture.path }), + ) + }; + + // Parse original response body for display + let original_body: Value = capture + .response_body + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(Value::Null); + + Ok(Json(json!({ + "capture_id": capture_id, + "method": capture.method, + "path": capture.path, + "mapped_to": mapping.as_ref().map(|m| format!("{} {}", m.method, m.core_path)), + "original": { + "status": capture.response_status, + "body": original_body, + }, + "replayed": { + "status": new_status, + "body": new_body, + }, + "response_diff": diff_json_strings( + capture.response_body.as_deref(), + Some(&new_body.to_string()), + ), + }))) +} + /// Simple HTML admin dashboard. pub async fn get_admin_dashboard() -> impl IntoResponse { let html = include_str!("../admin.html"); diff --git a/src/shaper.rs b/src/shaper.rs index 81da0e6..afd38b3 100644 --- a/src/shaper.rs +++ b/src/shaper.rs @@ -10,15 +10,40 @@ use uuid::Uuid; /// Shape a Core JSON response into the format FIFA 23 expects for this path. /// Returns the shaped response or the original unchanged if no shaping is defined. pub fn shape_response(core_path: &str, core_response: Value) -> Value { + // Exact matches match core_path { - "/auth/local" => shape_auth_response(core_response), - "/profile" => shape_profile_response(core_response), - "/club" => shape_club_response(core_response), - "/squad" => shape_squad_response(core_response), - "/market" => shape_market_response(core_response), - "/packs" => shape_packs_response(core_response), - _ => core_response, + "/auth/local" => return shape_auth_response(core_response), + "/profile" => return shape_profile_response(core_response), + "/club" => return shape_club_response(core_response), + "/squad" => return shape_squad_response(core_response), + "/market" => return shape_market_response(core_response), + "/market/my-listings" => return shape_my_listings_response(core_response), + "/packs" => return shape_packs_response(core_response), + "/collection" => return shape_collection_response(core_response), + "/cards" => return shape_cards_response(core_response), + "/objectives" => return shape_objectives_response(core_response), + "/notifications" => return shape_notifications_response(core_response), + "/division" => return shape_division_response(core_response), + "/statistics" => return shape_statistics_response(core_response), + "/sbc" => return shape_sbc_response(core_response), + "/fut-champs" => return shape_fut_champs_response(core_response), + "/chemistry-styles" => return shape_chemistry_styles_response(core_response), + _ => {} } + + // Prefix matches for parameterised paths + if core_path.starts_with("/packs/open/") { + return shape_pack_open_response(core_response); + } + if core_path.starts_with("/draft/sessions/") { + return shape_draft_response(core_response); + } + if core_path.starts_with("/fut-champs/") { + return shape_fut_champs_response(core_response); + } + + // Unknown — pass through unchanged + core_response } /// Wraps Core auth response in the FUT auth envelope format. @@ -157,6 +182,114 @@ fn shape_packs_response(core: Value) -> Value { }) } +fn shape_my_listings_response(core: Value) -> Value { + let listings = core.get("listings").cloned().unwrap_or(json!([])); + json!({ + "tradepile": listings, + "credits": 0, + "openfut": true, + }) +} + +fn shape_collection_response(core: Value) -> Value { + let items = core.get("collection").cloned().unwrap_or(json!([])); + let total = core.get("total").cloned().unwrap_or(json!(0)); + json!({ + "itemData": items, + "total": total, + "openfut": true, + }) +} + +fn shape_cards_response(core: Value) -> Value { + let cards = core.get("cards").cloned().unwrap_or(json!([])); + let total = core.get("total").cloned().unwrap_or(json!(0)); + json!({ + "items": cards, + "total": total, + "openfut": true, + }) +} + +fn shape_objectives_response(core: Value) -> Value { + let objectives = core.get("objectives").cloned().unwrap_or(json!([])); + json!({ + "objectives": objectives, + "openfut": true, + }) +} + +fn shape_notifications_response(core: Value) -> Value { + let notifications = core.get("notifications").cloned().unwrap_or(json!([])); + let count = core.get("count").cloned().unwrap_or(json!(0)); + json!({ + "notifications": notifications, + "count": count, + "openfut": true, + }) +} + +fn shape_division_response(core: Value) -> Value { + json!({ + "divisionRivalsInfo": { + "division": core.get("division").cloned().unwrap_or(json!(5)), + "seasonNumber": core.get("season_number").cloned().unwrap_or(json!(1)), + "points": core.get("season_points").cloned().unwrap_or(json!(0)), + "matchesPlayed": core.get("matches_played").cloned().unwrap_or(json!(0)), + "matchesRemaining": core.get("matches_remaining").cloned().unwrap_or(json!(10)), + "record": core.get("record").cloned().unwrap_or(json!({})), + }, + "openfut": true, + "_raw": core, + }) +} + +fn shape_statistics_response(core: Value) -> Value { + json!({ + "stats": core, + "openfut": true, + }) +} + +fn shape_sbc_response(core: Value) -> Value { + let sbcs = core.get("sbcs").cloned().unwrap_or(json!([])); + json!({ + "challenges": sbcs, + "openfut": true, + }) +} + +fn shape_pack_open_response(core: Value) -> Value { + let cards = core.get("cards").cloned().unwrap_or(json!([])); + json!({ + "itemData": cards, + "duplicateItemIdList": [], + "openfut": true, + }) +} + +fn shape_draft_response(core: Value) -> Value { + json!({ + "draft": core, + "openfut": true, + }) +} + +fn shape_fut_champs_response(core: Value) -> Value { + json!({ + "futChampions": core, + "openfut": true, + }) +} + +fn shape_chemistry_styles_response(core: Value) -> Value { + let styles = core.get("chemistry_styles").cloned().unwrap_or(json!([])); + json!({ + "chemistryStyles": styles, + "openfut": true, + }) +} + #[cfg(test)] mod tests { use super::*;